import { useState, useMemo, useEffect } from 'react' import { X, Star, MapPin, Camera, Aperture, Info, ChevronDown, ChevronRight, Check, Trash2, } 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_discarded: boolean user_title: string | null user_notes: string | null color_label: string | null exif_json: string | null } type ColorLabel = 'red' | 'orange' | 'yellow' | 'green' | 'blue' | 'purple' const COLOR_LABEL_OPTIONS: { value: ColorLabel; className: string }[] = [ { value: 'red', className: 'bg-red-500' }, { value: 'orange', className: 'bg-orange-500' }, { value: 'yellow', className: 'bg-yellow-400' }, { value: 'green', className: 'bg-green-500' }, { value: 'blue', className: 'bg-blue-500' }, { value: 'purple', className: 'bg-purple-500' }, ] 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>( 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({ queryKey: ['photo', activePhotoId], queryFn: () => photosApi.get(activePhotoId!), enabled: !!activePhotoId, staleTime: 60_000, }) // Mutation for any patchable field on the active photo. Invalidates both // the photo detail cache and the timeline list so the grid reflects the // change too. const updateMutation = useMutation({ mutationFn: (data: { rating?: number is_picked?: boolean is_discarded?: boolean user_title?: string | null user_notes?: string | null color_label?: string | null }) => photosApi.update(activePhotoId!, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['photo', activePhotoId] }) queryClient.invalidateQueries({ queryKey: ['photos'] }) }, }) // Local drafts for the editable text fields. These mirror the server value // but stay independent while the user is typing, so we don't fight focus or // clobber edits with stale refetches. const [titleDraft, setTitleDraft] = useState('') const [notesDraft, setNotesDraft] = useState('') useEffect(() => { setTitleDraft(photo?.user_title ?? '') setNotesDraft(photo?.user_notes ?? '') }, [photo?.id, photo?.user_title, photo?.user_notes]) 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 setColor = (label: ColorLabel | null) => { updateMutation.mutate({ color_label: label }) } const exif = useMemo(() => parseExif(photo?.exif_json ?? null), [photo?.exif_json]) if (selectedPhotos.length === 0) { return (

Select photos to view details

) } const multipleSelected = selectedPhotos.length > 1 const rating = photo?.rating ?? 0 const isPicked = photo?.is_picked ?? false const isDiscarded = photo?.is_discarded ?? false const colorLabel = (photo?.color_label ?? null) as ColorLabel | null return (
{/* Header */}

{multipleSelected ? `${selectedPhotos.length} Photos Selected` : 'Photo Details'}

{/* Quick Actions — operate on the active photo */} {photo && !multipleSelected && (
{/* Title (editable) */}
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="w-full rounded border border-border bg-bg px-2 py-1 text-sm text-text placeholder-text-faint focus:border-primary focus:outline-none" />
{/* Notes (editable) */}