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) } }} >