feat: per-photo permanent delete + discarded thumbnail treatment

Discarded photos now look discarded in the grid (50% opacity + grayscale)
with a red trash badge in the corner instead of a bare icon. The discard
action bar gains a "Delete N" button that permanently deletes only the
current selection, complementing the existing "Empty discard pile".

Backend: new DELETE /discard endpoint accepting {photo_ids: [...]} that
permanently removes only listed photos. Skips ids that aren't in the
discard pile so it can never bypass the soft-delete safety net.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-08 13:12:11 +02:00
parent 3a03a56db2
commit b870084be0
4 changed files with 108 additions and 12 deletions

View File

@@ -21,6 +21,7 @@ export function DiscardActionBar() {
const { data: photos = [] } = usePhotosQuery()
const [confirmOpen, setConfirmOpen] = useState(false)
const [deleteSelectedOpen, setDeleteSelectedOpen] = useState(false)
const restoreMutation = useMutation({
mutationFn: (ids: string[]) => discardApi.restore(ids),
@@ -32,6 +33,30 @@ export function DiscardActionBar() {
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) => {
@@ -70,15 +95,26 @@ export function DiscardActionBar() {
<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={() => 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)}
@@ -92,6 +128,22 @@ export function DiscardActionBar() {
</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?"