refactor: merge reject into trash as a single soft-trash concept

The Photo model previously had two near-identical "negative culling"
states: is_rejected (a flag) and is_trashed (a flag plus a file move).
Lightroom users typically use one or the other, never both, and the
file-move semantics of the old trash made it harder to undo. Merging
into a single soft is_trashed flag — file stays on disk, restore is a
flag flip, permanent deletion still happens via DELETE /trash/empty.

Backend
- Drop is_rejected from PhotoBase, PhotoResponse, PhotoUpdate, the
  list endpoint filter, and the bulk-action 'reject' branch.
- Add is_trashed to PhotoUpdate so the PATCH path can set it.
- Drop is_rejected Column declaration from the SQLAlchemy model. The
  legacy DB column may persist on existing installs but is no longer
  read or written; SQLAlchemy ignores extra columns.
- Rewrite DELETE /photos/{id} as a soft trash: just sets is_trashed=
  true and trashed_at=now, no shutil.move. Permanent deletion still
  goes through the trash router.

Frontend
- Photo TS type drops is_rejected, gains is_trashed.
- X keyboard shortcut now sets is_trashed=true (was is_rejected); U
  clears both is_picked and is_trashed.
- RightSidebar Reject button → Trash button (Trash2 icon).
- PhotoThumbnail flag overlay shows Trash2 icon for trashed photos
  instead of an X for rejected.
- KeyboardHints relabels X from "Reject" to "Trash".
- filterStore FlagFilter renames 'rejected' → 'trashed'; the params
  builder now sends is_trashed=true for the trashed filter (the list
  endpoint defaults to hiding trashed photos otherwise).
- FilterBar dropdown / URL sync allow-list updated accordingly.

No data migration: existing rejected photos remain as-is (flag stale)
and effectively become unflagged in the new model. Re-trash from the
UI to bring them into the new state.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-07 22:16:10 +02:00
parent f7bf22db29
commit ce2cda0565
9 changed files with 36 additions and 53 deletions

View File

@@ -55,7 +55,9 @@ 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)
is_rejected = Column(Boolean, default=False) # Note: is_rejected was merged into is_trashed (a single soft "trashed"
# concept). The DB column may still exist on legacy installs but is no
# longer read or written.
# Duplicate detection # Duplicate detection
is_duplicate = Column(Boolean, default=False) is_duplicate = Column(Boolean, default=False)

View File

@@ -33,7 +33,6 @@ 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_rejected: Optional[bool] = None,
is_trashed: Optional[bool] = False, is_trashed: Optional[bool] = False,
heap_id: Optional[str] = None, heap_id: Optional[str] = None,
sort: str = "taken_at", sort: str = "taken_at",
@@ -93,10 +92,8 @@ async def list_photos(
# Flag filters # Flag filters
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)
if is_rejected is not None:
filters.append(Photo.is_rejected == is_rejected)
# Trash filter # Trash filter — defaults to hiding trashed photos
filters.append(Photo.is_trashed == is_trashed) filters.append(Photo.is_trashed == is_trashed)
# Apply all filters # Apply all filters
@@ -414,7 +411,10 @@ async def trash_photo(
photo_id: str, photo_id: str,
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
"""Move photo to trash""" """Soft-trash a photo: sets is_trashed=true. The file stays on disk so
restore is just a flag flip. Permanent deletion happens via DELETE
/trash/{id} or DELETE /trash/empty.
"""
result = await db.execute( result = await db.execute(
select(Photo).where(Photo.id == photo_id) select(Photo).where(Photo.id == photo_id)
) )
@@ -423,22 +423,8 @@ 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")
# Move file to trash directory
import shutil
trash_dir = f"{settings.trash.path}/{photo_id}"
os.makedirs(trash_dir, exist_ok=True)
trash_path = f"{trash_dir}/original{Path(photo.filepath).suffix}"
try:
shutil.move(photo.filepath, trash_path)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to move file: {e}")
# Update database
photo.is_trashed = True photo.is_trashed = True
photo.trashed_at = datetime.utcnow() photo.trashed_at = datetime.utcnow()
await db.commit() await db.commit()
return {"status": "success", "message": "Photo moved to trash"} return {"status": "success", "message": "Photo moved to trash"}
@@ -476,11 +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_rejected = False photo.is_trashed = False
elif action.action == 'reject':
for photo in photos:
photo.is_rejected = True
photo.is_picked = False
else: else:
raise HTTPException(status_code=400, detail="Invalid action") raise HTTPException(status_code=400, detail="Invalid action")

View File

@@ -20,7 +20,6 @@ class PhotoBase(BaseModel):
rating: int = 0 rating: int = 0
color_label: Optional[str] = None color_label: Optional[str] = None
is_picked: bool = False is_picked: bool = False
is_rejected: bool = False
class PhotoResponse(PhotoBase): class PhotoResponse(PhotoBase):
"""Photo response schema""" """Photo response schema"""
@@ -53,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_rejected: Optional[bool] = None is_trashed: Optional[bool] = None
taken_at: Optional[datetime] = None taken_at: Optional[datetime] = None
class PhotoListResponse(BaseModel): class PhotoListResponse(BaseModel):

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: 'rejected', label: 'Rejected' }, { value: 'trashed', label: 'Trashed' },
{ value: 'unflagged', label: 'Unflagged' }, { value: 'unflagged', label: 'Unflagged' },
] ]

