feat: wire RightSidebar to real photo metadata

Replaces the mock photo data with a TanStack Query fetch driven by
activePhotoId, so the metadata panel reflects the photo currently
selected (or being viewed in the loupe). Parses exif_json defensively
and renders ExifTool fields with sensible aliases (Make/Model,
LensModel/Lens, FNumber, ExposureTime/ShutterSpeedValue, FocalLength,
GPSLatitude/Longitude). Falls back to '—' for missing fields.

Rating stars and Pick/Reject buttons now fire useMutation against
PATCH /photos/{id} and invalidate both the photo detail query and the
['photos'] list query so the timeline grid reflects the change too.

Multi-select keeps its bulk-action footer and shows the selection
count instead of metadata.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-07 08:39:48 +02:00
parent f4fc15101e
commit 9089ad2f61

View File

@@ -1,5 +1,5 @@
import { useState } from 'react' import { useState, useMemo } from 'react'
import { import {
X, X,
Star, Star,
MapPin, MapPin,
@@ -9,46 +9,114 @@ import {
ChevronDown, ChevronDown,
ChevronRight, ChevronRight,
Check, Check,
Plus
} from 'lucide-react' } from 'lucide-react'
import clsx from 'clsx' import clsx from 'clsx'
import { usePhotoStore } from '../../store/photoStore' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { format } from 'date-fns' 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_rejected: 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() { export function RightSidebar() {
const { selectedPhotos, clearSelection } = usePhotoStore() const { selectedPhotos, activePhotoId, clearSelection } = usePhotoStore()
const queryClient = useQueryClient()
const [expandedSections, setExpandedSections] = useState<Set<string>>( const [expandedSections, setExpandedSections] = useState<Set<string>>(
new Set(['basic', 'camera', 'location', 'tags']) new Set(['basic', 'camera', 'location'])
) )
const [rating, setRating] = useState(0)
const [flagStatus, setFlagStatus] = useState<'none' | 'pick' | 'reject'>('none')
const toggleSection = (section: string) => { const toggleSection = (section: string) => {
const newExpanded = new Set(expandedSections) const newExpanded = new Set(expandedSections)
if (newExpanded.has(section)) { if (newExpanded.has(section)) newExpanded.delete(section)
newExpanded.delete(section) else newExpanded.add(section)
} else {
newExpanded.add(section)
}
setExpandedSections(newExpanded) setExpandedSections(newExpanded)
} }
// Mock photo data - in real app, fetch based on selectedPhotos // Fetch the active photo's full record (with EXIF) on demand.
const mockPhoto = selectedPhotos.length > 0 ? { const { data: photo } = useQuery<PhotoDetails>({
filename: 'IMG_1234.jpg', queryKey: ['photo', activePhotoId],
size: '3.2 MB', queryFn: () => photosApi.get(activePhotoId!),
dimensions: '4032 × 3024', enabled: !!activePhotoId,
dateTaken: new Date('2024-01-15T14:30:00'), staleTime: 60_000,
camera: 'Canon EOS R5', })
lens: 'RF 24-70mm F2.8L IS USM',
iso: 400, // Mutations for rating / pick / reject. Optimistic-ish: invalidate the
aperture: 'f/2.8', // photo query and the timeline list query so the grid re-renders too.
shutterSpeed: '1/250', const updateMutation = useMutation({
focalLength: '50mm', mutationFn: (data: {
location: 'San Francisco, CA', rating?: number
tags: ['landscape', 'sunset', 'golden hour'], is_picked?: boolean
} : null is_rejected?: 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) { if (selectedPhotos.length === 0) {
return ( return (
<div className="flex h-full items-center justify-center p-4 text-center"> <div className="flex h-full items-center justify-center p-4 text-center">
@@ -59,232 +127,205 @@ export function RightSidebar() {
</div> </div>
) )
} }
const multipleSelected = selectedPhotos.length > 1 const multipleSelected = selectedPhotos.length > 1
const rating = photo?.rating ?? 0
const isPicked = photo?.is_picked ?? false
const isRejected = photo?.is_rejected ?? false
return ( return (
<div className="flex h-full flex-col bg-surface"> <div className="flex h-full flex-col bg-surface">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between border-b border-border px-4 py-3"> <div className="flex items-center justify-between border-b border-border px-4 py-3">
<h2 className="text-sm font-semibold text-text"> <h2 className="text-sm font-semibold text-text">
{multipleSelected {multipleSelected
? `${selectedPhotos.length} Photos Selected` ? `${selectedPhotos.length} Photos Selected`
: 'Photo Details'} : 'Photo Details'}
</h2> </h2>
<button <button
onClick={clearSelection} onClick={clearSelection}
className="rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text" className="rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text"
title="Clear selection"
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</button> </button>
</div> </div>
{/* Quick Actions */} {/* Quick Actions — operate on the active photo */}
<div className="border-b border-border p-4"> {photo && !multipleSelected && (
{/* Rating Stars */} <div className="border-b border-border p-4">
<div className="mb-3"> <div className="mb-3">
<label className="mb-1 block text-xs text-text-muted">Rating</label> <label className="mb-1 block text-xs text-text-muted">Rating</label>
<div className="flex gap-1"> <div className="flex gap-1">
{[1, 2, 3, 4, 5].map((value) => ( {[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 <button
key={value} onClick={() =>
onClick={() => setRating(rating === value ? 0 : value)} updateMutation.mutate({
className="p-0.5" is_picked: !isPicked,
is_rejected: 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'
)}
> >
<Star <Check className="h-3 w-3" />
className={clsx( Pick
'h-5 w-5 transition-colors',
value <= rating
? 'fill-star text-star'
: 'text-text-muted hover:text-star'
)}
/>
</button> </button>
))} <button
onClick={() =>
updateMutation.mutate({
is_rejected: !isRejected,
is_picked: false,
})
}
className={clsx(
'flex items-center gap-1 rounded px-2 py-1 text-sm transition-colors',
isRejected
? 'bg-reject/20 text-reject'
: 'bg-surface-2 text-text-muted hover:bg-surface-offset'
)}
>
<X className="h-3 w-3" />
Reject
</button>
</div>
</div> </div>
</div> </div>
)}
{/* Flag Status */}
<div> {/* Metadata */}
<label className="mb-1 block text-xs text-text-muted">Flag</label>
<div className="flex gap-2">
<button
onClick={() => setFlagStatus(flagStatus === 'pick' ? 'none' : 'pick')}
className={clsx(
'flex items-center gap-1 rounded px-2 py-1 text-sm transition-colors',
flagStatus === 'pick'
? '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={() => setFlagStatus(flagStatus === 'reject' ? 'none' : 'reject')}
className={clsx(
'flex items-center gap-1 rounded px-2 py-1 text-sm transition-colors',
flagStatus === 'reject'
? 'bg-reject/20 text-reject'
: 'bg-surface-2 text-text-muted hover:bg-surface-offset'
)}
>
<X className="h-3 w-3" />
Reject
</button>
</div>
</div>
</div>
{/* Metadata Sections */}
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
{mockPhoto && ( {photo && !multipleSelected && (
<> <>
{/* Basic Info */} {/* Basic Info */}
<div className="border-b border-border"> <Section
<button title="Basic Info"
onClick={() => toggleSection('basic')} expanded={expandedSections.has('basic')}
className="flex w-full items-center justify-between px-4 py-2 text-sm hover:bg-surface-2" onToggle={() => toggleSection('basic')}
> >
<span className="font-medium text-text">Basic Info</span> <div className="grid grid-cols-2 gap-2 text-xs">
{expandedSections.has('basic') ? ( <Field label="Filename" value={photo.filename} />
<ChevronDown className="h-4 w-4 text-text-muted" /> <Field label="Size" value={formatFileSize(photo.file_size)} />
) : ( <Field
<ChevronRight className="h-4 w-4 text-text-muted" /> label="Dimensions"
)} value={
</button> photo.width && photo.height
{expandedSections.has('basic') && ( ? `${photo.width} × ${photo.height}`
<div className="px-4 pb-3 text-xs"> : '—'
<div className="grid grid-cols-2 gap-2"> }
<div> />
<span className="text-text-muted">Filename:</span> <Field
<p className="text-text">{mockPhoto.filename}</p> label="Date Taken"
</div> value={
<div> photo.taken_at
<span className="text-text-muted">Size:</span> ? format(new Date(photo.taken_at), 'MMM d, yyyy HH:mm')
<p className="text-text">{mockPhoto.size}</p> : '—'
</div> }
<div> />
<span className="text-text-muted">Dimensions:</span> </div>
<p className="text-text">{mockPhoto.dimensions}</p> </Section>
</div>
<div> {/* Camera */}
<span className="text-text-muted">Date Taken:</span> <Section
<p className="text-text"> title="Camera"
{format(mockPhoto.dateTaken, 'MMM d, yyyy')} expanded={expandedSections.has('camera')}
</p> onToggle={() => toggleSection('camera')}
</div> >
</div> <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>
)} <div className="flex items-center gap-2">
</div> <Aperture className="h-3 w-3 text-text-muted" />
<span className="text-text">
{/* Camera Info */} {pickFirst(exif, 'LensModel', 'Lens')}
<div className="border-b border-border"> </span>
<button
onClick={() => toggleSection('camera')}
className="flex w-full items-center justify-between px-4 py-2 text-sm hover:bg-surface-2"
>
<span className="font-medium text-text">Camera</span>
{expandedSections.has('camera') ? (
<ChevronDown className="h-4 w-4 text-text-muted" />
) : (
<ChevronRight className="h-4 w-4 text-text-muted" />
)}
</button>
{expandedSections.has('camera') && (
<div className="px-4 pb-3 text-xs">
<div className="space-y-1">
<div className="flex items-center gap-2">
<Camera className="h-3 w-3 text-text-muted" />
<span className="text-text">{mockPhoto.camera}</span>
</div>
<div className="flex items-center gap-2">
<Aperture className="h-3 w-3 text-text-muted" />
<span className="text-text">{mockPhoto.lens}</span>
</div>
<div className="grid grid-cols-2 gap-2 mt-2">
<div>
<span className="text-text-muted">ISO:</span>
<span className="ml-1 text-text">{mockPhoto.iso}</span>
</div>
<div>
<span className="text-text-muted">Aperture:</span>
<span className="ml-1 text-text">{mockPhoto.aperture}</span>
</div>
<div>
<span className="text-text-muted">Shutter:</span>
<span className="ml-1 text-text">{mockPhoto.shutterSpeed}</span>
</div>
<div>
<span className="text-text-muted">Focal:</span>
<span className="ml-1 text-text">{mockPhoto.focalLength}</span>
</div>
</div>
</div>
</div> </div>
)} <div className="mt-2 grid grid-cols-2 gap-2">
</div> <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 */} {/* Location */}
<div className="border-b border-border"> <Section
<button title="Location"
onClick={() => toggleSection('location')} expanded={expandedSections.has('location')}
className="flex w-full items-center justify-between px-4 py-2 text-sm hover:bg-surface-2" onToggle={() => toggleSection('location')}
> >
<span className="font-medium text-text">Location</span> {exif.GPSLatitude && exif.GPSLongitude ? (
{expandedSections.has('location') ? ( <div className="flex items-center gap-2 text-xs">
<ChevronDown className="h-4 w-4 text-text-muted" /> <MapPin className="h-3 w-3 text-text-muted" />
) : ( <span className="font-mono text-text">
<ChevronRight className="h-4 w-4 text-text-muted" /> {String(exif.GPSLatitude)}, {String(exif.GPSLongitude)}
)} </span>
</button>
{expandedSections.has('location') && (
<div className="px-4 pb-3">
<div className="flex items-center gap-2 text-xs">
<MapPin className="h-3 w-3 text-text-muted" />
<span className="text-text">{mockPhoto.location}</span>
</div>
</div> </div>
) : (
<div className="text-xs text-text-muted">No GPS data</div>
)} )}
</div> </Section>
{/* Tags */}
<div className="border-b border-border">
<button
onClick={() => toggleSection('tags')}
className="flex w-full items-center justify-between px-4 py-2 text-sm hover:bg-surface-2"
>
<span className="font-medium text-text">Tags</span>
{expandedSections.has('tags') ? (
<ChevronDown className="h-4 w-4 text-text-muted" />
) : (
<ChevronRight className="h-4 w-4 text-text-muted" />
)}
</button>
{expandedSections.has('tags') && (
<div className="px-4 pb-3">
<div className="flex flex-wrap gap-1">
{mockPhoto.tags.map((tag) => (
<span
key={tag}
className="rounded bg-surface-2 px-2 py-0.5 text-xs text-text"
>
{tag}
</span>
))}
<button className="rounded bg-surface-2 px-2 py-0.5 text-xs text-text-muted hover:bg-surface-offset hover:text-text">
<Plus className="h-3 w-3" />
</button>
</div>
</div>
)}
</div>
</> </>
)} )}
{!photo && !multipleSelected && (
<div className="p-4 text-xs text-text-muted">Loading</div>
)}
</div> </div>
{/* Footer Actions */} {/* Footer Actions for multi-select */}
{multipleSelected && ( {multipleSelected && (
<div className="border-t border-border p-3"> <div className="border-t border-border p-3">
<div className="space-y-2"> <div className="space-y-2">
@@ -299,4 +340,42 @@ export function RightSidebar() {
)} )}
</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>
)
}