3 Commits

Author SHA1 Message Date
322969c938 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>
2026-04-07 22:59:34 +02:00
c7d2cc47e1 chore: drop E shortcut for preview, keep only Space
E was a Lightroom holdover and overlapped with the natural rating /
flag culling shortcuts. Space is the only binding now (double-click on
a thumbnail still works). Hints pill updated.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 22:56:09 +02:00
997e11db78 refactor: rename trash to discard end-to-end
User-facing labels and code now use "discard" (verb) and "Discarded"
(state/view label) instead of "trash" / "Trashed". The DB column names
stay (is_trashed / trashed_at) so no migration is required — only the
SQLAlchemy attribute names are renamed via Column('old_name', ...).

Backend
- Photo model: is_discarded / discarded_at attributes (DB columns
  unchanged).
- PhotoBase / PhotoResponse / PhotoUpdate schemas use the new field
  names.
- Photos list endpoint: is_discarded query param, filter logic.
- DELETE /photos/{id} now sets is_discarded; success message updated.
- Bulk action 'trash' renamed to 'discard'.
- backend/app/routers/trash.py renamed to discard.py with renamed
  functions and route prefix /api/v1/discard.
- main.py imports and mounts the discard router.
- tasks/scan.py marks missing files as is_discarded.

Frontend
- Photo TS type: is_discarded.
- PhotoThumbnail: shows the trash-can icon when is_discarded.
- RightSidebar: button label "Discard"; mutation field name; local
  variable rename.
- TopBar: discardPhotosMutation and "Discard" button; toast text
  "Discarded".
- LeftSidebar: virtual node id 'discarded' / label "Discarded".
- FilterBar / filterStore / useFilterUrlSync: FlagFilter enum value
  'trashed' → 'discarded'; backend param key is_discarded.
- KeyboardHints: X label "Discard".
- useKeyboardShortcuts: PhotoUpdate field rename, X handler.
- api.ts: /trash routes → /discard, trash export → discard,
  bulkUpdate trash field → discard.

Out of scope (intentional): the docker-compose trash_data volume,
backend/Dockerfile mkdir /data/trash, config.py TrashSettings, and
the spec doc — all unused since soft-discard, and renaming them is
churn for no benefit.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 22:54:31 +02:00
21 changed files with 357 additions and 121 deletions

View File

@@ -11,7 +11,7 @@ import os
from app.config import settings from app.config import settings
from app.database import init_db from app.database import init_db
from app.routers import photos, folders, heaps, tags, trash, library from app.routers import photos, folders, heaps, tags, discard, library
from app.services.scanner import start_initial_scan from app.services.scanner import start_initial_scan
# Configure logging # Configure logging
@@ -64,7 +64,7 @@ app.include_router(photos.router, prefix="/api/v1/photos", tags=["photos"])
app.include_router(folders.router, prefix="/api/v1/folders", tags=["folders"]) app.include_router(folders.router, prefix="/api/v1/folders", tags=["folders"])
app.include_router(heaps.router, prefix="/api/v1/heaps", tags=["heaps"]) app.include_router(heaps.router, prefix="/api/v1/heaps", tags=["heaps"])
app.include_router(tags.router, prefix="/api/v1/tags", tags=["tags"]) app.include_router(tags.router, prefix="/api/v1/tags", tags=["tags"])
app.include_router(trash.router, prefix="/api/v1/trash", tags=["trash"]) app.include_router(discard.router, prefix="/api/v1/discard", tags=["discard"])
app.include_router(library.router, prefix="/api/v1/library", tags=["library"]) app.include_router(library.router, prefix="/api/v1/library", tags=["library"])
@app.get("/") @app.get("/")

View File

