import type { GridAsset, TimeBucket } from './shared'; import type { Selection } from './useSelection'; import { useEffect, useMemo, useRef } from 'react'; import { AssetTile } from './AssetTile'; import { estimateBucketHeight, formatBucket, justifyRows } from './shared'; import { useContainerWidth } from './useContainerWidth'; // The month-bucketed timeline. // // Every month in the library is rendered from the moment the screen opens, but only as a correctly-sized empty // box: the bucket index gives a count, and `estimateBucketHeight` turns a count into a height. That is what // makes the scrollbar honest before anything is downloaded — the page is already its true length, so dragging // to the bottom lands on the oldest month instead of on a spinner that then pushes the content away. // // An IntersectionObserver with a tall rootMargin turns a box into a real request just before it reaches the // viewport. Loaded months are never unloaded; React Query holds them and scrolling back up is instant. const ROW_HEIGHT = 210; const GAP = 4; type TimelineGridProps = { buckets: TimeBucket[]; assetsByBucket: Record; onBucketVisible: (bucket: string) => void; token: string | null; selection: Selection; /** The scroll container the observer should watch. Panels scroll their own body, not the window. */ scrollRef: React.RefObject; }; export const TimelineGrid = ({ buckets, assetsByBucket, onBucketVisible, token, selection, scrollRef, }: TimelineGridProps) => { const [ref, width] = useContainerWidth(); const nodes = useRef(new Map()); const notify = useRef(onBucketVisible); notify.current = onBucketVisible; useEffect(() => { if (!width) return; const observer = new IntersectionObserver( (entries) => { for (const entry of entries) { const bucket = entry.target.getAttribute('data-bucket'); if (entry.isIntersecting && bucket) notify.current(bucket); } }, // Two viewports of lead time in each direction: enough that a fast scroll rarely outruns the fetch, // small enough that opening the screen doesn't request the entire library. { root: scrollRef.current ?? null, rootMargin: '200% 0px' }, ); for (const node of nodes.current.values()) observer.observe(node); return () => observer.disconnect(); }, [width, buckets, scrollRef]); return (
{buckets.map((bucket) => ( { if (node) nodes.current.set(bucket.timeBucket, node); else nodes.current.delete(bucket.timeBucket); }} /> ))}
); }; type BucketSectionProps = { bucket: TimeBucket; assets: GridAsset[] | undefined; width: number; token: string | null; selection: Selection; register: (node: HTMLElement | null) => void; }; const BucketSection = ({ bucket, assets, width, token, selection, register }: BucketSectionProps) => { const rows = useMemo( () => assets && width > 0 ? justifyRows({ items: assets, ratioOf: (a) => a.ratio, containerWidth: width, targetHeight: ROW_HEIGHT, gap: GAP, }) : [], [assets, width], ); // Until the month arrives, hold its place with the height it will occupy. The estimate uses the same row // height and gap the real layout will, so the jump when it loads is a few pixels rather than a page. const placeholder = !assets && width > 0 ? estimateBucketHeight({ count: bucket.count, containerWidth: width, rowHeight: ROW_HEIGHT, gap: GAP }) : 0; return (

{formatBucket(bucket.timeBucket)} {bucket.count}

{assets ? (
{rows.map((row, index) => (
{row.tiles.map(({ item, width: w, height: h }) => ( selection.toggle(id, ev)} /> ))}
))}
) : (
)}
); };