Files
mule-image/frontend/src/components/layout/RightSidebar.tsx
dtoro bd904aca36 fix: assorted UI polish from review pass
- FilterPill: drop the inline value text from the active state. Pills
  now stay the same width whether or not a filter is set; the popover
  is the canonical place to read the value, and the title attribute
  surfaces it on hover.
- TopBar: remove the search input — search lives in the filter bar now.
- FilterBar: add a search input on the left, with the pill cluster
  centered between it and a flex-shrink-0 Clear-all on the right.
- LeftSidebar / HeapsPanel: count badges use a fixed-width slot
  (h-5 min-w-[24px], tabular-nums) so counts line up in the same
  visual column across rows. Empty rows reserve the slot.
- LeftSidebar: pull section counts (All Photos, Rated, Duplicates,
  Discarded) from a new useLibraryStatsQuery hook backed by the
  expanded /library/stats endpoint. Tags count was already wired.
- backend/library: stats endpoint returns per-section counts that
  match the filter the sidebar applies on click.
- Stats invalidation hooked into the standard photo-mutation paths.
- RightSidebar header: h-12 to match TopBar height.
- Timeline sticky date overlay: only show once the natural in-grid
  header has scrolled OUT of the viewport. Avoids the duplicate-label
  flash when both labels would be visible.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 20:45:30 +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-12 flex-shrink-0 items-center justify-between border-b border-border px-4">
<h2 className="text-sm font-semibold text-text">Photo Details</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>
)
}