@@ -33,9 +33,10 @@ class Photo(Base):
added_at = Column(DateTime, server_default=func.now()) added_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, onupdate=func.now()) updated_at = Column(DateTime, onupdate=func.now())
# Trash status # Discard status. The DB column names stay is_trashed/trashed_at to avoid
is_trashed = Column(Boolean, default=False) # a migration; only the Python attribute name reflects the rename.
trashed_at = Column(DateTime) is_discarded = Column('is_trashed', Boolean, default=False)
discarded_at = Column('trashed_at', DateTime)
# Thumbnail paths # Thumbnail paths
thumb_small = Column(String) # path to 240px thumb thumb_small = Column(String) # path to 240px thumb
@@ -55,7 +56,7 @@ class Photo(Base):
rating = Column(Integer, default=0) # 0-5 stars rating = Column(Integer, default=0) # 0-5 stars
color_label = Column(String) # 'red'|'orange'|'yellow'|'green'|'blue'|'purple'|NULL color_label = Column(String) # 'red'|'orange'|'yellow'|'green'|'blue'|'purple'|NULL
is_picked = Column(Boolean, default=False) is_picked = Column(Boolean, default=False)
# Note: is_rejected was merged into is_trashed (a single soft "trashed" # Note: is_rejected was merged into is_discarded (a single soft "discarded"
# concept). The DB column may still exist on legacy installs but is no # concept). The DB column may still exist on legacy installs but is no
# longer read or written. # longer read or written.

View File

@@ -0,0 +1,68 @@
"""
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 app.database import get_db
from app.models import Photo
logger = logging.getLogger(__name__)
router = APIRouter()
@router.get("")
async def list_discarded(db: AsyncSession = Depends(get_db)):
"""List discarded photos"""
result = await db.execute(
select(Photo).where(Photo.is_discarded == True)
)
photos = result.scalars().all()
return photos
@router.post("/restore")
async def restore_photos(photo_ids: list[str], db: AsyncSession = Depends(get_db)):
"""Restore photos from the discard pile"""
result = await db.execute(
select(Photo).where(and_(Photo.id.in_(photo_ids), Photo.is_discarded == True))
)
photos = result.scalars().all()
for photo in photos:
photo.is_discarded = False
photo.discarded_at = None
await db.commit()
return {"status": "success", "restored": len(photos)}
@router.delete("/empty")
async def empty_discard(db: AsyncSession = Depends(get_db)):
"""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": deleted,
"file_errors": file_errors,
}

View File