View File

@@ -9,6 +9,7 @@ import {
ChevronDown, ChevronDown,
ChevronRight, ChevronRight,
Check, Check,
Trash2,
} from 'lucide-react' } from 'lucide-react'
import clsx from 'clsx' import clsx from 'clsx'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
@@ -26,7 +27,7 @@ interface PhotoDetails {
taken_at: string | null taken_at: string | null
rating: number rating: number
is_picked: boolean is_picked: boolean
is_rejected: boolean is_trashed: boolean
exif_json: string | null exif_json: string | null
} }
@@ -107,7 +108,7 @@ export function RightSidebar() {
mutationFn: (data: { mutationFn: (data: {
rating?: number rating?: number
is_picked?: boolean is_picked?: boolean
is_rejected?: boolean is_trashed?: boolean
}) => photosApi.update(activePhotoId!, data), }) => photosApi.update(activePhotoId!, data),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['photo', activePhotoId] }) queryClient.invalidateQueries({ queryKey: ['photo', activePhotoId] })
@@ -131,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 isRejected = photo?.is_rejected ?? false const isTrashed = photo?.is_trashed ?? false
return ( return (
<div className="flex h-full flex-col bg-surface"> <div className="flex h-full flex-col bg-surface">
@@ -186,7 +187,7 @@ export function RightSidebar() {
onClick={() => onClick={() =>
updateMutation.mutate({ updateMutation.mutate({
is_picked: !isPicked, is_picked: !isPicked,
is_rejected: false, is_trashed: false,
}) })
} }
className={clsx( className={clsx(
@@ -202,19 +203,19 @@ export function RightSidebar() {
<button <button
onClick={() => onClick={() =>
updateMutation.mutate({ updateMutation.mutate({
is_rejected: !isRejected, is_trashed: !isTrashed,
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',
isRejected isTrashed
? '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'
)} )}
> >
<X className="h-3 w-3" /> <Trash2 className="h-3 w-3" />
Reject Trash
</button> </button>
</div> </div>
</div> </div>

View File

@@ -16,7 +16,7 @@ const ALLOWED_COLORS: ColorLabel[] = [
'blue', 'blue',
'purple', 'purple',
] ]
const ALLOWED_FLAGS: FlagFilter[] = ['any', 'picked', 'rejected', 'unflagged'] const ALLOWED_FLAGS: FlagFilter[] = ['any', 'picked', 'trashed', '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_rejected?: boolean is_trashed?: boolean
color_label?: string | null color_label?: string | null
} }
@@ -129,17 +129,19 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
updateActive({ rating: 0 }) updateActive({ rating: 0 })
}) })
// Pick / reject / unflag. // Pick / trash / unflag. Trash is the merged "rejected" concept — a soft
// flag that hides the photo from the default timeline view; restore via
// the trash view (or the U shortcut).
useHotkeys('p', () => { useHotkeys('p', () => {
updateActive({ is_picked: true, is_rejected: false }) updateActive({ is_picked: true, is_trashed: false })
}) })
useHotkeys('x', () => { useHotkeys('x', () => {
updateActive({ is_rejected: true, is_picked: false }) updateActive({ is_trashed: true, is_picked: false })
}) })
useHotkeys('u', () => { useHotkeys('u', () => {
updateActive({ is_picked: false, is_rejected: false }) updateActive({ is_picked: false, is_trashed: false })
}) })
// Color labels 6-9 (red/orange/yellow/green per spec §6.4). // Color labels 6-9 (red/orange/yellow/green per spec §6.4).

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' | 'rejected' | 'unflagged' export type FlagFilter = 'any' | 'picked' | 'trashed' | 'unflagged'
export interface FilterState { export interface FilterState {
q: string q: string
@@ -77,11 +77,8 @@ 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 === 'rejected') params.is_rejected = 'true' else if (f.flag === 'trashed') params.is_trashed = 'true'
else if (f.flag === 'unflagged') { else if (f.flag === 'unflagged') params.is_picked = 'false'
params.is_picked = 'false'
params.is_rejected = '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_rejected: boolean is_trashed: boolean
file_hash: string file_hash: string
thumb_small?: string thumb_small?: string
thumb_medium?: string thumb_medium?: string