diff --git a/frontend/src/components/timeline/Timeline.tsx b/frontend/src/components/timeline/Timeline.tsx index 39eb1a5..9c3d836 100644 --- a/frontend/src/components/timeline/Timeline.tsx +++ b/frontend/src/components/timeline/Timeline.tsx @@ -35,6 +35,7 @@ type TimelineItem = function buildItems( photos: Photo[], columns: number, + rowHeight: number, sortBy: string, groupBy: 'date' | 'tag' ): TimelineItem[] { @@ -50,7 +51,7 @@ function buildItems( type: 'row', key: `${groupKey}::row::${i}`, cells: slice, - height: THUMBNAIL_SIZE + GAP, + height: rowHeight + GAP, }) } } @@ -180,13 +181,27 @@ export function Timeline() { const groupBy = useFilterStore((s) => s.groupBy) const viewMode = usePhotoStore((s) => s.viewMode) - // Calculate number of columns based on container width. - const columns = useMemo(() => { - if (containerWidth === 0) return 4 - return Math.max( + // 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 } + } + const available = containerWidth - PADDING * 2 + const cols = Math.max( 1, - Math.floor((containerWidth - PADDING * 2) / (THUMBNAIL_SIZE + GAP)) + Math.floor((available + GAP) / (THUMBNAIL_SIZE + GAP)) ) + const cell = Math.floor((available - (cols - 1) * GAP) / cols) + return { columns: cols, cellSize: cell } }, [containerWidth]) // Shared photos query — both Timeline and PreviewView use the same hook so @@ -203,8 +218,8 @@ export function Timeline() { // photos. Date headers appear when sorted by a date field; tag headers // appear when groupBy === 'tag' (overrides date grouping). const items = useMemo( - () => buildItems(photos, columns, sortBy, groupBy), - [photos, columns, sortBy, groupBy] + () => buildItems(photos, columns, cellSize, sortBy, groupBy), + [photos, columns, cellSize, sortBy, groupBy] ) // Pre-computed offset of every header in the virtualizer's coordinate @@ -225,7 +240,7 @@ export function Timeline() { const virtualizer = useVirtualizer({ count: items.length, getScrollElement: () => parentRef.current, - estimateSize: (index) => items[index]?.height ?? THUMBNAIL_SIZE, + estimateSize: (index) => items[index]?.height ?? cellSize, overscan: 5, }) @@ -264,16 +279,21 @@ export function Timeline() { return current }, [headerOffsets, scrollTop]) - // Measure container width on mount and resize. + // Measure container width on mount, window resize, and any layout + // change driven by the sidebar collapse / right panel toggle. Plain + // window.resize wouldn't catch those — ResizeObserver does. useEffect(() => { - const measureWidth = () => { - if (parentRef.current) { - setContainerWidth(parentRef.current.clientWidth) - } + const el = parentRef.current + if (!el) return + const measure = () => setContainerWidth(el.clientWidth) + measure() + const ro = new ResizeObserver(measure) + ro.observe(el) + window.addEventListener('resize', measure) + return () => { + ro.disconnect() + window.removeEventListener('resize', measure) } - measureWidth() - window.addEventListener('resize', measureWidth) - return () => window.removeEventListener('resize', measureWidth) }, []) // Photo rows in visual order — drops the header items so navigation @@ -508,7 +528,7 @@ export function Timeline() { ( - '/photos', - { + // + // The Timeline and grid views virtualize, so we load every match + // up-front rather than paginating in the UI. Backend caps per_page + // at 500, so for libraries / folders with more matches we walk + // pages until we have everything. Capped at 200 pages (= 100k + // photos) as a sanity bound. + const PER_PAGE = 500 + const MAX_PAGES = 200 + const all: Photo[] = [] + for (let page = 1; page <= MAX_PAGES; page++) { + const response = await api.get<{ + photos: Photo[] + total: number + pages: number + }>('/photos', { params: { - page: 1, - per_page: 500, + page, + per_page: PER_PAGE, ...filterParams, }, - } - ) - return response.data.photos || [] + }) + const photos = response.data.photos || [] + all.push(...photos) + const totalPages = response.data.pages ?? 1 + if (page >= totalPages || photos.length < PER_PAGE) break + } + return all }, staleTime: 30_000, })