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:
@@ -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:
|
||||||
|
|||||||
@@ -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,15 +95,26 @@ export function DiscardActionBar() {
|
|||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{selected > 0 && (
|
{selected > 0 && (
|
||||||
<button
|
<>
|
||||||
onClick={() => restoreMutation.mutate(selectedPhotos)}
|
<button
|
||||||
disabled={restoreMutation.isPending}
|
onClick={() => restoreMutation.mutate(selectedPhotos)}
|
||||||
className="flex items-center gap-1.5 rounded bg-surface-2 px-3 py-1 text-text hover:bg-surface-offset disabled:opacity-50"
|
disabled={restoreMutation.isPending}
|
||||||
title="Restore selected (U)"
|
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}
|
<RotateCcw className="h-3.5 w-3.5" />
|
||||||
</button>
|
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
|
<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?"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user