ui: uniform 160px thumbnail cells across all grid views

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) <noreply@anthropic.com>
This commit is contained in:
root
2026-04-15 15:41:40 +02:00
parent 66b3bc5e1f
commit 45f1649979
3 changed files with 66 additions and 95 deletions

View File

@@ -87,7 +87,7 @@ export function DuplicatesView() {
// Track the rendered column count of the duplicates grid so ↑/↓ can // Track the rendered column count of the duplicates grid so ↑/↓ can
// skip a row instead of jumping a single cell. The grid uses // 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 // We measure the FIRST section's grid container — every section uses
// the same auto-fill rule so any one is representative. // the same auto-fill rule so any one is representative.
const [columns, setColumns] = useState(4) const [columns, setColumns] = useState(4)
@@ -100,7 +100,7 @@ export function DuplicatesView() {
sampleObserverRef.current = null sampleObserverRef.current = null
if (!el) return if (!el) return
const measure = () => { const measure = () => {
const cols = Math.max(1, Math.floor(el.clientWidth / 180)) const cols = Math.max(1, Math.floor(el.clientWidth / 160))
setColumns(cols) setColumns(cols)
} }
measure() measure()
@@ -386,7 +386,7 @@ const DuplicateGroupSection = memo(function DuplicateGroupSection({
className="grid gap-1 p-2" className="grid gap-1 p-2"
style={{ style={{
gridTemplateColumns: gridTemplateColumns:
'repeat(auto-fill, minmax(180px, 1fr))', 'repeat(auto-fill, 160px)',
}} }}
> >
{group.members.map((member) => { {group.members.map((member) => {
@@ -399,7 +399,7 @@ const DuplicateGroupSection = memo(function DuplicateGroupSection({
> >
<PhotoThumbnail <PhotoThumbnail
photo={photoByMemberId.get(member.id)!} photo={photoByMemberId.get(member.id)!}
size={180} size={160}
fill fill
isSelected={selectedSet.has(member.id)} isSelected={selectedSet.has(member.id)}
onClick={handleClick} onClick={handleClick}

View File

@@ -13,7 +13,9 @@ import { PhotoThumbnail } from '../timeline/PhotoThumbnail'
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery' import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
import { useGridKeyNav } from '../../hooks/useGridKeyNav' import { useGridKeyNav } from '../../hooks/useGridKeyNav'
const CELL_SIZE = 180 // Match Timeline's THUMBNAIL_SIZE / GAP so memories cells line up
// visually with what the user sees in the main grid.
const THUMBNAIL_SIZE = 160
const GAP = 4 const GAP = 4
@@ -71,27 +73,41 @@ export function MemoriesView() {
[openPreview], [openPreview],
) )
// Measure column count from the first grid's actual computed template // Measure the scroll container directly and subtract its padding to
// so arrow nav matches what the user sees. Auto-fill re-flows on resize, // get the real grid width. A ResizeObserver on the container itself
// so a ResizeObserver keeps the count current. // always fires on width changes — sentinel elements can miss changes
const scrollRef = useRef<HTMLDivElement | null>(null) // in some edge cases (zero-height, detached during loading states).
const gridRefs = useRef<(HTMLDivElement | null)[]>([]) const [containerWidth, setContainerWidth] = useState(0)
const [columns, setColumns] = useState(1) const roRef = useRef<ResizeObserver | null>(null)
useEffect(() => { const scrollRef = useCallback((el: HTMLDivElement | null) => {
const el = gridRefs.current.find((g) => g) roRef.current?.disconnect()
roRef.current = null
scrollElRef.current = el
if (!el) return if (!el) return
const measure = () => { const measure = () => {
const cols = window const style = window.getComputedStyle(el)
.getComputedStyle(el) const padX =
.gridTemplateColumns.split(' ') parseFloat(style.paddingLeft) + parseFloat(style.paddingRight)
.filter(Boolean).length setContainerWidth(Math.max(0, el.clientWidth - padX))
if (cols > 0) setColumns(cols)
} }
measure() measure()
const ro = new ResizeObserver(measure) const ro = new ResizeObserver(measure)
ro.observe(el) ro.observe(el)
return () => ro.disconnect() roRef.current = ro
}, [memories.length]) }, [])
const scrollElRef = useRef<HTMLDivElement | null>(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 // Break each year's photos into rows of `columns` ids — matches the
// shape useGridKeyNav expects. Arrow-nav crosses year boundaries // shape useGridKeyNav expects. Arrow-nav crosses year boundaries
@@ -110,13 +126,13 @@ export function MemoriesView() {
const scrollRowIntoView = useCallback((rowIdx: number) => { const scrollRowIntoView = useCallback((rowIdx: number) => {
const firstId = gridNavRowsRef.current[rowIdx]?.cells[0]?.id const firstId = gridNavRowsRef.current[rowIdx]?.cells[0]?.id
if (!firstId) return if (!firstId) return
const scrollEl = scrollRef.current const scrollEl = scrollElRef.current
if (!scrollEl) return if (!scrollEl) return
const cellEl = scrollEl.querySelector<HTMLElement>( const cellEl = scrollEl.querySelector<HTMLElement>(
`[data-photo-id="${firstId}"]`, `[data-photo-id="${firstId}"]`,
) )
if (!cellEl) return if (!cellEl) return
const peek = Math.round(CELL_SIZE * 0.35) const peek = Math.round(cellSize * 0.35)
const cellTop = cellEl.offsetTop const cellTop = cellEl.offsetTop
const cellBottom = cellTop + cellEl.offsetHeight const cellBottom = cellTop + cellEl.offsetHeight
const viewTop = scrollEl.scrollTop const viewTop = scrollEl.scrollTop
@@ -172,7 +188,7 @@ export function MemoriesView() {
</h2> </h2>
<div className="space-y-6"> <div className="space-y-6">
{memories.map((group, groupIdx) => ( {memories.map((group) => (
<section key={group.year} className="space-y-2"> <section key={group.year} className="space-y-2">
<header className="flex items-baseline gap-2"> <header className="flex items-baseline gap-2">
<h3 className="text-sm font-semibold uppercase tracking-wide text-text"> <h3 className="text-sm font-semibold uppercase tracking-wide text-text">
@@ -183,13 +199,10 @@ export function MemoriesView() {
</span> </span>
</header> </header>
<div <div
ref={(el) => {
gridRefs.current[groupIdx] = el
}}
className="grid" className="grid"
style={{ style={{
gridTemplateColumns: `repeat(auto-fill, minmax(${CELL_SIZE}px, 1fr))`, gridTemplateColumns: `repeat(${columns}, ${cellSize}px)`,
gridAutoRows: `${CELL_SIZE}px`, gridAutoRows: `${cellSize}px`,
gap: `${GAP}px`, gap: `${GAP}px`,
}} }}
> >
@@ -197,7 +210,7 @@ export function MemoriesView() {
<div key={m.id} data-photo-id={m.id}> <div key={m.id} data-photo-id={m.id}>
<PhotoThumbnail <PhotoThumbnail
photo={memoryToPhoto(m)} photo={memoryToPhoto(m)}
size={CELL_SIZE} size={cellSize}
fill fill
isSelected={selectedPhotos.includes(m.id)} isSelected={selectedPhotos.includes(m.id)}
isInActiveHeap={activeHeapMembers.has(m.id)} isInActiveHeap={activeHeapMembers.has(m.id)}

View File

@@ -13,7 +13,7 @@ import { ImageOff } from 'lucide-react'
import type { Photo } from '../../types/photo' import type { Photo } from '../../types/photo'
// Layout constants for the grid + grouped headers. // Layout constants for the grid + grouped headers.
const THUMBNAIL_SIZE = 200 const THUMBNAIL_SIZE = 160
const GAP = 4 const GAP = 4
const PADDING = 16 const PADDING = 16
const HEADER_HEIGHT = 36 const HEADER_HEIGHT = 36
@@ -121,7 +121,7 @@ function buildItems(
} }
export function Timeline() { export function Timeline() {
const parentRef = useRef<HTMLDivElement>(null) const parentRef = useRef<HTMLDivElement | null>(null)
// Sentinel placed inside the inner virtualizer wrapper at the exact // Sentinel placed inside the inner virtualizer wrapper at the exact
// position rows will render. We measure THIS instead of parentRef, // position rows will render. We measure THIS instead of parentRef,
// because parentRef has padding and we'd otherwise have to subtract // 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 searchQuery = useFilterStore((s) => s.q)
const viewMode = usePhotoStore((s) => s.viewMode) const viewMode = usePhotoStore((s) => s.viewMode)
// Calculate number of columns + actual cell size based on container // Number of columns that fit at the FIXED THUMBNAIL_SIZE. Cells no
// width. Treat THUMBNAIL_SIZE as a *minimum* and let cells grow to // longer stretch to absorb the remaining width — we prefer a stable
// fill the remaining space, so we never leave a horizontal gap on // cell size across sidebar-open / sidebar-closed states over a
// the right side of the grid. // perfectly-flush right edge. A small gap may remain on the right
// // when containerWidth isn't an integer multiple of (T+G).
// 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(() => { const { columns, cellSize } = useMemo(() => {
if (containerWidth === 0) { if (containerWidth === 0) {
return { columns: 4, cellSize: THUMBNAIL_SIZE } 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( const cols = Math.max(
1, 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 return { columns: cols, cellSize: THUMBNAIL_SIZE }
// 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 }
}, [containerWidth]) }, [containerWidth])
// Shared photos query — both Timeline and PreviewView use the same hook so // Shared photos query — both Timeline and PreviewView use the same hook so
@@ -438,41 +425,29 @@ export function Timeline() {
return current return current
}, [headerOffsets, scrollTop]) }, [headerOffsets, scrollTop])
// Measure the sentinel's actual rendered width on mount, window // Measure the scroll container directly and subtract its padding to
// resize, and any layout change driven by the sidebar collapse / // get the real grid width. Observing the scroll container itself (vs
// right panel toggle. ResizeObserver picks up everything window // a sentinel child) reliably fires on every sidebar toggle and
// resize misses (sidebar collapse doesn't fire window resize). // window resize — no edge cases with zero-height observers or
// // early-returned loading states detaching the sentinel.
// 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.
const roRef = useRef<ResizeObserver | null>(null) const roRef = useRef<ResizeObserver | null>(null)
const measureElRef = useRef<HTMLDivElement | null>(null) const scrollContainerRef = useCallback((el: HTMLDivElement | null) => {
const widthSentinelRef = useCallback((el: HTMLDivElement | null) => {
roRef.current?.disconnect() roRef.current?.disconnect()
roRef.current = null roRef.current = null
measureElRef.current = el parentRef.current = el
if (!el) return 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() measure()
const ro = new ResizeObserver(measure) const ro = new ResizeObserver(measure)
ro.observe(el) ro.observe(el)
roRef.current = ro roRef.current = ro
}, []) }, [])
useEffect(() => { useEffect(() => () => roRef.current?.disconnect(), [])
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
}
}, [])
// Photo rows in visual order — drops the header items so navigation // 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 // walks the grid as the user sees it. Each row has cells of length
@@ -647,7 +622,7 @@ export function Timeline() {
)} )}
<div <div
ref={parentRef} ref={scrollContainerRef}
className="h-full overflow-auto bg-bg" className="h-full overflow-auto bg-bg"
// Extra bottom padding so the last row clears the floating // Extra bottom padding so the last row clears the floating
// KeyboardHints pill (which sits at bottom-4, ~40px tall). // KeyboardHints pill (which sits at bottom-4, ~40px tall).
@@ -660,23 +635,6 @@ export function Timeline() {
position: 'relative', position: 'relative',
}} }}
> >
{/* 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. */}
<div
ref={widthSentinelRef}
aria-hidden="true"
style={{
width: '100%',
height: 1,
marginBottom: -1,
pointerEvents: 'none',
}}
/>
{virtualizer.getVirtualItems().map((virtualItem) => { {virtualizer.getVirtualItems().map((virtualItem) => {
const item = items[virtualItem.index] const item = items[virtualItem.index]
if (!item) return null if (!item) return null