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:
71
frontend/src/store/undoStore.ts
Normal file
71
frontend/src/store/undoStore.ts
Normal 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))
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user