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:
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user