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
// 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({
>
<PhotoThumbnail
photo={photoByMemberId.get(member.id)!}
size={180}
size={160}
fill
isSelected={selectedSet.has(member.id)}
onClick={handleClick}

View File

@@ -13,7 +13,9 @@ import { PhotoThumbnail } from '../timeline/PhotoThumbnail'
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
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
@@ -71,27 +73,41 @@ export function MemoriesView() {
[openPreview],
)
// Measure column count from the first grid's actual computed template
// so arrow nav matches what the user sees. Auto-fill re-flows on resize,
// so a ResizeObserver keeps the count current.
const scrollRef = useRef<HTMLDivElement | null>(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<ResizeObserver | null>(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<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
// 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<HTMLElement>(
`[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() {
</h2>
<div className="space-y-6">
{memories.map((group, groupIdx) => (
{memories.map((group) => (
<section key={group.year} className="space-y-2">
<header className="flex items-baseline gap-2">
<h3 className="text-sm font-semibold uppercase tracking-wide text-text">
@@ -183,13 +199,10 @@ export function MemoriesView() {
</span>
</header>
<div
ref={(el) => {
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() {
<div key={m.id} data-photo-id={m.id}>
<PhotoThumbnail
photo={memoryToPhoto(m)}
size={CELL_SIZE}
size={cellSize}
fill
isSelected={selectedPhotos.includes(m.id)}
isInActiveHeap={activeHeapMembers.has(m.id)}

View File

@@ -13,7 +13,7 @@ import { ImageOff } from 'lucide-react'
import type { Photo } from '../../types/photo'
// Layout constants for the grid + grouped headers.
const THUMBNAIL_SIZE = 200
const THUMBNAIL_SIZE = 160
const GAP = 4
const PADDING = 16
const HEADER_HEIGHT = 36
@@ -121,7 +121,7 @@ function buildItems(
}
export function Timeline() {
const parentRef = useRef<HTMLDivElement>(null)
const parentRef = useRef<HTMLDivElement | null>(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<ResizeObserver | null>(null)
const measureElRef = useRef<HTMLDivElement | null>(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() {
)}
<div
ref={parentRef}
ref={scrollContainerRef}
className="h-full overflow-auto bg-bg"
// Extra bottom padding so the last row clears the floating
// KeyboardHints pill (which sits at bottom-4, ~40px tall).
@@ -660,23 +635,6 @@ export function Timeline() {
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) => {
const item = items[virtualItem.index]
if (!item) return null