feat(grid): user-configurable thumbnail size for grid views

Adds a "Size" pill in the FilterBar with 5 presets (XS/S/M/L/XL, 96–272px,
default M=160) that drives the cell size in the Timeline, Memories, and
Duplicates grids. Preference persists in localStorage. Preview filmstrip
is intentionally untouched — it's a fixed-track nav rail, not a grid.

Centralised in a new viewSettingsStore so every grid reads from the same
source. Duplicates' virtualizer is poked on size change so row heights
and the keyboard nav's column count stay in sync.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
root
2026-05-12 09:37:01 +02:00
parent 347f58b4f3
commit c30b387dc3
5 changed files with 167 additions and 26 deletions

View File

@@ -22,6 +22,7 @@ import {
THUMB_BADGE_PICK,
} from '../timeline/PhotoThumbnail'
import { usePhotoStore } from '../../store/photoStore'
import { useViewSettingsStore } from '../../store/viewSettingsStore'
import { registerUndoable } from '../../store/undoStore'
import { LIBRARY_STATS_QUERY_KEY } from '../../hooks/useLibraryStatsQuery'
import { toast } from '../ToastContainer'
@@ -85,9 +86,11 @@ export function DuplicatesView() {
const groupsRef = useRef(groups)
groupsRef.current = groups
const thumbnailSize = useViewSettingsStore((s) => s.thumbnailSize)
// 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(160px, 1fr))` so columns = floor(width/160).
// `repeat(auto-fill, ${thumbnailSize}px)` so columns = floor(width/size).
// 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)
@@ -95,12 +98,14 @@ export function DuplicatesView() {
// re-mount on scroll back — track the observer so we can disconnect
// cleanly every time the ref detaches (previously this leaked).
const sampleObserverRef = useRef<ResizeObserver | null>(null)
const thumbnailSizeRef = useRef(thumbnailSize)
thumbnailSizeRef.current = thumbnailSize
const sampleGridRef = useCallback((el: HTMLDivElement | null) => {
sampleObserverRef.current?.disconnect()
sampleObserverRef.current = null
if (!el) return
const measure = () => {
const cols = Math.max(1, Math.floor(el.clientWidth / 160))
const cols = Math.max(1, Math.floor(el.clientWidth / thumbnailSizeRef.current))
setColumns(cols)
}
measure()
@@ -124,6 +129,19 @@ export function DuplicatesView() {
getItemKey: (idx) => groups[idx].group_id,
})
// Resizing the thumbnails changes every section's height AND its
// column count, but the ResizeObserver only fires on width changes —
// so when the size flips, recompute columns directly and tell the
// virtualizer to re-measure rows.
useEffect(() => {
const el = scrollRef.current?.querySelector<HTMLDivElement>('[data-dup-grid]')
if (el) {
const cols = Math.max(1, Math.floor(el.clientWidth / thumbnailSize))
setColumns(cols)
}
virtualizer.measure()
}, [thumbnailSize, virtualizer])
// Window-level keyboard nav. Mirrors Timeline's handler but walks
// `allMemberIds` directly — duplicate groups don't have a uniform row
// grid so we approximate ↑/↓ via the measured `columns` count and
@@ -273,6 +291,7 @@ export function DuplicatesView() {
onSelectMember={handleSelectMember}
selectedPhotos={selectedPhotos}
isPending={discardMutation.isPending}
thumbnailSize={thumbnailSize}
// First section feeds the column-count sample ref —
// every section uses the same auto-fill rule.
gridRef={vItem.index === 0 ? sampleGridRef : undefined}
@@ -292,6 +311,7 @@ interface DuplicateGroupSectionProps {
onSelectMember: (memberId: string) => void
selectedPhotos: string[]
isPending: boolean
thumbnailSize: number
/** Optional callback ref attached to this section's grid container.
* Used by DuplicatesView to measure the rendered column count for
* / keyboard navigation. Only the first section gets one. */
@@ -305,6 +325,7 @@ const DuplicateGroupSection = memo(function DuplicateGroupSection({
onSelectMember,
selectedPhotos,
isPending,
thumbnailSize,
gridRef,
}: DuplicateGroupSectionProps) {
// Auto-pick "best" copy: highest pixel count, ties broken by file_size,
@@ -384,14 +405,15 @@ const DuplicateGroupSection = memo(function DuplicateGroupSection({
<div
ref={gridRef}
data-dup-grid
className="grid gap-1 p-2"
style={{
// Fix both axes so each cell reserves a 160×160 box before
// Fix both axes so each cell reserves a size×size box before
// its thumbnail finishes loading — otherwise rows collapse to
// the height of an empty <img> and jump when images arrive,
// which also throws off the virtualizer's row-height measure.
gridTemplateColumns: 'repeat(auto-fill, 160px)',
gridAutoRows: '160px',
gridTemplateColumns: `repeat(auto-fill, ${thumbnailSize}px)`,
gridAutoRows: `${thumbnailSize}px`,
}}
>
{group.members.map((member) => {
@@ -404,7 +426,7 @@ const DuplicateGroupSection = memo(function DuplicateGroupSection({
>
<PhotoThumbnail
photo={photoByMemberId.get(member.id)!}
size={160}
size={thumbnailSize}
fill
isSelected={selectedSet.has(member.id)}
onClick={handleClick}