import { useHotkeys } from 'react-hotkeys-hook' import { useMutation, useQueryClient } from '@tanstack/react-query' import { usePhotoStore } from '../store/photoStore' import { photos as photosApi, heaps as heapsApi, type Heap } from '../services/api' import { HEAPS_QUERY_KEY } from './useHeapsQuery' import { toast } from '../components/ToastContainer' import { registerUndoable, useUndoStore } from '../store/undoStore' import { LIBRARY_STATS_QUERY_KEY } from './useLibraryStatsQuery' 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 = { '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 invalidatePhotoQueries = () => { queryClient.invalidateQueries({ queryKey: ['photo'] }) queryClient.invalidateQueries({ queryKey: ['photos'] }) queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY }) } const bulkRatingMutation = useMutation({ mutationFn: ({ ids, rating }: { ids: string[]; rating: number }) => photosApi.bulkSetRating(ids, rating), onSuccess: invalidatePhotoQueries, onError: (e: any) => toast.error('Bulk rating failed', e.message || 'Unknown error'), }) const bulkColorMutation = useMutation({ mutationFn: ({ ids, color }: { ids: string[]; color: string | null }) => photosApi.bulkSetColor(ids, color), onSuccess: invalidatePhotoQueries, onError: (e: any) => toast.error('Bulk color failed', e.message || 'Unknown error'), }) const bulkDiscardMutation = useMutation({ mutationFn: (ids: string[]) => photosApi.bulkDiscard(ids), onSuccess: invalidatePhotoQueries, onError: (e: any) => toast.error('Bulk discard failed', e.message || 'Unknown error'), }) const bulkRestoreMutation = useMutation({ mutationFn: (ids: string[]) => photosApi.bulkRestore(ids), onSuccess: invalidatePhotoQueries, onError: (e: any) => toast.error('Bulk restore failed', e.message || 'Unknown error'), }) /** The set of photo ids the next culling action should apply to. * - Multi-selection → all selected photos * - Single selection → that one photo * - No selection but an activePhotoId set (last clicked) → that one * - Otherwise → empty */ const cullTargets = (): string[] => { const state = usePhotoStore.getState() if (state.selectedPhotos.length > 0) return state.selectedPhotos if (state.activePhotoId) return [state.activePhotoId] return [] } /** Apply a partial PhotoUpdate to the cull targets. Picks the right * bulk endpoint when there are 2+ photos so a single API call covers * the whole selection. */ const updateActive = (data: PhotoUpdate) => { const ids = cullTargets() if (ids.length === 0) return if (ids.length === 1) { const id = ids[0] updateMutation.mutate( { id, data }, { onSuccess: () => { // Only the discard/restore subset of single-photo updates is // undoable today — rating and color round-trip cleanly enough // that the manual fix is faster than maintaining per-photo // previous-value snapshots. if (data.is_discarded === true) { registerUndoable('Discarded 1 photo', async () => { await photosApi.bulkRestore([id]) invalidatePhotoQueries() }) } else if (data.is_discarded === false) { registerUndoable('Restored 1 photo', async () => { await photosApi.bulkDiscard([id]) invalidatePhotoQueries() }) } }, } ) return } // Multi-selection — fan out to the right bulk endpoint per field. if (data.rating !== undefined) { bulkRatingMutation.mutate({ ids, rating: data.rating }) } if (data.color_label !== undefined) { bulkColorMutation.mutate({ ids, color: data.color_label }) } if (data.is_discarded === true) { bulkDiscardMutation.mutate(ids, { onSuccess: () => { registerUndoable( `Discarded ${ids.length} photo${ids.length === 1 ? '' : 's'}`, async () => { await photosApi.bulkRestore(ids) invalidatePhotoQueries() } ) }, }) } else if (data.is_discarded === false) { bulkRestoreMutation.mutate(ids, { onSuccess: () => { registerUndoable( `Restored ${ids.length} photo${ids.length === 1 ? '' : 's'}`, async () => { await photosApi.bulkDiscard(ids) invalidatePhotoQueries() } ) }, }) } } // 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(key) const set = new Set(previous ?? []) if (remove) photoIds.forEach((id) => set.delete(id)) else photoIds.forEach((id) => set.add(id)) queryClient.setQueryData(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(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(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(['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. The right sidebar `i` shortcut is grid-only — in // preview mode the PreviewView mounts its own `i` handler for the // overlay info panel, and we don't want both to fire. useHotkeys('tab', onToggleLeftSidebar, HK_OPTS) useHotkeys('i', onToggleRightSidebar, { ...HK_OPTS, enabled: !isPreview }) // Cmd/Ctrl+Z → pop the most recent undoable action and reverse it. // Bound at the global level so it works in both grid and preview modes. useHotkeys( 'mod+z', async () => { const entry = useUndoStore.getState().pop() if (!entry) { toast.info('Nothing to undo') return } try { await entry.undo() } catch (e: any) { useUndoStore.getState().push({ label: entry.label, undo: entry.undo }) toast.error('Undo failed', e?.message || 'Unknown error') } }, HK_OPTS ) // Search focus (/ or Cmd/Ctrl+F). 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 state = usePhotoStore.getState() const id = state.activePhotoId ?? getFirstPhotoId?.() ?? null if (!id) return // Pass the current visible sequence explicitly so preview navigation // walks the order the user actually sees, even if the active photo // was selected before the publisher caught up. openPreview(id, state.visiblePhotoIds) } 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 ) }