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>
436 lines
15 KiB
TypeScript
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>
|
|
)
|
|
}
|