@@ -33,7 +33,7 @@ async def list_photos(
rating_max: Optional[int] = Query(None, ge=0, le=5), rating_max: Optional[int] = Query(None, ge=0, le=5),
color_label: Optional[str] = None, color_label: Optional[str] = None,
is_picked: Optional[bool] = None, is_picked: Optional[bool] = None,
is_trashed: Optional[bool] = False, is_discarded: Optional[bool] = False,
heap_id: Optional[str] = None, heap_id: Optional[str] = None,
sort: str = "taken_at", sort: str = "taken_at",
order: str = "desc", order: str = "desc",
@@ -93,8 +93,8 @@ async def list_photos(
if is_picked is not None: if is_picked is not None:
filters.append(Photo.is_picked == is_picked) filters.append(Photo.is_picked == is_picked)
# Trash filter — defaults to hiding trashed photos # Discard filter — defaults to hiding discarded photos
filters.append(Photo.is_trashed == is_trashed) filters.append(Photo.is_discarded == is_discarded)
# Apply all filters # Apply all filters
if filters: if filters:
@@ -407,13 +407,13 @@ async def update_photo(
return PhotoResponse.from_orm(photo) return PhotoResponse.from_orm(photo)
@router.delete("/{photo_id}") @router.delete("/{photo_id}")
async def trash_photo( async def discard_photo(
photo_id: str, photo_id: str,
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
"""Soft-trash a photo: sets is_trashed=true. The file stays on disk so """Soft-discard a photo: sets is_discarded=true. The file stays on disk so
restore is just a flag flip. Permanent deletion happens via DELETE restore is just a flag flip. Permanent deletion happens via DELETE
/trash/{id} or DELETE /trash/empty. /discard/{id} or DELETE /discard/empty.
""" """
result = await db.execute( result = await db.execute(
select(Photo).where(Photo.id == photo_id) select(Photo).where(Photo.id == photo_id)
@@ -423,11 +423,11 @@ async def trash_photo(
if not photo: if not photo:
raise HTTPException(status_code=404, detail="Photo not found") raise HTTPException(status_code=404, detail="Photo not found")
photo.is_trashed = True photo.is_discarded = True
photo.trashed_at = datetime.utcnow() photo.discarded_at = datetime.utcnow()
await db.commit() await db.commit()
return {"status": "success", "message": "Photo moved to trash"} return {"status": "success", "message": "Photo discarded"}
@router.post("/bulk") @router.post("/bulk")
async def bulk_action( async def bulk_action(
@@ -445,14 +445,14 @@ async def bulk_action(
raise HTTPException(status_code=404, detail="No photos found") raise HTTPException(status_code=404, detail="No photos found")
# Perform action based on type # Perform action based on type
if action.action == 'trash': if action.action == 'discard':
for photo in photos: for photo in photos:
photo.is_trashed = True photo.is_discarded = True
photo.trashed_at = datetime.utcnow() photo.discarded_at = datetime.utcnow()
elif action.action == 'restore': elif action.action == 'restore':
for photo in photos: for photo in photos:
photo.is_trashed = False photo.is_discarded = False
photo.trashed_at = None photo.discarded_at = None
elif action.action == 'set_rating': elif action.action == 'set_rating':
for photo in photos: for photo in photos:
photo.rating = action.value photo.rating = action.value
@@ -462,7 +462,7 @@ async def bulk_action(
elif action.action == 'pick': elif action.action == 'pick':
for photo in photos: for photo in photos:
photo.is_picked = True photo.is_picked = True
photo.is_trashed = False photo.is_discarded = False
else: else:
raise HTTPException(status_code=400, detail="Invalid action") raise HTTPException(status_code=400, detail="Invalid action")

View File

@@ -1,50 +0,0 @@
"""
Trash API router
"""
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
router = APIRouter()
@router.get("")
async def list_trashed(db: AsyncSession = Depends(get_db)):
"""List trashed photos"""
result = await db.execute(
select(Photo).where(Photo.is_trashed == True)
)
photos = result.scalars().all()
return photos
@router.post("/restore")
async def restore_photos(photo_ids: list[str], db: AsyncSession = Depends(get_db)):
"""Restore photos from trash"""
result = await db.execute(
select(Photo).where(and_(Photo.id.in_(photo_ids), Photo.is_trashed == True))
)
photos = result.scalars().all()
for photo in photos:
photo.is_trashed = False
photo.trashed_at = None
await db.commit()
return {"status": "success", "restored": len(photos)}
@router.delete("/empty")
async def empty_trash(db: AsyncSession = Depends(get_db)):
"""Permanently delete all trashed photos"""
result = await db.execute(
select(Photo).where(Photo.is_trashed == True)
)
photos = result.scalars().all()
for photo in photos:
await db.delete(photo)
await db.commit()
return {"status": "success", "deleted": len(photos)}

View File

@@ -29,8 +29,8 @@ class PhotoResponse(PhotoBase):
file_hash: Optional[str] = None file_hash: Optional[str] = None
added_at: datetime added_at: datetime
updated_at: Optional[datetime] = None updated_at: Optional[datetime] = None
is_trashed: bool = False is_discarded: bool = False
trashed_at: Optional[datetime] = None discarded_at: Optional[datetime] = None
thumb_small: Optional[str] = None thumb_small: Optional[str] = None
thumb_medium: Optional[str] = None thumb_medium: Optional[str] = None
thumb_large: Optional[str] = None thumb_large: Optional[str] = None
@@ -52,7 +52,7 @@ class PhotoUpdate(BaseModel):
rating: Optional[int] = Field(None, ge=0, le=5) rating: Optional[int] = Field(None, ge=0, le=5)
color_label: Optional[str] = None color_label: Optional[str] = None
is_picked: Optional[bool] = None is_picked: Optional[bool] = None
is_trashed: Optional[bool] = None is_discarded: Optional[bool] = None
taken_at: Optional[datetime] = None taken_at: Optional[datetime] = None
class PhotoListResponse(BaseModel): class PhotoListResponse(BaseModel):
@@ -66,5 +66,5 @@ class PhotoListResponse(BaseModel):
class BulkAction(BaseModel): class BulkAction(BaseModel):
"""Bulk action on photos""" """Bulk action on photos"""
ids: List[str] ids: List[str]
action: str # 'trash', 'restore', 'delete_permanent', 'move', 'copy', 'add_tag', 'remove_tag', 'set_rating', 'set_color', 'pick', 'reject' action: str # 'discard', 'restore', 'delete_permanent', 'move', 'copy', 'add_tag', 'remove_tag', 'set_rating', 'set_color', 'pick'
value: Optional[Any] = None # For actions that need a value (rating, color, tag_id, folder_id) value: Optional[Any] = None # For actions that need a value (rating, color, tag_id, folder_id)

View File

@@ -292,7 +292,7 @@ async def handle_file_deletion(filepath: str):
if photo: if photo:
# Mark as missing or delete from database # Mark as missing or delete from database
photo.is_trashed = True photo.is_discarded = True
photo.trashed_at = datetime.utcnow() photo.discarded_at = datetime.utcnow()
await session.commit() await session.commit()
logger.info(f"Marked photo as trashed: {filepath}") logger.info(f"Marked photo as discarded: {filepath}")

View File

@@ -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">

View File

@@ -12,15 +12,15 @@ export function KeyboardHints() {
? [ ? [
{ key: '1-5', action: 'Rate' }, { key: '1-5', action: 'Rate' },
{ key: 'P', action: 'Pick' }, { key: 'P', action: 'Pick' },
{ key: 'X', action: 'Trash' }, { key: 'X', action: 'Discard' },
{ key: 'E / Space', action: 'Preview' }, { key: 'Space', action: 'Preview' },
{ key: 'Esc', action: 'Deselect' }, { key: 'Esc', action: 'Deselect' },
] ]
: [ : [
{ key: '↑↓←→', action: 'Navigate' }, { key: '↑↓←→', action: 'Navigate' },
{ key: 'Click', action: 'Select' }, { key: 'Click', action: 'Select' },
{ key: 'Shift+Click', action: 'Range' }, { key: 'Shift+Click', action: 'Range' },
{ key: 'E / Space', action: 'Preview' }, { key: 'Space', action: 'Preview' },
{ key: '\\', action: 'Filters' }, { key: '\\', action: 'Filters' },
{ key: '/', action: 'Search' }, { key: '/', action: 'Search' },
] ]

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

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

View File

@@ -26,7 +26,7 @@ const COLOR_LABELS: { value: ColorLabel; className: string }[] = [
const FLAG_OPTIONS: { value: FlagFilter; label: string }[] = [ const FLAG_OPTIONS: { value: FlagFilter; label: string }[] = [
{ value: 'any', label: 'Any' }, { value: 'any', label: 'Any' },
{ value: 'picked', label: 'Picked' }, { value: 'picked', label: 'Picked' },
{ value: 'trashed', label: 'Trashed' }, { value: 'discarded', label: 'Discarded' },
{ value: 'unflagged', label: 'Unflagged' }, { value: 'unflagged', label: 'Unflagged' },
] ]

View File

@@ -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
@@ -35,6 +36,32 @@ export function LeftSidebar() {
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({
@@ -111,7 +138,7 @@ export function LeftSidebar() {
{ id: 'by-date', label: 'By Date', icon: <Calendar className="h-4 w-4" /> }, { id: 'by-date', label: 'By Date', icon: <Calendar className="h-4 w-4" /> },
{ id: 'rated', label: 'Rated', icon: <Star className="h-4 w-4" />, count: 0 }, { id: 'rated', label: 'Rated', icon: <Star className="h-4 w-4" />, count: 0 },
{ id: 'flagged', label: 'Flagged', icon: <Flag className="h-4 w-4" />, count: 0 }, { id: 'flagged', label: 'Flagged', icon: <Flag className="h-4 w-4" />, count: 0 },
{ id: 'trash', label: 'Trash', icon: <Trash2 className="h-4 w-4" />, count: 0 }, { id: 'discarded', label: 'Discarded', icon: <Trash2 className="h-4 w-4" />, count: 0 },
], ],
}, },
{ {
@@ -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)
} }
}} }}
> >

View File

@@ -27,7 +27,7 @@ interface PhotoDetails {
taken_at: string | null taken_at: string | null
rating: number rating: number
is_picked: boolean is_picked: boolean
is_trashed: boolean is_discarded: boolean
exif_json: string | null exif_json: string | null
} }
@@ -108,7 +108,7 @@ export function RightSidebar() {
mutationFn: (data: { mutationFn: (data: {
rating?: number rating?: number
is_picked?: boolean is_picked?: boolean
is_trashed?: boolean is_discarded?: boolean
}) => photosApi.update(activePhotoId!, data), }) => photosApi.update(activePhotoId!, data),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['photo', activePhotoId] }) queryClient.invalidateQueries({ queryKey: ['photo', activePhotoId] })
@@ -132,7 +132,7 @@ export function RightSidebar() {
const multipleSelected = selectedPhotos.length > 1 const multipleSelected = selectedPhotos.length > 1
const rating = photo?.rating ?? 0 const rating = photo?.rating ?? 0
const isPicked = photo?.is_picked ?? false const isPicked = photo?.is_picked ?? false
const isTrashed = photo?.is_trashed ?? false const isDiscarded = photo?.is_discarded ?? false
return ( return (
<div className="flex h-full flex-col bg-surface"> <div className="flex h-full flex-col bg-surface">
@@ -187,7 +187,7 @@ export function RightSidebar() {
onClick={() => onClick={() =>
updateMutation.mutate({ updateMutation.mutate({
is_picked: !isPicked, is_picked: !isPicked,
is_trashed: false, is_discarded: false,
}) })
} }
className={clsx( className={clsx(
@@ -203,19 +203,19 @@ export function RightSidebar() {
<button <button
onClick={() => onClick={() =>
updateMutation.mutate({ updateMutation.mutate({
is_trashed: !isTrashed, is_discarded: !isDiscarded,
is_picked: false, is_picked: false,
}) })
} }
className={clsx( className={clsx(
'flex items-center gap-1 rounded px-2 py-1 text-sm transition-colors', 'flex items-center gap-1 rounded px-2 py-1 text-sm transition-colors',
isTrashed isDiscarded
? 'bg-reject/20 text-reject' ? 'bg-reject/20 text-reject'
: 'bg-surface-2 text-text-muted hover:bg-surface-offset' : 'bg-surface-2 text-text-muted hover:bg-surface-offset'
)} )}
> >
<Trash2 className="h-3 w-3" /> <Trash2 className="h-3 w-3" />
Trash Discard
</button> </button>
</div> </div>
</div> </div>

View File

@@ -58,18 +58,18 @@ export function TopBar() {
const selectedCount = selectedPhotos.length const selectedCount = selectedPhotos.length
const queryClient = useQueryClient() const queryClient = useQueryClient()
// Mutation for moving photos to trash // Mutation for discarding selected photos
const trashPhotosMutation = useMutation({ const discardPhotosMutation = useMutation({
mutationFn: async () => { mutationFn: async () => {
await photos.bulkUpdate(selectedPhotos, { trash: true }) await photos.bulkUpdate(selectedPhotos, { discard: true })
}, },
onSuccess: () => { onSuccess: () => {
toast.success('Moved to Trash', `${selectedCount} photo${selectedCount > 1 ? 's' : ''} moved to trash`) toast.success('Discarded', `${selectedCount} photo${selectedCount > 1 ? 's' : ''} discarded`)
clearSelection() clearSelection()
queryClient.invalidateQueries({ queryKey: ['photos'] }) queryClient.invalidateQueries({ queryKey: ['photos'] })
}, },
onError: (error: any) => { onError: (error: any) => {
toast.error('Failed to Move to Trash', error.message || 'An error occurred') toast.error('Failed to discard', error.message || 'An error occurred')
}, },
}) })
@@ -96,13 +96,13 @@ export function TopBar() {
{selectedCount} selected {selectedCount} selected
</span> </span>
<button <button
onClick={() => trashPhotosMutation.mutate()} onClick={() => discardPhotosMutation.mutate()}
disabled={trashPhotosMutation.isPending} disabled={discardPhotosMutation.isPending}
className="flex items-center gap-1 rounded bg-reject/20 px-2 py-0.5 text-sm text-reject hover:bg-reject/30 disabled:opacity-50" className="flex items-center gap-1 rounded bg-reject/20 px-2 py-0.5 text-sm text-reject hover:bg-reject/30 disabled:opacity-50"
title="Move to trash" title="Discard"
> >
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
Trash Discard
</button> </button>
</> </>
)} )}

View File

@@ -171,7 +171,7 @@ export function PhotoThumbnail({ photo, size, isSelected, onClick, onDoubleClick
{photo.is_picked && ( {photo.is_picked && (
<Check className="h-4 w-4 text-pick" /> <Check className="h-4 w-4 text-pick" />
)} )}
{photo.is_trashed && ( {photo.is_discarded && (
<Trash2 className="h-4 w-4 text-reject" /> <Trash2 className="h-4 w-4 text-reject" />
)} )}
</div> </div>

View File

@@ -16,7 +16,7 @@ const ALLOWED_COLORS: ColorLabel[] = [
'blue', 'blue',
'purple', 'purple',
] ]
const ALLOWED_FLAGS: FlagFilter[] = ['any', 'picked', 'trashed', 'unflagged'] const ALLOWED_FLAGS: FlagFilter[] = ['any', 'picked', 'discarded', 'unflagged']
function parseUrl(): Partial<FilterState> { function parseUrl(): Partial<FilterState> {
const sp = new URLSearchParams(window.location.search) const sp = new URLSearchParams(window.location.search)

View File

@@ -14,7 +14,7 @@ interface KeyboardShortcutsProps {
interface PhotoUpdate { interface PhotoUpdate {
rating?: number rating?: number
is_picked?: boolean is_picked?: boolean
is_trashed?: boolean is_discarded?: boolean
color_label?: string | null color_label?: string | null
} }
@@ -75,8 +75,8 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
useHotkeys('/', focusSearch, HK_OPTS) useHotkeys('/', focusSearch, HK_OPTS)
useHotkeys('mod+f', focusSearch, HK_OPTS) useHotkeys('mod+f', focusSearch, HK_OPTS)
// E and Space both toggle the preview view (open from grid, close from // Space toggles the preview view (open from grid, close from preview).
// preview). Double-click on a thumbnail does the same. // Double-click on a thumbnail does the same.
const openPreviewFromGrid = () => { const openPreviewFromGrid = () => {
const id = usePhotoStore.getState().activePhotoId ?? getFirstPhotoId?.() ?? null const id = usePhotoStore.getState().activePhotoId ?? getFirstPhotoId?.() ?? null
if (id) openPreview(id) if (id) openPreview(id)
@@ -87,7 +87,6 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
else openPreviewFromGrid() else openPreviewFromGrid()
} }
useHotkeys('e', togglePreview, HK_OPTS, [isPreview, getFirstPhotoId])
useHotkeys('space', togglePreview, HK_OPTS, [isPreview, getFirstPhotoId]) useHotkeys('space', togglePreview, HK_OPTS, [isPreview, getFirstPhotoId])
// ── Culling shortcuts (work in both grid and preview) ──────────────────── // ── Culling shortcuts (work in both grid and preview) ────────────────────
@@ -104,24 +103,24 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
useHotkeys('0', () => updateActive({ rating: 0 }), HK_OPTS) useHotkeys('0', () => updateActive({ rating: 0 }), HK_OPTS)
// Pick / trash / unflag. Trash is the merged "rejected" concept — a soft // Pick / discard / unflag. Discard is a soft flag that hides the photo
// flag that hides the photo from the default timeline view; restore via // from the default timeline view; restore via the Discarded view (or the
// the trash view (or the U shortcut). // U shortcut).
useHotkeys( useHotkeys(
'p', 'p',
() => updateActive({ is_picked: true, is_trashed: false }), () => updateActive({ is_picked: true, is_discarded: false }),
HK_OPTS HK_OPTS
) )
useHotkeys( useHotkeys(
'x', 'x',
() => updateActive({ is_trashed: true, is_picked: false }), () => updateActive({ is_discarded: true, is_picked: false }),
HK_OPTS HK_OPTS
) )
useHotkeys( useHotkeys(
'u', 'u',
() => updateActive({ is_picked: false, is_trashed: false }), () => updateActive({ is_picked: false, is_discarded: false }),
HK_OPTS HK_OPTS
) )

View File

@@ -69,7 +69,7 @@ export const photos = {
rating?: number rating?: number
flag?: string flag?: string
heap_id?: string heap_id?: string
trash?: boolean discard?: boolean
}) => { }) => {
const response = await api.post('/photos/bulk', { const response = await api.post('/photos/bulk', {
photo_ids: photoIds, photo_ids: photoIds,
@@ -169,22 +169,22 @@ export const tags = {
}, },
} }
// Trash API // Discard API
export const trash = { export const discard = {
list: async () => { list: async () => {
const response = await api.get('/trash') const response = await api.get('/discard')
return response.data return response.data
}, },
restore: async (photoIds: string[]) => { restore: async (photoIds: string[]) => {
const response = await api.post('/trash/restore', { const response = await api.post('/discard/restore', {
photo_ids: photoIds, photo_ids: photoIds,
}) })
return response.data return response.data
}, },
empty: async () => { empty: async () => {
const response = await api.delete('/trash/empty') const response = await api.delete('/discard/empty')
return response.data return response.data
}, },
} }

View File

@@ -2,7 +2,7 @@ import { create } from 'zustand'
export type MediaType = 'photo' | 'video' | 'raw' | 'heic' export type MediaType = 'photo' | 'video' | 'raw' | 'heic'
export type ColorLabel = 'red' | 'orange' | 'yellow' | 'green' | 'blue' | 'purple' export type ColorLabel = 'red' | 'orange' | 'yellow' | 'green' | 'blue' | 'purple'
export type FlagFilter = 'any' | 'picked' | 'trashed' | 'unflagged' export type FlagFilter = 'any' | 'picked' | 'discarded' | 'unflagged'
export interface FilterState { export interface FilterState {
q: string q: string
@@ -77,7 +77,7 @@ export function filtersToParams(f: FilterState): Record<string, string | number>
if (f.ratingMin > 0) params.rating_min = f.ratingMin if (f.ratingMin > 0) params.rating_min = f.ratingMin
if (f.colorLabel) params.color_label = f.colorLabel if (f.colorLabel) params.color_label = f.colorLabel
if (f.flag === 'picked') params.is_picked = 'true' if (f.flag === 'picked') params.is_picked = 'true'
else if (f.flag === 'trashed') params.is_trashed = 'true' else if (f.flag === 'discarded') params.is_discarded = 'true'
else if (f.flag === 'unflagged') params.is_picked = 'false' else if (f.flag === 'unflagged') params.is_picked = 'false'
return params return params
} }

View File

@@ -8,7 +8,7 @@ export interface Photo {
taken_at: string | null taken_at: string | null
rating: number rating: number
is_picked: boolean is_picked: boolean
is_trashed: boolean is_discarded: boolean
file_hash: string file_hash: string
thumb_small?: string thumb_small?: string
thumb_medium?: string thumb_medium?: string