import { useRef, useEffect, useMemo, useState } from 'react' import { useVirtualizer } from '@tanstack/react-virtual' import { format, parseISO } from 'date-fns' import { usePhotoStore } from '../../store/photoStore' import { useFilterStore } from '../../store/filterStore' import { PhotoThumbnail } from './PhotoThumbnail' import { usePhotosQuery } from '../../hooks/usePhotosQuery' import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery' 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 groups by month label when sorted by a date field. For non-temporal * sorts (filename / file_size / rating) we return a single un-headered group. */ function buildItems( photos: Photo[], columns: number, sortBy: string ): TimelineItem[] { if (photos.length === 0) return [] const isDateSort = sortBy === 'taken_at' || sortBy === 'added_at' 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: THUMBNAIL_SIZE + GAP, }) } } if (!isDateSort) { // 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 as any).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) const [containerWidth, setContainerWidth] = useState(0) const { selectedPhotos, lastSelectedIndex, rangeStartIndex, selectPhoto, togglePhotoSelection, clearSelection, openPreview, } = usePhotoStore() const sortBy = useFilterStore((s) => s.sortBy) // Calculate number of columns based on container width. const columns = useMemo(() => { if (containerWidth === 0) return 4 return Math.max( 1, Math.floor((containerWidth - PADDING * 2) / (THUMBNAIL_SIZE + GAP)) ) }, [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() // Membership in the active heap (for the basket affordance). Subscribed // once at this level so we don't have hundreds of thumbnails each // subscribing to the same query. const { memberIds: activeHeapMembers } = useActiveHeapMembers() // Build the flat virtualizer items: a mix of date-group headers and rows // of photos. Headers only appear when sorted by a date field. const items = useMemo( () => buildItems(photos, columns, sortBy), [photos, columns, sortBy] ) // 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]) // Range-selection helper. Operates on the global photos array, not on // virtualizer items. const selectRange = (endIndex: number) => { const startIndex = rangeStartIndex ?? lastSelectedIndex ?? 0 const minIndex = Math.min(startIndex, endIndex) const maxIndex = Math.max(startIndex, endIndex) for (let i = minIndex; i <= maxIndex; i++) { if (i < photos.length && !selectedPhotos.includes(photos[i].id)) { togglePhotoSelection(photos[i].id, i) } } } // Virtual scrolling setup with per-item heights. const virtualizer = useVirtualizer({ count: items.length, getScrollElement: () => parentRef.current, estimateSize: (index) => items[index]?.height ?? THUMBNAIL_SIZE, overscan: 5, }) // Re-measure when items change (column count, group structure). useEffect(() => { virtualizer.measure() }, [items, virtualizer]) // Track scroll position so we can show the current group label as a // pinned overlay at the top of the scroll container. The virtualizer's // items use transform translateY (so CSS position: sticky doesn't work // on the inline headers); the overlay sidesteps that by living outside // the virtualizer's positioned children. const [scrollTop, setScrollTop] = useState(0) useEffect(() => { const el = parentRef.current if (!el) return const onScroll = () => setScrollTop(el.scrollTop) el.addEventListener('scroll', onScroll, { passive: true }) return () => el.removeEventListener('scroll', onScroll) }, []) // Find the latest header whose start <= scrollTop. That's the label of // the group containing whatever is currently at the top of the viewport. const stickyLabel = useMemo(() => { if (headerOffsets.length === 0) return null let current: string | null = null for (const h of headerOffsets) { if (h.offset <= scrollTop) current = h.label else break } return current }, [headerOffsets, scrollTop]) // Measure container width on mount and resize. useEffect(() => { const measureWidth = () => { if (parentRef.current) { setContainerWidth(parentRef.current.clientWidth) } } measureWidth() window.addEventListener('resize', measureWidth) return () => window.removeEventListener('resize', measureWidth) }, []) // Handle keyboard shortcuts for photo navigation. Operates on the flat // photos array, so it ignores grouping. useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (photos.length === 0) return const target = e.target as HTMLElement | null if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) { return } const currentIndex = lastSelectedIndex ?? -1 switch (e.key) { case 'ArrowUp': e.preventDefault() if (currentIndex > columns - 1) { const newIndex = currentIndex - columns if (e.shiftKey) selectRange(newIndex) else selectPhoto(photos[newIndex].id, newIndex) } break case 'ArrowDown': e.preventDefault() if (currentIndex < photos.length - columns) { const newIndex = Math.min(currentIndex + columns, photos.length - 1) if (e.shiftKey) selectRange(newIndex) else selectPhoto(photos[newIndex].id, newIndex) } break case 'ArrowLeft': e.preventDefault() if (currentIndex > 0) { const newIndex = currentIndex - 1 if (e.shiftKey) selectRange(newIndex) else selectPhoto(photos[newIndex].id, newIndex) } break case 'ArrowRight': e.preventDefault() if (currentIndex < photos.length - 1) { const newIndex = currentIndex + 1 if (e.shiftKey) selectRange(newIndex) else selectPhoto(photos[newIndex].id, newIndex) } break case 'a': if (e.ctrlKey || e.metaKey) { e.preventDefault() photos.forEach((photo, index) => { if (!selectedPhotos.includes(photo.id)) { togglePhotoSelection(photo.id, index) } }) } break case 'Escape': e.preventDefault() clearSelection() break } } window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) // eslint-disable-next-line react-hooks/exhaustive-deps }, [photos, selectedPhotos, lastSelectedIndex, columns]) if (isLoading) { return (
Loading photos...
) } if (photos.length === 0) { return (
No photos found. Add a source folder to get started.
) } 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}

)}
{virtualizer.getVirtualItems().map((virtualItem) => { const item = items[virtualItem.index] if (!item) return null if (item.type === 'header') { return (

{item.label}

) } // row return (
{item.cells.map(({ photo, globalIndex }) => ( { if (e.shiftKey && lastSelectedIndex !== null) { selectRange(globalIndex) } else if (e.ctrlKey || e.metaKey) { togglePhotoSelection(photo.id, globalIndex) } else { selectPhoto(photo.id, globalIndex) } }} onDoubleClick={() => openPreview(photo.id)} /> ))}
) })}
) }