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
This commit is contained in:
145
k-photos-frontend/components/date-scrubber.tsx
Normal file
145
k-photos-frontend/components/date-scrubber.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user