diff --git a/backend/app/models/photos.py b/backend/app/models/photos.py index 730c6f5..4f28da4 100644 --- a/backend/app/models/photos.py +++ b/backend/app/models/photos.py @@ -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) diff --git a/backend/app/routers/photos.py b/backend/app/routers/photos.py index 46f074a..4b052fb 100644 --- a/backend/app/routers/photos.py +++ b/backend/app/routers/photos.py @@ -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") diff --git a/backend/app/schemas/photos.py b/backend/app/schemas/photos.py index 2aace2c..baaf710 100644 --- a/backend/app/schemas/photos.py +++ b/backend/app/schemas/photos.py @@ -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): diff --git a/frontend/src/components/filter/FilterBar.tsx b/frontend/src/components/filter/FilterBar.tsx index 0d1eb75..47b8ed3 100644 --- a/frontend/src/components/filter/FilterBar.tsx +++ b/frontend/src/components/filter/FilterBar.tsx @@ -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' }, ] diff --git a/frontend/src/components/layout/RightSidebar.tsx b/frontend/src/components/layout/RightSidebar.tsx index 34fb6ae..7b72c6e 100644 --- a/frontend/src/components/layout/RightSidebar.tsx +++ b/frontend/src/components/layout/RightSidebar.tsx @@ -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 (
@@ -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() {
diff --git a/frontend/src/hooks/useFilterUrlSync.ts b/frontend/src/hooks/useFilterUrlSync.ts index a099342..c9a995a 100644 --- a/frontend/src/hooks/useFilterUrlSync.ts +++ b/frontend/src/hooks/useFilterUrlSync.ts @@ -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 { const sp = new URLSearchParams(window.location.search) diff --git a/frontend/src/hooks/useKeyboardShortcuts.ts b/frontend/src/hooks/useKeyboardShortcuts.ts index 90adf82..8856c52 100644 --- a/frontend/src/hooks/useKeyboardShortcuts.ts +++ b/frontend/src/hooks/useKeyboardShortcuts.ts @@ -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). diff --git a/frontend/src/store/filterStore.ts b/frontend/src/store/filterStore.ts index 6d58aab..4feabf9 100644 --- a/frontend/src/store/filterStore.ts +++ b/frontend/src/store/filterStore.ts @@ -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 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 } diff --git a/frontend/src/types/photo.ts b/frontend/src/types/photo.ts index 5fe9b38..eae9540 100644 --- a/frontend/src/types/photo.ts +++ b/frontend/src/types/photo.ts @@ -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