import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useVirtualizer } from "@tanstack/react-virtual"; import { ImageRecord, tileSizeForZoom, useGalleryStore } from "../store"; import { ContextMenu, ImageTile } from "./Gallery"; import { Tooltip } from "./Tooltip"; const GAP = 6; const HEADER_HEIGHT = 52; const SCRUBBER_WIDTH = 48; const MONTH_SHORT = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"] as const; interface TimelineGroup { key: string; label: string; images: ImageRecord[]; } // One virtualized row: either a month header or a row of up to `cols` tiles. type TimelineRow = | { type: "header"; group: TimelineGroup } | { type: "tiles"; images: ImageRecord[] }; interface ScrubberMonth { monthNum: number; label: string; groupIndex: number; } interface ScrubberYear { year: string; firstGroupIndex: number; months: ScrubberMonth[]; } function buildLabel(key: string): string { if (key === "unknown") return "Unknown Date"; const [yearStr, monthStr] = key.split("-"); const year = Number(yearStr); const month = Number(monthStr); if (!isFinite(year) || !isFinite(month) || month < 1 || month > 12) return "Unknown Date"; const date = new Date(year, month - 1); if (isNaN(date.getTime())) return "Unknown Date"; return date.toLocaleDateString(undefined, { month: "long", year: "numeric" }); } function groupImages(images: ImageRecord[]): TimelineGroup[] { const map = new Map(); for (const img of images) { const ds = img.taken_at ?? img.modified_at; const key = ds ? ds.substring(0, 7) : "unknown"; let bucket = map.get(key); if (bucket === undefined) { bucket = []; map.set(key, bucket); } bucket.push(img); } return Array.from(map.entries()) .sort(([a], [b]) => { if (a === "unknown") return 1; if (b === "unknown") return -1; return a < b ? -1 : a > b ? 1 : 0; }) .map(([key, imgs]) => ({ key, label: buildLabel(key), images: imgs })); } function buildScrubberYears(groups: TimelineGroup[]): ScrubberYear[] { const byYear = new Map(); for (let i = 0; i < groups.length; i++) { const group = groups[i]; if (group.key === "unknown") continue; const year = group.key.substring(0, 4); const monthNum = Number(group.key.substring(5, 7)); if (!byYear.has(year)) { byYear.set(year, { year, firstGroupIndex: i, months: [] }); } byYear.get(year)!.months.push({ monthNum, label: MONTH_SHORT[monthNum - 1] ?? "", groupIndex: i, }); } // Keep insertion order so the scrubber runs the same direction as the scrolled // content (oldest at top with taken_asc), keeping the active highlight aligned. return Array.from(byYear.values()); } 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], ); // Flatten the month groups into a single list of fixed-height rows — one // header row per group, then one tile-row per `cols` images. This lets the // virtualizer render only the on-screen rows, exactly like the Gallery. // Previously each *group* was one virtual item that rendered ALL of its // images, so scrolling into a busy month mounted thousands of tiles at once. const { rows, rowToGroupIndex, groupFirstRow } = useMemo(() => { const rows: TimelineRow[] = []; const rowToGroupIndex: number[] = []; const groupFirstRow: number[] = []; groups.forEach((group, groupIndex) => { groupFirstRow[groupIndex] = rows.length; rows.push({ type: "header", group }); rowToGroupIndex.push(groupIndex); for (let i = 0; i < group.images.length; i += cols) { rows.push({ type: "tiles", images: group.images.slice(i, i + cols) }); rowToGroupIndex.push(groupIndex); } }); return { rows, rowToGroupIndex, groupFirstRow }; }, [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]); useEffect(() => { const close = (e: PointerEvent) => { if ((e.target as HTMLElement | null)?.closest("[data-gallery-context-menu]")) return; setContextMenu(null); }; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setContextMenu(null); }; window.addEventListener("pointerdown", close); window.addEventListener("keydown", onKey); return () => { window.removeEventListener("pointerdown", close); window.removeEventListener("keydown", onKey); }; }, []); 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 ? (

Loading timeline

Fetching results

) : images.length === 0 ? (

{imageLoadError ? "Could not load timeline" : "No media found"}

{imageLoadError ?? "Add a folder to see your timeline"}

) : (
{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}
); } interface ScrubberYearBlockProps { yearEntry: ScrubberYear; activeGroupIndex: number; onScrollTo: (index: number) => void; } function ScrubberYearBlock({ yearEntry, activeGroupIndex, onScrollTo }: ScrubberYearBlockProps) { const isYearActive = yearEntry.months.some((m) => m.groupIndex === activeGroupIndex); return (
{Array.from({ length: 12 }, (_, i) => { const monthNum = i + 1; const monthEntry = yearEntry.months.find((m) => m.monthNum === monthNum); const isActive = monthEntry !== undefined && monthEntry.groupIndex === activeGroupIndex; if (!monthEntry) { return ; } return (
); }