From d72a218b460b583238f001a5bfe6ccdb49b2fa74 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 15 Apr 2026 11:51:13 +0200 Subject: [PATCH] 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) --- backend/app/routers/photos.py | 4 + .../components/duplicates/DuplicatesView.tsx | 157 +++++++++--- .../src/components/memories/MemoriesView.tsx | 105 +++++++- frontend/src/components/timeline/Timeline.tsx | 225 ++++-------------- frontend/src/hooks/useBulkPhotoMutations.ts | 59 +++++ frontend/src/hooks/useGridKeyNav.ts | 161 +++++++++++++ frontend/src/hooks/useKeyboardShortcuts.ts | 42 ++++ frontend/src/services/api.ts | 2 + 8 files changed, 527 insertions(+), 228 deletions(-) create mode 100644 frontend/src/hooks/useGridKeyNav.ts diff --git a/backend/app/routers/photos.py b/backend/app/routers/photos.py index 4fd5459..d882525 100644 --- a/backend/app/routers/photos.py +++ b/backend/app/routers/photos.py @@ -422,6 +422,8 @@ async def get_memories( Photo.width, Photo.height, Photo.rating, + Photo.color_label, + Photo.is_discarded, ) .where( Photo.user_id == current_user.id, @@ -454,6 +456,8 @@ async def get_memories( "width": row.width, "height": row.height, "rating": row.rating, + "color_label": row.color_label, + "is_discarded": row.is_discarded, }) memories = [ diff --git a/frontend/src/components/duplicates/DuplicatesView.tsx b/frontend/src/components/duplicates/DuplicatesView.tsx index 9409f06..2a9ac8d 100644 --- a/frontend/src/components/duplicates/DuplicatesView.tsx +++ b/frontend/src/components/duplicates/DuplicatesView.tsx @@ -1,6 +1,7 @@ -import { useMemo, useState, useEffect, useCallback } from 'react' +import { memo, useMemo, useRef, useState, useEffect, useCallback } from 'react' import { Copy, Layers, Sparkles, Trash2, Loader2, Info, Crown } from 'lucide-react' import { cn } from '@/lib/utils' +import { useVirtualizer } from '@tanstack/react-virtual' import { useMutation, useQueryClient } from '@tanstack/react-query' import { Button } from '@/components/ui/button' import { formatApiError } from '../../lib/apiError' @@ -79,6 +80,10 @@ export function DuplicatesView() { () => groups.flatMap((g) => g.members.map((m) => m.id)), [groups] ) + // Ref mirror so the stable keyboard handler can reach into the latest + // groups without re-binding the listener on every render. + const groupsRef = useRef(groups) + groupsRef.current = groups // Track the rendered column count of the duplicates grid so ↑/↓ can // skip a row instead of jumping a single cell. The grid uses @@ -86,7 +91,13 @@ export function DuplicatesView() { // 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) + // With virtualisation the first section can unmount on scroll and + // re-mount on scroll back — track the observer so we can disconnect + // cleanly every time the ref detaches (previously this leaked). + const sampleObserverRef = useRef(null) 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 / 180)) @@ -95,10 +106,24 @@ export function DuplicatesView() { measure() const ro = new ResizeObserver(measure) ro.observe(el) - // Caller doesn't get the cleanup hook but ResizeObserver disconnects - // when the element unmounts, which is fine for our lifecycle. + sampleObserverRef.current = ro }, []) + // Virtualise the group list. Each group has a variable height + // (header + dynamic row count), so we let TanStack measure rendered + // DOM via `measureElement` and use a conservative estimate for + // unmeasured rows. ~360px handles a 2-row, 180px-cell section + header + // + group padding without underestimating in most cases. + const scrollRef = useRef(null) + const virtualizer = useVirtualizer({ + count: groups.length, + getScrollElement: () => scrollRef.current, + estimateSize: () => 360, + overscan: 2, + measureElement: (el) => el.getBoundingClientRect().height, + getItemKey: (idx) => groups[idx].group_id, + }) + // 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 @@ -135,18 +160,25 @@ export function DuplicatesView() { const nextId = allMemberIds[nextIdx] if (!nextId) return selectPhoto(nextId) - // Scroll the now-active cell into view if it's off-screen. The - // PhotoThumbnail wrapper carries data-dup-id so we can find it - // without threading refs through every cell. - const el = document.querySelector( - `[data-dup-id="${nextId}"]` + // With the group list virtualised, the destination cell might not + // be mounted yet. Scroll its owning group into view first (cheap + // virtualizer op), then scrollIntoView on the cell once it mounts. + const currentGroups = groupsRef.current + const ownerIdx = currentGroups.findIndex((g) => + g.members.some((m) => m.id === nextId) ) - el?.scrollIntoView({ block: 'nearest', inline: 'nearest' }) + if (ownerIdx >= 0) virtualizer.scrollToIndex(ownerIdx, { align: 'auto' }) + requestAnimationFrame(() => { + const el = document.querySelector( + `[data-dup-id="${nextId}"]` + ) + el?.scrollIntoView({ block: 'nearest', inline: 'nearest' }) + }) } window.addEventListener('keydown', onKeyDown) return () => window.removeEventListener('keydown', onKeyDown) - }, [allMemberIds, activePhotoId, columns, selectPhoto]) + }, [allMemberIds, activePhotoId, columns, selectPhoto, virtualizer]) if (isLoading) { return ( @@ -179,8 +211,27 @@ export function DuplicatesView() { ) } + // Stable handlers — passed through memoised section + thumbnail so + // React.memo is actually effective. The `discardMutation.mutate` and + // store actions have stable identity already; we wrap them once so the + // closure identity doesn't change per render. + const allMemberIdsRef = useRef(allMemberIds) + allMemberIdsRef.current = allMemberIds + const handleKeepBest = useCallback( + (discardIds: string[]) => discardMutation.mutate(discardIds), + [discardMutation] + ) + const handlePreviewMember = useCallback( + (memberId: string) => openPreview(memberId, allMemberIdsRef.current), + [openPreview] + ) + const handleSelectMember = useCallback( + (memberId: string) => selectPhoto(memberId), + [selectPhoto] + ) + return ( -
+
@@ -191,22 +242,43 @@ export function DuplicatesView() {
-
- {groups.map((group, idx) => ( - discardMutation.mutate(discardIds)} - onPreviewMember={(memberId) => openPreview(memberId, allMemberIds)} - onSelectMember={(memberId) => selectPhoto(memberId)} - selectedPhotos={selectedPhotos} - isPending={discardMutation.isPending} - // Hand the column-measurement ref to the first section only - // — every section's grid uses the same auto-fill rule so any - // one is representative of the rendered column count. - gridRef={idx === 0 ? sampleGridRef : undefined} - /> - ))} +
+ {virtualizer.getVirtualItems().map((vItem) => { + const group = groups[vItem.index] + return ( +
+ +
+ ) + })}
) @@ -225,7 +297,7 @@ interface DuplicateGroupSectionProps { gridRef?: (el: HTMLDivElement | null) => void } -function DuplicateGroupSection({ +const DuplicateGroupSection = memo(function DuplicateGroupSection({ group, onKeepBest, onPreviewMember, @@ -251,6 +323,25 @@ function DuplicateGroupSection({ const discardCount = group.member_count - 1 const isExact = group.reason === 'exact' + // Adapt members to Photo once per group (group.members reference is + // stable across renders as long as the query data doesn't change), + // so PhotoThumbnail's memo doesn't re-render on parent tick. + const photoByMemberId = useMemo(() => { + const map = new Map() + for (const m of group.members) map.set(m.id, memberToPhoto(m)) + return map + }, [group.members]) + + const handleClick = useCallback( + (p: Photo) => onSelectMember(p.id), + [onSelectMember] + ) + const handleDoubleClick = useCallback( + (p: Photo) => onPreviewMember(p.id), + [onPreviewMember] + ) + const selectedSet = useMemo(() => new Set(selectedPhotos), [selectedPhotos]) + return (
@@ -307,12 +398,12 @@ function DuplicateGroupSection({ className="group/dup relative" > onSelectMember(p.id)} - onDoubleClick={(p) => onPreviewMember(p.id)} + isSelected={selectedSet.has(member.id)} + onClick={handleClick} + onDoubleClick={handleDoubleClick} /> {/* BEST pill — top-right, pick-coloured. Composes the same * THUMB_BADGE_* family used by PhotoThumbnail so the full @@ -373,7 +464,7 @@ function DuplicateGroupSection({
) -} +}) // ── Helpers ────────────────────────────────────────────────────────────── diff --git a/frontend/src/components/memories/MemoriesView.tsx b/frontend/src/components/memories/MemoriesView.tsx index 6ba0581..df042f8 100644 --- a/frontend/src/components/memories/MemoriesView.tsx +++ b/frontend/src/components/memories/MemoriesView.tsx @@ -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([]) 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(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( + `[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 (
@@ -90,6 +162,7 @@ export function MemoriesView() { return (
- {memories.map((group) => ( + {memories.map((group, groupIdx) => (

@@ -110,6 +183,9 @@ export function MemoriesView() {

{ + 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) => ( - +
+ +
))}
@@ -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, diff --git a/frontend/src/components/timeline/Timeline.tsx b/frontend/src/components/timeline/Timeline.tsx index f8dc458..9e4be4f 100644 --- a/frontend/src/components/timeline/Timeline.tsx +++ b/frontend/src/components/timeline/Timeline.tsx @@ -7,6 +7,7 @@ import { useFilterStore, hasActiveFilters } from '../../store/filterStore' import { PhotoThumbnail } from './PhotoThumbnail' import { usePhotosQuery } from '../../hooks/usePhotosQuery' import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery' +import { useGridKeyNav } from '../../hooks/useGridKeyNav' import { Button } from '@/components/ui/button' import { ImageOff } from 'lucide-react' import type { Photo } from '../../types/photo' @@ -133,7 +134,6 @@ export function Timeline() { selectPhoto, togglePhotoSelection, selectRange, - clearSelection, openPreview, } = usePhotoStore() // Pulled via a focused selector so the publisher subscription doesn't @@ -528,189 +528,50 @@ export function Timeline() { visibleSequenceRef.current = visibleSequence }, [visibleSequence, setVisiblePhotoIds]) - // Locate the active photo in the visual grid. Returns the FIRST - // Handle keyboard shortcuts for photo navigation. Operates on the - // grouped grid the user sees, so a half-full last row of a group - // doesn't make ArrowDown skip into the wrong place. + // Keyboard grid nav (arrows / Ctrl+A / Escape) is shared with + // MemoriesView via useGridKeyNav. Timeline provides its own scroll + // callback because row geometry comes from the TanStack virtualizer's + // item heights; MemoriesView reads DOM offsets instead. // - // Inert in preview mode — PreviewView mounts its own arrow handlers, - // and a window-level grid handler firing alongside them used to race - // against PreviewView's setActivePhoto, landing the user on the wrong - // photo. The grid handler stays attached so it can re-engage the - // moment the user closes preview. - // - // The handler reads its inputs through a ref so the listener can bind - // once per (viewMode, currentSection) — every other dep used to be in - // the array and triggered an unbind/rebind on every state tick (eight - // values, several with new identity each render). - const navStateRef = useRef({ - photoRows, - photos, - selectedPhotos, - activePhotoId, - photoRowItemIndex, - items, - cellSize, + // Inert in preview mode (PreviewView has its own arrow handlers) and + // in the duplicates section (DuplicatesView manages its own grouped + // nav against a different snapshot of the visible grid). + const gridNavRows = useMemo( + () => + photoRows.map((row) => ({ + cells: row.cells.map((c) => ({ id: c.photo.id })), + })), + [photoRows] + ) + const scrollRowIntoView = useCallback( + (rowIdx: number) => { + const itemIdx = photoRowItemIndex[rowIdx] + const scrollEl = parentRef.current + if (itemIdx === undefined || !scrollEl) return + // Sum item heights up to itemIdx to get this row's offset in the + // virtualizer's coordinate space. Cheap enough at O(items) and + // avoids reaching into virtualizer.measurementsCache internals. + let rowTop = 0 + for (let i = 0; i < itemIdx; i++) rowTop += items[i].height + const rowHeight = items[itemIdx].height + const peek = Math.round(cellSize * 0.35) + const viewTop = scrollEl.scrollTop + const viewBottom = viewTop + scrollEl.clientHeight + if (rowTop - peek < viewTop) { + scrollEl.scrollTo({ top: Math.max(0, rowTop - peek) }) + } else if (rowTop + rowHeight + peek > viewBottom) { + scrollEl.scrollTo({ + top: rowTop + rowHeight + peek - scrollEl.clientHeight, + }) + } + }, + [photoRowItemIndex, items, cellSize] + ) + useGridKeyNav({ + rows: gridNavRows, + enabled: viewMode === 'grid' && currentSection !== 'duplicates', + scrollRowIntoView, }) - navStateRef.current = { - photoRows, - photos, - selectedPhotos, - activePhotoId, - photoRowItemIndex, - items, - cellSize, - } - useEffect(() => { - if (viewMode !== 'grid') return - // The duplicates section mounts its own grouped view (DuplicatesView) - // with its own keyboard nav — bail out so we don't double-handle - // arrow keys and try to navigate against a photoRows snapshot that - // doesn't match what the user actually sees on screen. - if (currentSection === 'duplicates') return - const handleKeyDown = (e: KeyboardEvent) => { - const { - photoRows, - photos, - selectedPhotos, - photoRowItemIndex, - items, - cellSize, - } = navStateRef.current - if (photoRows.length === 0) return - const target = e.target as HTMLElement | null - if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) { - return - } - - const findActive = (): { row: number; col: number } | null => { - const aid = navStateRef.current.activePhotoId - if (!aid) return null - for (let r = 0; r < photoRows.length; r++) { - const c = photoRows[r].cells.findIndex((cell) => cell.photo.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 = photoRows[nextRow].cells.length - 1 - } - while ( - nextRow < photoRows.length && - nextCol >= photoRows[nextRow].cells.length - ) { - if (nextRow === photoRows.length - 1) { - nextCol = photoRows[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 >= photoRows.length) nextRow = photoRows.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 = photoRows[nextRow].cells.length - if (nextCol >= rowLen) nextCol = rowLen - 1 - if (nextCol < 0) nextCol = 0 - } - - const dest = photoRows[nextRow]?.cells[nextCol] - if (!dest) return - if (e.shiftKey) { - selectRange(dest.photo.id) - } else { - selectPhoto(dest.photo.id) - } - // Bring the destination row into view if it's off-screen, leaving - // a "peek" margin so the next row above/below stays partly visible - // — cues the user that there's more content in the scroll direction. - // In-viewport moves are a no-op, so same-row arrow presses don't - // jitter the scroll position. - const itemIdx = photoRowItemIndex[nextRow] - const scrollEl = parentRef.current - if (itemIdx !== undefined && scrollEl) { - // Sum item heights up to itemIdx to get this row's offset in the - // virtualizer's coordinate space. Cheap enough at O(items) and - // avoids reaching into virtualizer.measurementsCache internals. - let rowTop = 0 - for (let i = 0; i < itemIdx; i++) rowTop += items[i].height - const rowHeight = items[itemIdx].height - const peek = Math.round(cellSize * 0.35) - const viewTop = scrollEl.scrollTop - const viewBottom = viewTop + scrollEl.clientHeight - if (rowTop - peek < viewTop) { - // Destination is above (or flush with) the viewport top. Leave - // `peek` pixels of the previous row visible above it. - scrollEl.scrollTo({ top: Math.max(0, rowTop - peek) }) - } else if (rowTop + rowHeight + peek > viewBottom) { - // Destination is below the viewport bottom. Leave `peek` pixels - // of the next row visible below it. - scrollEl.scrollTo({ - top: rowTop + rowHeight + peek - scrollEl.clientHeight, - }) - } - } - } - - 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() - photos.forEach((photo) => { - if (!selectedPhotos.includes(photo.id)) { - togglePhotoSelection(photo.id) - } - }) - } - break - case 'Escape': - e.preventDefault() - clearSelection() - break - } - } - - window.addEventListener('keydown', handleKeyDown) - return () => window.removeEventListener('keydown', handleKeyDown) - }, [ - viewMode, - currentSection, - selectRange, - selectPhoto, - togglePhotoSelection, - clearSelection, - ]) if (isLoading) { return ( diff --git a/frontend/src/hooks/useBulkPhotoMutations.ts b/frontend/src/hooks/useBulkPhotoMutations.ts index 3091564..bab3915 100644 --- a/frontend/src/hooks/useBulkPhotoMutations.ts +++ b/frontend/src/hooks/useBulkPhotoMutations.ts @@ -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', 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(['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) + } + } + } + } return snap } @@ -62,6 +79,27 @@ export function useBulkPhotoMutations() { const cur = queryClient.getQueryData(['photo', id]) if (cur) queryClient.setQueryData(['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 = {} + 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(['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>) => { @@ -81,6 +119,27 @@ export function useBulkPhotoMutations() { queryClient.setQueryData(['photo', id], { ...cur, ...orig }) } } + queryClient.setQueryData(['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({ diff --git a/frontend/src/hooks/useGridKeyNav.ts b/frontend/src/hooks/useGridKeyNav.ts new file mode 100644 index 0000000..4bd527e --- /dev/null +++ b/frontend/src/hooks/useGridKeyNav.ts @@ -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]) +} diff --git a/frontend/src/hooks/useKeyboardShortcuts.ts b/frontend/src/hooks/useKeyboardShortcuts.ts index 1154474..6265da3 100644 --- a/frontend/src/hooks/useKeyboardShortcuts.ts +++ b/frontend/src/hooks/useKeyboardShortcuts.ts @@ -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(['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(['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', 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 diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index ead380a..0facf51 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -600,6 +600,8 @@ export interface MemoryPhoto { width: number | null height: number | null rating: number + color_label: string | null + is_discarded: boolean } export interface MemoryGroup {