Files
k-photos/k-photos-frontend/components/date-scrubber.tsx
Gabriel Kaszewski 957737ac9b feat: frontend MVP — auth, timeline, upload, albums, admin, image viewer
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
2026-06-01 01:35:43 +02:00

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>
)
}