feat: discard view with restore and empty actions

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>
This commit is contained in:
2026-04-07 22:59:34 +02:00
parent c7d2cc47e1
commit 322969c938
5 changed files with 240 additions and 4 deletions

View File

@@ -1,14 +1,17 @@
"""
Discard API router
"""
import os
import logging
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select, and_
from sqlalchemy.ext.asyncio import AsyncSession
from datetime import datetime
from app.database import get_db
from app.models import Photo
logger = logging.getLogger(__name__)
router = APIRouter()
@router.get("")
@@ -37,14 +40,29 @@ async def restore_photos(photo_ids: list[str], db: AsyncSession = Depends(get_db
@router.delete("/empty")
async def empty_discard(db: AsyncSession = Depends(get_db)):
"""Permanently delete all discarded photos"""
"""Permanently delete all discarded photos and unlink their files from
disk. Failures on individual files are logged but don't abort the batch.
"""
result = await db.execute(
select(Photo).where(Photo.is_discarded == True)
)
photos = result.scalars().all()
deleted = 0
file_errors = 0
for photo in photos:
try:
if photo.filepath and os.path.exists(photo.filepath):
os.unlink(photo.filepath)
except OSError as e:
file_errors += 1
logger.error(f"Failed to unlink {photo.filepath}: {e}")
await db.delete(photo)
deleted += 1
await db.commit()
return {"status": "success", "deleted": len(photos)}
return {
"status": "success",
"deleted": deleted,
"file_errors": file_errors,
}