Files
mule-image/frontend/src/components/sidebar/PhotoInfoPanel.tsx
2026-04-09 23:48:02 +02:00

764 lines
24 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 clsx from 'clsx'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { format } from 'date-fns'
import {
photos as photosApi,
heaps as heapsApi,
tags as tagsApi,
type Tag,
} 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 { toast } from '../ToastContainer'
import {
COLOR_LABEL_OPTIONS,
type ColorLabel,
} from '../../constants/colorLabels'
interface PhotoTagSummary {
id: string
name: string
color: string | null
}
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_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(['basic', 'camera', 'location', 'tags'])
)
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.
const { data: photo } = useQuery<PhotoDetails>({
queryKey: ['photo', photoId],
queryFn: () => photosApi.get(photoId),
enabled: !!photoId,
staleTime: 60_000,
})
// 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
}) => 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', e?.message || 'Unknown error'),
})
const attachExistingTagMutation = useMutation({
mutationFn: (tagId: string) => tagsApi.addToPhoto(photoId, [tagId]),
onSuccess: () => invalidateTagsAndPhoto(),
onError: (e: any) =>
toast.error('Add tag failed', e?.message || 'Unknown error'),
})
const removeTagMutation = useMutation({
mutationFn: (tagId: string) => tagsApi.removeFromPhoto(photoId, tagId),
onSuccess: () => invalidateTagsAndPhoto(),
onError: (e: any) =>
toast.error('Remove tag failed', e?.message || 'Unknown error'),
})
// 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('')
useEffect(() => {
setFilenameDraft(photo?.filename ?? '')
setTitleDraft(photo?.user_title ?? '')
setNotesDraft(photo?.user_notes ?? '')
}, [photo?.id, photo?.filename, photo?.user_title, photo?.user_notes])
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',
e?.response?.data?.detail || e.message || 'Unknown error'
)
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 })
}
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 = clsx(
'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 = clsx(
'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'
)
return (
<div className="flex h-full flex-col">
{/* Edit fields */}
<div className="space-y-2.5 border-b border-border 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>
<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={clsx(inputClass, 'resize-none')}
/>
</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={clsx(
'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={clsx(
'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 — Pick + 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={clsx(
'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 ? 'Picked' : 'Pick'}
</button>
<button
onClick={() => updateMutation.mutate({ is_discarded: !isDiscarded })}
className={clsx(
'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>
{/* Read-only metadata sections */}
<div className="flex-1 overflow-y-auto">
<Section
title="Tags"
expanded={expandedSections.has('tags')}
onToggle={() => toggleSection('tags')}
>
<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)}
/>
</Section>
<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}`
: '—'
}
/>
<Field
label="Date Taken"
value={
photo.taken_at
? format(new Date(photo.taken_at), 'MMM d, yyyy HH:mm')
: '—'
}
/>
</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>
</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-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" />
)}
</button>
{expanded && <div className="px-3 pb-2.5">{children}</div>}
</div>
)
}
interface TagsEditorProps {
photoTags: PhotoTagSummary[]
allTags: Tag[]
tagInput: string
onTagInputChange: (value: string) => void
onAttachExisting: (id: string) => void
onCreateAndAttach: (name: string) => void
onRemove: (id: string) => void
}
function TagsEditor({
photoTags,
allTags,
tagInput,
onTagInputChange,
onAttachExisting,
onCreateAndAttach,
onRemove,
}: TagsEditorProps) {
const trimmed = tagInput.trim()
const lowerTrimmed = trimmed.toLowerCase()
const photoTagIds = new Set(photoTags.map((t) => t.id))
const suggestions = trimmed
? allTags
.filter(
(t) =>
!photoTagIds.has(t.id) &&
t.name.toLowerCase().includes(lowerTrimmed)
)
.slice(0, 6)
: []
const exactMatch = trimmed
? allTags.find((t) => t.name.toLowerCase() === lowerTrimmed)
: null
const handleSubmit = () => {
if (!trimmed) return
if (exactMatch) {
if (!photoTagIds.has(exactMatch.id)) {
onAttachExisting(exactMatch.id)
}
onTagInputChange('')
} else {
onCreateAndAttach(trimmed)
}
}
return (
<div className="space-y-2">
{photoTags.length > 0 ? (
<div className="flex flex-wrap gap-1">
{photoTags.map((tag) => (
<span
key={tag.id}
className="flex items-center gap-1 rounded bg-surface-2 px-2 py-0.5 text-xs text-text"
style={
tag.color
? { backgroundColor: `${tag.color}33`, color: tag.color }
: undefined
}
>
{tag.name}
<button
onClick={() => onRemove(tag.id)}
className="rounded p-0.5 opacity-60 hover:bg-surface-offset hover:opacity-100"
title="Remove tag"
>
<X className="h-3 w-3" />
</button>
</span>
))}
</div>
) : (
<div className="text-xs text-text-faint">No tags</div>
)}
<div className="relative">
<input
type="text"
value={tagInput}
onChange={(e) => onTagInputChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
handleSubmit()
} else if (e.key === 'Escape') {
onTagInputChange('')
}
}}
placeholder="Add tag…"
className="w-full rounded border border-border bg-bg px-2 py-1 text-xs text-text placeholder-text-faint focus:border-primary focus:outline-none"
/>
{suggestions.length > 0 && (
<div className="mt-1 rounded border border-border bg-bg shadow-md">
{suggestions.map((s) => (
<button
key={s.id}
onClick={() => {
onAttachExisting(s.id)
onTagInputChange('')
}}
className="block w-full px-2 py-1 text-left text-xs text-text hover:bg-surface-2"
>
{s.name}
</button>
))}
</div>
)}
{trimmed && !exactMatch && (
<button
onClick={handleSubmit}
className="mt-1 w-full rounded border border-dashed border-primary/50 px-2 py-1 text-left text-xs text-primary hover:bg-primary/10"
>
+ Create "{trimmed}"
</button>
)}
</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>
)
}