import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { useVirtualizer } from '@tanstack/react-virtual' import { ImageRecord, tileSizeForZoom, useGalleryStore } from '../store' import { ImageTile } from './gallery/ImageTile' import { ImageContextMenu } from './ImageContextMenu' import { ScrubberYearBlock } from './timeline/ScrubberYearBlock' import { TimelineEmptyState, TimelineLoadingState } from './timeline/TimelineEmptyState' import { buildScrubberYears, buildTimelineRows, groupImages } from './timeline/timelineModel' const GAP = 6 const HEADER_HEIGHT = 52 const SCRUBBER_WIDTH = 48 export function Timeline() { const images = useGalleryStore((s) => s.images) const loadMoreImages = useGalleryStore((s) => s.loadMoreImages) const openImage = useGalleryStore((s) => s.openImage) const totalImages = useGalleryStore((s) => s.totalImages) const loadingImages = useGalleryStore((s) => s.loadingImages) const imageLoadError = useGalleryStore((s) => s.imageLoadError) const zoomPreset = useGalleryStore((s) => s.zoomPreset) const parentRef = useRef(null) const [containerWidth, setContainerWidth] = useState(0) const [activeGroupIndex, setActiveGroupIndex] = useState(0) const [contextMenu, setContextMenu] = useState<{ x: number y: number image: ImageRecord } | null>(null) // parentRef is the scroll container. Its clientWidth already excludes the // scrubber because they are flex siblings, so no further adjustment is needed. useLayoutEffect(() => { const el = parentRef.current if (!el) return setContainerWidth(el.clientWidth) const ro = new ResizeObserver((entries) => { setContainerWidth(entries[0].contentRect.width) }) ro.observe(el) return () => ro.disconnect() }, []) const tileSize = tileSizeForZoom(zoomPreset) const groups = useMemo(() => groupImages(images), [images]) const scrubberYears = useMemo(() => buildScrubberYears(groups), [groups]) // Show as soon as there's more than one month to jump between — not gated on // a full year. With taken_asc sort the loaded set is oldest-first, so this // reflects whatever range is currently loaded. const showScrubber = groups.length > 1 const cols = useMemo( () => Math.max(1, Math.floor((containerWidth - GAP) / (tileSize + GAP))), [containerWidth, tileSize] ) const { rows, rowToGroupIndex, groupFirstRow } = useMemo( () => buildTimelineRows(groups, cols), [groups, cols] ) const estimateSize = useCallback( (index: number): number => (rows[index]?.type === 'header' ? HEADER_HEIGHT : tileSize + GAP), [rows, tileSize] ) const virtualizer = useVirtualizer({ count: rows.length, getScrollElement: () => parentRef.current, estimateSize, overscan: 6, paddingStart: GAP, }) // Refs so the scroll handler can read the latest mappings without re-binding. const rowToGroupIndexRef = useRef(rowToGroupIndex) rowToGroupIndexRef.current = rowToGroupIndex const groupFirstRowRef = useRef(groupFirstRow) groupFirstRowRef.current = groupFirstRow useEffect(() => { virtualizer.measure() }, [cols, virtualizer]) const handleScroll = useCallback(() => { const el = parentRef.current if (!el) return // Active month = the group owning the first row still visible at the top. const scrollTop = el.scrollTop const items = virtualizer.getVirtualItems() let activeRow = items.length > 0 ? items[0].index : 0 for (const item of items) { if (item.start + item.size > scrollTop + HEADER_HEIGHT / 2) { activeRow = item.index break } } setActiveGroupIndex(rowToGroupIndexRef.current[activeRow] ?? 0) if (scrollTop < 24) return const nearBottom = scrollTop + el.clientHeight >= el.scrollHeight - 600 if (nearBottom && !loadingImages && images.length < totalImages) { void loadMoreImages() } }, [virtualizer, images.length, loadMoreImages, loadingImages, totalImages]) useEffect(() => { const el = parentRef.current if (!el) return el.addEventListener('scroll', handleScroll, { passive: true }) return () => el.removeEventListener('scroll', handleScroll) }, [handleScroll]) const scrollToGroup = useCallback( (groupIndex: number) => { const row = groupFirstRowRef.current[groupIndex] ?? 0 virtualizer.scrollToIndex(row, { align: 'start' }) }, [virtualizer] ) return ( // Outer flex-row: fills remaining height in
's flex-col, then // splits horizontally between the scroll area and the scrubber.
{/* Scroll container — flex-1 takes all width except the scrubber */}
{images.length === 0 && loadingImages ? ( ) : images.length === 0 ? ( ) : (
{virtualizer.getVirtualItems().map((virtualItem) => { const row = rows[virtualItem.index] if (!row) return null return (
{row.type === 'header' ? (
{row.group.label} {row.group.images.length}
) : (
{row.images.map((image) => ( openImage(image)} onContextMenu={(event) => { event.preventDefault() setContextMenu({ x: event.clientX, y: event.clientY, image }) }} /> ))}
)}
) })}
)} {images.length > 0 && loadingImages ? (
) : null}
{/* Scrubber — flex sibling so it stays visible while the left panel scrolls */} {showScrubber ? (
{scrubberYears.map((yearEntry) => ( ))}
) : null} {contextMenu ? ( setContextMenu(null)} /> ) : null}
) }