feat: bulk rating / color / discard / pick from selection

Culling actions used to operate on a single photo (the activePhotoId)
even when many were selected — pressing 5 with ten thumbnails high-
lighted only rated one. Same for the RightSidebar buttons, which
weren't even visible in multi-select mode. Lightroom semantics: every
culling action applies to the whole selection.

Fix
- Three new bulk helpers in services/api.ts:
    photos.bulkSetRating(ids, rating)
    photos.bulkSetColor(ids, color | null)
    (existing photos.bulkDiscard / bulkRestore reused for X / U)
  All matching the backend BulkAction { ids, action, value } shape
  the /photos/bulk endpoint already accepts.

useKeyboardShortcuts
- New cullTargets() helper: selectedPhotos if non-empty, else
  activePhotoId in a singleton, else empty.
- updateActive() now branches on cullTargets().length:
    1 → existing PATCH /photos/{id} path (single-photo).
    2+ → fans out to the right bulk endpoint per field. rating goes
         to bulkSetRating, color_label to bulkSetColor, is_discarded
         to bulkDiscard / bulkRestore.
- 1-5 / 0 / X / U / 6-9 shortcuts now Just Work on multi-select
  without further changes — they all funnel through updateActive.

RightSidebar
- Restructured the Quick Actions block: filename / title / notes are
  hidden in multi-select (they only make sense for one photo); but
  rating / color / flag controls are now always visible when at
  least one photo is selected. A small "Rating, color, and flag
  apply to all N selected" hint shows in multi mode.
- New applyRating / setColor / applyDiscard helpers fan out to the
  bulk endpoints when selectedPhotos.length > 1, otherwise hit the
  per-photo PATCH path. The displayed value still reflects the
  active photo (last clicked) so the user has a visual anchor —
  matches Lightroom's "focused vs selected" model.
- Pick/Heap-toggle button is now selection-aware too: heapMutation
  takes ids[], the click handler reads selectedPhotos, and the
  add-vs-remove decision uses "every selected is a member" exactly
  like the P keyboard shortcut. Optimistic membership cache update
  also flips the basket badge across all selected thumbnails
  instantly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-08 12:19:53 +02:00
parent 8413b112ee
commit 8fd8bfe3de
3 changed files with 217 additions and 72 deletions

View File

