feat: wire culling keyboard shortcuts to photo mutations

Replaces the console.log stubs in useKeyboardShortcuts with real
PATCH /photos/{id} mutations against the active photo, so 1-5 / 0 / P /
X / U / 6-9 actually rate, flag, and color-label photos. Mutations
invalidate both the photo detail query and the timeline list query, so
the RightSidebar and grid update immediately.

Shortcuts now work in BOTH grid and loupe modes (the previous
{ enabled: isGrid } gate is removed) so the user can cull while
browsing in the loupe — the Lightroom workflow.

Color labels 6-9 are wired to red/orange/yellow/green per spec §6.4.

Active photo id is read fresh via usePhotoStore.getState() inside each
handler, so we don't re-bind hotkeys on every selection change.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-07 21:44:47 +02:00
parent 9089ad2f61
commit 892e8e1da4

View File

@@ -1,5 +1,7 @@
import { useHotkeys } from 'react-hotkeys-hook'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { usePhotoStore } from '../store/photoStore'
import { photos as photosApi } from '../services/api'
interface KeyboardShortcutsProps {
onToggleLeftSidebar: () => void
@@ -8,17 +10,50 @@ interface KeyboardShortcutsProps {
getFirstPhotoId?: () => string | null
}
interface PhotoUpdate {
rating?: number
is_picked?: boolean
is_rejected?: 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',
}
export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
const { onToggleLeftSidebar, onToggleRightSidebar, getFirstPhotoId } = props
const viewMode = usePhotoStore((s) => s.viewMode)
const activePhotoId = usePhotoStore((s) => s.activePhotoId)
const openLoupe = usePhotoStore((s) => s.openLoupe)
const closeLoupe = usePhotoStore((s) => s.closeLoupe)
const isGrid = viewMode === 'grid'
const isLoupe = viewMode === 'loupe'
// 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 })
}
// Toggle sidebars (allowed in both modes; right sidebar is hidden in loupe
// by App-level CSS so toggling it is effectively grid-only.)
useHotkeys('tab', (e) => {
@@ -31,15 +66,14 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
onToggleRightSidebar()
})
// Grid view: G always returns to grid (closes loupe if open).
// G always returns to grid (closes loupe if open).
useHotkeys('g', () => {
closeLoupe()
})
// Loupe view: E toggles loupe (open from grid, close from loupe).
// Enter also opens loupe from grid.
// E toggles loupe (open from grid, close from loupe). Enter opens from grid.
const openLoupeFromGrid = () => {
const id = activePhotoId ?? getFirstPhotoId?.() ?? null
const id = usePhotoStore.getState().activePhotoId ?? getFirstPhotoId?.() ?? null
if (id) openLoupe(id)
}
@@ -52,7 +86,7 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
openLoupeFromGrid()
}
},
[isLoupe, activePhotoId, getFirstPhotoId]
[isLoupe, getFirstPhotoId]
)
useHotkeys(
@@ -64,33 +98,37 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
}
},
{ enabled: isGrid },
[isGrid, activePhotoId, getFirstPhotoId]
[isGrid, getFirstPhotoId]
)
// Rating shortcuts (grid only — stubs from a different phase)
useHotkeys(
'1,2,3,4,5',
(_e, handler) => {
const rating = parseInt(handler.keys![0])
console.log('Set rating:', rating)
},
{ enabled: isGrid }
)
// ── Culling shortcuts (work in both grid and loupe) ──────────────────────
// 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 })
})
useHotkeys('0', () => {
console.log('Remove rating')
}, { enabled: isGrid })
updateActive({ rating: 0 })
})
// Flag shortcuts (grid only)
// Pick / reject / unflag.
useHotkeys('p', () => {
console.log('Pick photo')
}, { enabled: isGrid })
updateActive({ is_picked: true, is_rejected: false })
})
useHotkeys('x', () => {
console.log('Reject photo')
}, { enabled: isGrid })
updateActive({ is_rejected: true, is_picked: false })
})
useHotkeys('u', () => {
console.log('Unflag photo')
}, { enabled: isGrid })
updateActive({ is_picked: false, is_rejected: false })
})
// 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 })
})
}