feat: full shortcut parity + perf fixes across memories and duplicates

Memories view now supports the same keyboard shortcuts, heap membership,
and optimistic cache updates as the Timeline. Arrow/Ctrl+A/Escape nav is
extracted into a shared useGridKeyNav hook so both views stay in lockstep.
Duplicates view is virtualised with @tanstack/react-virtual and has
stabilised PhotoThumbnail props so React.memo actually elides work when
scrolling or toggling selection.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
root
2026-04-15 11:51:13 +02:00
parent 744a7fa0c3
commit d72a218b46
8 changed files with 527 additions and 228 deletions

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Loader2 } from 'lucide-react'
import {
@@ -10,10 +10,13 @@ import type { Photo } from '../../types/photo'
import { usePhotoStore } from '../../store/photoStore'
import { useFilterStore } from '../../store/filterStore'
import { PhotoThumbnail } from '../timeline/PhotoThumbnail'
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
import { useGridKeyNav } from '../../hooks/useGridKeyNav'
const CELL_SIZE = 180
const GAP = 4
/**
* "On this day" view. Same visual + interaction grid as Timeline —
* PhotoThumbnail cells wired up to the shared photo store, so selection,
@@ -46,7 +49,9 @@ export function MemoriesView() {
const togglePhotoSelection = usePhotoStore((s) => s.togglePhotoSelection)
const selectRange = usePhotoStore((s) => s.selectRange)
const openPreview = usePhotoStore((s) => s.openPreview)
const viewMode = usePhotoStore((s) => s.viewMode)
const searchQuery = useFilterStore((s) => s.q)
const { memberIds: activeHeapMembers } = useActiveHeapMembers()
const visibleSequenceRef = useRef<string[]>([])
visibleSequenceRef.current = visibleSequence
@@ -66,6 +71,73 @@ 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)
if (!el) return
const measure = () => {
const cols = window
.getComputedStyle(el)
.gridTemplateColumns.split(' ')
.filter(Boolean).length
if (cols > 0) setColumns(cols)
}
measure()
const ro = new ResizeObserver(measure)
ro.observe(el)
return () => ro.disconnect()
}, [memories.length])
// Break each year's photos into rows of `columns` ids — matches the
// shape useGridKeyNav expects. Arrow-nav crosses year boundaries
// naturally because rows flow in visual order across groups.
const gridNavRows = useMemo(() => {
const rows: { cells: { id: string }[] }[] = []
for (const group of memories) {
const cells = group.photos.map((m) => ({ id: m.id }))
for (let i = 0; i < cells.length; i += columns) {
rows.push({ cells: cells.slice(i, i + columns) })
}
}
return rows
}, [memories, columns])
const scrollRowIntoView = useCallback((rowIdx: number) => {
const firstId = gridNavRowsRef.current[rowIdx]?.cells[0]?.id
if (!firstId) return
const scrollEl = scrollRef.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 cellTop = cellEl.offsetTop
const cellBottom = cellTop + cellEl.offsetHeight
const viewTop = scrollEl.scrollTop
const viewBottom = viewTop + scrollEl.clientHeight
if (cellTop - peek < viewTop) {
scrollEl.scrollTo({ top: Math.max(0, cellTop - peek) })
} else if (cellBottom + peek > viewBottom) {
scrollEl.scrollTo({ top: cellBottom + peek - scrollEl.clientHeight })
}
}, [])
// Rows change on resize / data load — read via ref so the callback
// identity stays stable.
const gridNavRowsRef = useRef(gridNavRows)
gridNavRowsRef.current = gridNavRows
useGridKeyNav({
rows: gridNavRows,
enabled: viewMode === 'grid',
scrollRowIntoView,
})
if (isLoading) {
return (
<div className="flex h-full items-center justify-center text-text-muted">
@@ -90,6 +162,7 @@ export function MemoriesView() {
return (
<div
ref={scrollRef}
className="h-full overflow-auto bg-bg p-4"
role="grid"
aria-label="Memories"
@@ -99,7 +172,7 @@ export function MemoriesView() {
</h2>
<div className="space-y-6">
{memories.map((group) => (
{memories.map((group, groupIdx) => (
<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">
@@ -110,6 +183,9 @@ export function MemoriesView() {
</span>
</header>
<div
ref={(el) => {
gridRefs.current[groupIdx] = el
}}
className="grid"
style={{
gridTemplateColumns: `repeat(auto-fill, minmax(${CELL_SIZE}px, 1fr))`,
@@ -118,16 +194,18 @@ export function MemoriesView() {
}}
>
{group.photos.map((m) => (
<PhotoThumbnail
key={m.id}
photo={memoryToPhoto(m)}
size={CELL_SIZE}
fill
isSelected={selectedPhotos.includes(m.id)}
searchQuery={searchQuery}
onClick={handleCellClick}
onDoubleClick={handleCellDoubleClick}
/>
<div key={m.id} data-photo-id={m.id}>
<PhotoThumbnail
photo={memoryToPhoto(m)}
size={CELL_SIZE}
fill
isSelected={selectedPhotos.includes(m.id)}
isInActiveHeap={activeHeapMembers.has(m.id)}
searchQuery={searchQuery}
onClick={handleCellClick}
onDoubleClick={handleCellDoubleClick}
/>
</div>
))}
</div>
</section>
@@ -151,7 +229,8 @@ function memoryToPhoto(m: MemoryPhoto): Photo {
height: m.height,
taken_at: m.taken_at,
rating: m.rating,
is_discarded: false,
color_label: m.color_label,
is_discarded: m.is_discarded,
is_duplicate: false,
file_hash: '',
folder_id: null,