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:
@@ -422,6 +422,8 @@ async def get_memories(
|
|||||||
Photo.width,
|
Photo.width,
|
||||||
Photo.height,
|
Photo.height,
|
||||||
Photo.rating,
|
Photo.rating,
|
||||||
|
Photo.color_label,
|
||||||
|
Photo.is_discarded,
|
||||||
)
|
)
|
||||||
.where(
|
.where(
|
||||||
Photo.user_id == current_user.id,
|
Photo.user_id == current_user.id,
|
||||||
@@ -454,6 +456,8 @@ async def get_memories(
|
|||||||
"width": row.width,
|
"width": row.width,
|
||||||
"height": row.height,
|
"height": row.height,
|
||||||
"rating": row.rating,
|
"rating": row.rating,
|
||||||
|
"color_label": row.color_label,
|
||||||
|
"is_discarded": row.is_discarded,
|
||||||
})
|
})
|
||||||
|
|
||||||
memories = [
|
memories = [
|
||||||
|
|||||||
@@ -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 { Copy, Layers, Sparkles, Trash2, Loader2, Info, Crown } from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { formatApiError } from '../../lib/apiError'
|
import { formatApiError } from '../../lib/apiError'
|
||||||
@@ -79,6 +80,10 @@ export function DuplicatesView() {
|
|||||||
() => groups.flatMap((g) => g.members.map((m) => m.id)),
|
() => groups.flatMap((g) => g.members.map((m) => m.id)),
|
||||||
[groups]
|
[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
|
// Track the rendered column count of the duplicates grid so ↑/↓ can
|
||||||
// skip a row instead of jumping a single cell. The grid uses
|
// 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
|
// We measure the FIRST section's grid container — every section uses
|
||||||
// the same auto-fill rule so any one is representative.
|
// the same auto-fill rule so any one is representative.
|
||||||
const [columns, setColumns] = useState(4)
|
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<ResizeObserver | null>(null)
|
||||||
const sampleGridRef = useCallback((el: HTMLDivElement | null) => {
|
const sampleGridRef = useCallback((el: HTMLDivElement | null) => {
|
||||||
|
sampleObserverRef.current?.disconnect()
|
||||||
|
sampleObserverRef.current = null
|
||||||
if (!el) return
|
if (!el) return
|
||||||
const measure = () => {
|
const measure = () => {
|
||||||
const cols = Math.max(1, Math.floor(el.clientWidth / 180))
|
const cols = Math.max(1, Math.floor(el.clientWidth / 180))
|
||||||
@@ -95,10 +106,24 @@ export function DuplicatesView() {
|
|||||||
measure()
|
measure()
|
||||||
const ro = new ResizeObserver(measure)
|
const ro = new ResizeObserver(measure)
|
||||||
ro.observe(el)
|
ro.observe(el)
|
||||||
// Caller doesn't get the cleanup hook but ResizeObserver disconnects
|
sampleObserverRef.current = ro
|
||||||
// when the element unmounts, which is fine for our lifecycle.
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// 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<HTMLDivElement | null>(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
|
// Window-level keyboard nav. Mirrors Timeline's handler but walks
|
||||||
// `allMemberIds` directly — duplicate groups don't have a uniform row
|
// `allMemberIds` directly — duplicate groups don't have a uniform row
|
||||||
// grid so we approximate ↑/↓ via the measured `columns` count and
|
// grid so we approximate ↑/↓ via the measured `columns` count and
|
||||||
@@ -135,18 +160,25 @@ export function DuplicatesView() {
|
|||||||
const nextId = allMemberIds[nextIdx]
|
const nextId = allMemberIds[nextIdx]
|
||||||
if (!nextId) return
|
if (!nextId) return
|
||||||
selectPhoto(nextId)
|
selectPhoto(nextId)
|
||||||
// Scroll the now-active cell into view if it's off-screen. The
|
// With the group list virtualised, the destination cell might not
|
||||||
// PhotoThumbnail wrapper carries data-dup-id so we can find it
|
// be mounted yet. Scroll its owning group into view first (cheap
|
||||||
// without threading refs through every cell.
|
// 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)
|
||||||
|
)
|
||||||
|
if (ownerIdx >= 0) virtualizer.scrollToIndex(ownerIdx, { align: 'auto' })
|
||||||
|
requestAnimationFrame(() => {
|
||||||
const el = document.querySelector<HTMLElement>(
|
const el = document.querySelector<HTMLElement>(
|
||||||
`[data-dup-id="${nextId}"]`
|
`[data-dup-id="${nextId}"]`
|
||||||
)
|
)
|
||||||
el?.scrollIntoView({ block: 'nearest', inline: 'nearest' })
|
el?.scrollIntoView({ block: 'nearest', inline: 'nearest' })
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('keydown', onKeyDown)
|
window.addEventListener('keydown', onKeyDown)
|
||||||
return () => window.removeEventListener('keydown', onKeyDown)
|
return () => window.removeEventListener('keydown', onKeyDown)
|
||||||
}, [allMemberIds, activePhotoId, columns, selectPhoto])
|
}, [allMemberIds, activePhotoId, columns, selectPhoto, virtualizer])
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
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 (
|
return (
|
||||||
<div className="h-full overflow-auto bg-bg p-4 pb-20">
|
<div ref={scrollRef} className="h-full overflow-auto bg-bg p-4 pb-20">
|
||||||
<div className="mb-4 flex items-center gap-2 text-xs text-text-muted">
|
<div className="mb-4 flex items-center gap-2 text-xs text-text-muted">
|
||||||
<Info className="h-3.5 w-3.5" />
|
<Info className="h-3.5 w-3.5" />
|
||||||
<span>
|
<span>
|
||||||
@@ -191,22 +242,43 @@ export function DuplicatesView() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div
|
||||||
{groups.map((group, idx) => (
|
style={{
|
||||||
<DuplicateGroupSection
|
height: virtualizer.getTotalSize(),
|
||||||
|
position: 'relative',
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{virtualizer.getVirtualItems().map((vItem) => {
|
||||||
|
const group = groups[vItem.index]
|
||||||
|
return (
|
||||||
|
<div
|
||||||
key={group.group_id}
|
key={group.group_id}
|
||||||
|
data-index={vItem.index}
|
||||||
|
ref={virtualizer.measureElement}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '100%',
|
||||||
|
transform: `translateY(${vItem.start}px)`,
|
||||||
|
paddingBottom: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DuplicateGroupSection
|
||||||
group={group}
|
group={group}
|
||||||
onKeepBest={(discardIds) => discardMutation.mutate(discardIds)}
|
onKeepBest={handleKeepBest}
|
||||||
onPreviewMember={(memberId) => openPreview(memberId, allMemberIds)}
|
onPreviewMember={handlePreviewMember}
|
||||||
onSelectMember={(memberId) => selectPhoto(memberId)}
|
onSelectMember={handleSelectMember}
|
||||||
selectedPhotos={selectedPhotos}
|
selectedPhotos={selectedPhotos}
|
||||||
isPending={discardMutation.isPending}
|
isPending={discardMutation.isPending}
|
||||||
// Hand the column-measurement ref to the first section only
|
// First section feeds the column-count sample ref —
|
||||||
// — every section's grid uses the same auto-fill rule so any
|
// every section uses the same auto-fill rule.
|
||||||
// one is representative of the rendered column count.
|
gridRef={vItem.index === 0 ? sampleGridRef : undefined}
|
||||||
gridRef={idx === 0 ? sampleGridRef : undefined}
|
|
||||||
/>
|
/>
|
||||||
))}
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -225,7 +297,7 @@ interface DuplicateGroupSectionProps {
|
|||||||
gridRef?: (el: HTMLDivElement | null) => void
|
gridRef?: (el: HTMLDivElement | null) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function DuplicateGroupSection({
|
const DuplicateGroupSection = memo(function DuplicateGroupSection({
|
||||||
group,
|
group,
|
||||||
onKeepBest,
|
onKeepBest,
|
||||||
onPreviewMember,
|
onPreviewMember,
|
||||||
@@ -251,6 +323,25 @@ function DuplicateGroupSection({
|
|||||||
const discardCount = group.member_count - 1
|
const discardCount = group.member_count - 1
|
||||||
const isExact = group.reason === 'exact'
|
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<string, Photo>()
|
||||||
|
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 (
|
return (
|
||||||
<section className="rounded-lg border border-border bg-surface">
|
<section className="rounded-lg border border-border bg-surface">
|
||||||
<header className="flex items-center justify-between gap-3 border-b border-border px-3 py-2">
|
<header className="flex items-center justify-between gap-3 border-b border-border px-3 py-2">
|
||||||
@@ -307,12 +398,12 @@ function DuplicateGroupSection({
|
|||||||
className="group/dup relative"
|
className="group/dup relative"
|
||||||
>
|
>
|
||||||
<PhotoThumbnail
|
<PhotoThumbnail
|
||||||
photo={memberToPhoto(member)}
|
photo={photoByMemberId.get(member.id)!}
|
||||||
size={180}
|
size={180}
|
||||||
fill
|
fill
|
||||||
isSelected={selectedPhotos.includes(member.id)}
|
isSelected={selectedSet.has(member.id)}
|
||||||
onClick={(p) => onSelectMember(p.id)}
|
onClick={handleClick}
|
||||||
onDoubleClick={(p) => onPreviewMember(p.id)}
|
onDoubleClick={handleDoubleClick}
|
||||||
/>
|
/>
|
||||||
{/* BEST pill — top-right, pick-coloured. Composes the same
|
{/* BEST pill — top-right, pick-coloured. Composes the same
|
||||||
* THUMB_BADGE_* family used by PhotoThumbnail so the full
|
* THUMB_BADGE_* family used by PhotoThumbnail so the full
|
||||||
@@ -373,7 +464,7 @@ function DuplicateGroupSection({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -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 { useQuery } from '@tanstack/react-query'
|
||||||
import { Loader2 } from 'lucide-react'
|
import { Loader2 } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
@@ -10,10 +10,13 @@ import type { Photo } from '../../types/photo'
|
|||||||
import { usePhotoStore } from '../../store/photoStore'
|
import { usePhotoStore } from '../../store/photoStore'
|
||||||
import { useFilterStore } from '../../store/filterStore'
|
import { useFilterStore } from '../../store/filterStore'
|
||||||
import { PhotoThumbnail } from '../timeline/PhotoThumbnail'
|
import { PhotoThumbnail } from '../timeline/PhotoThumbnail'
|
||||||
|
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
|
||||||
|
import { useGridKeyNav } from '../../hooks/useGridKeyNav'
|
||||||
|
|
||||||
const CELL_SIZE = 180
|
const CELL_SIZE = 180
|
||||||
const GAP = 4
|
const GAP = 4
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* "On this day" view. Same visual + interaction grid as Timeline —
|
* "On this day" view. Same visual + interaction grid as Timeline —
|
||||||
* PhotoThumbnail cells wired up to the shared photo store, so selection,
|
* 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 togglePhotoSelection = usePhotoStore((s) => s.togglePhotoSelection)
|
||||||
const selectRange = usePhotoStore((s) => s.selectRange)
|
const selectRange = usePhotoStore((s) => s.selectRange)
|
||||||
const openPreview = usePhotoStore((s) => s.openPreview)
|
const openPreview = usePhotoStore((s) => s.openPreview)
|
||||||
|
const viewMode = usePhotoStore((s) => s.viewMode)
|
||||||
const searchQuery = useFilterStore((s) => s.q)
|
const searchQuery = useFilterStore((s) => s.q)
|
||||||
|
const { memberIds: activeHeapMembers } = useActiveHeapMembers()
|
||||||
|
|
||||||
const visibleSequenceRef = useRef<string[]>([])
|
const visibleSequenceRef = useRef<string[]>([])
|
||||||
visibleSequenceRef.current = visibleSequence
|
visibleSequenceRef.current = visibleSequence
|
||||||
@@ -66,6 +71,73 @@ export function MemoriesView() {
|
|||||||
[openPreview],
|
[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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center text-text-muted">
|
<div className="flex h-full items-center justify-center text-text-muted">
|
||||||
@@ -90,6 +162,7 @@ export function MemoriesView() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
className="h-full overflow-auto bg-bg p-4"
|
className="h-full overflow-auto bg-bg p-4"
|
||||||
role="grid"
|
role="grid"
|
||||||
aria-label="Memories"
|
aria-label="Memories"
|
||||||
@@ -99,7 +172,7 @@ export function MemoriesView() {
|
|||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{memories.map((group) => (
|
{memories.map((group, groupIdx) => (
|
||||||
<section key={group.year} className="space-y-2">
|
<section key={group.year} className="space-y-2">
|
||||||
<header className="flex items-baseline gap-2">
|
<header className="flex items-baseline gap-2">
|
||||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-text">
|
<h3 className="text-sm font-semibold uppercase tracking-wide text-text">
|
||||||
@@ -110,6 +183,9 @@ export function MemoriesView() {
|
|||||||
</span>
|
</span>
|
||||||
</header>
|
</header>
|
||||||
<div
|
<div
|
||||||
|
ref={(el) => {
|
||||||
|
gridRefs.current[groupIdx] = el
|
||||||
|
}}
|
||||||
className="grid"
|
className="grid"
|
||||||
style={{
|
style={{
|
||||||
gridTemplateColumns: `repeat(auto-fill, minmax(${CELL_SIZE}px, 1fr))`,
|
gridTemplateColumns: `repeat(auto-fill, minmax(${CELL_SIZE}px, 1fr))`,
|
||||||
@@ -118,16 +194,18 @@ export function MemoriesView() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{group.photos.map((m) => (
|
{group.photos.map((m) => (
|
||||||
|
<div key={m.id} data-photo-id={m.id}>
|
||||||
<PhotoThumbnail
|
<PhotoThumbnail
|
||||||
key={m.id}
|
|
||||||
photo={memoryToPhoto(m)}
|
photo={memoryToPhoto(m)}
|
||||||
size={CELL_SIZE}
|
size={CELL_SIZE}
|
||||||
fill
|
fill
|
||||||
isSelected={selectedPhotos.includes(m.id)}
|
isSelected={selectedPhotos.includes(m.id)}
|
||||||
|
isInActiveHeap={activeHeapMembers.has(m.id)}
|
||||||
searchQuery={searchQuery}
|
searchQuery={searchQuery}
|
||||||
onClick={handleCellClick}
|
onClick={handleCellClick}
|
||||||
onDoubleClick={handleCellDoubleClick}
|
onDoubleClick={handleCellDoubleClick}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -151,7 +229,8 @@ function memoryToPhoto(m: MemoryPhoto): Photo {
|
|||||||
height: m.height,
|
height: m.height,
|
||||||
taken_at: m.taken_at,
|
taken_at: m.taken_at,
|
||||||
rating: m.rating,
|
rating: m.rating,
|
||||||
is_discarded: false,
|
color_label: m.color_label,
|
||||||
|
is_discarded: m.is_discarded,
|
||||||
is_duplicate: false,
|
is_duplicate: false,
|
||||||
file_hash: '',
|
file_hash: '',
|
||||||
folder_id: null,
|
folder_id: null,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useFilterStore, hasActiveFilters } from '../../store/filterStore'
|
|||||||
import { PhotoThumbnail } from './PhotoThumbnail'
|
import { PhotoThumbnail } from './PhotoThumbnail'
|
||||||
import { usePhotosQuery } from '../../hooks/usePhotosQuery'
|
import { usePhotosQuery } from '../../hooks/usePhotosQuery'
|
||||||
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
|
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
|
||||||
|
import { useGridKeyNav } from '../../hooks/useGridKeyNav'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { ImageOff } from 'lucide-react'
|
import { ImageOff } from 'lucide-react'
|
||||||
import type { Photo } from '../../types/photo'
|
import type { Photo } from '../../types/photo'
|
||||||
@@ -133,7 +134,6 @@ export function Timeline() {
|
|||||||
selectPhoto,
|
selectPhoto,
|
||||||
togglePhotoSelection,
|
togglePhotoSelection,
|
||||||
selectRange,
|
selectRange,
|
||||||
clearSelection,
|
|
||||||
openPreview,
|
openPreview,
|
||||||
} = usePhotoStore()
|
} = usePhotoStore()
|
||||||
// Pulled via a focused selector so the publisher subscription doesn't
|
// Pulled via a focused selector so the publisher subscription doesn't
|
||||||
@@ -528,122 +528,26 @@ export function Timeline() {
|
|||||||
visibleSequenceRef.current = visibleSequence
|
visibleSequenceRef.current = visibleSequence
|
||||||
}, [visibleSequence, setVisiblePhotoIds])
|
}, [visibleSequence, setVisiblePhotoIds])
|
||||||
|
|
||||||
// Locate the active photo in the visual grid. Returns the FIRST
|
// Keyboard grid nav (arrows / Ctrl+A / Escape) is shared with
|
||||||
// Handle keyboard shortcuts for photo navigation. Operates on the
|
// MemoriesView via useGridKeyNav. Timeline provides its own scroll
|
||||||
// grouped grid the user sees, so a half-full last row of a group
|
// callback because row geometry comes from the TanStack virtualizer's
|
||||||
// doesn't make ArrowDown skip into the wrong place.
|
// item heights; MemoriesView reads DOM offsets instead.
|
||||||
//
|
//
|
||||||
// Inert in preview mode — PreviewView mounts its own arrow handlers,
|
// Inert in preview mode (PreviewView has its own arrow handlers) and
|
||||||
// and a window-level grid handler firing alongside them used to race
|
// in the duplicates section (DuplicatesView manages its own grouped
|
||||||
// against PreviewView's setActivePhoto, landing the user on the wrong
|
// nav against a different snapshot of the visible grid).
|
||||||
// photo. The grid handler stays attached so it can re-engage the
|
const gridNavRows = useMemo(
|
||||||
// moment the user closes preview.
|
() =>
|
||||||
//
|
photoRows.map((row) => ({
|
||||||
// The handler reads its inputs through a ref so the listener can bind
|
cells: row.cells.map((c) => ({ id: c.photo.id })),
|
||||||
// once per (viewMode, currentSection) — every other dep used to be in
|
})),
|
||||||
// the array and triggered an unbind/rebind on every state tick (eight
|
[photoRows]
|
||||||
// values, several with new identity each render).
|
)
|
||||||
const navStateRef = useRef({
|
const scrollRowIntoView = useCallback(
|
||||||
photoRows,
|
(rowIdx: number) => {
|
||||||
photos,
|
const itemIdx = photoRowItemIndex[rowIdx]
|
||||||
selectedPhotos,
|
|
||||||
activePhotoId,
|
|
||||||
photoRowItemIndex,
|
|
||||||
items,
|
|
||||||
cellSize,
|
|
||||||
})
|
|
||||||
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
|
const scrollEl = parentRef.current
|
||||||
if (itemIdx !== undefined && scrollEl) {
|
if (itemIdx === undefined || !scrollEl) return
|
||||||
// Sum item heights up to itemIdx to get this row's offset in the
|
// Sum item heights up to itemIdx to get this row's offset in the
|
||||||
// virtualizer's coordinate space. Cheap enough at O(items) and
|
// virtualizer's coordinate space. Cheap enough at O(items) and
|
||||||
// avoids reaching into virtualizer.measurementsCache internals.
|
// avoids reaching into virtualizer.measurementsCache internals.
|
||||||
@@ -654,63 +558,20 @@ export function Timeline() {
|
|||||||
const viewTop = scrollEl.scrollTop
|
const viewTop = scrollEl.scrollTop
|
||||||
const viewBottom = viewTop + scrollEl.clientHeight
|
const viewBottom = viewTop + scrollEl.clientHeight
|
||||||
if (rowTop - peek < viewTop) {
|
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) })
|
scrollEl.scrollTo({ top: Math.max(0, rowTop - peek) })
|
||||||
} else if (rowTop + rowHeight + peek > viewBottom) {
|
} else if (rowTop + rowHeight + peek > viewBottom) {
|
||||||
// Destination is below the viewport bottom. Leave `peek` pixels
|
|
||||||
// of the next row visible below it.
|
|
||||||
scrollEl.scrollTo({
|
scrollEl.scrollTo({
|
||||||
top: rowTop + rowHeight + peek - scrollEl.clientHeight,
|
top: rowTop + rowHeight + peek - scrollEl.clientHeight,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
[photoRowItemIndex, items, cellSize]
|
||||||
|
)
|
||||||
switch (e.key) {
|
useGridKeyNav({
|
||||||
case 'ArrowUp':
|
rows: gridNavRows,
|
||||||
e.preventDefault()
|
enabled: viewMode === 'grid' && currentSection !== 'duplicates',
|
||||||
move(-1, 0)
|
scrollRowIntoView,
|
||||||
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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { toast } from '../components/ToastContainer'
|
|||||||
import { formatApiError } from '../lib/apiError'
|
import { formatApiError } from '../lib/apiError'
|
||||||
import { LIBRARY_STATS_QUERY_KEY } from './useLibraryStatsQuery'
|
import { LIBRARY_STATS_QUERY_KEY } from './useLibraryStatsQuery'
|
||||||
import type { Photo } from '../types/photo'
|
import type { Photo } from '../types/photo'
|
||||||
|
import type { MemoriesResponse, MemoryPhoto } from '../services/api'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Centralised bulk-mutation hook used by both the RightSidebar multi-
|
* Centralised bulk-mutation hook used by both the RightSidebar multi-
|
||||||
@@ -48,6 +49,22 @@ export function useBulkPhotoMutations() {
|
|||||||
const single = queryClient.getQueryData<Photo>(['photo', id])
|
const single = queryClient.getQueryData<Photo>(['photo', id])
|
||||||
if (single) snap.set(id, { ...single })
|
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
|
return snap
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,6 +79,27 @@ export function useBulkPhotoMutations() {
|
|||||||
const cur = queryClient.getQueryData<Photo>(['photo', id])
|
const cur = queryClient.getQueryData<Photo>(['photo', id])
|
||||||
if (cur) queryClient.setQueryData<Photo>(['photo', id], { ...cur, ...patch })
|
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>>) => {
|
const restoreFromSnapshot = (snap: Map<string, Partial<Photo>>) => {
|
||||||
@@ -81,6 +119,27 @@ export function useBulkPhotoMutations() {
|
|||||||
queryClient.setQueryData<Photo>(['photo', id], { ...cur, ...orig })
|
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({
|
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 { useBulkPhotoMutations } from './useBulkPhotoMutations'
|
||||||
import { formatApiError } from '../lib/apiError'
|
import { formatApiError } from '../lib/apiError'
|
||||||
import type { Photo } from '../types/photo'
|
import type { Photo } from '../types/photo'
|
||||||
|
import type { MemoriesResponse, MemoryPhoto } from '../services/api'
|
||||||
|
|
||||||
interface KeyboardShortcutsProps {
|
interface KeyboardShortcutsProps {
|
||||||
onToggleLeftSidebar: () => void
|
onToggleLeftSidebar: () => void
|
||||||
@@ -76,6 +77,21 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
|
|||||||
? prev.map((p) => (set.has(p.id) ? { ...p, is_discarded: discarded } : p))
|
? prev.map((p) => (set.has(p.id) ? { ...p, is_discarded: discarded } : p))
|
||||||
: prev
|
: 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
|
/** 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)
|
const p = list.find((x) => x.id === id)
|
||||||
if (p) return p
|
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])
|
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-
|
// Discard / restore mutation — the only path that doesn't auto-
|
||||||
// invalidate ['photos']. Invalidating would refetch with the active
|
// invalidate ['photos']. Invalidating would refetch with the active
|
||||||
// filter (which excludes discarded photos in every section except
|
// filter (which excludes discarded photos in every section except
|
||||||
|
|||||||
@@ -600,6 +600,8 @@ export interface MemoryPhoto {
|
|||||||
width: number | null
|
width: number | null
|
||||||
height: number | null
|
height: number | null
|
||||||
rating: number
|
rating: number
|
||||||
|
color_label: string | null
|
||||||
|
is_discarded: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MemoryGroup {
|
export interface MemoryGroup {
|
||||||
|
|||||||
Reference in New Issue
Block a user