diff --git a/frontend/src/components/layout/RightSidebar.tsx b/frontend/src/components/layout/RightSidebar.tsx index 9aa98b0..f370e94 100644 --- a/frontend/src/components/layout/RightSidebar.tsx +++ b/frontend/src/components/layout/RightSidebar.tsx @@ -1,49 +1,12 @@ -import { useState, useMemo, useEffect } from 'react' -import { - X, - Star, - MapPin, - Camera, - Aperture, - Info, - ChevronDown, - ChevronRight, - ShoppingBasket, - Trash2, -} from 'lucide-react' +import { X, Star, Info, ShoppingBasket, Trash2 } from 'lucide-react' import clsx from 'clsx' -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import { format } from 'date-fns' +import { useMutation, useQueryClient } from '@tanstack/react-query' 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[] -} +import { PhotoInfoPanel } from '../sidebar/PhotoInfoPanel' type ColorLabel = 'red' | 'orange' | 'yellow' | 'green' | 'blue' | 'purple' @@ -56,101 +19,21 @@ const COLOR_LABEL_OPTIONS: { value: ColorLabel; className: string }[] = [ { 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 {} - } -} - +/** + * Right-hand details panel. + * - 1 photo selected → delegates to PhotoInfoPanel for the full editor. + * - 2+ photos selected → renders a slim bulk-action panel that fans out + * rating / color / discard / pick across the entire selection. + */ 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'] }) - }, - }) - - // Bulk equivalents — used when more than one photo is selected so the - // rating / color / discard buttons apply to the whole selection. const invalidatePhotoQueries = () => { queryClient.invalidateQueries({ queryKey: ['photo'] }) queryClient.invalidateQueries({ queryKey: ['photos'] }) } + const bulkRatingMutation = useMutation({ mutationFn: ({ ids, rating }: { ids: string[]; rating: number }) => photosApi.bulkSetRating(ids, rating), @@ -165,15 +48,9 @@ export function RightSidebar() { mutationFn: (ids: string[]) => photosApi.bulkDiscard(ids), onSuccess: invalidatePhotoQueries, }) - const bulkRestoreMutation = useMutation({ - mutationFn: (ids: string[]) => photosApi.bulkRestore(ids), - onSuccess: invalidatePhotoQueries, - }) - // Membership in the active heap (for the Pick toggle button). + // Active heap membership for the bulk Pick toggle. const { activeHeap, memberIds: activeHeapMembers } = useActiveHeapMembers() - const isInActiveHeap = - !!activePhotoId && activeHeapMembers.has(activePhotoId) const heapMutation = useMutation({ mutationFn: ({ ids, remove }: { ids: string[]; remove: boolean }) => { @@ -182,7 +59,6 @@ export function RightSidebar() { ? heapsApi.removePhotos(activeHeap.id, ids) : heapsApi.addPhotos(activeHeap.id, ids) }, - // Optimistic flip so the badge / button label update instantly. onMutate: ({ ids, remove }) => { if (!activeHeap || ids.length === 0) return { previous: undefined } const key = ['heap-photo-ids', activeHeap.id] as const @@ -193,10 +69,11 @@ export function RightSidebar() { queryClient.setQueryData(key, Array.from(set)) return { previous } }, - onError: (_e, _vars, ctx) => { + onError: (e: any, _vars, ctx) => { if (activeHeap && ctx?.previous) { queryClient.setQueryData(['heap-photo-ids', activeHeap.id], ctx.previous) } + toast.error('Heap update failed', e?.message || 'Unknown error') }, onSettled: () => { queryClient.invalidateQueries({ queryKey: HEAPS_QUERY_KEY }) @@ -208,128 +85,6 @@ export function RightSidebar() { }, }) - // ── 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 }) - } - - // Apply a rating / color / discard to the current selection. Falls back - // to the single-photo path when only one photo is selected so the - // RightSidebar matches the keyboard shortcut behaviour exactly. - const applyRating = (value: number) => { - if (selectedPhotos.length > 1) { - bulkRatingMutation.mutate({ ids: selectedPhotos, rating: value }) - } else { - updateMutation.mutate({ rating: value }) - } - } - const setColor = (label: ColorLabel | null) => { - if (selectedPhotos.length > 1) { - bulkColorMutation.mutate({ ids: selectedPhotos, color: label }) - } else { - updateMutation.mutate({ color_label: label }) - } - } - const applyDiscard = (next: boolean) => { - if (selectedPhotos.length > 1) { - if (next) bulkDiscardMutation.mutate(selectedPhotos) - else bulkRestoreMutation.mutate(selectedPhotos) - } else { - updateMutation.mutate({ is_discarded: next }) - } - } - - const exif = useMemo(() => parseExif(photo?.exif_json ?? null), [photo?.exif_json]) - if (selectedPhotos.length === 0) { return (
@@ -341,19 +96,34 @@ export function RightSidebar() { ) } - 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 + // ── Single-photo: full editor via PhotoInfoPanel ──────────────────── + if (selectedPhotos.length === 1) { + const id = activePhotoId ?? selectedPhotos[0] + return ( +
+
+

Photo Details

+ +
+ +
+ ) + } + + // ── Multi-photo: bulk action panel ────────────────────────────────── + const allMembers = selectedPhotos.every((id) => activeHeapMembers.has(id)) return (
- {/* Header */}

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

- {/* Quick Actions */} - {photo && ( -
- {multipleSelected && ( -

- Rating, color, and flag apply to all {selectedPhotos.length} selected. -

- )} +
+

+ Rating, color, and flag apply to all {selectedPhotos.length} selected. +

- {/* Per-photo fields — only meaningful for a single selection */} - {!multipleSelected && ( - <> -
- - 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" - /> -
- -
- - 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" - /> -
- -
- -