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>
383 lines
12 KiB
TypeScript
383 lines
12 KiB
TypeScript
import { useState, useMemo } from 'react'
|
||
import {
|
||
X,
|
||
Star,
|
||
MapPin,
|
||
Camera,
|
||
Aperture,
|
||
Info,
|
||
ChevronDown,
|
||
ChevronRight,
|
||
Check,
|
||
Trash2,
|
||
} from 'lucide-react'
|
||
import clsx from 'clsx'
|
||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||
import { format } from 'date-fns'
|
||
import { usePhotoStore } from '../../store/photoStore'
|
||
import { photos as photosApi } from '../../services/api'
|
||
|
||
interface PhotoDetails {
|
||
id: string
|
||
filename: string
|
||
filepath: string
|
||
width: number | null
|
||
height: number | null
|
||
file_size: number | null
|
||
taken_at: string | null
|
||
rating: number
|
||
is_picked: boolean
|
||
is_trashed: boolean
|
||
exif_json: string | null
|
||
}
|
||
|
||
interface ExifData {
|
||
Make?: string
|
||
Model?: string
|
||
LensModel?: string
|
||
Lens?: string
|
||
ISO?: number | string
|
||
FNumber?: number | string
|
||
ApertureValue?: number | string
|
||
ExposureTime?: string
|
||
ShutterSpeedValue?: string
|
||
FocalLength?: string
|
||
FocalLengthIn35mmFormat?: string
|
||
GPSLatitude?: number | string
|
||
GPSLongitude?: number | string
|
||
[key: string]: unknown
|
||
}
|
||
|
||
function formatFileSize(bytes: number | null): string {
|
||
if (bytes == null) return '—'
|
||
if (bytes < 1024) return `${bytes} B`
|
||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`
|
||
}
|
||
|
||
function formatExifValue(v: unknown): string {
|
||
if (v == null || v === '') return '—'
|
||
return String(v)
|
||
}
|
||
|
||
function pickFirst(exif: ExifData, ...keys: string[]): string {
|
||
for (const k of keys) {
|
||
const v = exif[k]
|
||
if (v != null && v !== '') return String(v)
|
||
}
|
||
return '—'
|
||
}
|
||
|
||
function parseExif(json: string | null): ExifData {
|
||
if (!json) return {}
|
||
try {
|
||
const parsed = JSON.parse(json)
|
||
return typeof parsed === 'object' && parsed !== null ? (parsed as ExifData) : {}
|
||
} catch {
|
||
return {}
|
||
}
|
||
}
|
||
|
||
export function RightSidebar() {
|
||
const { selectedPhotos, activePhotoId, clearSelection } = usePhotoStore()
|
||
const queryClient = useQueryClient()
|
||
|
||
const [expandedSections, setExpandedSections] = useState<Set<string>>(
|
||
new Set(['basic', 'camera', 'location'])
|
||
)
|
||
|
||
const toggleSection = (section: string) => {
|
||
const newExpanded = new Set(expandedSections)
|
||
if (newExpanded.has(section)) newExpanded.delete(section)
|
||
else newExpanded.add(section)
|
||
setExpandedSections(newExpanded)
|
||
}
|
||
|
||
// Fetch the active photo's full record (with EXIF) on demand.
|
||
const { data: photo } = useQuery<PhotoDetails>({
|
||
queryKey: ['photo', activePhotoId],
|
||
queryFn: () => photosApi.get(activePhotoId!),
|
||
enabled: !!activePhotoId,
|
||
staleTime: 60_000,
|
||
})
|
||
|
||
// Mutations for rating / pick / reject. Optimistic-ish: invalidate the
|
||
// photo query and the timeline list query so the grid re-renders too.
|
||
const updateMutation = useMutation({
|
||
mutationFn: (data: {
|
||
rating?: number
|
||
is_picked?: boolean
|
||
is_trashed?: boolean
|
||
}) => photosApi.update(activePhotoId!, data),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ['photo', activePhotoId] })
|
||
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||
},
|
||
})
|
||
|
||
const exif = useMemo(() => parseExif(photo?.exif_json ?? null), [photo?.exif_json])
|
||
|
||
if (selectedPhotos.length === 0) {
|
||
return (
|
||
<div className="flex h-full items-center justify-center p-4 text-center">
|
||
<div className="text-text-muted">
|
||
<Info className="mx-auto mb-2 h-8 w-8" />
|
||
<p className="text-sm">Select photos to view details</p>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const multipleSelected = selectedPhotos.length > 1
|
||
const rating = photo?.rating ?? 0
|
||
const isPicked = photo?.is_picked ?? false
|
||
const isTrashed = photo?.is_trashed ?? false
|
||
|
||
return (
|
||
<div className="flex h-full flex-col bg-surface">
|
||
{/* Header */}
|
||
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
||
<h2 className="text-sm font-semibold text-text">
|
||
{multipleSelected
|
||
? `${selectedPhotos.length} Photos Selected`
|
||
: 'Photo Details'}
|
||
</h2>
|
||
<button
|
||
onClick={clearSelection}
|
||
className="rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text"
|
||
title="Clear selection"
|
||
>
|
||
<X className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
|
||
{/* Quick Actions — operate on the active photo */}
|
||
{photo && !multipleSelected && (
|
||
<div className="border-b border-border p-4">
|
||
<div className="mb-3">
|
||
<label className="mb-1 block text-xs text-text-muted">Rating</label>
|
||
<div className="flex gap-1">
|
||
{[1, 2, 3, 4, 5].map((value) => (
|
||
<button
|
||
key={value}
|
||
onClick={() =>
|
||
updateMutation.mutate({ rating: rating === value ? 0 : value })
|
||
}
|
||
className="p-0.5"
|
||
title={`Set rating to ${value}`}
|
||
>
|
||
<Star
|
||
className={clsx(
|
||
'h-5 w-5 transition-colors',
|
||
value <= rating
|
||
? 'fill-star text-star'
|
||
: 'text-text-muted hover:text-star'
|
||
)}
|
||
/>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="mb-1 block text-xs text-text-muted">Flag</label>
|
||
<div className="flex gap-2">
|
||
<button
|
||
onClick={() =>
|
||
updateMutation.mutate({
|
||
is_picked: !isPicked,
|
||
is_trashed: false,
|
||
})
|
||
}
|
||
className={clsx(
|
||
'flex items-center gap-1 rounded px-2 py-1 text-sm transition-colors',
|
||
isPicked
|
||
? 'bg-pick/20 text-pick'
|
||
: 'bg-surface-2 text-text-muted hover:bg-surface-offset'
|
||
)}
|
||
>
|
||
<Check className="h-3 w-3" />
|
||
Pick
|
||
</button>
|
||
<button
|
||
onClick={() =>
|
||
updateMutation.mutate({
|
||
is_trashed: !isTrashed,
|
||
is_picked: false,
|
||
})
|
||
}
|
||
className={clsx(
|
||
'flex items-center gap-1 rounded px-2 py-1 text-sm transition-colors',
|
||
isTrashed
|
||
? 'bg-reject/20 text-reject'
|
||
: 'bg-surface-2 text-text-muted hover:bg-surface-offset'
|
||
)}
|
||
>
|
||
<Trash2 className="h-3 w-3" />
|
||
Trash
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Metadata */}
|
||
<div className="flex-1 overflow-y-auto">
|
||
{photo && !multipleSelected && (
|
||
<>
|
||
{/* Basic Info */}
|
||
<Section
|
||
title="Basic Info"
|
||
expanded={expandedSections.has('basic')}
|
||
onToggle={() => toggleSection('basic')}
|
||
>
|
||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||
<Field label="Filename" value={photo.filename} />
|
||
<Field label="Size" value={formatFileSize(photo.file_size)} />
|
||
<Field
|
||
label="Dimensions"
|
||
value={
|
||
photo.width && photo.height
|
||
? `${photo.width} × ${photo.height}`
|
||
: '—'
|
||
}
|
||
/>
|
||
<Field
|
||
label="Date Taken"
|
||
value={
|
||
photo.taken_at
|
||
? format(new Date(photo.taken_at), 'MMM d, yyyy HH:mm')
|
||
: '—'
|
||
}
|
||
/>
|
||
</div>
|
||
</Section>
|
||
|
||
{/* Camera */}
|
||
<Section
|
||
title="Camera"
|
||
expanded={expandedSections.has('camera')}
|
||
onToggle={() => toggleSection('camera')}
|
||
>
|
||
<div className="space-y-1 text-xs">
|
||
<div className="flex items-center gap-2">
|
||
<Camera className="h-3 w-3 text-text-muted" />
|
||
<span className="text-text">
|
||
{pickFirst(exif, 'Make', 'Model') === '—'
|
||
? '—'
|
||
: `${formatExifValue(exif.Make)} ${formatExifValue(exif.Model)}`.trim()}
|
||
</span>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Aperture className="h-3 w-3 text-text-muted" />
|
||
<span className="text-text">
|
||
{pickFirst(exif, 'LensModel', 'Lens')}
|
||
</span>
|
||
</div>
|
||
<div className="mt-2 grid grid-cols-2 gap-2">
|
||
<Field label="ISO" value={formatExifValue(exif.ISO)} />
|
||
<Field
|
||
label="Aperture"
|
||
value={
|
||
exif.FNumber
|
||
? `f/${exif.FNumber}`
|
||
: pickFirst(exif, 'ApertureValue')
|
||
}
|
||
/>
|
||
<Field
|
||
label="Shutter"
|
||
value={pickFirst(exif, 'ExposureTime', 'ShutterSpeedValue')}
|
||
/>
|
||
<Field
|
||
label="Focal"
|
||
value={pickFirst(
|
||
exif,
|
||
'FocalLength',
|
||
'FocalLengthIn35mmFormat'
|
||
)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</Section>
|
||
|
||
{/* Location */}
|
||
<Section
|
||
title="Location"
|
||
expanded={expandedSections.has('location')}
|
||
onToggle={() => toggleSection('location')}
|
||
>
|
||
{exif.GPSLatitude && exif.GPSLongitude ? (
|
||
<div className="flex items-center gap-2 text-xs">
|
||
<MapPin className="h-3 w-3 text-text-muted" />
|
||
<span className="font-mono text-text">
|
||
{String(exif.GPSLatitude)}, {String(exif.GPSLongitude)}
|
||
</span>
|
||
</div>
|
||
) : (
|
||
<div className="text-xs text-text-muted">No GPS data</div>
|
||
)}
|
||
</Section>
|
||
</>
|
||
)}
|
||
|
||
{!photo && !multipleSelected && (
|
||
<div className="p-4 text-xs text-text-muted">Loading…</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Footer Actions for multi-select */}
|
||
{multipleSelected && (
|
||
<div className="border-t border-border p-3">
|
||
<div className="space-y-2">
|
||
<button className="w-full rounded bg-surface-2 px-3 py-1.5 text-sm text-text hover:bg-surface-offset">
|
||
Add to Heap
|
||
</button>
|
||
<button className="w-full rounded bg-surface-2 px-3 py-1.5 text-sm text-text hover:bg-surface-offset">
|
||
Export Selected
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function Section({
|
||
title,
|
||
expanded,
|
||
onToggle,
|
||
children,
|
||
}: {
|
||
title: string
|
||
expanded: boolean
|
||
onToggle: () => void
|
||
children: React.ReactNode
|
||
}) {
|
||
return (
|
||
<div className="border-b border-border">
|
||
<button
|
||
onClick={onToggle}
|
||
className="flex w-full items-center justify-between px-4 py-2 text-sm hover:bg-surface-2"
|
||
>
|
||
<span className="font-medium text-text">{title}</span>
|
||
{expanded ? (
|
||
<ChevronDown className="h-4 w-4 text-text-muted" />
|
||
) : (
|
||
<ChevronRight className="h-4 w-4 text-text-muted" />
|
||
)}
|
||
</button>
|
||
{expanded && <div className="px-4 pb-3">{children}</div>}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function Field({ label, value }: { label: string; value: string }) {
|
||
return (
|
||
<div>
|
||
<span className="text-text-muted">{label}:</span>
|
||
<p className="break-words text-text">{value}</p>
|
||
</div>
|
||
)
|
||
}
|