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

@@ -0,0 +1,71 @@
import { create } from 'zustand'
import { toast } from '../components/ToastContainer'
const MAX_STACK = 20
export interface UndoEntry {
id: string
/** Short description of what happened, e.g. "Discarded 12 photos". */
label: string
/** Function that reverses the action. May be async; errors should be
* surfaced via toast.error from inside the function. */
undo: () => void | Promise<void>
}
interface UndoStore {
stack: UndoEntry[]
/** Push a new entry. Caps the stack at MAX_STACK by dropping the oldest. */
push: (entry: Omit<UndoEntry, 'id'>) => void
/** Pop the most recent entry. Returns null when the stack is empty. */
pop: () => UndoEntry | null
clear: () => void
}
export const useUndoStore = create<UndoStore>((set, get) => ({
stack: [],
push: (entry) => {
const id = Date.now().toString() + Math.random().toString(36).slice(2, 6)
set((s) => {
const next = [...s.stack, { ...entry, id }]
if (next.length > MAX_STACK) next.shift()
return { stack: next }
})
},
pop: () => {
const stack = get().stack
if (stack.length === 0) return null
const last = stack[stack.length - 1]
set({ stack: stack.slice(0, -1) })
return last
},
clear: () => set({ stack: [] }),
}))
/**
* Convenience: register an undoable action AND show the user a success
* toast with an inline Undo button. The toast and Cmd+Z hotkey both pop
* from the same stack so either path works.
*/
export function registerUndoable(label: string, undo: () => void | Promise<void>) {
useUndoStore.getState().push({ label, undo })
toast.success(label, 'Press ⌘Z to undo', {
label: 'Undo',
onClick: async () => {
// Pop the entry we just pushed (or whatever is now on top, if the
// user fired multiple actions in quick succession — Undo always
// reverses the most recent thing).
const entry = useUndoStore.getState().pop()
if (!entry) return
try {
await entry.undo()
} catch (e) {
// Re-push so the user can try again, and surface the failure.
useUndoStore.getState().push({ label: entry.label, undo: entry.undo })
toast.error('Undo failed', e instanceof Error ? e.message : String(e))
}
},
})
}