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
color_label = Column(String) # 'red'|'orange'|'yellow'|'green'|'blue'|'purple'|NULL
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
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),
color_label: Optional[str] = None,
is_picked: Optional[bool] = None,
is_rejected: Optional[bool] = None,
is_trashed: Optional[bool] = False,
heap_id: Optional[str] = None,
sort: str = "taken_at",
@@ -93,10 +92,8 @@ async def list_photos(
# Flag filters
if is_picked is not None:
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)
# Apply all filters
@@ -414,33 +411,22 @@ async def trash_photo(
photo_id: str,
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(
select(Photo).where(Photo.id == photo_id)
)
photo = result.scalar_one_or_none()
if not photo:
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.trashed_at = datetime.utcnow()
await db.commit()
return {"status": "success", "message": "Photo moved to trash"}
@router.post("/bulk")
@@ -476,11 +462,7 @@ async def bulk_action(
elif action.action == 'pick':
for photo in photos:
photo.is_picked = True
photo.is_rejected = False
elif action.action == 'reject':
for photo in photos:
photo.is_rejected = True
photo.is_picked = False
photo.is_trashed = False
else:
raise HTTPException(status_code=400, detail="Invalid action")

View File

@@ -20,7 +20,6 @@ class PhotoBase(BaseModel):
rating: int = 0
color_label: Optional[str] = None
is_picked: bool = False
is_rejected: bool = False
class PhotoResponse(PhotoBase):
"""Photo response schema"""
@@ -53,7 +52,7 @@ class PhotoUpdate(BaseModel):
rating: Optional[int] = Field(None, ge=0, le=5)
color_label: Optional[str] = None
is_picked: Optional[bool] = None
is_rejected: Optional[bool] = None
is_trashed: Optional[bool] = None
taken_at: Optional[datetime] = None
class PhotoListResponse(BaseModel):

View File

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

View File

@@ -9,6 +9,7 @@ import {
ChevronDown,
ChevronRight,
Check,
Trash2,
} from 'lucide-react'
import clsx from 'clsx'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
@@ -26,7 +27,7 @@ interface PhotoDetails {
taken_at: string | null
rating: number
is_picked: boolean
is_rejected: boolean
is_trashed: boolean
exif_json: string | null
}
@@ -107,7 +108,7 @@ export function RightSidebar() {
mutationFn: (data: {
rating?: number
is_picked?: boolean
is_rejected?: boolean
is_trashed?: boolean
}) => photosApi.update(activePhotoId!, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['photo', activePhotoId] })
@@ -131,7 +132,7 @@ export function RightSidebar() {
const multipleSelected = selectedPhotos.length > 1
const rating = photo?.rating ?? 0
const isPicked = photo?.is_picked ?? false
const isRejected = photo?.is_rejected ?? false
const isTrashed = photo?.is_trashed ?? false
return (
<div className="flex h-full flex-col bg-surface">
@@ -186,7 +187,7 @@ export function RightSidebar() {
onClick={() =>
updateMutation.mutate({
is_picked: !isPicked,
is_rejected: false,
is_trashed: false,
})
}
className={clsx(
@@ -202,19 +203,19 @@ export function RightSidebar() {
<button
onClick={() =>
updateMutation.mutate({
is_rejected: !isRejected,
is_trashed: !isTrashed,
is_picked: false,
})
}
className={clsx(
'flex items-center gap-1 rounded px-2 py-1 text-sm transition-colors',
isRejected
isTrashed
? 'bg-reject/20 text-reject'
: 'bg-surface-2 text-text-muted hover:bg-surface-offset'
)}
>
<X className="h-3 w-3" />
Reject
<Trash2 className="h-3 w-3" />
Trash
</button>
</div>
</div>

View File

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

View File

@@ -14,7 +14,7 @@ interface KeyboardShortcutsProps {
interface PhotoUpdate {
rating?: number
is_picked?: boolean
is_rejected?: boolean
is_trashed?: boolean
color_label?: string | null
}
@@ -129,17 +129,19 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
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', () => {
updateActive({ is_picked: true, is_rejected: false })
updateActive({ is_picked: true, is_trashed: false })
})
useHotkeys('x', () => {
updateActive({ is_rejected: true, is_picked: false })
updateActive({ is_trashed: true, is_picked: false })
})
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).

View File

@@ -2,7 +2,7 @@ import { create } from 'zustand'
export type MediaType = 'photo' | 'video' | 'raw' | 'heic'
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 {
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.colorLabel) params.color_label = f.colorLabel
if (f.flag === 'picked') params.is_picked = 'true'
else if (f.flag === 'rejected') params.is_rejected = 'true'
else if (f.flag === 'unflagged') {
params.is_picked = 'false'
params.is_rejected = 'false'
}
else if (f.flag === 'trashed') params.is_trashed = 'true'
else if (f.flag === 'unflagged') params.is_picked = 'false'
return params
}

View File

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