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:
@@ -1,14 +1,17 @@
|
|||||||
"""
|
"""
|
||||||
Discard API router
|
Discard API router
|
||||||
"""
|
"""
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from sqlalchemy import select, and_
|
from sqlalchemy import select, and_
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models import Photo
|
from app.models import Photo
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
@@ -37,14 +40,29 @@ async def restore_photos(photo_ids: list[str], db: AsyncSession = Depends(get_db
|
|||||||
|
|
||||||
@router.delete("/empty")
|
@router.delete("/empty")
|
||||||
async def empty_discard(db: AsyncSession = Depends(get_db)):
|
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(
|
result = await db.execute(
|
||||||
select(Photo).where(Photo.is_discarded == True)
|
select(Photo).where(Photo.is_discarded == True)
|
||||||
)
|
)
|
||||||
photos = result.scalars().all()
|
photos = result.scalars().all()
|
||||||
|
|
||||||
|
deleted = 0
|
||||||
|
file_errors = 0
|
||||||
for photo in photos:
|
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)
|
await db.delete(photo)
|
||||||
|
deleted += 1
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return {"status": "success", "deleted": len(photos)}
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"deleted": deleted,
|
||||||
|
"file_errors": file_errors,
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { KeyboardHints } from './components/KeyboardHints'
|
|||||||
import { PreviewView } from './components/preview/PreviewView'
|
import { PreviewView } from './components/preview/PreviewView'
|
||||||
import { FilterBar } from './components/filter/FilterBar'
|
import { FilterBar } from './components/filter/FilterBar'
|
||||||
import { ActiveFilterChips } from './components/filter/ActiveFilterChips'
|
import { ActiveFilterChips } from './components/filter/ActiveFilterChips'
|
||||||
|
import { DiscardActionBar } from './components/discard/DiscardActionBar'
|
||||||
import { usePhotoStore } from './store/photoStore'
|
import { usePhotoStore } from './store/photoStore'
|
||||||
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
|
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
|
||||||
import { useFilterUrlSync } from './hooks/useFilterUrlSync'
|
import { useFilterUrlSync } from './hooks/useFilterUrlSync'
|
||||||
@@ -52,6 +53,7 @@ function App() {
|
|||||||
<TopBar />
|
<TopBar />
|
||||||
<FilterBar />
|
<FilterBar />
|
||||||
<ActiveFilterChips />
|
<ActiveFilterChips />
|
||||||
|
<DiscardActionBar />
|
||||||
<KeyboardHints />
|
<KeyboardHints />
|
||||||
|
|
||||||
<div className="flex flex-1 overflow-hidden">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
|
|||||||
75
frontend/src/components/dialogs/ConfirmDialog.tsx
Normal file
75
frontend/src/components/dialogs/ConfirmDialog.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="fixed inset-0 z-50">
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
|
||||||
|
<div className="relative z-10 w-96 rounded-lg border border-border bg-surface p-5 shadow-2xl">
|
||||||
|
<h2 className="mb-2 text-base font-semibold text-text">{title}</h2>
|
||||||
|
<div className="mb-4 text-sm text-text-muted">{message}</div>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded border border-border px-3 py-1.5 text-sm text-text hover:bg-surface-2"
|
||||||
|
>
|
||||||
|
{cancelLabel}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onConfirm}
|
||||||
|
className={clsx(
|
||||||
|
'rounded px-3 py-1.5 text-sm font-medium text-white',
|
||||||
|
destructive
|
||||||
|
? 'bg-reject hover:bg-reject/80'
|
||||||
|
: 'bg-primary hover:bg-primary/80'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{confirmLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
112
frontend/src/components/discard/DiscardActionBar.tsx
Normal file
112
frontend/src/components/discard/DiscardActionBar.tsx
Normal file
@@ -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 (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between gap-3 border-b border-border bg-reject/10 px-4 py-2 text-sm">
|
||||||
|
<div className="flex items-center gap-2 text-text">
|
||||||
|
<Trash2 className="h-4 w-4 text-reject" />
|
||||||
|
<span className="font-medium">Discarded</span>
|
||||||
|
<span className="text-text-muted">
|
||||||
|
{total} photo{total === 1 ? '' : 's'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{selected > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => restoreMutation.mutate(selectedPhotos)}
|
||||||
|
disabled={restoreMutation.isPending}
|
||||||
|
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}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirmOpen(true)}
|
||||||
|
disabled={total === 0 || emptyMutation.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 all discarded photos and files"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
Empty discard pile
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={confirmOpen}
|
||||||
|
title="Empty discard pile?"
|
||||||
|
message={
|
||||||
|
<>
|
||||||
|
This will <strong className="text-text">permanently delete</strong>{' '}
|
||||||
|
{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)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import { AddSourceFolderDialog } from '../dialogs/AddSourceFolderDialog'
|
|||||||
import { sourceFolders, library } from '../../services/api'
|
import { sourceFolders, library } from '../../services/api'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { toast } from '../ToastContainer'
|
import { toast } from '../ToastContainer'
|
||||||
|
import { useFilterStore } from '../../store/filterStore'
|
||||||
|
|
||||||
interface TreeItem {
|
interface TreeItem {
|
||||||
id: string
|
id: string
|
||||||
@@ -33,8 +34,34 @@ export function LeftSidebar() {
|
|||||||
const [selectedItem, setSelectedItem] = useState<string | null>('all-photos')
|
const [selectedItem, setSelectedItem] = useState<string | null>('all-photos')
|
||||||
const [showAddFolderDialog, setShowAddFolderDialog] = useState(false)
|
const [showAddFolderDialog, setShowAddFolderDialog] = useState(false)
|
||||||
const [isScanning, setIsScanning] = useState(false)
|
const [isScanning, setIsScanning] = useState(false)
|
||||||
|
|
||||||
const queryClient = useQueryClient()
|
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
|
// Fetch folders from API
|
||||||
const { data: foldersData, refetch: refetchFolders } = useQuery({
|
const { data: foldersData, refetch: refetchFolders } = useQuery({
|
||||||
@@ -152,6 +179,8 @@ export function LeftSidebar() {
|
|||||||
setSelectedItem(item.id)
|
setSelectedItem(item.id)
|
||||||
if (hasChildren) {
|
if (hasChildren) {
|
||||||
toggleExpanded(item.id)
|
toggleExpanded(item.id)
|
||||||
|
} else {
|
||||||
|
applyLibraryNode(item.id)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user