import { useState } from 'react' import { X, Star, ShoppingBasket, Trash2 } from 'lucide-react' import { cn } from '@/lib/utils' import { useMutation, useQueryClient } from '@tanstack/react-query' import { usePhotoStore } from '../../store/photoStore' import { photos as photosApi, heaps as heapsApi, tags as tagsApi, } from '../../services/api' import type { Photo } from '../../types/photo' import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery' import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery' import { useTagsQuery, TAGS_QUERY_KEY } from '../../hooks/useTagsQuery' import { stripPhotosFromCache } from '../../hooks/usePhotosQuery' import { toast } from '../ToastContainer' import { PhotoInfoPanel } from '../sidebar/PhotoInfoPanel' import { BulkTakenAtEditor } from '../sidebar/BulkTakenAtEditor' import { BulkTagsEditor } from '../sidebar/BulkTagsEditor' import { COLOR_LABEL_OPTIONS } from '../../constants/colorLabels' import { Button } from '@/components/ui/button' import { Label } from '@/components/ui/label' import { useBulkPhotoMutations } from '../../hooks/useBulkPhotoMutations' import { formatApiError } from '../../lib/apiError' /** * 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 { bulkRating: bulkRatingMutation, bulkColor: bulkColorMutation, invalidatePhotoQueries, } = useBulkPhotoMutations() const bulkDiscardMutation = useMutation({ mutationFn: (ids: string[]) => photosApi.bulkDiscard(ids), // Yank the photos from the timeline before the network round-trip // so the grid reflows immediately. Same pattern as the X hotkey // path in useKeyboardShortcuts. onMutate: (ids) => { usePhotoStore.getState().removePhotosFromTimeline(ids) stripPhotosFromCache(queryClient, ids) }, onSuccess: invalidatePhotoQueries, }) // Shared report-and-invalidate tail for both bulk taken_at mutations. // They return a partial-apply shape (updated/skipped/errors) because // EXIF writes can fail per-photo (unsupported format, missing file) // without wrecking the rest of the batch. const reportBulkTakenAt = ( data: { status: string updated: number skipped: number errors: { id: string; message: string }[] }, ) => { const errCount = data.errors?.length ?? 0 const detail = errCount > 0 ? `${data.updated} updated · ${errCount} error${errCount === 1 ? '' : 's'}` : `${data.updated} updated` if (errCount > 0) { toast.error('Date update partial', detail) } else { toast.success('Dates updated', detail) } invalidatePhotoQueries() } const bulkTakenAtMutation = useMutation({ mutationFn: ({ ids, iso }: { ids: string[]; iso: string }) => photosApi.bulkSetTakenAt(ids, iso), onSuccess: reportBulkTakenAt, onError: (e: any) => toast.error('Date update failed', formatApiError(e)), }) const bulkTakenAtMapMutation = useMutation({ mutationFn: (map: Record) => photosApi.bulkSetTakenAtMap(map), onSuccess: reportBulkTakenAt, onError: (e: any) => toast.error('Date update failed', formatApiError(e)), }) // Bulk tag mutations. Tag mutations also need to invalidate the tags // query so the FilterBar / sidebar tag counts stay fresh. const invalidateTagsAndPhotos = () => { invalidatePhotoQueries() queryClient.invalidateQueries({ queryKey: TAGS_QUERY_KEY }) } const bulkAddTagsMutation = useMutation({ mutationFn: ({ ids, tagIds }: { ids: string[]; tagIds: string[] }) => photosApi.bulkAddTags(ids, tagIds), onSuccess: (data) => { const added = data?.added ?? 0 toast.success( 'Tags added', `${added} new link${added === 1 ? '' : 's'}` ) invalidateTagsAndPhotos() }, onError: (e: any) => toast.error('Add tags failed', formatApiError(e)), }) const bulkRemoveTagsMutation = useMutation({ mutationFn: ({ ids, tagIds }: { ids: string[]; tagIds: string[] }) => photosApi.bulkRemoveTags(ids, tagIds), onSuccess: (data) => { const removed = data?.removed ?? 0 toast.success( 'Tags removed', `${removed} link${removed === 1 ? '' : 's'} removed` ) invalidateTagsAndPhotos() }, onError: (e: any) => toast.error('Remove tags failed', formatApiError(e)), }) // Idempotent create-and-attach: lets the user type a brand-new tag // name and apply it to the whole selection in one click. const createAndAttachMutation = useMutation({ mutationFn: async ({ name, ids }: { name: string; ids: string[] }) => { const created = await tagsApi.create(name) return photosApi.bulkAddTags(ids, [created.id]) }, onSuccess: () => { toast.success('Tag created and applied') invalidateTagsAndPhotos() }, onError: (e: any) => toast.error('Create tag failed', formatApiError(e)), }) const { data: allTags = [] } = useTagsQuery() const [tagInput, setTagInput] = useState('') // Active heap membership for the bulk Select toggle. const { activeHeap, memberIds: activeHeapMembers } = useActiveHeapMembers() const heapMutation = useMutation({ mutationFn: ({ ids, remove }: { ids: string[]; remove: boolean }) => { if (!activeHeap || ids.length === 0) return Promise.resolve(null) return remove ? heapsApi.removePhotos(activeHeap.id, ids) : heapsApi.addPhotos(activeHeap.id, ids) }, onMutate: ({ ids, remove }) => { if (!activeHeap || ids.length === 0) return { previous: undefined } const key = ['heap-photo-ids', activeHeap.id] as const const previous = queryClient.getQueryData(key) const set = new Set(previous ?? []) if (remove) ids.forEach((id) => set.delete(id)) else ids.forEach((id) => set.add(id)) queryClient.setQueryData(key, Array.from(set)) return { previous } }, onError: (e: any, _vars, ctx) => { if (activeHeap && ctx?.previous) { queryClient.setQueryData(['heap-photo-ids', activeHeap.id], ctx.previous) } toast.error('Heap update failed', formatApiError(e)) }, onSettled: () => { queryClient.invalidateQueries({ queryKey: HEAPS_QUERY_KEY }) if (activeHeap) { queryClient.invalidateQueries({ queryKey: ['heap-photo-ids', activeHeap.id], }) } }, }) // Unified header rendered in every branch so the collapse button is // always reachable regardless of selection state. Title and the // clear-selection X adapt to what's selected. const headerTitle = selectedPhotos.length === 0 ? 'Metadata' : selectedPhotos.length === 1 ? 'Metadata' : `${selectedPhotos.length} Photos Selected` const Header = () => (

{headerTitle}

{selectedPhotos.length > 0 && ( )}
) if (selectedPhotos.length === 0) { return (
{`⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⡀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣰⣿⡇⠀⣠⣤⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣼⣿⣿⡇⢸⣿⡟⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢰⣿⣿⡿⠀⣿⡿⠁⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⣿⣿⠃⣼⡟⠁⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⣀⣀⣀⣤⣶⣿⣿⣿⣧⣀⠋⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠠⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⡄⠀⠀⠀⠀⠀⠀
⠀⠀⠀⢀⡀⣶⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⠛⠻⣿⣿⡄⠀⠀⠀⠀⠀
⠀⠀⢠⣼⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣶⣿⣿⣷⡀⠀⠀⠀⠀⠀
⣠⣤⣿⣿⣿⣿⣿⣿⣿⣿⡿⠻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣦⡀⠀⠀⠀
⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣦⠘⢿⣿⣿⣿⣿⣿⣿⣿⢿⡆⠀⠀
⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠁⠀⠙⠛⠿⠿⣿⣿⠿⣾⡇⠀⠀
⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡟⠁⠀⠀⠀⠀⠀⠀⠹⣿⡿⣦⠈⠁⠀⠀
⣿⣿⣿⣿⣿⣿⣿⣿⣿⠟⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠛⠛⠛⠛⠛⠛⠛⠛⠋⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀`}

Select photos to view details

) } // ── Single-photo: full editor via PhotoInfoPanel ──────────────────── if (selectedPhotos.length === 1) { const id = activePhotoId ?? selectedPhotos[0] return (
) } // ── Multi-photo: bulk action panel ────────────────────────────────── const allMembers = selectedPhotos.every((id) => activeHeapMembers.has(id)) /** Walk the react-query cache for every selected id and return the * full Photo records. Checks the standalone `['photo', id]` entry * first (populated whenever a single-photo view or preview opens), * then falls back to scanning every cached timeline list for the * id. Any id with no cached record is skipped — the selection UI * can't act on a photo the user hasn't loaded yet anyway. */ const collectSelectedPhotos = (): Photo[] => { const out: Photo[] = [] const seen = new Set() for (const id of selectedPhotos) { if (seen.has(id)) continue const direct = queryClient.getQueryData(['photo', id]) if (direct) { out.push(direct) seen.add(id) continue } const lists = queryClient.getQueriesData({ queryKey: ['photos'] }) for (const [, list] of lists) { if (!list) continue const hit = list.find((p) => p.id === id) if (hit) { out.push(hit) seen.add(id) break } } } return out } return (

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

{/* Bulk rating */}
{[1, 2, 3, 4, 5].map((value) => ( ))}
{/* Bulk color */}
{COLOR_LABEL_OPTIONS.map(({ value, className }) => (
{/* Bulk flag */}
{/* Bulk tags. Click an existing tag chip to apply it to the * whole selection; long-press / X icon to remove. The text * input adds an existing tag if it matches a name, or creates * a new tag and applies it. */}
bulkAddTagsMutation.mutate({ ids: selectedPhotos, tagIds: [tagId] }) } onRemove={(tagId) => bulkRemoveTagsMutation.mutate({ ids: selectedPhotos, tagIds: [tagId], }) } onCreate={(name) => { createAndAttachMutation.mutate({ name, ids: selectedPhotos }) setTagInput('') }} />
{/* Bulk Date Taken — lets an operator repair the capture date on * a whole selection at once, either by applying one date to * everything or by inferring a per-photo date from each file's * folder path and filename. Useful for cameras that lost their * clock (1970 epoch) and for legacy libraries where the folder * structure is the only trustworthy date signal. */}
bulkTakenAtMutation.mutate({ ids: selectedPhotos, iso }) } onApplyMap={(map) => bulkTakenAtMapMutation.mutate(map)} />
) }