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

@@ -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)}