Files
mule-image/frontend/src/components/layout/RightSidebar.tsx
dtoro a56062d353 fix: preview navigation walks the timeline's visible order
Previously the preview view walked the raw API photos array for arrow
navigation and the filmstrip. In tag-grouped mode (and any future
layout where the visible grid order diverges from the API sort) that
diverged from the order the user actually saw — they'd hit ← / → and
land on a photo that wasn't adjacent in the grid.

Fix: Timeline publishes its flat visible-order id sequence into the
photo store as visiblePhotoIds whenever its layout items change
(including duplicates from tag buckets, which is what the user wants
in tag mode — landing on a photo's second appearance in the next
bucket is the right behavior). PreviewView resolves that sequence
back to Photo objects via the rawPhotos map and uses the result for
both arrow nav and the filmstrip. Falls back to the raw photos list
when the sequence isn't populated yet.

Also clean up the lingering hardcoded http://localhost:8001 in
usePhotosQuery — switched to the shared axios instance with the
relative /api/v1 baseURL so the hook works cross-machine through the
nginx / vite proxy.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 21:13:08 +02:00

436 lines
15 KiB
TypeScript

import { useState } from 'react'
import { X, Star, Info, ShoppingBasket, Trash2, Plus } from 'lucide-react'
import clsx from 'clsx'
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 { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
import { useTagsQuery, TAGS_QUERY_KEY } from '../../hooks/useTagsQuery'
import { toast } from '../ToastContainer'
import { PhotoInfoPanel } from '../sidebar/PhotoInfoPanel'
import { COLOR_LABEL_OPTIONS } from '../../constants/colorLabels'
/**
* 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 invalidatePhotoQueries = () => {
queryClient.invalidateQueries({ queryKey: ['photo'] })
queryClient.invalidateQueries({ queryKey: ['photos'] })
}
const bulkRatingMutation = useMutation({
mutationFn: ({ ids, rating }: { ids: string[]; rating: number }) =>
photosApi.bulkSetRating(ids, rating),
onSuccess: invalidatePhotoQueries,
})
const bulkColorMutation = useMutation({
mutationFn: ({ ids, color }: { ids: string[]; color: string | null }) =>
photosApi.bulkSetColor(ids, color),
onSuccess: invalidatePhotoQueries,
})
const bulkDiscardMutation = useMutation({
mutationFn: (ids: string[]) => photosApi.bulkDiscard(ids),
onSuccess: invalidatePhotoQueries,
})
// 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', e?.message || 'Unknown error'),
})
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', e?.message || 'Unknown error'),
})
// 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', e?.message || 'Unknown error'),
})
const { data: allTags = [] } = useTagsQuery()
const [tagInput, setTagInput] = useState('')
// Active heap membership for the bulk Pick 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', e?.message || 'Unknown error')
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: HEAPS_QUERY_KEY })
if (activeHeap) {
queryClient.invalidateQueries({
queryKey: ['heap-photo-ids', activeHeap.id],
})
}
},
})
if (selectedPhotos.length === 0) {
return (
<div className="flex h-full items-center justify-center p-4 text-center">
<div className="text-text-muted">
<Info className="mx-auto mb-2 h-8 w-8" />
<p className="text-sm">Select photos to view details</p>
</div>
</div>
)
}
// ── Single-photo: full editor via PhotoInfoPanel ────────────────────
if (selectedPhotos.length === 1) {
const id = activePhotoId ?? selectedPhotos[0]
return (
<div className="flex h-full flex-col bg-surface">
<div className="flex h-11 flex-shrink-0 items-center justify-between border-b border-border px-4">
<h2 className="text-sm font-semibold text-text">Metadata</h2>
<button
onClick={clearSelection}
className="rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text"
title="Clear selection"
aria-label="Clear selection"
>
<X className="h-4 w-4" />
</button>
</div>
<PhotoInfoPanel photoId={id} />
</div>
)
}
// ── Multi-photo: bulk action panel ──────────────────────────────────
const allMembers = selectedPhotos.every((id) => activeHeapMembers.has(id))
return (
<div className="flex h-full flex-col bg-surface">
<div className="flex items-center justify-between border-b border-border px-4 py-3">
<h2 className="text-sm font-semibold text-text">
{selectedPhotos.length} Photos Selected
</h2>
<button
onClick={clearSelection}
className="rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text"
title="Clear selection"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="space-y-3 border-b border-border p-4">
<p className="text-xs text-text-muted">
Rating, color, and flag apply to all {selectedPhotos.length} selected.
</p>
{/* Bulk rating */}
<div>
<label className="mb-1 block text-xs text-text-muted">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 text-xs text-text-muted">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={clsx(
'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 text-xs text-text-muted">Flag</label>
<div className="flex gap-2">
<button
onClick={() => {
if (!activeHeap) return
heapMutation.mutate({ ids: selectedPhotos, remove: allMembers })
}}
disabled={!activeHeap || heapMutation.isPending}
className={clsx(
'flex items-center gap-1 rounded px-2 py-1 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-50',
allMembers
? 'bg-pick/20 text-pick'
: '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="h-3 w-3" />
{allMembers ? 'Picked' : 'Pick'}
</button>
<button
onClick={() => bulkDiscardMutation.mutate(selectedPhotos)}
className="flex items-center gap-1 rounded bg-surface-2 px-2 py-1 text-sm text-text-muted transition-colors hover:bg-surface-offset"
>
<Trash2 className="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 text-xs text-text-muted">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>
</div>
</div>
)
}
interface BulkTagsEditorProps {
allTags: { id: string; name: string; color: string | null }[]
tagInput: string
onTagInputChange: (value: string) => void
disabled: boolean
onApply: (tagId: string) => void
onRemove: (tagId: string) => void
onCreate: (name: string) => void
}
/**
* Compact bulk tag editor for the multi-select right sidebar. Unlike the
* single-photo TagsEditor we don't show "current tags" — there's no clean
* single-photo notion of that across an arbitrary selection. Instead the
* user picks an existing tag (apply to all) or types a new one (create
* and apply to all).
*/
function BulkTagsEditor({
allTags,
tagInput,
onTagInputChange,
disabled,
onApply,
onRemove,
onCreate,
}: BulkTagsEditorProps) {
const trimmed = tagInput.trim()
const lower = trimmed.toLowerCase()
const filtered = trimmed
? allTags.filter((t) => t.name.toLowerCase().includes(lower))
: allTags
const exactMatch = trimmed
? allTags.find((t) => t.name.toLowerCase() === lower)
: null
const handleSubmit = () => {
if (!trimmed || disabled) return
if (exactMatch) {
onApply(exactMatch.id)
onTagInputChange('')
} else {
onCreate(trimmed)
}
}
return (
<div className="space-y-2">
<input
type="text"
value={tagInput}
onChange={(e) => onTagInputChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
handleSubmit()
} else if (e.key === 'Escape') {
onTagInputChange('')
}
}}
placeholder="Filter or create…"
disabled={disabled}
className="w-full rounded border border-border bg-bg px-2 py-1 text-xs text-text placeholder-text-faint focus:border-primary focus:outline-none disabled:opacity-50"
/>
{trimmed && !exactMatch && (
<button
onClick={handleSubmit}
disabled={disabled}
className="flex w-full items-center justify-center gap-1 rounded border border-dashed border-primary/50 px-2 py-1 text-xs text-primary hover:bg-primary/10 disabled:opacity-50"
>
<Plus className="h-3 w-3" />
Create "{trimmed}" and apply
</button>
)}
{filtered.length > 0 ? (
<div className="flex max-h-40 flex-wrap gap-1 overflow-y-auto">
{filtered.map((tag) => (
<span
key={tag.id}
className="flex items-center gap-1 rounded bg-surface-2 px-2 py-0.5 text-xs text-text"
style={
tag.color
? { backgroundColor: `${tag.color}33`, color: tag.color }
: undefined
}
>
<button
onClick={() => onApply(tag.id)}
disabled={disabled}
className="hover:underline disabled:opacity-50"
title={`Apply "${tag.name}" to selection`}
>
{tag.name}
</button>
<button
onClick={() => onRemove(tag.id)}
disabled={disabled}
className="rounded p-0.5 opacity-60 hover:bg-surface-offset hover:opacity-100 disabled:opacity-30"
title={`Remove "${tag.name}" from selection`}
aria-label={`Remove ${tag.name} from selection`}
>
<X className="h-3 w-3" />
</button>
</span>
))}
</div>
) : (
<div className="text-xs text-text-faint">No tags match</div>
)}
</div>
)
}