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 = { '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(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 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 ) }