feat: discard view with restore and empty actions

Adds the destructive-action loop the discard concept needed:

- Click "Discarded" in the left sidebar → activates the discarded
  filter; the timeline reloads showing discarded photos.
- DiscardActionBar appears at the top of the timeline only when the
  discarded filter is active. Shows the count, a Restore button (when
  photos are selected), and an Empty discard pile button.
- Empty action goes through a ConfirmDialog (new tiny reusable modal,
  same overlay pattern as AddSourceFolderDialog).
- Restore goes through POST /api/v1/discard/restore.
- DELETE /api/v1/discard/empty now actually os.unlink()s the files
  from disk in addition to removing the DB rows. Per-file failures
  are logged and reported in the response so a single permission
  error doesn't abort the batch.

Other library nodes wired in passing:
- "All Photos"   → clearAll()
- "Rated"        → setRatingMin(1)
- "Flagged"      → setFlag('picked')
- "Discarded"    → setFlag('discarded')
- "By Date"      left unwired (needs a date-grouping UI)

Single-photo restore via the U keyboard shortcut already worked from
an earlier round, no change needed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-07 22:59:34 +02:00
parent c7d2cc47e1
commit 322969c938
5 changed files with 240 additions and 4 deletions

View File

@@ -9,6 +9,7 @@ import { KeyboardHints } from './components/KeyboardHints'
import { PreviewView } from './components/preview/PreviewView'
import { FilterBar } from './components/filter/FilterBar'
import { ActiveFilterChips } from './components/filter/ActiveFilterChips'
import { DiscardActionBar } from './components/discard/DiscardActionBar'
import { usePhotoStore } from './store/photoStore'
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
import { useFilterUrlSync } from './hooks/useFilterUrlSync'
@@ -52,6 +53,7 @@ function App() {
<TopBar />
<FilterBar />
<ActiveFilterChips />
<DiscardActionBar />
<KeyboardHints />
<div className="flex flex-1 overflow-hidden">

View File

@@ -0,0 +1,75 @@
import { useEffect } from 'react'
import clsx from 'clsx'
interface ConfirmDialogProps {
isOpen: boolean
title: string
message: React.ReactNode
confirmLabel?: string
cancelLabel?: string
/** When true, the confirm button uses the destructive accent. */
destructive?: boolean
onConfirm: () => void
onClose: () => void
}
/**
* Tiny modal-confirmation dialog. Mirrors the AddSourceFolderDialog overlay
* pattern (custom fixed inset-0 backdrop, no shadcn Dialog dep). Esc closes.
*/
export function ConfirmDialog({
isOpen,
title,
message,
confirmLabel = 'Confirm',
cancelLabel = 'Cancel',
destructive = false,
onConfirm,
onClose,
}: ConfirmDialogProps) {
// Esc to close.
useEffect(() => {
if (!isOpen) return
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [isOpen, onClose])
if (!isOpen) return null
return (
<div className="fixed inset-0 z-50">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
<div className="relative z-10 w-96 rounded-lg border border-border bg-surface p-5 shadow-2xl">
<h2 className="mb-2 text-base font-semibold text-text">{title}</h2>
<div className="mb-4 text-sm text-text-muted">{message}</div>
<div className="flex justify-end gap-2">
<button
onClick={onClose}
className="rounded border border-border px-3 py-1.5 text-sm text-text hover:bg-surface-2"
>
{cancelLabel}
</button>
<button
onClick={onConfirm}
className={clsx(
'rounded px-3 py-1.5 text-sm font-medium text-white',
destructive
? 'bg-reject hover:bg-reject/80'
: 'bg-primary hover:bg-primary/80'
)}
>
{confirmLabel}
</button>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,112 @@
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 } from '../../services/api'
import { toast } from '../ToastContainer'
import { ConfirmDialog } from '../dialogs/ConfirmDialog'
/**
* 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 restoreMutation = useMutation({
mutationFn: (ids: string[]) => discardApi.restore(ids),
onSuccess: (_, ids) => {
toast.success('Restored', `${ids.length} photo${ids.length > 1 ? 's' : ''} restored`)
clearSelection()
queryClient.invalidateQueries({ queryKey: ['photos'] })
},
onError: (e: any) => toast.error('Restore 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={() => 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={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)}
/>
</>
)
}

View File

@@ -18,6 +18,7 @@ import { AddSourceFolderDialog } from '../dialogs/AddSourceFolderDialog'
import { sourceFolders, library } from '../../services/api'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from '../ToastContainer'
import { useFilterStore } from '../../store/filterStore'
interface TreeItem {
id: string
@@ -33,8 +34,34 @@ export function LeftSidebar() {
const [selectedItem, setSelectedItem] = useState<string | null>('all-photos')
const [showAddFolderDialog, setShowAddFolderDialog] = useState(false)
const [isScanning, setIsScanning] = useState(false)
const queryClient = useQueryClient()
const clearAllFilters = useFilterStore((s) => s.clearAll)
const setRatingMin = useFilterStore((s) => s.setRatingMin)
const setFlag = useFilterStore((s) => s.setFlag)
// Map a library tree id to a filter-store mutation. Each "virtual node" in
// the library tree is just a saved filter preset.
const applyLibraryNode = (id: string) => {
switch (id) {
case 'all-photos':
clearAllFilters()
break
case 'rated':
clearAllFilters()
setRatingMin(1)
break
case 'flagged':
clearAllFilters()
setFlag('picked')
break
case 'discarded':
clearAllFilters()
setFlag('discarded')
break
// 'by-date' is purely visual until we add a date-grouping UI
}
}
// Fetch folders from API
const { data: foldersData, refetch: refetchFolders } = useQuery({
@@ -152,6 +179,8 @@ export function LeftSidebar() {
setSelectedItem(item.id)
if (hasChildren) {
toggleExpanded(item.id)
} else {
applyLibraryNode(item.id)
}
}}
>