Files
mule-image/frontend/src/components/layout/RightSidebar.tsx
dtoro 9089ad2f61 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>
2026-04-07 08:39:48 +02:00

382 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useMemo } from 'react'
import {
X,
Star,
MapPin,
Camera,
Aperture,
Info,
ChevronDown,
ChevronRight,
Check,
} 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_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() {
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_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) {
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 isRejected = photo?.is_rejected ?? 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_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'
)}
>
<Check className="h-3 w-3" />
Pick
</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>
)}
{/* 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>
)
}