import { useState, useMemo, useEffect } from 'react' import { X, Star, MapPin, Camera, Aperture, Info, 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 { usePhotoStore } from '../../store/photoStore' import { photos as photosApi, heaps as heapsApi } from '../../services/api' import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery' import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery' import { useTagsQuery, TAGS_QUERY_KEY } from '../../hooks/useTagsQuery' import { tags as tagsApi, type Tag } from '../../services/api' import { toast } from '../ToastContainer' 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 tags?: PhotoTagSummary[] } 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', 'tags']) ) 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: { filename?: string rating?: number 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'] }) }, }) // Membership in the active heap (for the Pick toggle button). const { activeHeap, memberIds: activeHeapMembers } = useActiveHeapMembers() const isInActiveHeap = !!activePhotoId && activeHeapMembers.has(activePhotoId) const heapMutation = useMutation({ mutationFn: ({ remove }: { remove: boolean }) => { if (!activeHeap || !activePhotoId) return Promise.resolve(null) return remove ? heapsApi.removePhotos(activeHeap.id, [activePhotoId]) : heapsApi.addPhotos(activeHeap.id, [activePhotoId]) }, // Optimistic flip so the badge / button label update instantly. onMutate: ({ remove }) => { if (!activeHeap || !activePhotoId) 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(activePhotoId) else set.add(activePhotoId) 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', activePhotoId] }) queryClient.invalidateQueries({ queryKey: ['photos'] }) } const addTagMutation = useMutation({ mutationFn: async (name: string) => { // Idempotent create — backend returns existing row if name matches. const created = await tagsApi.create(name) if (activePhotoId) { await tagsApi.addToPhoto(activePhotoId, [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(activePhotoId!, [tagId]), onSuccess: () => invalidateTagsAndPhoto(), onError: (e: any) => toast.error('Add tag failed', e?.message || 'Unknown error'), }) const removeTagMutation = useMutation({ mutationFn: (tagId: string) => tagsApi.removeFromPhoto(activePhotoId!, tagId), onSuccess: () => invalidateTagsAndPhoto(), onError: (e: any) => toast.error('Remove tag failed', e?.message || 'Unknown error'), }) // 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 [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) { // Reset draft if user cleared it; we never send an empty filename. 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 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 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 && (
{/* Filename (editable, renames the file on disk) */}
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="w-full rounded border border-border bg-bg px-2 py-1 font-mono text-xs text-text focus:border-primary focus:outline-none" />
{/* 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) */}