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

@@ -3,7 +3,7 @@ Discard API router
""" """
import os import os
import logging import logging
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException, Body
from sqlalchemy import select, and_ from sqlalchemy import select, and_
from sqlalchemy.ext.asyncio import AsyncSession 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) select(Photo).where(Photo.is_discarded == True)
) )
photos = result.scalars().all() 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 deleted = 0
file_errors = 0 file_errors = 0
for photo in photos: for photo in photos:

View File

@@ -21,6 +21,7 @@ export function DiscardActionBar() {
const { data: photos = [] } = usePhotosQuery() const { data: photos = [] } = usePhotosQuery()
const [confirmOpen, setConfirmOpen] = useState(false) const [confirmOpen, setConfirmOpen] = useState(false)
const [deleteSelectedOpen, setDeleteSelectedOpen] = useState(false)
const restoreMutation = useMutation({ const restoreMutation = useMutation({
mutationFn: (ids: string[]) => discardApi.restore(ids), mutationFn: (ids: string[]) => discardApi.restore(ids),
@@ -32,6 +33,30 @@ export function DiscardActionBar() {
onError: (e: any) => toast.error('Restore failed', e.message || 'Unknown error'), 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({ const emptyMutation = useMutation({
mutationFn: () => discardApi.empty(), mutationFn: () => discardApi.empty(),
onSuccess: (data: any) => { onSuccess: (data: any) => {
@@ -70,6 +95,7 @@ export function DiscardActionBar() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{selected > 0 && ( {selected > 0 && (
<>
<button <button
onClick={() => restoreMutation.mutate(selectedPhotos)} onClick={() => restoreMutation.mutate(selectedPhotos)}
disabled={restoreMutation.isPending} disabled={restoreMutation.isPending}
@@ -79,6 +105,16 @@ export function DiscardActionBar() {
<RotateCcw className="h-3.5 w-3.5" /> <RotateCcw className="h-3.5 w-3.5" />
Restore {selected} Restore {selected}
</button> </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 <button
onClick={() => setConfirmOpen(true)} onClick={() => setConfirmOpen(true)}
@@ -92,6 +128,22 @@ export function DiscardActionBar() {
</div> </div>
</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 <ConfirmDialog
isOpen={confirmOpen} isOpen={confirmOpen}
title="Empty discard pile?" title="Empty discard pile?"

View File

@@ -140,7 +140,10 @@ export function PhotoThumbnail({
alt={photo.filename} alt={photo.filename}
className={clsx( className={clsx(
'h-full w-full object-cover transition-opacity duration-200', 'h-full w-full object-cover transition-opacity duration-200',
imageLoaded ? 'opacity-100' : 'opacity-0' imageLoaded ? 'opacity-100' : 'opacity-0',
// Discarded photos fade out + desaturate so the trash section
// reads as a trash section, not just another grid view.
photo.is_discarded && 'opacity-50 grayscale'
)} )}
onLoad={handleImageLoad} onLoad={handleImageLoad}
onError={handleImageError} onError={handleImageError}
@@ -216,7 +219,12 @@ export function PhotoThumbnail({
</div> </div>
)} )}
{photo.is_discarded && ( {photo.is_discarded && (
<Trash2 className="h-4 w-4 text-reject" /> <div
className="flex h-5 w-5 items-center justify-center rounded-full bg-reject text-white shadow-md"
title="Discarded"
>
<Trash2 className="h-3 w-3" />
</div>
)} )}
</div> </div>

View File

@@ -305,6 +305,16 @@ export const discard = {
const response = await api.delete('/discard/empty') const response = await api.delete('/discard/empty')
return response.data 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 export default api