From b870084be0389412c3826a44d012f37e6bd6207b Mon Sep 17 00:00:00 2001 From: dtoro Date: Wed, 8 Apr 2026 13:12:11 +0200 Subject: [PATCH] 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) --- backend/app/routers/discard.py | 28 +++++++- .../components/discard/DiscardActionBar.tsx | 70 ++++++++++++++++--- .../components/timeline/PhotoThumbnail.tsx | 12 +++- frontend/src/services/api.ts | 10 +++ 4 files changed, 108 insertions(+), 12 deletions(-) diff --git a/backend/app/routers/discard.py b/backend/app/routers/discard.py index f4f140a..b5c0c2d 100644 --- a/backend/app/routers/discard.py +++ b/backend/app/routers/discard.py @@ -3,7 +3,7 @@ Discard API router """ import os import logging -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Body from sqlalchemy import select, and_ from sqlalchemy.ext.asyncio import AsyncSession @@ -47,7 +47,33 @@ async def empty_discard(db: AsyncSession = Depends(get_db)): select(Photo).where(Photo.is_discarded == True) ) photos = result.scalars().all() + return await _permanently_delete(db, photos) + +@router.delete("") +async def delete_discarded( + photo_ids: list[str] = Body(..., embed=True), + db: AsyncSession = Depends(get_db), +): + """Permanently delete a specific subset of discarded photos. The photos + must already be in the discard pile — non-discarded ids are skipped so + this can never bypass the soft-delete safety net. + """ + if not photo_ids: + return {"status": "success", "deleted": 0, "file_errors": 0} + result = await db.execute( + select(Photo).where( + and_(Photo.id.in_(photo_ids), Photo.is_discarded == True) + ) + ) + photos = result.scalars().all() + return await _permanently_delete(db, photos) + + +async def _permanently_delete(db: AsyncSession, photos: list[Photo]) -> dict: + """Shared helper: unlink files for the given photos and delete their + rows. Per-file errors are counted but don't abort the batch. + """ deleted = 0 file_errors = 0 for photo in photos: diff --git a/frontend/src/components/discard/DiscardActionBar.tsx b/frontend/src/components/discard/DiscardActionBar.tsx index 492989a..e61fdc0 100644 --- a/frontend/src/components/discard/DiscardActionBar.tsx +++ b/frontend/src/components/discard/DiscardActionBar.tsx @@ -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() {
{selected > 0 && ( - + <> + + + )}
+ + This will permanently delete{' '} + {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)} + /> + )} {photo.is_discarded && ( - +
+ +
)} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 1e58b4c..55cdbed 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -305,6 +305,16 @@ export const discard = { const response = await api.delete('/discard/empty') return response.data }, + + /** Permanently delete a specific subset of discarded photos. The backend + * silently skips ids that aren't in the pile, so this can never bypass + * the soft-delete safety net. */ + deletePermanent: async (photoIds: string[]) => { + const response = await api.delete('/discard', { + data: { photo_ids: photoIds }, + }) + return response.data + }, } export default api \ No newline at end of file