import { useRef, useEffect, useMemo, useState, useCallback } from 'react' import { useVirtualizer } from '@tanstack/react-virtual' import { format, parseISO } from 'date-fns' import clsx from 'clsx' import { usePhotoStore } from '../../store/photoStore' import { useFilterStore, hasActiveFilters } from '../../store/filterStore' import { PhotoThumbnail } from './PhotoThumbnail' import { usePhotosQuery } from '../../hooks/usePhotosQuery' import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery' import { Button } from '@/components/ui/button' import { ImageOff } from 'lucide-react' import type { Photo } from '../../types/photo' // Layout constants for the grid + grouped headers. const THUMBNAIL_SIZE = 200 const GAP = 4 const PADDING = 16 const HEADER_HEIGHT = 36 interface PhotoCell { photo: Photo globalIndex: number } type TimelineItem = | { type: 'header'; key: string; label: string; height: number } | { type: 'row'; key: string; cells: PhotoCell[]; height: number } /** * Build the flat header|row item array the virtualizer renders. * * Two modes: * - sortBy is a date field: month buckets. * - otherwise: one un-headered stream. * * Tag, rating, and color grouping now live in their own dedicated views * (TagsView, RatedView, ColorsView) instead of being handled here. */ function buildItems( photos: Photo[], columns: number, rowHeight: number, sortBy: string, groupBy: string, ): TimelineItem[] { if (photos.length === 0) return [] const items: TimelineItem[] = [] // Helper: split a flat array of cells into rows of `columns` cells. const pushRowsForGroup = (groupKey: string, cells: PhotoCell[]) => { for (let i = 0; i < cells.length; i += columns) { const slice = cells.slice(i, i + columns) items.push({ type: 'row', key: `${groupKey}::row::${i}`, cells: slice, height: rowHeight + GAP, }) } } // Date grouping only applies when groupBy is explicitly 'date' and // the sort field is a date column. Other sections (tags, colors, // rated, people) reuse Timeline for their detail views and should // render a flat grid without month headers. const isDateSort = sortBy === 'taken_at' || sortBy === 'added_at' if (!isDateSort || groupBy !== 'date') { // No grouping — one row stream. const cells: PhotoCell[] = photos.map((photo, globalIndex) => ({ photo, globalIndex, })) pushRowsForGroup('all', cells) return items } // Walk photos in order, breaking into groups whenever the month label changes. let currentLabel: string | null = null let bucket: PhotoCell[] = [] let bucketIndex = 0 const flushBucket = () => { if (bucket.length === 0 || currentLabel === null) return items.push({ type: 'header', key: `header::${bucketIndex}::${currentLabel}`, label: currentLabel, height: HEADER_HEIGHT, }) pushRowsForGroup(`${bucketIndex}::${currentLabel}`, bucket) bucketIndex++ bucket = [] } photos.forEach((photo, globalIndex) => { const dateStr = sortBy === 'taken_at' ? photo.taken_at : photo.added_at ?? photo.taken_at let label: string if (dateStr) { try { label = format(parseISO(dateStr), 'MMMM yyyy') } catch { label = 'Unknown date' } } else { label = 'Unknown date' } if (label !== currentLabel) { flushBucket() currentLabel = label } bucket.push({ photo, globalIndex }) }) flushBucket() return items } export function Timeline() { const parentRef = useRef(null) // Sentinel placed inside the inner virtualizer wrapper at the exact // position rows will render. We measure THIS instead of parentRef, // because parentRef has padding and we'd otherwise have to subtract // it (and account for any scrollbar) — easy to get wrong by a pixel // and end up with a column count off by one. const [containerWidth, setContainerWidth] = useState(0) const { selectedPhotos, activePhotoId, selectPhoto, togglePhotoSelection, selectRange, clearSelection, openPreview, } = usePhotoStore() // Pulled via a focused selector so the publisher subscription doesn't // re-render Timeline on every unrelated photo store change. const setVisiblePhotoIds = usePhotoStore((s) => s.setVisiblePhotoIds) const sortBy = useFilterStore((s) => s.sortBy) const groupBy = useFilterStore((s) => s.groupBy) const currentSection = useFilterStore((s) => s.currentSection) const viewMode = usePhotoStore((s) => s.viewMode) // Calculate number of columns + actual cell size based on container // width. Treat THUMBNAIL_SIZE as a *minimum* and let cells grow to // fill the remaining space, so we never leave a horizontal gap on // the right side of the grid. // // Column math: with N columns there are N-1 inter-cell gaps, so the // width needed is N*T + (N-1)*G. Solving for the largest N that fits // in the available width gives N = floor((available + G) / (T + G)). // The previous formula floor((available) / (T + G)) was off-by-one // and lost a whole column whenever the remainder almost fit. const { columns, cellSize } = useMemo(() => { if (containerWidth === 0) { return { columns: 4, cellSize: THUMBNAIL_SIZE } } // containerWidth here is the sentinel's actual rendered width — no // padding subtraction needed, the sentinel already lives inside the // padded scroll container. const available = containerWidth const cols = Math.max( 1, Math.floor((available + GAP) / (THUMBNAIL_SIZE + GAP)) ) // Exact float — no floor. cellSize × cols + (cols-1) × gap == available // by construction, so the row fills edge-to-edge without any // sub-pixel rounding gap. const cell = (available - (cols - 1) * GAP) / cols return { columns: cols, cellSize: cell } }, [containerWidth]) // Shared photos query — both Timeline and PreviewView use the same hook so // they share one cache entry, regardless of filter state. const { data: photos = [], isLoading } = usePhotosQuery() // Tracks the previous viewMode so the "preview just closed" scroll // effect (defined further down, after photoRows) only fires on the // actual transition rather than every items[] recomputation. const prevViewModeRef = useRef(viewMode) // Auto-focus the first photo on initial grid load so arrow-key nav // works immediately without a pre-click. One-shot — after the user // explicitly clears the selection (Escape), we don't re-focus, so // the metadata sidebar can collapse and stay collapsed. const didAutoFocusRef = useRef(false) useEffect(() => { if (didAutoFocusRef.current) return if (viewMode !== 'grid') return if (activePhotoId) { didAutoFocusRef.current = true return } if (photos.length === 0) return didAutoFocusRef.current = true selectPhoto(photos[0].id) }, [viewMode, activePhotoId, photos, selectPhoto]) // Membership in the active heap (drives the green tint on each // thumbnail). Subscribed once at this level so we don't have hundreds // of thumbnails each subscribing to the same query. const { memberIds: activeHeapMembers } = useActiveHeapMembers() // Stable cell handlers. PhotoThumbnail is wrapped in React.memo so // identity-stable callbacks let it skip re-render on unrelated store // churn (e.g. heap membership invalidation). visibleSequence is read // through a ref at click time so scrolling doesn't rebind the // double-click handler. const visibleSequenceRef = useRef([]) const handleCellClick = useCallback( (photo: Photo, e: React.MouseEvent) => { if (e.shiftKey) selectRange(photo.id) else if (e.ctrlKey || e.metaKey) togglePhotoSelection(photo.id) else selectPhoto(photo.id) }, [selectRange, togglePhotoSelection, selectPhoto], ) const handleCellDoubleClick = useCallback( (photo: Photo) => { openPreview(photo.id, visibleSequenceRef.current) }, [openPreview], ) // Build the flat virtualizer items: a mix of group headers and rows of // photos. Date headers appear only in the main timeline (groupBy='date'). const items = useMemo( () => buildItems(photos, columns, cellSize, sortBy, groupBy), [photos, columns, cellSize, sortBy, groupBy] ) // Used by the sticky header, the row-date index, and the floating // scrollbar chip — all three only make sense in date-sorted views. const isDateSort = sortBy === 'taken_at' || sortBy === 'added_at' // Pre-computed offset of every header in the virtualizer's coordinate // space, used to drive the sticky-header overlay below. const headerOffsets = useMemo(() => { const result: { offset: number; label: string }[] = [] let cumulative = 0 for (const item of items) { if (item.type === 'header') { result.push({ offset: cumulative, label: item.label }) } cumulative += item.height } return result }, [items]) // Virtual scrolling setup with per-item heights. const virtualizer = useVirtualizer({ count: items.length, getScrollElement: () => parentRef.current, estimateSize: (index) => items[index]?.height ?? cellSize, overscan: 5, }) // Re-measure when items change (column count, group structure). useEffect(() => { virtualizer.measure() }, [items, virtualizer]) // Track scroll position so we can (a) show the current group label as // a pinned overlay at the top of the scroll container and (b) drive // the floating "date next to the scrollbar" indicator. The virtualizer's // items use transform translateY (so CSS position: sticky doesn't work // on the inline headers); the overlays sidestep that by living outside // the virtualizer's positioned children. // // We capture clientHeight + scrollHeight alongside top so the floating // indicator can position itself proportionally to scroll progress // without needing a second observer to react to viewport resizes. // `isScrolling` is a short-lived flag reset by a debounced timer — // drives the fade in/out of the date chip so it only appears while // the user is actively dragging the scrollbar / wheel-scrolling. const [scrollMetrics, setScrollMetrics] = useState({ top: 0, clientHeight: 0, scrollHeight: 0, }) const [isScrolling, setIsScrolling] = useState(false) const scrollIdleTimerRef = useRef(null) useEffect(() => { const el = parentRef.current if (!el) return const capture = () => { setScrollMetrics({ top: el.scrollTop, clientHeight: el.clientHeight, scrollHeight: el.scrollHeight, }) } capture() const onScroll = () => { capture() setIsScrolling(true) if (scrollIdleTimerRef.current !== null) { window.clearTimeout(scrollIdleTimerRef.current) } scrollIdleTimerRef.current = window.setTimeout(() => { setIsScrolling(false) scrollIdleTimerRef.current = null }, 700) } el.addEventListener('scroll', onScroll, { passive: true }) return () => { el.removeEventListener('scroll', onScroll) if (scrollIdleTimerRef.current !== null) { window.clearTimeout(scrollIdleTimerRef.current) scrollIdleTimerRef.current = null } } // Depend on isLoading + photo presence so the effect re-attaches // after the early-return loading/empty JSX gives way to the real // scroll container — on first mount parentRef.current is null and // an empty-deps effect would never bind the listener. }, [isLoading, photos.length]) // Alias for the sticky-header math below, which only needs the scroll // offset. Kept as a local so the existing logic reads the same as // before the metrics refactor. const scrollTop = scrollMetrics.top // Parallel index of every row's (cumulative offset, raw date string), // built from the same flat items array the virtualizer reads. Used by // the floating scrollbar-date chip to answer "what date is the user // currently looking at" in O(log n) without reaching into the // virtualizer's internals. Rebuilt whenever items or sort field change. const rowDateIndex = useMemo(() => { if (!isDateSort) return [] as { offset: number; raw: string | null }[] const out: { offset: number; raw: string | null }[] = [] let cum = 0 for (const item of items) { if (item.type === 'row') { const first = item.cells[0]?.photo const raw = sortBy === 'added_at' ? first?.added_at ?? first?.taken_at ?? null : first?.taken_at ?? null out.push({ offset: cum, raw }) } cum += item.height } return out }, [items, sortBy, isDateSort]) // Date shown in the floating chip next to the scrollbar. Finds the // deepest row whose offset is at/above the viewport top (plus a // small lookahead so fast flicks feel responsive) and formats its // photo's capture date. Null when not date-sorted, not scrolling, or // when the topmost row has no date — we'd rather hide the chip than // show an unhelpful "Unknown". const scrollDateLabel = useMemo(() => { if (!isDateSort || rowDateIndex.length === 0) return null const target = scrollMetrics.top + 20 let raw: string | null = null // Binary search: largest offset ≤ target. let lo = 0 let hi = rowDateIndex.length - 1 while (lo <= hi) { const mid = (lo + hi) >> 1 if (rowDateIndex[mid].offset <= target) { raw = rowDateIndex[mid].raw lo = mid + 1 } else { hi = mid - 1 } } if (!raw) return null try { return format(parseISO(raw), 'MMM d, yyyy') } catch { return null } }, [rowDateIndex, scrollMetrics.top, isDateSort]) // Vertical position of the floating chip in scroll-viewport pixels. // Tracks the scrollbar thumb's position by mapping scroll progress // linearly onto the viewport height. Clamped so the chip never // overflows the top/bottom padding even at scroll extremes. const scrollIndicatorTop = useMemo(() => { const { top, clientHeight, scrollHeight } = scrollMetrics if (scrollHeight <= clientHeight) return 8 const ratio = top / (scrollHeight - clientHeight) const chipHeight = 28 const usable = clientHeight - chipHeight - 16 return 8 + Math.max(0, Math.min(usable, ratio * usable)) }, [scrollMetrics]) // Find the latest header whose BOTTOM is above the viewport top. That's // the group whose natural in-grid header has scrolled out of view — // exactly the case where we want to pin the label as a sticky overlay. // If the natural header is still visible (scrolled but not yet past), // we return null and let the in-grid label do the work, avoiding the // duplicate-label flash. const stickyLabel = useMemo(() => { if (headerOffsets.length === 0) return null let current: string | null = null for (const h of headerOffsets) { if (h.offset + HEADER_HEIGHT <= scrollTop) current = h.label else break } return current }, [headerOffsets, scrollTop]) // Measure the sentinel's actual rendered width on mount, window // resize, and any layout change driven by the sidebar collapse / // right panel toggle. ResizeObserver picks up everything window // resize misses (sidebar collapse doesn't fire window resize). // // Uses a callback ref (not useRef + useEffect) because Timeline // early-returns a loading/empty state before the sentinel exists, // so a mount-only effect would see a null ref and never install // the observer. The callback ref fires whenever the sentinel // actually attaches, which is the moment we can measure it. const roRef = useRef(null) const measureElRef = useRef(null) const widthSentinelRef = useCallback((el: HTMLDivElement | null) => { roRef.current?.disconnect() roRef.current = null measureElRef.current = el if (!el) return const measure = () => setContainerWidth(el.clientWidth) measure() const ro = new ResizeObserver(measure) ro.observe(el) roRef.current = ro }, []) useEffect(() => { const onResize = () => { const el = measureElRef.current if (el) setContainerWidth(el.clientWidth) } window.addEventListener('resize', onResize) return () => { window.removeEventListener('resize', onResize) roRef.current?.disconnect() roRef.current = null } }, []) // Photo rows in visual order — drops the header items so navigation // walks the grid as the user sees it. Each row has cells of length // [1..columns], the last row of a group can be short, and a single // photo with multiple tags will appear in multiple rows. const photoRows = useMemo( () => items.filter((it): it is Extract => it.type === 'row'), [items] ) // Parallel array: photoRows[i] corresponds to items[photoRowItemIndex[i]]. // Lets keyboard nav jump the virtualizer to the destination row even when // it hasn't been rendered yet (beyond the overscan window). const photoRowItemIndex = useMemo(() => { const map: number[] = [] items.forEach((it, idx) => { if (it.type === 'row') map.push(idx) }) return map }, [items]) // After the preview closes, scroll the photo it was originally // opened on back into view. The store's closePreview already // restored activePhotoId to that origin id; we just need to make // sure it's actually visible in the scroll viewport. Guarded by // prevViewModeRef so this only fires on the actual preview→grid // transition, not every time photoRows recomputes. useEffect(() => { const prev = prevViewModeRef.current prevViewModeRef.current = viewMode if (prev !== 'preview' || viewMode !== 'grid') return if (!activePhotoId) return let rowIdx = -1 for (let r = 0; r < photoRows.length; r++) { if (photoRows[r].cells.some((c) => c.photo.id === activePhotoId)) { rowIdx = r break } } if (rowIdx < 0) return const itemIdx = photoRowItemIndex[rowIdx] const scrollEl = parentRef.current if (itemIdx === undefined || !scrollEl) return let rowTop = 0 for (let i = 0; i < itemIdx; i++) rowTop += items[i].height const rowHeight = items[itemIdx].height const viewTop = scrollEl.scrollTop const viewBottom = viewTop + scrollEl.clientHeight if (rowTop >= viewTop && rowTop + rowHeight <= viewBottom) return // Center the row in the viewport — the user is returning to a // specific photo, not resuming a scroll, so context above and // below is what they want. const target = Math.max( 0, rowTop - scrollEl.clientHeight / 2 + rowHeight / 2 ) scrollEl.scrollTo({ top: target }) }, [viewMode, activePhotoId, photoRows, photoRowItemIndex, items]) // Flat visible-order id sequence — exactly the order the user reads // off the grid (top-to-bottom, left-to-right within each row). // Includes duplicates from tag-grouping; landing on the same photo's // "second" appearance in the next tag bucket is the right behavior // in tag mode. const visibleSequence = useMemo(() => { const ids: string[] = [] for (const row of photoRows) { for (const cell of row.cells) { ids.push(cell.photo.id) } } return ids }, [photoRows]) // Publish to the photo store so PreviewView's arrow nav and filmstrip // can walk the same order even when opened from a non-click path // (e.g. the global Space hotkey). useEffect(() => { setVisiblePhotoIds(visibleSequence) visibleSequenceRef.current = visibleSequence }, [visibleSequence, setVisiblePhotoIds]) // Locate the active photo in the visual grid. Returns the FIRST // (rowIndex, colIndex) where its id appears, since a tag-grouped view // can repeat a photo across groups. Returns null when there's no // active photo or it isn't currently rendered. const findActiveCell = (): { row: number; col: number } | null => { if (!activePhotoId) return null for (let r = 0; r < photoRows.length; r++) { const row = photoRows[r] const c = row.cells.findIndex((cell) => cell.photo.id === activePhotoId) if (c >= 0) return { row: r, col: c } } return null } // Handle keyboard shortcuts for photo navigation. Operates on the // grouped grid the user sees, so a half-full last row of a group // doesn't make ArrowDown skip into the wrong place. // // Inert in preview mode — PreviewView mounts its own arrow handlers, // and a window-level grid handler firing alongside them used to race // against PreviewView's setActivePhoto, landing the user on the wrong // photo. The grid handler stays attached so it can re-engage the // moment the user closes preview. useEffect(() => { if (viewMode !== 'grid') return // The duplicates section mounts its own grouped view (DuplicatesView) // with its own keyboard nav — bail out so we don't double-handle // arrow keys and try to navigate against a photoRows snapshot that // doesn't match what the user actually sees on screen. if (currentSection === 'duplicates') return const handleKeyDown = (e: KeyboardEvent) => { if (photoRows.length === 0) return const target = e.target as HTMLElement | null if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) { return } const move = (dr: number, dc: number) => { const current = findActiveCell() ?? { row: 0, col: -1 } let nextRow = current.row let nextCol = current.col + dc if (dc !== 0) { // Wrap left/right across row boundaries. while (nextCol < 0 && nextRow > 0) { nextRow -= 1 nextCol = photoRows[nextRow].cells.length - 1 } while ( nextRow < photoRows.length && nextCol >= photoRows[nextRow].cells.length ) { if (nextRow === photoRows.length - 1) { nextCol = photoRows[nextRow].cells.length - 1 break } nextRow += 1 nextCol = 0 } if (nextCol < 0) nextCol = 0 } if (dr !== 0) { nextRow += dr if (nextRow < 0) nextRow = 0 if (nextRow >= photoRows.length) nextRow = photoRows.length - 1 // Clamp the column to the destination row's actual width so // moving down into a half-full row lands on its last cell // instead of nothing. const rowLen = photoRows[nextRow].cells.length if (nextCol >= rowLen) nextCol = rowLen - 1 if (nextCol < 0) nextCol = 0 } const dest = photoRows[nextRow]?.cells[nextCol] if (!dest) return if (e.shiftKey) { selectRange(dest.photo.id) } else { selectPhoto(dest.photo.id) } // Bring the destination row into view if it's off-screen, leaving // a "peek" margin so the next row above/below stays partly visible // — cues the user that there's more content in the scroll direction. // In-viewport moves are a no-op, so same-row arrow presses don't // jitter the scroll position. const itemIdx = photoRowItemIndex[nextRow] const scrollEl = parentRef.current if (itemIdx !== undefined && scrollEl) { // Sum item heights up to itemIdx to get this row's offset in the // virtualizer's coordinate space. Cheap enough at O(items) and // avoids reaching into virtualizer.measurementsCache internals. let rowTop = 0 for (let i = 0; i < itemIdx; i++) rowTop += items[i].height const rowHeight = items[itemIdx].height const peek = Math.round(cellSize * 0.35) const viewTop = scrollEl.scrollTop const viewBottom = viewTop + scrollEl.clientHeight if (rowTop - peek < viewTop) { // Destination is above (or flush with) the viewport top. Leave // `peek` pixels of the previous row visible above it. scrollEl.scrollTo({ top: Math.max(0, rowTop - peek) }) } else if (rowTop + rowHeight + peek > viewBottom) { // Destination is below the viewport bottom. Leave `peek` pixels // of the next row visible below it. scrollEl.scrollTo({ top: rowTop + rowHeight + peek - scrollEl.clientHeight, }) } } } switch (e.key) { case 'ArrowUp': e.preventDefault() move(-1, 0) break case 'ArrowDown': e.preventDefault() move(1, 0) break case 'ArrowLeft': e.preventDefault() move(0, -1) break case 'ArrowRight': e.preventDefault() move(0, 1) break case 'a': if (e.ctrlKey || e.metaKey) { e.preventDefault() photos.forEach((photo) => { if (!selectedPhotos.includes(photo.id)) { togglePhotoSelection(photo.id) } }) } break case 'Escape': e.preventDefault() clearSelection() break } } window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) // eslint-disable-next-line react-hooks/exhaustive-deps }, [viewMode, photoRows, photos, selectedPhotos, activePhotoId, photoRowItemIndex, items, cellSize, currentSection]) if (isLoading) { return (
Loading photos...
) } if (photos.length === 0) { return } return (
{/* Sticky group-header overlay. Lives outside the virtualizer's * positioned children so it isn't affected by translateY transforms. * Updates as the user scrolls past month boundaries. */} {stickyLabel && (

{stickyLabel}

)} {/* Floating scrollbar date chip. Sits just inside the right edge, * vertically tracking the scrollbar thumb's position, and fades * out 700ms after the user stops scrolling. Read-only/decorative, * so pointer-events-none — it must never steal scrollbar clicks. */} {isDateSort && scrollDateLabel && (
{scrollDateLabel}
)}
{/* Width sentinel — a 1px-tall normal-flow div that takes the * full width of the inner virtualizer wrapper, which is the * exact width rows render at. clientWidth on this is what we * base the column count on, sidestepping any padding / * scrollbar mismatch the parentRef-based measurement is * vulnerable to. ResizeObserver doesn't reliably fire on * zero-area absolute elements, so 1px tall + relative flow. */}
) } /** * Empty-state rendered when the current filter/section returns zero photos. * Distinguishes "library-empty" from "filters-too-strict": the former hints * at upload, the latter offers a one-click Clear all. */ function EmptyTimelineState() { const filterState = useFilterStore() const clearAll = useFilterStore((s) => s.clearAll) const currentSection = useFilterStore((s) => s.currentSection) const filtersActive = hasActiveFilters(filterState) const { title, hint } = sectionEmptyCopy(currentSection, filtersActive) return (
{title}

{hint}

{filtersActive && ( )}
) } function sectionEmptyCopy( section: string, filtersActive: boolean, ): { title: string; hint: string } { if (filtersActive) { return { title: 'No photos match', hint: 'Your filters are excluding everything in this section. Clear them to see the full library.', } } switch (section) { case 'discarded': return { title: 'Discard pile is empty', hint: 'Photos you discard (X) land here until you empty them permanently.', } case 'rated': return { title: 'No rated photos yet', hint: 'Rate photos 1–5 with the number keys and they will appear here.', } case 'tags': return { title: 'No tagged photos', hint: 'Add tags from a photo\u2019s metadata panel or via the bulk tag editor.', } default: return { title: 'Library is empty', hint: 'Add photos via the upload button, or point the PHOTO_DIRS volume at a folder with existing images.', } } }