From 45f164997932d4db5ceae2304ccb85f309b5e1ef Mon Sep 17 00:00:00 2001 From: root Date: Wed, 15 Apr 2026 15:41:40 +0200 Subject: [PATCH] ui: uniform 160px thumbnail cells across all grid views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Timeline, Memories, and Duplicates now share a single fixed cell size (THUMBNAIL_SIZE=160) with no 1fr stretching — cells stay exactly 160px regardless of sidebar state, at the cost of a small right-edge strip when the container width isn't a multiple of (160+gap). Width is measured on the scroll container itself with padding subtracted so sidebar expand/collapse reliably reflows the grid. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/duplicates/DuplicatesView.tsx | 8 +- .../src/components/memories/MemoriesView.tsx | 63 +++++++------ frontend/src/components/timeline/Timeline.tsx | 90 +++++-------------- 3 files changed, 66 insertions(+), 95 deletions(-) diff --git a/frontend/src/components/duplicates/DuplicatesView.tsx b/frontend/src/components/duplicates/DuplicatesView.tsx index 2a9ac8d..76455bc 100644 --- a/frontend/src/components/duplicates/DuplicatesView.tsx +++ b/frontend/src/components/duplicates/DuplicatesView.tsx @@ -87,7 +87,7 @@ export function DuplicatesView() { // Track the rendered column count of the duplicates grid so ↑/↓ can // skip a row instead of jumping a single cell. The grid uses - // `repeat(auto-fill, minmax(180px, 1fr))` so columns = floor(width/180). + // `repeat(auto-fill, minmax(160px, 1fr))` so columns = floor(width/160). // We measure the FIRST section's grid container — every section uses // the same auto-fill rule so any one is representative. const [columns, setColumns] = useState(4) @@ -100,7 +100,7 @@ export function DuplicatesView() { sampleObserverRef.current = null if (!el) return const measure = () => { - const cols = Math.max(1, Math.floor(el.clientWidth / 180)) + const cols = Math.max(1, Math.floor(el.clientWidth / 160)) setColumns(cols) } measure() @@ -386,7 +386,7 @@ const DuplicateGroupSection = memo(function DuplicateGroupSection({ className="grid gap-1 p-2" style={{ gridTemplateColumns: - 'repeat(auto-fill, minmax(180px, 1fr))', + 'repeat(auto-fill, 160px)', }} > {group.members.map((member) => { @@ -399,7 +399,7 @@ const DuplicateGroupSection = memo(function DuplicateGroupSection({ > (null) - const gridRefs = useRef<(HTMLDivElement | null)[]>([]) - const [columns, setColumns] = useState(1) - useEffect(() => { - const el = gridRefs.current.find((g) => g) + // Measure the scroll container directly and subtract its padding to + // get the real grid width. A ResizeObserver on the container itself + // always fires on width changes — sentinel elements can miss changes + // in some edge cases (zero-height, detached during loading states). + const [containerWidth, setContainerWidth] = useState(0) + const roRef = useRef(null) + const scrollRef = useCallback((el: HTMLDivElement | null) => { + roRef.current?.disconnect() + roRef.current = null + scrollElRef.current = el if (!el) return const measure = () => { - const cols = window - .getComputedStyle(el) - .gridTemplateColumns.split(' ') - .filter(Boolean).length - if (cols > 0) setColumns(cols) + const style = window.getComputedStyle(el) + const padX = + parseFloat(style.paddingLeft) + parseFloat(style.paddingRight) + setContainerWidth(Math.max(0, el.clientWidth - padX)) } measure() const ro = new ResizeObserver(measure) ro.observe(el) - return () => ro.disconnect() - }, [memories.length]) + roRef.current = ro + }, []) + const scrollElRef = useRef(null) + useEffect(() => () => roRef.current?.disconnect(), []) + // Fixed-size cells — always exactly THUMBNAIL_SIZE px, regardless of + // container width. See Timeline's column-math comment for the + // tradeoff (may leave a small gap on the right edge). + const { columns, cellSize } = useMemo(() => { + if (containerWidth === 0) return { columns: 4, cellSize: THUMBNAIL_SIZE } + const cols = Math.max( + 1, + Math.floor((containerWidth + GAP) / (THUMBNAIL_SIZE + GAP)), + ) + return { columns: cols, cellSize: THUMBNAIL_SIZE } + }, [containerWidth]) // Break each year's photos into rows of `columns` ids — matches the // shape useGridKeyNav expects. Arrow-nav crosses year boundaries @@ -110,13 +126,13 @@ export function MemoriesView() { const scrollRowIntoView = useCallback((rowIdx: number) => { const firstId = gridNavRowsRef.current[rowIdx]?.cells[0]?.id if (!firstId) return - const scrollEl = scrollRef.current + const scrollEl = scrollElRef.current if (!scrollEl) return const cellEl = scrollEl.querySelector( `[data-photo-id="${firstId}"]`, ) if (!cellEl) return - const peek = Math.round(CELL_SIZE * 0.35) + const peek = Math.round(cellSize * 0.35) const cellTop = cellEl.offsetTop const cellBottom = cellTop + cellEl.offsetHeight const viewTop = scrollEl.scrollTop @@ -172,7 +188,7 @@ export function MemoriesView() {
- {memories.map((group, groupIdx) => ( + {memories.map((group) => (

@@ -183,13 +199,10 @@ export function MemoriesView() {

{ - gridRefs.current[groupIdx] = el - }} className="grid" style={{ - gridTemplateColumns: `repeat(auto-fill, minmax(${CELL_SIZE}px, 1fr))`, - gridAutoRows: `${CELL_SIZE}px`, + gridTemplateColumns: `repeat(${columns}, ${cellSize}px)`, + gridAutoRows: `${cellSize}px`, gap: `${GAP}px`, }} > @@ -197,7 +210,7 @@ export function MemoriesView() {
(null) + const parentRef = useRef(null) // Sentinel placed inside the inner virtualizer wrapper at the exact // position rows will render. We measure THIS instead of parentRef, // because parentRef has padding and we'd otherwise have to subtract @@ -150,33 +150,20 @@ export function Timeline() { const searchQuery = useFilterStore((s) => s.q) const viewMode = usePhotoStore((s) => s.viewMode) - // 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. + // Number of columns that fit at the FIXED THUMBNAIL_SIZE. Cells no + // longer stretch to absorb the remaining width — we prefer a stable + // cell size across sidebar-open / sidebar-closed states over a + // perfectly-flush right edge. A small gap may remain on the right + // when containerWidth isn't an integer multiple of (T+G). const { columns, cellSize } = useMemo(() => { if (containerWidth === 0) { return { columns: 4, cellSize: THUMBNAIL_SIZE } } - // containerWidth here is the sentinel's actual rendered width — no - // padding subtraction needed, the sentinel already lives inside the - // padded scroll container. - const available = containerWidth const cols = Math.max( 1, - Math.floor((available + GAP) / (THUMBNAIL_SIZE + GAP)) + Math.floor((containerWidth + GAP) / (THUMBNAIL_SIZE + GAP)) ) - // Exact float — no floor. cellSize × cols + (cols-1) × gap == available - // by construction, so the row fills edge-to-edge without any - // sub-pixel rounding gap. - const cell = (available - (cols - 1) * GAP) / cols - return { columns: cols, cellSize: cell } + return { columns: cols, cellSize: THUMBNAIL_SIZE } }, [containerWidth]) // Shared photos query — both Timeline and PreviewView use the same hook so @@ -438,41 +425,29 @@ export function Timeline() { return current }, [headerOffsets, scrollTop]) - // Measure the sentinel's actual rendered width on mount, window - // resize, and any layout change driven by the sidebar collapse / - // right panel toggle. ResizeObserver picks up everything window - // resize misses (sidebar collapse doesn't fire window resize). - // - // Uses a callback ref (not useRef + useEffect) because Timeline - // early-returns a loading/empty state before the sentinel exists, - // so a mount-only effect would see a null ref and never install - // the observer. The callback ref fires whenever the sentinel - // actually attaches, which is the moment we can measure it. + // Measure the scroll container directly and subtract its padding to + // get the real grid width. Observing the scroll container itself (vs + // a sentinel child) reliably fires on every sidebar toggle and + // window resize — no edge cases with zero-height observers or + // early-returned loading states detaching the sentinel. const roRef = useRef(null) - const measureElRef = useRef(null) - const widthSentinelRef = useCallback((el: HTMLDivElement | null) => { + const scrollContainerRef = useCallback((el: HTMLDivElement | null) => { roRef.current?.disconnect() roRef.current = null - measureElRef.current = el + parentRef.current = el if (!el) return - const measure = () => setContainerWidth(el.clientWidth) + const measure = () => { + const style = window.getComputedStyle(el) + const padX = + parseFloat(style.paddingLeft) + parseFloat(style.paddingRight) + setContainerWidth(Math.max(0, el.clientWidth - padX)) + } measure() const ro = new ResizeObserver(measure) ro.observe(el) roRef.current = ro }, []) - useEffect(() => { - const onResize = () => { - const el = measureElRef.current - if (el) setContainerWidth(el.clientWidth) - } - window.addEventListener('resize', onResize) - return () => { - window.removeEventListener('resize', onResize) - roRef.current?.disconnect() - roRef.current = null - } - }, []) + useEffect(() => () => roRef.current?.disconnect(), []) // Photo rows in visual order — drops the header items so navigation // walks the grid as the user sees it. Each row has cells of length @@ -647,7 +622,7 @@ export function Timeline() { )}
- {/* Width sentinel — a 1px-tall normal-flow div that takes the - * full width of the inner virtualizer wrapper, which is the - * exact width rows render at. clientWidth on this is what we - * base the column count on, sidestepping any padding / - * scrollbar mismatch the parentRef-based measurement is - * vulnerable to. ResizeObserver doesn't reliably fire on - * zero-area absolute elements, so 1px tall + relative flow. */} -