Files
mule-image/frontend/src/components/layout/RightSidebar.tsx
Claudio 172f869e15 ui(sidebar): pin heap, single scroll area, collapsible metadata group
The right sidepanel had three stacked flex regions (heap card +
header + PhotoInfoPanel) with PhotoInfoPanel maintaining its own
internal scroll. That made the editable fields (filename, title,
notes, rating, color, flag) stick at the top — separate from the
readonly metadata that scrolled below. Effectively two scroll
boundaries on one sidebar.

Move the scroll boundary up so only ActiveHeapCard + Header stay
pinned; editable fields and readonly metadata now scroll together.
Wrap the four readonly sections (Tags / Basic Info / Camera /
Location) in a single outer 'Metadata' collapsible so the user can
hide the whole block with one click. Sub-sections inside stay
individually collapsible.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:36:18 +02:00

466 lines
17 KiB
TypeScript

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 { ActiveHeapCard } from '../heaps/ActiveHeapCard'
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<string, string>) =>
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<string[]>(key)
const set = new Set(previous ?? [])
if (remove) ids.forEach((id) => set.delete(id))
else ids.forEach((id) => set.add(id))
queryClient.setQueryData<string[]>(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 = () => (
<div className="flex h-9 flex-shrink-0 items-center justify-between border-b border-border px-3">
<h2 className="text-[11px] font-semibold uppercase tracking-[0.14em] text-text-muted">
{headerTitle}
</h2>
<div className="flex items-center gap-0.5">
{selectedPhotos.length > 0 && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-text-muted"
onClick={clearSelection}
title="Clear selection (Esc)"
aria-label="Clear selection"
>
<X className="h-3.5 w-3.5" />
</Button>
)}
</div>
</div>
)
if (selectedPhotos.length === 0) {
return (
<div
className="flex h-full flex-col bg-surface"
role="region"
aria-label="Photo metadata"
>
<ActiveHeapCard />
<Header />
<div className="flex flex-1 items-center justify-center p-4 text-center">
<div className="text-text-muted">
<pre className="mx-auto mb-3 text-[6px] leading-[6px] opacity-30">{`⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⡀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣰⣿⡇⠀⣠⣤⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣼⣿⣿⡇⢸⣿⡟⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢰⣿⣿⡿⠀⣿⡿⠁⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⣿⣿⠃⣼⡟⠁⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⣀⣀⣀⣤⣶⣿⣿⣿⣧⣀⠋⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠠⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⡄⠀⠀⠀⠀⠀⠀
⠀⠀⠀⢀⡀⣶⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⠛⠻⣿⣿⡄⠀⠀⠀⠀⠀
⠀⠀⢠⣼⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣶⣿⣿⣷⡀⠀⠀⠀⠀⠀
⣠⣤⣿⣿⣿⣿⣿⣿⣿⣿⡿⠻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣦⡀⠀⠀⠀
⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣦⠘⢿⣿⣿⣿⣿⣿⣿⣿⢿⡆⠀⠀
⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠁⠀⠙⠛⠿⠿⣿⣿⠿⣾⡇⠀⠀
⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡟⠁⠀⠀⠀⠀⠀⠀⠹⣿⡿⣦⠈⠁⠀⠀
⣿⣿⣿⣿⣿⣿⣿⣿⣿⠟⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠛⠛⠛⠛⠛⠛⠛⠛⠋⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀`}</pre>
<p className="text-sm">Select photos to view details</p>
</div>
</div>
</div>
)
}
// ── Single-photo: full editor via PhotoInfoPanel ────────────────────
// Heap card + header stay pinned at the top; the edit fields and
// readonly metadata sections scroll together in a single overflow
// region below.
if (selectedPhotos.length === 1) {
const id = activePhotoId ?? selectedPhotos[0]
return (
<div
className="flex h-full flex-col bg-surface"
role="region"
aria-label="Photo metadata"
>
<ActiveHeapCard />
<Header />
<div className="flex-1 overflow-y-auto">
<PhotoInfoPanel photoId={id} />
</div>
</div>
)
}
// ── 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<string>()
for (const id of selectedPhotos) {
if (seen.has(id)) continue
const direct = queryClient.getQueryData<Photo>(['photo', id])
if (direct) {
out.push(direct)
seen.add(id)
continue
}
const lists = queryClient.getQueriesData<Photo[]>({ 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 (
<div className="flex h-full flex-col bg-surface">
<Header />
<div className="space-y-2.5 border-b border-border p-3">
<p className="text-[11px] text-text-muted">
Rating, color, and flag apply to all {selectedPhotos.length} selected.
</p>
{/* Bulk rating */}
<div>
<Label className="mb-1 block">Rating</Label>
<div className="flex gap-1">
{[1, 2, 3, 4, 5].map((value) => (
<button
key={value}
onClick={() =>
bulkRatingMutation.mutate({ ids: selectedPhotos, rating: value })
}
className="p-0.5"
title={`Set rating to ${value}`}
>
<Star className="h-5 w-5 text-text-muted hover:text-star" />
</button>
))}
<button
onClick={() =>
bulkRatingMutation.mutate({ ids: selectedPhotos, rating: 0 })
}
className="ml-1 rounded px-1 text-xs text-text-muted hover:text-text"
title="Clear rating"
>
clear
</button>
</div>
</div>
{/* Bulk color */}
<div>
<Label className="mb-1 block">Color label</Label>
<div className="flex items-center gap-1.5">
{COLOR_LABEL_OPTIONS.map(({ value, className }) => (
<button
key={value}
onClick={() =>
bulkColorMutation.mutate({ ids: selectedPhotos, color: value })
}
className={cn(
'h-5 w-5 rounded-full opacity-80 ring-offset-2 ring-offset-surface transition-all hover:opacity-100',
className
)}
title={value}
/>
))}
<button
onClick={() =>
bulkColorMutation.mutate({ ids: selectedPhotos, color: null })
}
className="ml-1 rounded p-0.5 text-text-muted hover:bg-surface-2 hover:text-text"
title="Clear color label"
>
<X className="h-3 w-3" />
</button>
</div>
</div>
{/* Bulk flag */}
<div>
<Label className="mb-1 block">Flag</Label>
<div className="flex gap-2">
<Button
size="sm"
onClick={() => {
if (!activeHeap) return
heapMutation.mutate({ ids: selectedPhotos, remove: allMembers })
}}
disabled={!activeHeap || heapMutation.isPending}
className={cn(
allMembers
? 'bg-pick/20 text-pick hover:bg-pick/30'
: 'bg-surface-2 text-text-muted hover:bg-surface-offset'
)}
title={
activeHeap
? allMembers
? `Remove all from "${activeHeap.name}"`
: `Add all to "${activeHeap.name}"`
: 'Set an active heap first'
}
>
<ShoppingBasket className="mr-1 h-3 w-3" />
{allMembers ? 'Selected' : 'Select'}
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => bulkDiscardMutation.mutate(selectedPhotos)}
className="text-text-muted"
>
<Trash2 className="mr-1 h-3 w-3" />
Discard
</Button>
</div>
</div>
{/* 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. */}
<div>
<Label className="mb-1 block">Tags</Label>
<BulkTagsEditor
allTags={allTags}
tagInput={tagInput}
onTagInputChange={setTagInput}
disabled={
bulkAddTagsMutation.isPending ||
bulkRemoveTagsMutation.isPending ||
createAndAttachMutation.isPending
}
onApply={(tagId) =>
bulkAddTagsMutation.mutate({ ids: selectedPhotos, tagIds: [tagId] })
}
onRemove={(tagId) =>
bulkRemoveTagsMutation.mutate({
ids: selectedPhotos,
tagIds: [tagId],
})
}
onCreate={(name) => {
createAndAttachMutation.mutate({ name, ids: selectedPhotos })
setTagInput('')
}}
/>
</div>
{/* 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. */}
<div>
<Label className="mb-1 block">Date Taken</Label>
<BulkTakenAtEditor
disabled={
bulkTakenAtMutation.isPending || bulkTakenAtMapMutation.isPending
}
selectedCount={selectedPhotos.length}
collectPhotos={collectSelectedPhotos}
onApplyUniform={(iso) =>
bulkTakenAtMutation.mutate({ ids: selectedPhotos, iso })
}
onApplyMap={(map) => bulkTakenAtMapMutation.mutate(map)}
/>
</div>
</div>
</div>
)
}