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

@@ -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() {
<div className="mt-0.5 text-sm text-text-muted">{toast.message}</div>
)}
</div>
{toast.action && (
<button
onClick={() => {
toast.action!.onClick()
removeToast(toast.id)
}}
className="pointer-events-auto self-center rounded border border-border bg-surface px-2 py-1 text-xs font-medium text-text hover:bg-surface-2"
>
{toast.action.label}
</button>
)}
<button
onClick={() => removeToast(toast.id)}
className="pointer-events-auto rounded p-0.5 text-text-muted hover:bg-surface-offset hover:text-text"