Backend: - user roles (DB + JWT + first-user-is-admin) - volume-aware file resolver (multi-volume asset serving) - directory scanner uses volume URI directly - date-summary endpoint (capture date from EXIF) - timeline ordered by capture date - list endpoints: volumes, plugins, pipelines, library paths - delete endpoints: volumes, library paths - configurable upload body limit (MAX_UPLOAD_BYTES) Frontend: - auth: login/register, token refresh, role-based admin gate - timeline: date-grouped grid, infinite scroll, date scrubber - image viewer: fullscreen zoom/pan/pinch, metadata sidebar - upload: drag-drop, sequential upload, progress tracking - albums: create, add/remove photos, asset picker dialog - admin: storage (import library), jobs (pagination, error details), plugins (list + toggle), pipelines, sidecars, duplicates - multi-select mode with add-to-album action - TanStack Query for all data fetching
146 lines
3.8 KiB
TypeScript
146 lines
3.8 KiB
TypeScript
"use client"
|
|
|
|
import { useEffect, useState, useMemo, useCallback, useRef } from "react"
|
|
import { format, parseISO } from "date-fns"
|
|
import type { DateCountEntry } from "@/lib/types"
|
|
|
|
interface DateScrubberProps {
|
|
dates: DateCountEntry[]
|
|
}
|
|
|
|
interface ScrubberEntry {
|
|
label: string
|
|
date: string
|
|
dateId: string
|
|
}
|
|
|
|
function findVisibleDateId(): string | null {
|
|
const headers = document.querySelectorAll<HTMLElement>("[data-date]")
|
|
const viewportTop = window.scrollY + window.innerHeight * 0.15
|
|
let best: HTMLElement | null = null
|
|
for (const h of headers) {
|
|
if (h.offsetTop <= viewportTop) best = h
|
|
else break
|
|
}
|
|
return best?.id ?? headers[0]?.id ?? null
|
|
}
|
|
|
|
export function DateScrubber({ dates }: DateScrubberProps) {
|
|
const [activeDate, setActiveDate] = useState<string | null>(null)
|
|
const scrollingRef = useRef(false)
|
|
|
|
const entries = useMemo<ScrubberEntry[]>(() => {
|
|
const compact = dates.length > 30
|
|
|
|
let lastYear = ""
|
|
let lastMonth = ""
|
|
const result: ScrubberEntry[] = []
|
|
|
|
for (const { date } of dates) {
|
|
const d = parseISO(date)
|
|
const monthKey = format(d, "yyyy-MM")
|
|
|
|
if (compact && monthKey === lastMonth) continue
|
|
lastMonth = monthKey
|
|
|
|
const year = format(d, "yyyy")
|
|
const showYear = year !== lastYear
|
|
lastYear = year
|
|
|
|
const label = compact
|
|
? showYear
|
|
? format(d, "MMM yyyy")
|
|
: format(d, "MMM")
|
|
: showYear
|
|
? format(d, "MMM d, yyyy")
|
|
: format(d, "MMM d")
|
|
|
|
result.push({ label, date, dateId: `date-${date}` })
|
|
}
|
|
return result
|
|
}, [dates])
|
|
|
|
useEffect(() => {
|
|
let raf = 0
|
|
const onScroll = () => {
|
|
cancelAnimationFrame(raf)
|
|
raf = requestAnimationFrame(() => {
|
|
if (!scrollingRef.current) {
|
|
setActiveDate(findVisibleDateId())
|
|
}
|
|
})
|
|
}
|
|
window.addEventListener("scroll", onScroll, { passive: true })
|
|
onScroll()
|
|
return () => {
|
|
window.removeEventListener("scroll", onScroll)
|
|
cancelAnimationFrame(raf)
|
|
}
|
|
}, [])
|
|
|
|
const scrollToDate = useCallback((dateId: string) => {
|
|
const el = document.getElementById(dateId)
|
|
if (!el) return
|
|
|
|
scrollingRef.current = true
|
|
setActiveDate(dateId)
|
|
el.scrollIntoView({ behavior: "smooth", block: "start" })
|
|
setTimeout(() => {
|
|
scrollingRef.current = false
|
|
}, 800)
|
|
}, [])
|
|
|
|
const handleClick = useCallback(
|
|
(entry: ScrubberEntry) => {
|
|
const el = document.getElementById(entry.dateId)
|
|
if (el) {
|
|
scrollToDate(entry.dateId)
|
|
return
|
|
}
|
|
|
|
const headers = Array.from(
|
|
document.querySelectorAll<HTMLElement>("[data-date]"),
|
|
)
|
|
let closest: HTMLElement | null = null
|
|
for (const h of headers) {
|
|
const d = h.dataset.date ?? ""
|
|
if (d >= entry.date) {
|
|
closest = h
|
|
break
|
|
}
|
|
}
|
|
if (!closest) closest = headers[headers.length - 1] ?? null
|
|
|
|
if (closest) {
|
|
scrollingRef.current = true
|
|
setActiveDate(entry.dateId)
|
|
closest.scrollIntoView({ behavior: "smooth", block: "start" })
|
|
setTimeout(() => {
|
|
scrollingRef.current = false
|
|
}, 800)
|
|
}
|
|
},
|
|
[scrollToDate],
|
|
)
|
|
|
|
if (entries.length < 1) return null
|
|
|
|
return (
|
|
<div className="sticky top-0 flex h-[calc(100svh-7rem)] w-8 shrink-0 flex-col items-center justify-start gap-0.5 overflow-y-auto py-2">
|
|
{entries.map((entry) => (
|
|
<button
|
|
key={entry.dateId}
|
|
onClick={() => handleClick(entry)}
|
|
className={`w-full rounded px-0.5 py-0.5 text-center text-[9px] leading-tight transition-colors ${
|
|
activeDate === entry.dateId
|
|
? "font-semibold text-foreground"
|
|
: "text-muted-foreground hover:text-foreground"
|
|
}`}
|
|
>
|
|
{entry.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|