feat: undo for destructive photo actions

Add a global last-action stack with toast-based "Undo" buttons and a
Cmd/Ctrl+Z hotkey for the destructive photo operations.

Reversible:
- X (discard) → bulkRestore
- U (restore) → bulkDiscard
- Drag-onto-Discarded → bulkRestore
- Drag-onto-folder (move) → move back to per-photo source folders. The
  source folder ids are snapshotted from the photos cache before the
  move runs, then grouped so multi-source moves restore correctly.
- Restore button in the discard action bar → bulkDiscard

Toast gains an optional action button (label + onClick); toasts with an
action stay visible longer so the user has time to click. The undo
store caps at 20 entries; failed undo re-pushes the entry so the user
can try again.

Not reversible (call out, document later): rating, color label, copy,
permanent delete from trash, tag changes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-08 14:08:58 +02:00
parent b870084be0
commit 07b9660e92
6 changed files with 256 additions and 29 deletions

View File

@@ -4,6 +4,7 @@ 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'
interface KeyboardShortcutsProps {
onToggleLeftSidebar: () => void
@@ -106,7 +107,29 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
if (ids.length === 0) return
if (ids.length === 1) {
updateMutation.mutate({ id: ids[0], data })
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
}
@@ -118,9 +141,29 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
bulkColorMutation.mutate({ ids, color: data.color_label })
}
if (data.is_discarded === true) {
bulkDiscardMutation.mutate(ids)
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)
bulkRestoreMutation.mutate(ids, {
onSuccess: () => {
registerUndoable(
`Restored ${ids.length} photo${ids.length === 1 ? '' : 's'}`,
async () => {
await photosApi.bulkDiscard(ids)
invalidatePhotoQueries()
}
)
},
})
}
}
@@ -223,6 +266,26 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
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