diff --git a/backend/app/routers/discard.py b/backend/app/routers/discard.py
index 8604ecd..f4f140a 100644
--- a/backend/app/routers/discard.py
+++ b/backend/app/routers/discard.py
@@ -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,
+ }
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 6a2ceca..a1f3dc6 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -9,6 +9,7 @@ import { KeyboardHints } from './components/KeyboardHints'
import { PreviewView } from './components/preview/PreviewView'
import { FilterBar } from './components/filter/FilterBar'
import { ActiveFilterChips } from './components/filter/ActiveFilterChips'
+import { DiscardActionBar } from './components/discard/DiscardActionBar'
import { usePhotoStore } from './store/photoStore'
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
import { useFilterUrlSync } from './hooks/useFilterUrlSync'
@@ -52,6 +53,7 @@ function App() {
+
diff --git a/frontend/src/components/dialogs/ConfirmDialog.tsx b/frontend/src/components/dialogs/ConfirmDialog.tsx
new file mode 100644
index 0000000..c400652
--- /dev/null
+++ b/frontend/src/components/dialogs/ConfirmDialog.tsx
@@ -0,0 +1,75 @@
+import { useEffect } from 'react'
+import clsx from 'clsx'
+
+interface ConfirmDialogProps {
+ isOpen: boolean
+ title: string
+ message: React.ReactNode
+ confirmLabel?: string
+ cancelLabel?: string
+ /** When true, the confirm button uses the destructive accent. */
+ destructive?: boolean
+ onConfirm: () => void
+ onClose: () => void
+}
+
+/**
+ * Tiny modal-confirmation dialog. Mirrors the AddSourceFolderDialog overlay
+ * pattern (custom fixed inset-0 backdrop, no shadcn Dialog dep). Esc closes.
+ */
+export function ConfirmDialog({
+ isOpen,
+ title,
+ message,
+ confirmLabel = 'Confirm',
+ cancelLabel = 'Cancel',
+ destructive = false,
+ onConfirm,
+ onClose,
+}: ConfirmDialogProps) {
+ // Esc to close.
+ useEffect(() => {
+ if (!isOpen) return
+ const handler = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') onClose()
+ }
+ window.addEventListener('keydown', handler)
+ return () => window.removeEventListener('keydown', handler)
+ }, [isOpen, onClose])
+
+ if (!isOpen) return null
+
+ return (
+
+
+
+
+
{title}
+
{message}
+
+
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/components/discard/DiscardActionBar.tsx b/frontend/src/components/discard/DiscardActionBar.tsx
new file mode 100644
index 0000000..492989a
--- /dev/null
+++ b/frontend/src/components/discard/DiscardActionBar.tsx
@@ -0,0 +1,112 @@
+import { useState } from 'react'
+import { RotateCcw, Trash2 } from 'lucide-react'
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+import { usePhotoStore } from '../../store/photoStore'
+import { useFilterStore } from '../../store/filterStore'
+import { usePhotosQuery } from '../../hooks/usePhotosQuery'
+import { discard as discardApi } from '../../services/api'
+import { toast } from '../ToastContainer'
+import { ConfirmDialog } from '../dialogs/ConfirmDialog'
+
+/**
+ * Top-of-timeline bar visible only when the discarded filter is active.
+ * Shows a count, lets the user restore the current selection, and lets them
+ * permanently empty the discard pile (with confirmation).
+ */
+export function DiscardActionBar() {
+ const flag = useFilterStore((s) => s.flag)
+ const selectedPhotos = usePhotoStore((s) => s.selectedPhotos)
+ const clearSelection = usePhotoStore((s) => s.clearSelection)
+ const queryClient = useQueryClient()
+ const { data: photos = [] } = usePhotosQuery()
+
+ const [confirmOpen, setConfirmOpen] = useState(false)
+
+ const restoreMutation = useMutation({
+ mutationFn: (ids: string[]) => discardApi.restore(ids),
+ onSuccess: (_, ids) => {
+ toast.success('Restored', `${ids.length} photo${ids.length > 1 ? 's' : ''} restored`)
+ clearSelection()
+ queryClient.invalidateQueries({ queryKey: ['photos'] })
+ },
+ onError: (e: any) => toast.error('Restore failed', e.message || 'Unknown error'),
+ })
+
+ const emptyMutation = useMutation({
+ mutationFn: () => discardApi.empty(),
+ onSuccess: (data: any) => {
+ const count = data?.deleted ?? 0
+ const errors = data?.file_errors ?? 0
+ if (errors > 0) {
+ toast.error(
+ `Emptied with ${errors} error${errors > 1 ? 's' : ''}`,
+ `${count} record${count > 1 ? 's' : ''} deleted; some files could not be removed`
+ )
+ } else {
+ toast.success('Discard pile emptied', `${count} photo${count > 1 ? 's' : ''} permanently deleted`)
+ }
+ clearSelection()
+ queryClient.invalidateQueries({ queryKey: ['photos'] })
+ setConfirmOpen(false)
+ },
+ onError: (e: any) => toast.error('Empty failed', e.message || 'Unknown error'),
+ })
+
+ if (flag !== 'discarded') return null
+
+ const total = photos.length
+ const selected = selectedPhotos.length
+
+ return (
+ <>
+
+
+
+ Discarded
+
+ {total} photo{total === 1 ? '' : 's'}
+
+
+
+
+ {selected > 0 && (
+
+ )}
+
+
+
+
+
+ This will permanently delete{' '}
+ {total} photo{total === 1 ? '' : 's'} and remove the file
+ {total === 1 ? '' : 's'} from disk. This cannot be undone.
+ >
+ }
+ confirmLabel="Empty pile"
+ destructive
+ onConfirm={() => emptyMutation.mutate()}
+ onClose={() => setConfirmOpen(false)}
+ />
+ >
+ )
+}
diff --git a/frontend/src/components/layout/LeftSidebar.tsx b/frontend/src/components/layout/LeftSidebar.tsx
index 453add1..15eaa74 100644
--- a/frontend/src/components/layout/LeftSidebar.tsx
+++ b/frontend/src/components/layout/LeftSidebar.tsx
@@ -18,6 +18,7 @@ import { AddSourceFolderDialog } from '../dialogs/AddSourceFolderDialog'
import { sourceFolders, library } from '../../services/api'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from '../ToastContainer'
+import { useFilterStore } from '../../store/filterStore'
interface TreeItem {
id: string
@@ -33,8 +34,34 @@ export function LeftSidebar() {
const [selectedItem, setSelectedItem] = useState('all-photos')
const [showAddFolderDialog, setShowAddFolderDialog] = useState(false)
const [isScanning, setIsScanning] = useState(false)
-
+
const queryClient = useQueryClient()
+ const clearAllFilters = useFilterStore((s) => s.clearAll)
+ const setRatingMin = useFilterStore((s) => s.setRatingMin)
+ const setFlag = useFilterStore((s) => s.setFlag)
+
+ // Map a library tree id to a filter-store mutation. Each "virtual node" in
+ // the library tree is just a saved filter preset.
+ const applyLibraryNode = (id: string) => {
+ switch (id) {
+ case 'all-photos':
+ clearAllFilters()
+ break
+ case 'rated':
+ clearAllFilters()
+ setRatingMin(1)
+ break
+ case 'flagged':
+ clearAllFilters()
+ setFlag('picked')
+ break
+ case 'discarded':
+ clearAllFilters()
+ setFlag('discarded')
+ break
+ // 'by-date' is purely visual until we add a date-grouping UI
+ }
+ }
// Fetch folders from API
const { data: foldersData, refetch: refetchFolders } = useQuery({
@@ -152,6 +179,8 @@ export function LeftSidebar() {
setSelectedItem(item.id)
if (hasChildren) {
toggleExpanded(item.id)
+ } else {
+ applyLibraryNode(item.id)
}
}}
>