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:
@@ -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)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user