From 07b9660e92a2d199af4be83f847e8ca3a5dd135a Mon Sep 17 00:00:00 2001 From: dtoro Date: Wed, 8 Apr 2026 14:08:58 +0200 Subject: [PATCH] feat: undo for destructive photo actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- frontend/src/components/ToastContainer.tsx | 49 ++++++++--- .../components/discard/DiscardActionBar.tsx | 11 ++- .../src/components/layout/LeftSidebar.tsx | 84 +++++++++++++++---- frontend/src/hooks/useKeyboardShortcuts.ts | 69 ++++++++++++++- frontend/src/store/undoStore.ts | 71 ++++++++++++++++ frontend/src/types/photo.ts | 1 + 6 files changed, 256 insertions(+), 29 deletions(-) create mode 100644 frontend/src/store/undoStore.ts diff --git a/frontend/src/components/ToastContainer.tsx b/frontend/src/components/ToastContainer.tsx index 2598b9e..331f37b 100644 --- a/frontend/src/components/ToastContainer.tsx +++ b/frontend/src/components/ToastContainer.tsx @@ -2,12 +2,18 @@ import { useEffect, useState } from 'react' import { CheckCircle, XCircle, Info, AlertCircle, X } from 'lucide-react' import clsx from 'clsx' +export interface ToastAction { + label: string + onClick: () => void +} + export interface Toast { id: string type: 'success' | 'error' | 'info' | 'warning' title: string message?: string duration?: number + action?: ToastAction } // Global toast state (in production, use Zustand or Context) @@ -15,22 +21,34 @@ let toastListeners: ((toasts: Toast[]) => void)[] = [] let toastList: Toast[] = [] export const toast = { - success: (title: string, message?: string) => addToast('success', title, message), - error: (title: string, message?: string) => addToast('error', title, message), - info: (title: string, message?: string) => addToast('info', title, message), - warning: (title: string, message?: string) => addToast('warning', title, message), + success: (title: string, message?: string, action?: ToastAction) => + addToast('success', title, message, 5000, action), + error: (title: string, message?: string, action?: ToastAction) => + addToast('error', title, message, 5000, action), + info: (title: string, message?: string, action?: ToastAction) => + addToast('info', title, message, 5000, action), + warning: (title: string, message?: string, action?: ToastAction) => + addToast('warning', title, message, 5000, action), } -function addToast(type: Toast['type'], title: string, message?: string, duration = 5000) { - const id = Date.now().toString() - const newToast: Toast = { id, type, title, message, duration } +function addToast( + type: Toast['type'], + title: string, + message?: string, + duration = 5000, + action?: ToastAction +) { + const id = Date.now().toString() + Math.random().toString(36).slice(2, 6) + const newToast: Toast = { id, type, title, message, duration, action } toastList = [...toastList, newToast] toastListeners.forEach(listener => listener(toastList)) - - // Auto-remove after duration + + // Auto-remove after duration. Toasts with an action get a longer window + // so the user has time to actually click Undo. + const removeAfter = action ? Math.max(duration, 8000) : duration setTimeout(() => { removeToast(id) - }, duration) + }, removeAfter) } function removeToast(id: string) { @@ -82,6 +100,17 @@ export function ToastContainer() {
{toast.message}
)} + {toast.action && ( + + )}