Files
mule-image/frontend/src/components/sidebar/PhotoInfoPanel.tsx
Claudio eeeb16a0f1 ui(sidebar): split editable vs read-only between Edit and Metadata
The Metadata collapsible was hosting two editable widgets (TagsEditor
and TakenAtEditor) buried inside the readonly sub-sections — Tags as
its own Section, taken-at wedged into Basic Info between size/dims
and the filepath. With both top-level collapsibles in place, the
clearer split is editable up top, readonly below.

Moved into the Edit collapsible (in identification → description →
categorization order):
  Filename, Title, Date Taken, Notes, Tags, Rating, Color, Flag

Metadata now holds only readonly sub-sections:
  Basic Info (size, dims, path), Camera, Location

Dropped the now-empty Tags Section from Metadata and the 'tags' key
from the default-expanded set.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:49:44 +02:00

752 lines
25 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 { useEffect, useMemo, useState } from 'react'
import {
X,
Star,
MapPin,
Camera,
Aperture,
ChevronDown,
ChevronRight,
ShoppingBasket,
Trash2,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import {
useQuery,
useMutation,
useQueryClient,
keepPreviousData,
} from '@tanstack/react-query'
import {
photos as photosApi,
heaps as heapsApi,
tags as tagsApi,
} from '../../services/api'
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
import { LIBRARY_STATS_QUERY_KEY } from '../../hooks/useLibraryStatsQuery'
import { useTagsQuery, TAGS_QUERY_KEY } from '../../hooks/useTagsQuery'
import { formatApiError } from '../../lib/apiError'
import { toast } from '../ToastContainer'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import {
COLOR_LABEL_OPTIONS,
type ColorLabel,
} from '../../constants/colorLabels'
import { toDatetimeLocalValue } from '../../lib/guessDateFromPath'
import { TagsEditor } from './TagsEditor'
import { TakenAtEditor } from './TakenAtEditor'
export interface PhotoTagSummary {
id: string
name: string
color: string | null
}
export interface PhotoDetails {
id: string
filename: string
filepath: string
width: number | null
height: number | null
file_size: number | null
taken_at: string | null
taken_at_source: string | null
rating: number
is_discarded: boolean
user_title: string | null
user_notes: string | null
color_label: string | null
exif_json: string | null
latitude?: number | null
longitude?: number | null
tags?: PhotoTagSummary[]
}
/** Format a signed decimal degree value with the hemisphere letter, e.g.
* ``48.12777° N``. Keeps the panel readable without dragging in a heavy
* formatting lib. */
function formatLatLon(value: number, axis: 'lat' | 'lon'): string {
const abs = Math.abs(value).toFixed(5)
const ref = axis === 'lat' ? (value >= 0 ? 'N' : 'S') : (value >= 0 ? 'E' : 'W')
return `${abs}° ${ref}`
}
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 {}
}
}
interface PhotoInfoPanelProps {
/** The photo to show metadata for. Drives an on-demand detail fetch. */
photoId: string
/** When true, the editable text fields (filename, title, notes) render
* with a darker theme to read against a black preview backdrop. */
darkTheme?: boolean
}
/**
* Reusable single-photo metadata + edit panel. Used by both the grid
* RightSidebar (when one photo is selected) and the PreviewView's optional
* info overlay. Self-contained — owns its own queries and mutations.
*/
export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelProps) {
const queryClient = useQueryClient()
const [expandedSections, setExpandedSections] = useState<Set<string>>(
new Set(['edit', 'metadata', 'basic', 'camera', 'location'])
)
const toggleSection = (section: string) => {
const next = new Set(expandedSections)
if (next.has(section)) next.delete(section)
else next.add(section)
setExpandedSections(next)
}
// Fetch the photo's full record (with EXIF) on demand. `keepPreviousData`
// holds the last photo on screen while the next one loads, so arrow-nav
// through the preview doesn't flash the "Loading…" placeholder between
// every neighbour — the panel swaps in place once the new record arrives.
const { data: photo, isPlaceholderData } = useQuery<PhotoDetails>({
queryKey: ['photo', photoId],
queryFn: () => photosApi.get(photoId),
enabled: !!photoId,
staleTime: 60_000,
placeholderData: keepPreviousData,
})
// Mutation for any patchable field. Invalidates both the photo detail
// cache and the timeline list so the grid reflects the change too.
const updateMutation = useMutation({
mutationFn: (data: {
filename?: string
rating?: number
is_discarded?: boolean
user_title?: string | null
user_notes?: string | null
color_label?: string | null
taken_at?: string
}) => photosApi.update(photoId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['photo', photoId] })
queryClient.invalidateQueries({ queryKey: ['photos'] })
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
},
})
// Active heap membership for the Pick toggle button.
const { activeHeap, memberIds: activeHeapMembers } = useActiveHeapMembers()
const isInActiveHeap = activeHeapMembers.has(photoId)
const heapMutation = useMutation({
mutationFn: ({ remove }: { remove: boolean }) => {
if (!activeHeap) return Promise.resolve(null)
return remove
? heapsApi.removePhotos(activeHeap.id, [photoId])
: heapsApi.addPhotos(activeHeap.id, [photoId])
},
onMutate: ({ remove }) => {
if (!activeHeap) return { previous: undefined }
const key = ['heap-photo-ids', activeHeap.id] as const
const previous = queryClient.getQueryData<string[]>(key)
const set = new Set(previous ?? [])
if (remove) set.delete(photoId)
else set.add(photoId)
queryClient.setQueryData<string[]>(key, Array.from(set))
return { previous }
},
onError: (_e, _vars, ctx) => {
if (activeHeap && ctx?.previous) {
queryClient.setQueryData(['heap-photo-ids', activeHeap.id], ctx.previous)
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: HEAPS_QUERY_KEY })
if (activeHeap) {
queryClient.invalidateQueries({
queryKey: ['heap-photo-ids', activeHeap.id],
})
}
},
})
// ── Tags state + mutations ──────────────────────────────────────────
const { data: allTags = [] } = useTagsQuery()
const [tagInput, setTagInput] = useState('')
const invalidateTagsAndPhoto = () => {
queryClient.invalidateQueries({ queryKey: TAGS_QUERY_KEY })
queryClient.invalidateQueries({ queryKey: ['photo', photoId] })
queryClient.invalidateQueries({ queryKey: ['photos'] })
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
}
const addTagMutation = useMutation({
mutationFn: async (name: string) => {
const created = await tagsApi.create(name)
await tagsApi.addToPhoto(photoId, [created.id])
return created
},
onSuccess: () => invalidateTagsAndPhoto(),
onError: (e: any) =>
toast.error('Add tag failed', formatApiError(e)),
})
const attachExistingTagMutation = useMutation({
mutationFn: (tagId: string) => tagsApi.addToPhoto(photoId, [tagId]),
onSuccess: () => invalidateTagsAndPhoto(),
onError: (e: any) =>
toast.error('Add tag failed', formatApiError(e)),
})
const removeTagMutation = useMutation({
mutationFn: (tagId: string) => tagsApi.removeFromPhoto(photoId, tagId),
onSuccess: () => invalidateTagsAndPhoto(),
onError: (e: any) =>
toast.error('Remove tag failed', formatApiError(e)),
})
// Local drafts for the text fields. Mirror the server value but stay
// independent while typing so we don't fight focus or clobber edits.
const [filenameDraft, setFilenameDraft] = useState('')
const [titleDraft, setTitleDraft] = useState('')
const [notesDraft, setNotesDraft] = useState('')
const [takenAtDraft, setTakenAtDraft] = useState('')
useEffect(() => {
setFilenameDraft(photo?.filename ?? '')
setTitleDraft(photo?.user_title ?? '')
setNotesDraft(photo?.user_notes ?? '')
setTakenAtDraft(
photo?.taken_at ? toDatetimeLocalValue(new Date(photo.taken_at)) : ''
)
}, [
photo?.id,
photo?.filename,
photo?.user_title,
photo?.user_notes,
photo?.taken_at,
])
const commitFilename = () => {
const next = filenameDraft.trim()
const current = photo?.filename ?? ''
if (!next || next === current) {
setFilenameDraft(current)
return
}
if (next.includes('/') || next.includes('\\') || next === '.' || next === '..') {
toast.error('Invalid filename', 'No path separators allowed')
setFilenameDraft(current)
return
}
updateMutation.mutate(
{ filename: next },
{
onError: (e: any) => {
toast.error(
'Rename failed',
formatApiError(e)
)
setFilenameDraft(current)
},
}
)
}
const commitTitle = () => {
const next = titleDraft.trim()
const current = photo?.user_title ?? ''
if (next === current) return
updateMutation.mutate({ user_title: next || null })
}
const commitNotes = () => {
const next = notesDraft
const current = photo?.user_notes ?? ''
if (next === current) return
updateMutation.mutate({ user_notes: next || null })
}
/** Commit a datetime-local draft back to the server. The backend also
* rewrites EXIF on disk, so a failure here rolls the draft back to the
* server value — we never want the UI to silently disagree with the
* file. An empty string is a no-op because the input's `required` is
* off and we don't yet have a "clear date" affordance. */
const commitTakenAt = (rawValue?: string) => {
const source = rawValue ?? takenAtDraft
if (!source) return
const parsed = new Date(source)
if (Number.isNaN(parsed.getTime())) {
toast.error('Invalid date', 'Could not parse the value')
setTakenAtDraft(
photo?.taken_at ? toDatetimeLocalValue(new Date(photo.taken_at)) : ''
)
return
}
const iso = parsed.toISOString()
if (photo?.taken_at && new Date(photo.taken_at).toISOString() === iso) {
return
}
updateMutation.mutate(
{ taken_at: iso },
{
onError: (e: any) => {
toast.error(
'Date update failed',
formatApiError(e)
)
setTakenAtDraft(
photo?.taken_at
? toDatetimeLocalValue(new Date(photo.taken_at))
: ''
)
},
}
)
}
const exif = useMemo(() => parseExif(photo?.exif_json ?? null), [photo?.exif_json])
if (!photo) {
return <div className="p-4 text-xs text-text-muted">Loading</div>
}
const rating = photo.rating ?? 0
const isDiscarded = photo.is_discarded ?? false
const colorLabel = (photo.color_label ?? null) as ColorLabel | null
// Single themable input class so the same component reads against either
// the surface (grid sidebar) or a darker preview overlay.
const inputClass = cn(
'w-full rounded border px-2 py-1 text-sm focus:outline-none',
darkTheme
? 'border-white/15 bg-black/40 text-white placeholder-white/40 focus:border-primary'
: 'border-border bg-bg text-text placeholder-text-faint focus:border-primary'
)
const monoInputClass = cn(
'w-full rounded border px-2 py-1 font-mono text-xs focus:outline-none',
darkTheme
? 'border-white/15 bg-black/40 text-white placeholder-white/40 focus:border-primary'
: 'border-border bg-bg text-text placeholder-text-faint focus:border-primary'
)
// Note: no h-full / flex-1 here — the parent (RightSidebar) owns the
// scroll container so the edit fields and readonly metadata scroll
// together as one block beneath the pinned heap + header.
return (
<div
className={cn(
'flex flex-col transition-opacity duration-150',
isPlaceholderData && 'opacity-70'
)}
>
{/* Edit fields — collapsible group so the user can hide the
* editable form (filename, title, notes, rating, color, flag)
* the same way they can hide the readonly metadata block below. */}
<Collapsible
open={expandedSections.has('edit')}
onOpenChange={() => toggleSection('edit')}
className="border-b border-border"
>
<CollapsibleTrigger className="flex w-full items-center justify-between border-b border-border bg-surface-2/40 px-3 py-2 text-[11px] font-semibold uppercase tracking-[0.14em] text-text-muted hover:bg-surface-2 hover:text-text">
<span>Edit</span>
{expandedSections.has('edit') ? (
<ChevronDown className="h-3 w-3" />
) : (
<ChevronRight className="h-3 w-3" />
)}
</CollapsibleTrigger>
<CollapsibleContent>
<div className="space-y-2.5 p-3">
<div>
<label className="mb-1 block text-xs text-text-muted">Filename</label>
<Input
type="text"
value={filenameDraft}
onChange={(e) => setFilenameDraft(e.target.value)}
onBlur={commitFilename}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.currentTarget.blur()
} else if (e.key === 'Escape') {
setFilenameDraft(photo.filename ?? '')
e.currentTarget.blur()
}
}}
className={monoInputClass}
/>
</div>
<div>
<label className="mb-1 block text-xs text-text-muted">Title</label>
<Input
type="text"
value={titleDraft}
onChange={(e) => setTitleDraft(e.target.value)}
onBlur={commitTitle}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.currentTarget.blur()
} else if (e.key === 'Escape') {
setTitleDraft(photo.user_title ?? '')
e.currentTarget.blur()
}
}}
placeholder="No title"
className={inputClass}
/>
</div>
<TakenAtEditor
photo={photo}
draft={takenAtDraft}
onDraftChange={setTakenAtDraft}
onCommit={commitTakenAt}
darkTheme={darkTheme}
/>
<div>
<label className="mb-1 block text-xs text-text-muted">Notes</label>
<Textarea
value={notesDraft}
onChange={(e) => setNotesDraft(e.target.value)}
onBlur={commitNotes}
placeholder="Add notes…"
rows={3}
className={cn(inputClass, 'resize-none')}
/>
</div>
<div>
<label className="mb-1 block text-xs text-text-muted">Tags</label>
<TagsEditor
photoTags={photo.tags ?? []}
allTags={allTags}
tagInput={tagInput}
onTagInputChange={setTagInput}
onAttachExisting={(id) => attachExistingTagMutation.mutate(id)}
onCreateAndAttach={(name) => {
addTagMutation.mutate(name)
setTagInput('')
}}
onRemove={(id) => removeTagMutation.mutate(id)}
/>
</div>
{/* Rating */}
<div>
<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={cn(
'h-5 w-5 transition-colors',
value <= rating
? 'fill-star text-star'
: 'text-text-muted hover:text-star'
)}
/>
</button>
))}
</div>
</div>
{/* Color label */}
<div>
<label className="mb-1 block text-xs text-text-muted">Color label</label>
<div className="flex items-center gap-1.5">
{COLOR_LABEL_OPTIONS.map(({ value, className }) => {
const active = colorLabel === value
return (
<button
key={value}
onClick={() =>
updateMutation.mutate({ color_label: active ? null : value })
}
className={cn(
'h-5 w-5 rounded-full ring-offset-2 ring-offset-surface transition-all',
className,
active ? 'ring-2 ring-primary' : 'opacity-60 hover:opacity-100'
)}
title={value}
/>
)
})}
{colorLabel && (
<button
onClick={() => updateMutation.mutate({ color_label: null })}
className="ml-1 rounded p-0.5 text-text-muted hover:bg-surface-2 hover:text-text"
title="Clear color label"
>
<X className="h-3 w-3" />
</button>
)}
</div>
</div>
{/* Flag — Select + Discard */}
<div>
<label className="mb-1 block text-xs text-text-muted">Flag</label>
<div className="flex gap-2">
<button
onClick={() => {
if (!activeHeap) return
heapMutation.mutate({ remove: isInActiveHeap })
}}
disabled={!activeHeap || heapMutation.isPending}
className={cn(
'flex items-center gap-1 rounded px-2 py-1 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-50',
isInActiveHeap
? 'bg-pick/20 text-pick'
: 'bg-surface-2 text-text-muted hover:bg-surface-offset'
)}
title={
activeHeap
? isInActiveHeap
? `Remove from "${activeHeap.name}"`
: `Add to "${activeHeap.name}"`
: 'Set an active heap first'
}
>
<ShoppingBasket className="h-3 w-3" />
{isInActiveHeap ? 'Selected' : 'Select'}
</button>
<button
onClick={() => updateMutation.mutate({ is_discarded: !isDiscarded })}
className={cn(
'flex items-center gap-1 rounded px-2 py-1 text-sm transition-colors',
isDiscarded
? 'bg-reject/20 text-reject'
: 'bg-surface-2 text-text-muted hover:bg-surface-offset'
)}
>
<Trash2 className="h-3 w-3" />
Discard
</button>
</div>
</div>
</div>
</CollapsibleContent>
</Collapsible>
{/* Read-only metadata — collapsed/expanded as one block so the user
* can hide everything below the editable form with a single click.
* Sub-sections inside stay individually collapsible for finer
* control once the outer group is open. */}
<Collapsible
open={expandedSections.has('metadata')}
onOpenChange={() => toggleSection('metadata')}
className="border-b border-border"
>
<CollapsibleTrigger className="flex w-full items-center justify-between border-b border-border bg-surface-2/40 px-3 py-2 text-[11px] font-semibold uppercase tracking-[0.14em] text-text-muted hover:bg-surface-2 hover:text-text">
<span>Metadata</span>
{expandedSections.has('metadata') ? (
<ChevronDown className="h-3 w-3" />
) : (
<ChevronRight className="h-3 w-3" />
)}
</CollapsibleTrigger>
<CollapsibleContent>
<Section
title="Basic Info"
expanded={expandedSections.has('basic')}
onToggle={() => toggleSection('basic')}
>
<div className="grid grid-cols-2 gap-2 text-xs">
<Field label="Size" value={formatFileSize(photo.file_size)} />
<Field
label="Dimensions"
value={
photo.width && photo.height
? `${photo.width} × ${photo.height}`
: '—'
}
/>
</div>
{/* Filepath spans the full sidebar width — most paths are long
* enough that the two-column grid above wraps them painfully.
* Mono so each character lines up under the next, break-all
* so we never overflow horizontally on a long basename. */}
<div className="mt-2 text-xs">
<span className="text-text-muted">Path:</span>
<p className="mt-0.5 break-all font-mono text-[11px] text-text" title={photo.filepath}>
{photo.filepath || '—'}
</p>
</div>
</Section>
<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>
<Section
title="Location"
expanded={expandedSections.has('location')}
onToggle={() => toggleSection('location')}
>
{photo.latitude != null && photo.longitude != null ? (
<a
href={`https://www.openstreetmap.org/?mlat=${photo.latitude}&mlon=${photo.longitude}#map=15/${photo.latitude}/${photo.longitude}`}
target="_blank"
rel="noreferrer"
className="flex items-center gap-2 text-xs hover:underline"
title="Open in OpenStreetMap"
>
<MapPin className="h-3 w-3 text-text-muted" />
<span className="font-mono text-text">
{formatLatLon(photo.latitude, 'lat')},{' '}
{formatLatLon(photo.longitude, 'lon')}
</span>
</a>
) : (
<div className="text-xs text-text-muted">No GPS data</div>
)}
</Section>
</CollapsibleContent>
</Collapsible>
</div>
)
}
function Section({
title,
expanded,
onToggle,
children,
}: {
title: string
expanded: boolean
onToggle: () => void
children: React.ReactNode
}) {
return (
<Collapsible
open={expanded}
onOpenChange={onToggle}
className="border-b border-border"
>
<CollapsibleTrigger className="flex w-full items-center justify-between px-3 py-1.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-text-muted hover:bg-surface-2 hover:text-text">
<span>{title}</span>
{expanded ? (
<ChevronDown className="h-3 w-3" />
) : (
<ChevronRight className="h-3 w-3" />
)}
</CollapsibleTrigger>
<CollapsibleContent className="px-3 pb-2.5">
{children}
</CollapsibleContent>
</Collapsible>
)
}
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>
)
}