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:
@@ -4,6 +4,7 @@ import { toast } from '../components/ToastContainer'
|
||||
import { formatApiError } from '../lib/apiError'
|
||||
import { LIBRARY_STATS_QUERY_KEY } from './useLibraryStatsQuery'
|
||||
import type { Photo } from '../types/photo'
|
||||
import type { MemoriesResponse, MemoryPhoto } from '../services/api'
|
||||
|
||||
/**
|
||||
* Centralised bulk-mutation hook used by both the RightSidebar multi-
|
||||
@@ -48,6 +49,22 @@ export function useBulkPhotoMutations() {
|
||||
const single = queryClient.getQueryData<Photo>(['photo', id])
|
||||
if (single) snap.set(id, { ...single })
|
||||
}
|
||||
// Fall back to memories cache for photos only surfaced via
|
||||
// "On this day" — otherwise a rollback on error can't restore them.
|
||||
const mem = queryClient.getQueryData<MemoriesResponse>(['memories'])
|
||||
if (mem) {
|
||||
for (const g of mem.memories) {
|
||||
for (const p of g.photos) {
|
||||
if (want.has(p.id) && !snap.has(p.id)) {
|
||||
snap.set(p.id, {
|
||||
rating: p.rating,
|
||||
color_label: p.color_label,
|
||||
is_discarded: p.is_discarded,
|
||||
} as Partial<Photo>)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return snap
|
||||
}
|
||||
|
||||
@@ -62,6 +79,27 @@ export function useBulkPhotoMutations() {
|
||||
const cur = queryClient.getQueryData<Photo>(['photo', id])
|
||||
if (cur) queryClient.setQueryData<Photo>(['photo', id], { ...cur, ...patch })
|
||||
}
|
||||
// Memories cache has a nested {memories:[{photos:[...]}]} shape —
|
||||
// only the fields MemoryPhoto carries (rating/color_label/is_discarded)
|
||||
// can be patched in-place, which covers every culling shortcut.
|
||||
const memPatch: Partial<MemoryPhoto> = {}
|
||||
if (patch.rating !== undefined) memPatch.rating = patch.rating
|
||||
if (patch.color_label !== undefined) memPatch.color_label = patch.color_label
|
||||
if (patch.is_discarded !== undefined) memPatch.is_discarded = patch.is_discarded
|
||||
if (Object.keys(memPatch).length === 0) return
|
||||
queryClient.setQueryData<MemoriesResponse>(['memories'], (prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
memories: prev.memories.map((g) => ({
|
||||
...g,
|
||||
photos: g.photos.map((p) =>
|
||||
want.has(p.id) ? { ...p, ...memPatch } : p,
|
||||
),
|
||||
})),
|
||||
}
|
||||
: prev,
|
||||
)
|
||||
}
|
||||
|
||||
const restoreFromSnapshot = (snap: Map<string, Partial<Photo>>) => {
|
||||
@@ -81,6 +119,27 @@ export function useBulkPhotoMutations() {
|
||||
queryClient.setQueryData<Photo>(['photo', id], { ...cur, ...orig })
|
||||
}
|
||||
}
|
||||
queryClient.setQueryData<MemoriesResponse>(['memories'], (prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
memories: prev.memories.map((g) => ({
|
||||
...g,
|
||||
photos: g.photos.map((p) => {
|
||||
const orig = snap.get(p.id)
|
||||
if (!orig) return p
|
||||
const out: MemoryPhoto = { ...p }
|
||||
if (orig.rating !== undefined) out.rating = orig.rating
|
||||
if (orig.color_label !== undefined)
|
||||
out.color_label = orig.color_label ?? null
|
||||
if (orig.is_discarded !== undefined)
|
||||
out.is_discarded = orig.is_discarded
|
||||
return out
|
||||
}),
|
||||
})),
|
||||
}
|
||||
: prev,
|
||||
)
|
||||
}
|
||||
|
||||
const bulkRating = useMutation({
|
||||
|
||||
161
frontend/src/hooks/useGridKeyNav.ts
Normal file
161
frontend/src/hooks/useGridKeyNav.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { usePhotoStore } from '../store/photoStore'
|
||||
|
||||
/** A single row of the visual grid. Only the photo id is required —
|
||||
* callers (Timeline, MemoriesView) can keep richer cell shapes but we
|
||||
* only care about id for navigation. */
|
||||
export interface GridNavRow {
|
||||
cells: { id: string }[]
|
||||
}
|
||||
|
||||
interface UseGridKeyNavArgs {
|
||||
/** The flat, in-order grid rows the user sees. Navigation wraps at
|
||||
* row ends and clamps to the destination row's width on vertical
|
||||
* moves so half-full trailing rows don't land on empty cells. */
|
||||
rows: GridNavRow[]
|
||||
/** Gate the listener. Typically `viewMode === 'grid'` AND the view
|
||||
* is mounted. When false, the window keydown listener is detached. */
|
||||
enabled: boolean
|
||||
/** Called after a successful arrow-nav move so the caller can scroll
|
||||
* the destination row into view. No-op moves (same row, within
|
||||
* viewport) still call it — the callback decides whether to scroll. */
|
||||
scrollRowIntoView?: (rowIdx: number) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared arrow/Ctrl+A/Escape grid keyboard handler. Centralises the
|
||||
* navigation logic Timeline and MemoriesView both need so they stay in
|
||||
* lockstep — any future grid view can opt in by supplying its own
|
||||
* `rows` and (optionally) a scroll callback.
|
||||
*
|
||||
* Scroll math stays with the caller because each grid measures row
|
||||
* geometry differently (TanStack virtualizer for Timeline; native DOM
|
||||
* offsets for the non-virtualised MemoriesView).
|
||||
*/
|
||||
export function useGridKeyNav({
|
||||
rows,
|
||||
enabled,
|
||||
scrollRowIntoView,
|
||||
}: UseGridKeyNavArgs) {
|
||||
// Read inputs through a ref so the listener binds once per `enabled`
|
||||
// toggle — without this the handler would re-attach on every render,
|
||||
// churning the window event map.
|
||||
const stateRef = useRef({ rows, scrollRowIntoView })
|
||||
stateRef.current = { rows, scrollRowIntoView }
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const { rows, scrollRowIntoView } = stateRef.current
|
||||
if (rows.length === 0) return
|
||||
|
||||
const target = e.target as HTMLElement | null
|
||||
if (
|
||||
target &&
|
||||
(target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const store = usePhotoStore.getState()
|
||||
|
||||
const findActive = (): { row: number; col: number } | null => {
|
||||
const aid = store.activePhotoId
|
||||
if (!aid) return null
|
||||
for (let r = 0; r < rows.length; r++) {
|
||||
const c = rows[r].cells.findIndex((cell) => cell.id === aid)
|
||||
if (c >= 0) return { row: r, col: c }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const move = (dr: number, dc: number) => {
|
||||
const current = findActive() ?? { row: 0, col: -1 }
|
||||
let nextRow = current.row
|
||||
let nextCol = current.col + dc
|
||||
|
||||
if (dc !== 0) {
|
||||
// Wrap left/right across row boundaries.
|
||||
while (nextCol < 0 && nextRow > 0) {
|
||||
nextRow -= 1
|
||||
nextCol = rows[nextRow].cells.length - 1
|
||||
}
|
||||
while (
|
||||
nextRow < rows.length &&
|
||||
nextCol >= rows[nextRow].cells.length
|
||||
) {
|
||||
if (nextRow === rows.length - 1) {
|
||||
nextCol = rows[nextRow].cells.length - 1
|
||||
break
|
||||
}
|
||||
nextRow += 1
|
||||
nextCol = 0
|
||||
}
|
||||
if (nextCol < 0) nextCol = 0
|
||||
}
|
||||
|
||||
if (dr !== 0) {
|
||||
nextRow += dr
|
||||
if (nextRow < 0) nextRow = 0
|
||||
if (nextRow >= rows.length) nextRow = rows.length - 1
|
||||
// Clamp the column to the destination row's actual width so
|
||||
// moving down into a half-full row lands on its last cell
|
||||
// instead of nothing.
|
||||
const rowLen = rows[nextRow].cells.length
|
||||
if (nextCol >= rowLen) nextCol = rowLen - 1
|
||||
if (nextCol < 0) nextCol = 0
|
||||
}
|
||||
|
||||
const dest = rows[nextRow]?.cells[nextCol]
|
||||
if (!dest) return
|
||||
|
||||
// Read fresh actions each call so the hook stays zero-dep.
|
||||
const { selectRange, selectPhoto } = usePhotoStore.getState()
|
||||
if (e.shiftKey) selectRange(dest.id)
|
||||
else selectPhoto(dest.id)
|
||||
|
||||
scrollRowIntoView?.(nextRow)
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowUp':
|
||||
e.preventDefault()
|
||||
move(-1, 0)
|
||||
break
|
||||
case 'ArrowDown':
|
||||
e.preventDefault()
|
||||
move(1, 0)
|
||||
break
|
||||
case 'ArrowLeft':
|
||||
e.preventDefault()
|
||||
move(0, -1)
|
||||
break
|
||||
case 'ArrowRight':
|
||||
e.preventDefault()
|
||||
move(0, 1)
|
||||
break
|
||||
case 'a':
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault()
|
||||
const { selectedPhotos, togglePhotoSelection } =
|
||||
usePhotoStore.getState()
|
||||
const selected = new Set(selectedPhotos)
|
||||
for (const row of rows) {
|
||||
for (const cell of row.cells) {
|
||||
if (!selected.has(cell.id)) togglePhotoSelection(cell.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
break
|
||||
case 'Escape':
|
||||
e.preventDefault()
|
||||
usePhotoStore.getState().clearSelection()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [enabled])
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { LIBRARY_STATS_QUERY_KEY } from './useLibraryStatsQuery'
|
||||
import { useBulkPhotoMutations } from './useBulkPhotoMutations'
|
||||
import { formatApiError } from '../lib/apiError'
|
||||
import type { Photo } from '../types/photo'
|
||||
import type { MemoriesResponse, MemoryPhoto } from '../services/api'
|
||||
|
||||
interface KeyboardShortcutsProps {
|
||||
onToggleLeftSidebar: () => void
|
||||
@@ -76,6 +77,21 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
|
||||
? prev.map((p) => (set.has(p.id) ? { ...p, is_discarded: discarded } : p))
|
||||
: prev
|
||||
)
|
||||
// Memories view has its own cache shape ({memories: [{photos: [...]}]})
|
||||
// — patch it too so the grayscale flip shows up in "On this day".
|
||||
queryClient.setQueryData<MemoriesResponse>(['memories'], (prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
memories: prev.memories.map((g) => ({
|
||||
...g,
|
||||
photos: g.photos.map((p) =>
|
||||
set.has(p.id) ? { ...p, is_discarded: discarded } : p,
|
||||
),
|
||||
})),
|
||||
}
|
||||
: prev,
|
||||
)
|
||||
}
|
||||
|
||||
/** Look up a photo's CURRENT cached state (is_discarded etc) without
|
||||
@@ -88,9 +104,35 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
|
||||
const p = list.find((x) => x.id === id)
|
||||
if (p) return p
|
||||
}
|
||||
// Fall through to memories cache (On this day) and then per-photo.
|
||||
const mem = queryClient.getQueryData<MemoriesResponse>(['memories'])
|
||||
if (mem) {
|
||||
for (const g of mem.memories) {
|
||||
const m = g.photos.find((p: MemoryPhoto) => p.id === id)
|
||||
if (m) return memoryToPhotoPartial(m)
|
||||
}
|
||||
}
|
||||
return queryClient.getQueryData<Photo>(['photo', id])
|
||||
}
|
||||
|
||||
const memoryToPhotoPartial = (m: MemoryPhoto): Photo =>
|
||||
({
|
||||
id: m.id,
|
||||
filepath: m.filename,
|
||||
filename: m.filename,
|
||||
media_type: m.media_type,
|
||||
width: m.width,
|
||||
height: m.height,
|
||||
taken_at: m.taken_at,
|
||||
rating: m.rating,
|
||||
color_label: m.color_label,
|
||||
is_discarded: m.is_discarded,
|
||||
is_duplicate: false,
|
||||
file_hash: '',
|
||||
folder_id: null,
|
||||
added_at: null,
|
||||
} as Photo)
|
||||
|
||||
// Discard / restore mutation — the only path that doesn't auto-
|
||||
// invalidate ['photos']. Invalidating would refetch with the active
|
||||
// filter (which excludes discarded photos in every section except
|
||||
|
||||
Reference in New Issue
Block a user