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