@@ -145,26 +145,51 @@ export function RightSidebar() {
},
})
// Bulk equivalents — used when more than one photo is selected so the
// rating / color / discard buttons apply to the whole selection.
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,
})
const bulkRestoreMutation = useMutation({
mutationFn: (ids: string[]) => photosApi.bulkRestore(ids),
onSuccess: invalidatePhotoQueries,
})
// Membership in the active heap (for the Pick toggle button).
const { activeHeap, memberIds: activeHeapMembers } = useActiveHeapMembers()
const isInActiveHeap =
!!activePhotoId && activeHeapMembers.has(activePhotoId)
const heapMutation = useMutation({
mutationFn: ({ remove }: { remove: boolean }) => {
if (!activeHeap || !activePhotoId) return Promise.resolve(null)
mutationFn: ({ ids, remove }: { ids: string[]; remove: boolean }) => {
if (!activeHeap || ids.length === 0) return Promise.resolve(null)
return remove
? heapsApi.removePhotos(activeHeap.id, [activePhotoId])
: heapsApi.addPhotos(activeHeap.id, [activePhotoId])
? heapsApi.removePhotos(activeHeap.id, ids)
: heapsApi.addPhotos(activeHeap.id, ids)
},
// Optimistic flip so the badge / button label update instantly.
onMutate: ({ remove }) => {
if (!activeHeap || !activePhotoId) return { previous: undefined }
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) set.delete(activePhotoId)
else set.add(activePhotoId)
if (remove) ids.forEach((id) => set.delete(id))
else ids.forEach((id) => set.add(id))
queryClient.setQueryData<string[]>(key, Array.from(set))
return { previous }
},
@@ -277,8 +302,30 @@ export function RightSidebar() {
updateMutation.mutate({ user_notes: next || null })
}
// Apply a rating / color / discard to the current selection. Falls back
// to the single-photo path when only one photo is selected so the
// RightSidebar matches the keyboard shortcut behaviour exactly.
const applyRating = (value: number) => {
if (selectedPhotos.length > 1) {
bulkRatingMutation.mutate({ ids: selectedPhotos, rating: value })
} else {
updateMutation.mutate({ rating: value })
}
}
const setColor = (label: ColorLabel | null) => {
updateMutation.mutate({ color_label: label })
if (selectedPhotos.length > 1) {
bulkColorMutation.mutate({ ids: selectedPhotos, color: label })
} else {
updateMutation.mutate({ color_label: label })
}
}
const applyDiscard = (next: boolean) => {
if (selectedPhotos.length > 1) {
if (next) bulkDiscardMutation.mutate(selectedPhotos)
else bulkRestoreMutation.mutate(selectedPhotos)
} else {
updateMutation.mutate({ is_discarded: next })
}
}
const exif = useMemo(() => parseExif(photo?.exif_json ?? null), [photo?.exif_json])
@@ -317,62 +364,70 @@ export function RightSidebar() {
</button>
</div>
{/* Quick Actions — operate on the active photo */}
{photo && !multipleSelected && (
{/* Quick Actions */}
{photo && (
<div className="space-y-3 border-b border-border p-4">
{/* Filename (editable, renames the file on disk) */}
<div>
<label className="mb-1 block text-xs text-text-muted">Filename</label>
<input
type="text"
value={filenameDraft}
onChange={(e) => setFilenameDraft(e.target.value)}
onBlur={commitFilename}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.currentTarget.blur()
} else if (e.key === 'Escape') {
setFilenameDraft(photo.filename ?? '')
e.currentTarget.blur()
}
}}
className="w-full rounded border border-border bg-bg px-2 py-1 font-mono text-xs text-text focus:border-primary focus:outline-none"
/>
</div>
{multipleSelected && (
<p className="text-xs text-text-muted">
Rating, color, and flag apply to all {selectedPhotos.length} selected.
</p>
)}
{/* Title (editable) */}
<div>
<label className="mb-1 block text-xs text-text-muted">Title</label>
<input
type="text"
value={titleDraft}
onChange={(e) => setTitleDraft(e.target.value)}
onBlur={commitTitle}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.currentTarget.blur()
} else if (e.key === 'Escape') {
setTitleDraft(photo.user_title ?? '')
e.currentTarget.blur()
}
}}
placeholder="No title"
className="w-full rounded border border-border bg-bg px-2 py-1 text-sm text-text placeholder-text-faint focus:border-primary focus:outline-none"
/>
</div>
{/* Per-photo fields — only meaningful for a single selection */}
{!multipleSelected && (
<>
<div>
<label className="mb-1 block text-xs text-text-muted">Filename</label>
<input
type="text"
value={filenameDraft}
onChange={(e) => setFilenameDraft(e.target.value)}
onBlur={commitFilename}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.currentTarget.blur()
} else if (e.key === 'Escape') {
setFilenameDraft(photo.filename ?? '')
e.currentTarget.blur()
}
}}
className="w-full rounded border border-border bg-bg px-2 py-1 font-mono text-xs text-text focus:border-primary focus:outline-none"
/>
</div>
{/* Notes (editable) */}
<div>
<label className="mb-1 block text-xs text-text-muted">Notes</label>
<textarea
value={notesDraft}
onChange={(e) => setNotesDraft(e.target.value)}
onBlur={commitNotes}
placeholder="Add notes…"
rows={3}
className="w-full resize-none rounded border border-border bg-bg px-2 py-1 text-sm text-text placeholder-text-faint focus:border-primary focus:outline-none"
/>
</div>
<div>
<label className="mb-1 block text-xs text-text-muted">Title</label>
<input
type="text"
value={titleDraft}
onChange={(e) => setTitleDraft(e.target.value)}
onBlur={commitTitle}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.currentTarget.blur()
} else if (e.key === 'Escape') {
setTitleDraft(photo.user_title ?? '')
e.currentTarget.blur()
}
}}
placeholder="No title"
className="w-full rounded border border-border bg-bg px-2 py-1 text-sm text-text placeholder-text-faint focus:border-primary focus:outline-none"
/>
</div>
<div>
<label className="mb-1 block text-xs text-text-muted">Notes</label>
<textarea
value={notesDraft}
onChange={(e) => setNotesDraft(e.target.value)}
onBlur={commitNotes}
placeholder="Add notes…"
rows={3}
className="w-full resize-none rounded border border-border bg-bg px-2 py-1 text-sm text-text placeholder-text-faint focus:border-primary focus:outline-none"
/>
</div>
</>
)}
{/* Rating */}
<div>
@@ -381,9 +436,7 @@ export function RightSidebar() {
{[1, 2, 3, 4, 5].map((value) => (
<button
key={value}
onClick={() =>
updateMutation.mutate({ rating: rating === value ? 0 : value })
}
onClick={() => applyRating(rating === value ? 0 : value)}
className="p-0.5"
title={`Set rating to ${value}`}
>
@@ -436,7 +489,17 @@ export function RightSidebar() {
<label className="mb-1 block text-xs text-text-muted">Flag</label>
<div className="flex gap-2">
<button
onClick={() => heapMutation.mutate({ remove: isInActiveHeap })}
onClick={() => {
const ids = selectedPhotos.length > 0
? selectedPhotos
: activePhotoId ? [activePhotoId] : []
if (!activeHeap || ids.length === 0) return
// If every selected photo is already a member, remove
// them; otherwise add the missing ones. Mirrors the
// P keyboard shortcut behaviour exactly.
const allMembers = ids.every((id) => activeHeapMembers.has(id))
heapMutation.mutate({ ids, 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',
@@ -456,9 +519,7 @@ export function RightSidebar() {
{isInActiveHeap ? 'Picked' : 'Pick'}
</button>
<button
onClick={() =>
updateMutation.mutate({ is_discarded: !isDiscarded })
}
onClick={() => applyDiscard(!isDiscarded)}
className={clsx(
'flex items-center gap-1 rounded px-2 py-1 text-sm transition-colors',
isDiscarded