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>( 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({ 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(key) const set = new Set(previous ?? []) if (remove) set.delete(photoId) else set.add(photoId) queryClient.setQueryData(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
Loading…
} 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 (
{/* 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. */} toggleSection('edit')} className="border-b border-border" > Edit {expandedSections.has('edit') ? ( ) : ( )}
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} />
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} />