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>
172 lines
6.4 KiB
TypeScript
172 lines
6.4 KiB
TypeScript
import { useState } from 'react'
|
|
import { RotateCcw, Trash2 } from 'lucide-react'
|
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
|
import { usePhotoStore } from '../../store/photoStore'
|
|
import { useFilterStore } from '../../store/filterStore'
|
|
import { usePhotosQuery } from '../../hooks/usePhotosQuery'
|
|
import { discard as discardApi, photos as photosApi } from '../../services/api'
|
|
import { toast } from '../ToastContainer'
|
|
import { ConfirmDialog } from '../dialogs/ConfirmDialog'
|
|
import { registerUndoable } from '../../store/undoStore'
|
|
|
|
/**
|
|
* Top-of-timeline bar visible only when the discarded filter is active.
|
|
* Shows a count, lets the user restore the current selection, and lets them
|
|
* permanently empty the discard pile (with confirmation).
|
|
*/
|
|
export function DiscardActionBar() {
|
|
const flag = useFilterStore((s) => s.flag)
|
|
const selectedPhotos = usePhotoStore((s) => s.selectedPhotos)
|
|
const clearSelection = usePhotoStore((s) => s.clearSelection)
|
|
const queryClient = useQueryClient()
|
|
const { data: photos = [] } = usePhotosQuery()
|
|
|
|
const [confirmOpen, setConfirmOpen] = useState(false)
|
|
const [deleteSelectedOpen, setDeleteSelectedOpen] = useState(false)
|
|
|
|
const restoreMutation = useMutation({
|
|
mutationFn: (ids: string[]) => discardApi.restore(ids),
|
|
onSuccess: (_, ids) => {
|
|
registerUndoable(
|
|
`Restored ${ids.length} photo${ids.length === 1 ? '' : 's'}`,
|
|
async () => {
|
|
await photosApi.bulkDiscard(ids)
|
|
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
|
}
|
|
)
|
|
clearSelection()
|
|
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
|
},
|
|
onError: (e: any) => toast.error('Restore failed', e.message || 'Unknown error'),
|
|
})
|
|
|
|
const deleteSelectedMutation = useMutation({
|
|
mutationFn: (ids: string[]) => discardApi.deletePermanent(ids),
|
|
onSuccess: (data: any) => {
|
|
const count = data?.deleted ?? 0
|
|
const errors = data?.file_errors ?? 0
|
|
if (errors > 0) {
|
|
toast.error(
|
|
`Deleted with ${errors} error${errors > 1 ? 's' : ''}`,
|
|
`${count} record${count === 1 ? '' : 's'} deleted; some files could not be removed`
|
|
)
|
|
} else {
|
|
toast.success(
|
|
'Permanently deleted',
|
|
`${count} photo${count === 1 ? '' : 's'} removed from disk`
|
|
)
|
|
}
|
|
clearSelection()
|
|
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
|
setDeleteSelectedOpen(false)
|
|
},
|
|
onError: (e: any) =>
|
|
toast.error('Delete failed', e.message || 'Unknown error'),
|
|
})
|
|
|
|
const emptyMutation = useMutation({
|
|
mutationFn: () => discardApi.empty(),
|
|
onSuccess: (data: any) => {
|
|
const count = data?.deleted ?? 0
|
|
const errors = data?.file_errors ?? 0
|
|
if (errors > 0) {
|
|
toast.error(
|
|
`Emptied with ${errors} error${errors > 1 ? 's' : ''}`,
|
|
`${count} record${count > 1 ? 's' : ''} deleted; some files could not be removed`
|
|
)
|
|
} else {
|
|
toast.success('Discard pile emptied', `${count} photo${count > 1 ? 's' : ''} permanently deleted`)
|
|
}
|
|
clearSelection()
|
|
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
|
setConfirmOpen(false)
|
|
},
|
|
onError: (e: any) => toast.error('Empty failed', e.message || 'Unknown error'),
|
|
})
|
|
|
|
if (flag !== 'discarded') return null
|
|
|
|
const total = photos.length
|
|
const selected = selectedPhotos.length
|
|
|
|
return (
|
|
<>
|
|
<div className="flex items-center justify-between gap-3 border-b border-border bg-reject/10 px-4 py-2 text-sm">
|
|
<div className="flex items-center gap-2 text-text">
|
|
<Trash2 className="h-4 w-4 text-reject" />
|
|
<span className="font-medium">Discarded</span>
|
|
<span className="text-text-muted">
|
|
{total} photo{total === 1 ? '' : 's'}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
{selected > 0 && (
|
|
<>
|
|
<button
|
|
onClick={() => restoreMutation.mutate(selectedPhotos)}
|
|
disabled={restoreMutation.isPending}
|
|
className="flex items-center gap-1.5 rounded bg-surface-2 px-3 py-1 text-text hover:bg-surface-offset disabled:opacity-50"
|
|
title="Restore selected (U)"
|
|
>
|
|
<RotateCcw className="h-3.5 w-3.5" />
|
|
Restore {selected}
|
|
</button>
|
|
<button
|
|
onClick={() => setDeleteSelectedOpen(true)}
|
|
disabled={deleteSelectedMutation.isPending}
|
|
className="flex items-center gap-1.5 rounded bg-reject/20 px-3 py-1 text-reject hover:bg-reject/30 disabled:opacity-50"
|
|
title="Permanently delete selected"
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
Delete {selected}
|
|
</button>
|
|
</>
|
|
)}
|
|
<button
|
|
onClick={() => setConfirmOpen(true)}
|
|
disabled={total === 0 || emptyMutation.isPending}
|
|
className="flex items-center gap-1.5 rounded bg-reject/20 px-3 py-1 text-reject hover:bg-reject/30 disabled:opacity-50"
|
|
title="Permanently delete all discarded photos and files"
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
Empty discard pile
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<ConfirmDialog
|
|
isOpen={deleteSelectedOpen}
|
|
title={`Delete ${selected} photo${selected === 1 ? '' : 's'}?`}
|
|
message={
|
|
<>
|
|
This will <strong className="text-text">permanently delete</strong>{' '}
|
|
{selected} photo{selected === 1 ? '' : 's'} and remove the file
|
|
{selected === 1 ? '' : 's'} from disk. This cannot be undone.
|
|
</>
|
|
}
|
|
confirmLabel="Delete"
|
|
destructive
|
|
onConfirm={() => deleteSelectedMutation.mutate(selectedPhotos)}
|
|
onClose={() => setDeleteSelectedOpen(false)}
|
|
/>
|
|
|
|
<ConfirmDialog
|
|
isOpen={confirmOpen}
|
|
title="Empty discard pile?"
|
|
message={
|
|
<>
|
|
This will <strong className="text-text">permanently delete</strong>{' '}
|
|
{total} photo{total === 1 ? '' : 's'} and remove the file
|
|
{total === 1 ? '' : 's'} from disk. This cannot be undone.
|
|
</>
|
|
}
|
|
confirmLabel="Empty pile"
|
|
destructive
|
|
onConfirm={() => emptyMutation.mutate()}
|
|
onClose={() => setConfirmOpen(false)}
|
|
/>
|
|
</>
|
|
)
|
|
}
|