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>
72 lines
2.2 KiB
TypeScript
72 lines
2.2 KiB
TypeScript
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))
|
|
}
|
|
},
|
|
})
|
|
}
|