P already toggled membership in the active heap (remove if every selected photo is a member, otherwise add the missing ones), but the mutation only invalidate-then-refetched the heap-photo-ids cache on success. Pressing P twice in quick succession could read the stale cache and mis-toggle. Both heap-toggle mutations (useKeyboardShortcuts P shortcut and the RightSidebar Pick button) now do an optimistic update in onMutate: - Read the current ['heap-photo-ids', heapId] cache - Add or remove the affected ids in a Set - Write the new array back via setQueryData - Roll back from the captured `previous` on error - Re-sync via invalidateQueries in onSettled (heap counts in particular still need server truth) Result: the basket affordance flips the moment you press P, and a quick second press always reads the new state. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
220 lines
7.7 KiB
TypeScript
220 lines
7.7 KiB
TypeScript
import { useHotkeys } from 'react-hotkeys-hook'
|
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
|
import { usePhotoStore } from '../store/photoStore'
|
|
import { useFilterStore } from '../store/filterStore'
|
|
import { photos as photosApi, heaps as heapsApi, type Heap } from '../services/api'
|
|
import { HEAPS_QUERY_KEY } from './useHeapsQuery'
|
|
import { toast } from '../components/ToastContainer'
|
|
|
|
interface KeyboardShortcutsProps {
|
|
onToggleLeftSidebar: () => void
|
|
onToggleRightSidebar: () => void
|
|
/** Returns the first photo id in the current timeline, or null if empty. */
|
|
getFirstPhotoId?: () => string | null
|
|
}
|
|
|
|
interface PhotoUpdate {
|
|
rating?: number
|
|
is_discarded?: boolean
|
|
color_label?: string | null
|
|
}
|
|
|
|
// Spec §6.4 number-key color labels.
|
|
const COLOR_LABELS: Record<string, string> = {
|
|
'6': 'red',
|
|
'7': 'orange',
|
|
'8': 'yellow',
|
|
'9': 'green',
|
|
}
|
|
|
|
// Default options shared by every shortcut: preventDefault stops the browser
|
|
// from claiming the event (Firefox quick-find on letter keys, Cmd+F search,
|
|
// `/` quick-find, Tab focus traversal). enableOnFormTags is left default-off
|
|
// so typing in inputs doesn't fire culling shortcuts.
|
|
const HK_OPTS = { preventDefault: true } as const
|
|
|
|
export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
|
|
const { onToggleLeftSidebar, onToggleRightSidebar, getFirstPhotoId } = props
|
|
|
|
const viewMode = usePhotoStore((s) => s.viewMode)
|
|
const openPreview = usePhotoStore((s) => s.openPreview)
|
|
const closePreview = usePhotoStore((s) => s.closePreview)
|
|
|
|
const isPreview = viewMode === 'preview'
|
|
|
|
// Photo mutation shared by every culling shortcut. Reads the active photo
|
|
// id from the store at fire time so the closure stays fresh without forcing
|
|
// hotkey re-binding on every selection change.
|
|
const queryClient = useQueryClient()
|
|
const updateMutation = useMutation({
|
|
mutationFn: ({ id, data }: { id: string; data: PhotoUpdate }) =>
|
|
photosApi.update(id, data),
|
|
onSuccess: (_data, vars) => {
|
|
queryClient.invalidateQueries({ queryKey: ['photo', vars.id] })
|
|
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
|
},
|
|
})
|
|
|
|
const updateActive = (data: PhotoUpdate) => {
|
|
const id = usePhotoStore.getState().activePhotoId
|
|
if (!id) return
|
|
updateMutation.mutate({ id, data })
|
|
}
|
|
|
|
// P key (Pick): toggle the current selection's membership in the active
|
|
// heap. If every selected photo is already a member, remove them; otherwise
|
|
// add the missing ones. No active heap → toast hint.
|
|
const heapMutation = useMutation({
|
|
mutationFn: ({
|
|
heapId,
|
|
photoIds,
|
|
remove,
|
|
}: {
|
|
heapId: string
|
|
photoIds: string[]
|
|
remove: boolean
|
|
}) =>
|
|
remove
|
|
? heapsApi.removePhotos(heapId, photoIds)
|
|
: heapsApi.addPhotos(heapId, photoIds),
|
|
// Optimistically flip the membership cache so the basket affordance
|
|
// updates instantly and a quick second P press reads the new state
|
|
// (otherwise invalidate-then-refetch leaves a brief stale window).
|
|
onMutate: ({ heapId, photoIds, remove }) => {
|
|
const key = ['heap-photo-ids', heapId] as const
|
|
const previous = queryClient.getQueryData<string[]>(key)
|
|
const set = new Set(previous ?? [])
|
|
if (remove) photoIds.forEach((id) => set.delete(id))
|
|
else photoIds.forEach((id) => set.add(id))
|
|
queryClient.setQueryData<string[]>(key, Array.from(set))
|
|
return { previous }
|
|
},
|
|
onError: (e: any, _vars, ctx) => {
|
|
// Roll back the optimistic update on failure.
|
|
if (ctx?.previous) {
|
|
queryClient.setQueryData(['heap-photo-ids', _vars.heapId], ctx.previous)
|
|
}
|
|
toast.error('Heap update failed', e.message || 'Unknown error')
|
|
},
|
|
onSuccess: (data, vars) => {
|
|
const heap = (queryClient.getQueryData<Heap[]>(HEAPS_QUERY_KEY) ?? []).find(
|
|
(h) => h.id === vars.heapId
|
|
)
|
|
const heapName = heap?.name ?? 'heap'
|
|
if (vars.remove) {
|
|
const removed = data?.removed ?? 0
|
|
toast.success(`Removed from ${heapName}`, `${removed} photo${removed === 1 ? '' : 's'}`)
|
|
} else {
|
|
const added = data?.added ?? 0
|
|
const already = data?.already_present ?? 0
|
|
if (added > 0) {
|
|
toast.success(
|
|
`Added to ${heapName}`,
|
|
`${added} photo${added > 1 ? 's' : ''}${already > 0 ? ` (${already} already present)` : ''}`
|
|
)
|
|
}
|
|
}
|
|
},
|
|
onSettled: (_data, _err, vars) => {
|
|
// Re-sync with server truth (heap counts in particular need this).
|
|
queryClient.invalidateQueries({ queryKey: HEAPS_QUERY_KEY })
|
|
queryClient.invalidateQueries({ queryKey: ['heap-photo-ids', vars.heapId] })
|
|
},
|
|
})
|
|
|
|
const togglePickOnSelection = () => {
|
|
const state = usePhotoStore.getState()
|
|
const ids =
|
|
state.selectedPhotos.length > 0
|
|
? state.selectedPhotos
|
|
: state.activePhotoId
|
|
? [state.activePhotoId]
|
|
: []
|
|
if (ids.length === 0) {
|
|
toast.info('Nothing selected', 'Select photos first, then press P')
|
|
return
|
|
}
|
|
const heapsList = queryClient.getQueryData<Heap[]>(HEAPS_QUERY_KEY) ?? []
|
|
const active = heapsList.find((h) => h.is_active)
|
|
if (!active) {
|
|
toast.info('No active heap', 'Set an active heap (target icon next to a heap)')
|
|
return
|
|
}
|
|
// Determine direction: if every selected photo is already a member, this
|
|
// press REMOVES them; otherwise it ADDS the missing ones. Mirrors how
|
|
// Lightroom's flag-toggle works.
|
|
const memberIds =
|
|
queryClient.getQueryData<string[]>(['heap-photo-ids', active.id]) ?? []
|
|
const memberSet = new Set(memberIds)
|
|
const allMembers = ids.every((id) => memberSet.has(id))
|
|
heapMutation.mutate({
|
|
heapId: active.id,
|
|
photoIds: ids,
|
|
remove: allMembers,
|
|
})
|
|
}
|
|
|
|
// Toggle sidebars
|
|
useHotkeys('tab', onToggleLeftSidebar, HK_OPTS)
|
|
useHotkeys('i', onToggleRightSidebar, HK_OPTS)
|
|
|
|
// Filter bar toggle (\) and search focus (/ or Cmd/Ctrl+F).
|
|
useHotkeys('\\', () => useFilterStore.getState().toggleFilterBar(), HK_OPTS)
|
|
|
|
const focusSearch = () => {
|
|
const el = document.getElementById('topbar-search') as HTMLInputElement | null
|
|
el?.focus()
|
|
el?.select()
|
|
}
|
|
useHotkeys('/', focusSearch, HK_OPTS)
|
|
useHotkeys('mod+f', focusSearch, HK_OPTS)
|
|
|
|
// Space toggles the preview view (open from grid, close from preview).
|
|
// Double-click on a thumbnail does the same.
|
|
const openPreviewFromGrid = () => {
|
|
const id = usePhotoStore.getState().activePhotoId ?? getFirstPhotoId?.() ?? null
|
|
if (id) openPreview(id)
|
|
}
|
|
|
|
const togglePreview = () => {
|
|
if (isPreview) closePreview()
|
|
else openPreviewFromGrid()
|
|
}
|
|
|
|
useHotkeys('space', togglePreview, HK_OPTS, [isPreview, getFirstPhotoId])
|
|
|
|
// ── Culling shortcuts (work in both grid and preview) ────────────────────
|
|
|
|
// Star rating: 1-5 set, 0 clears.
|
|
useHotkeys(
|
|
'1,2,3,4,5',
|
|
(_e, handler) => {
|
|
const rating = parseInt(handler.keys![0])
|
|
if (Number.isFinite(rating)) updateActive({ rating })
|
|
},
|
|
HK_OPTS
|
|
)
|
|
|
|
useHotkeys('0', () => updateActive({ rating: 0 }), HK_OPTS)
|
|
|
|
// P (Pick) is unified with "add to active heap" — Pick a photo and you're
|
|
// adding it to the heap you set as active. Toggling on already-picked
|
|
// photos removes them from the heap.
|
|
useHotkeys('p', togglePickOnSelection, HK_OPTS)
|
|
|
|
useHotkeys('x', () => updateActive({ is_discarded: true }), HK_OPTS)
|
|
|
|
useHotkeys('u', () => updateActive({ is_discarded: false }), HK_OPTS)
|
|
|
|
// Color labels 6-9 (red/orange/yellow/green per spec §6.4).
|
|
useHotkeys(
|
|
'6,7,8,9',
|
|
(_e, handler) => {
|
|
const label = COLOR_LABELS[handler.keys![0]]
|
|
if (label) updateActive({ color_label: label })
|
|
},
|
|
HK_OPTS
|
|
)
|
|
|
|
}
|