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