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>
113 lines
4.2 KiB
TypeScript
113 lines
4.2 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 } 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)}
|
|
/>
|
|
</>
|
|
)
|
|
}
|