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:
@@ -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).
|
// Membership in the active heap (for the Pick toggle button).
|
||||||
const { activeHeap, memberIds: activeHeapMembers } = useActiveHeapMembers()
|
const { activeHeap, memberIds: activeHeapMembers } = useActiveHeapMembers()
|
||||||
const isInActiveHeap =
|
const isInActiveHeap =
|
||||||
!!activePhotoId && activeHeapMembers.has(activePhotoId)
|
!!activePhotoId && activeHeapMembers.has(activePhotoId)
|
||||||
|
|
||||||
const heapMutation = useMutation({
|
const heapMutation = useMutation({
|
||||||
mutationFn: ({ remove }: { remove: boolean }) => {
|
mutationFn: ({ ids, remove }: { ids: string[]; remove: boolean }) => {
|
||||||
if (!activeHeap || !activePhotoId) return Promise.resolve(null)
|
if (!activeHeap || ids.length === 0) return Promise.resolve(null)
|
||||||
return remove
|
return remove
|
||||||
? heapsApi.removePhotos(activeHeap.id, [activePhotoId])
|
? heapsApi.removePhotos(activeHeap.id, ids)
|
||||||
: heapsApi.addPhotos(activeHeap.id, [activePhotoId])
|
: heapsApi.addPhotos(activeHeap.id, ids)
|
||||||
},
|
},
|
||||||
// Optimistic flip so the badge / button label update instantly.
|
// Optimistic flip so the badge / button label update instantly.
|
||||||
onMutate: ({ remove }) => {
|
onMutate: ({ ids, remove }) => {
|
||||||
if (!activeHeap || !activePhotoId) return { previous: undefined }
|
if (!activeHeap || ids.length === 0) return { previous: undefined }
|
||||||
const key = ['heap-photo-ids', activeHeap.id] as const
|
const key = ['heap-photo-ids', activeHeap.id] as const
|
||||||
const previous = queryClient.getQueryData<string[]>(key)
|
const previous = queryClient.getQueryData<string[]>(key)
|
||||||
const set = new Set(previous ?? [])
|
const set = new Set(previous ?? [])
|
||||||
if (remove) set.delete(activePhotoId)
|
if (remove) ids.forEach((id) => set.delete(id))
|
||||||
else set.add(activePhotoId)
|
else ids.forEach((id) => set.add(id))
|
||||||
queryClient.setQueryData<string[]>(key, Array.from(set))
|
queryClient.setQueryData<string[]>(key, Array.from(set))
|
||||||
return { previous }
|
return { previous }
|
||||||
},
|
},
|
||||||
@@ -277,8 +302,30 @@ export function RightSidebar() {
|
|||||||
updateMutation.mutate({ user_notes: next || null })
|
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) => {
|
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])
|
const exif = useMemo(() => parseExif(photo?.exif_json ?? null), [photo?.exif_json])
|
||||||
@@ -317,62 +364,70 @@ export function RightSidebar() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick Actions — operate on the active photo */}
|
{/* Quick Actions */}
|
||||||
{photo && !multipleSelected && (
|
{photo && (
|
||||||
<div className="space-y-3 border-b border-border p-4">
|
<div className="space-y-3 border-b border-border p-4">
|
||||||
{/* Filename (editable, renames the file on disk) */}
|
{multipleSelected && (
|
||||||
<div>
|
<p className="text-xs text-text-muted">
|
||||||
<label className="mb-1 block text-xs text-text-muted">Filename</label>
|
Rating, color, and flag apply to all {selectedPhotos.length} selected.
|
||||||
<input
|
</p>
|
||||||
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>
|
|
||||||
|
|
||||||
{/* Title (editable) */}
|
{/* Per-photo fields — only meaningful for a single selection */}
|
||||||
<div>
|
{!multipleSelected && (
|
||||||
<label className="mb-1 block text-xs text-text-muted">Title</label>
|
<>
|
||||||
<input
|
<div>
|
||||||
type="text"
|
<label className="mb-1 block text-xs text-text-muted">Filename</label>
|
||||||
value={titleDraft}
|
<input
|
||||||
onChange={(e) => setTitleDraft(e.target.value)}
|
type="text"
|
||||||
onBlur={commitTitle}
|
value={filenameDraft}
|
||||||
onKeyDown={(e) => {
|
onChange={(e) => setFilenameDraft(e.target.value)}
|
||||||
if (e.key === 'Enter') {
|
onBlur={commitFilename}
|
||||||
e.currentTarget.blur()
|
onKeyDown={(e) => {
|
||||||
} else if (e.key === 'Escape') {
|
if (e.key === 'Enter') {
|
||||||
setTitleDraft(photo.user_title ?? '')
|
e.currentTarget.blur()
|
||||||
e.currentTarget.blur()
|
} else if (e.key === 'Escape') {
|
||||||
}
|
setFilenameDraft(photo.filename ?? '')
|
||||||
}}
|
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"
|
}}
|
||||||
/>
|
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>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Notes (editable) */}
|
<div>
|
||||||
<div>
|
<label className="mb-1 block text-xs text-text-muted">Title</label>
|
||||||
<label className="mb-1 block text-xs text-text-muted">Notes</label>
|
<input
|
||||||
<textarea
|
type="text"
|
||||||
value={notesDraft}
|
value={titleDraft}
|
||||||
onChange={(e) => setNotesDraft(e.target.value)}
|
onChange={(e) => setTitleDraft(e.target.value)}
|
||||||
onBlur={commitNotes}
|
onBlur={commitTitle}
|
||||||
placeholder="Add notes…"
|
onKeyDown={(e) => {
|
||||||
rows={3}
|
if (e.key === 'Enter') {
|
||||||
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"
|
e.currentTarget.blur()
|
||||||
/>
|
} else if (e.key === 'Escape') {
|
||||||
</div>
|
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 */}
|
{/* Rating */}
|
||||||
<div>
|
<div>
|
||||||
@@ -381,9 +436,7 @@ export function RightSidebar() {
|
|||||||
{[1, 2, 3, 4, 5].map((value) => (
|
{[1, 2, 3, 4, 5].map((value) => (
|
||||||
<button
|
<button
|
||||||
key={value}
|
key={value}
|
||||||
onClick={() =>
|
onClick={() => applyRating(rating === value ? 0 : value)}
|
||||||
updateMutation.mutate({ rating: rating === value ? 0 : value })
|
|
||||||
}
|
|
||||||
className="p-0.5"
|
className="p-0.5"
|
||||||
title={`Set rating to ${value}`}
|
title={`Set rating to ${value}`}
|
||||||
>
|
>
|
||||||
@@ -436,7 +489,17 @@ export function RightSidebar() {
|
|||||||
<label className="mb-1 block text-xs text-text-muted">Flag</label>
|
<label className="mb-1 block text-xs text-text-muted">Flag</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<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}
|
disabled={!activeHeap || heapMutation.isPending}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'flex items-center gap-1 rounded px-2 py-1 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-50',
|
'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'}
|
{isInActiveHeap ? 'Picked' : 'Pick'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() =>
|
onClick={() => applyDiscard(!isDiscarded)}
|
||||||
updateMutation.mutate({ is_discarded: !isDiscarded })
|
|
||||||
}
|
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'flex items-center gap-1 rounded px-2 py-1 text-sm transition-colors',
|
'flex items-center gap-1 rounded px-2 py-1 text-sm transition-colors',
|
||||||
isDiscarded
|
isDiscarded
|
||||||
|
|||||||
@@ -55,10 +55,74 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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,
|
||||||
|
onError: (e: any) => toast.error('Bulk rating failed', e.message || 'Unknown error'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const bulkColorMutation = useMutation({
|
||||||
|
mutationFn: ({ ids, color }: { ids: string[]; color: string | null }) =>
|
||||||
|
photosApi.bulkSetColor(ids, color),
|
||||||
|
onSuccess: invalidatePhotoQueries,
|
||||||
|
onError: (e: any) => toast.error('Bulk color failed', e.message || 'Unknown error'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const bulkDiscardMutation = useMutation({
|
||||||
|
mutationFn: (ids: string[]) => photosApi.bulkDiscard(ids),
|
||||||
|
onSuccess: invalidatePhotoQueries,
|
||||||
|
onError: (e: any) => toast.error('Bulk discard failed', e.message || 'Unknown error'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const bulkRestoreMutation = useMutation({
|
||||||
|
mutationFn: (ids: string[]) => photosApi.bulkRestore(ids),
|
||||||
|
onSuccess: invalidatePhotoQueries,
|
||||||
|
onError: (e: any) => toast.error('Bulk restore failed', e.message || 'Unknown error'),
|
||||||
|
})
|
||||||
|
|
||||||
|
/** The set of photo ids the next culling action should apply to.
|
||||||
|
* - Multi-selection → all selected photos
|
||||||
|
* - Single selection → that one photo
|
||||||
|
* - No selection but an activePhotoId set (last clicked) → that one
|
||||||
|
* - Otherwise → empty
|
||||||
|
*/
|
||||||
|
const cullTargets = (): string[] => {
|
||||||
|
const state = usePhotoStore.getState()
|
||||||
|
if (state.selectedPhotos.length > 0) return state.selectedPhotos
|
||||||
|
if (state.activePhotoId) return [state.activePhotoId]
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply a partial PhotoUpdate to the cull targets. Picks the right
|
||||||
|
* bulk endpoint when there are 2+ photos so a single API call covers
|
||||||
|
* the whole selection. */
|
||||||
const updateActive = (data: PhotoUpdate) => {
|
const updateActive = (data: PhotoUpdate) => {
|
||||||
const id = usePhotoStore.getState().activePhotoId
|
const ids = cullTargets()
|
||||||
if (!id) return
|
if (ids.length === 0) return
|
||||||
updateMutation.mutate({ id, data })
|
|
||||||
|
if (ids.length === 1) {
|
||||||
|
updateMutation.mutate({ id: ids[0], data })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multi-selection — fan out to the right bulk endpoint per field.
|
||||||
|
if (data.rating !== undefined) {
|
||||||
|
bulkRatingMutation.mutate({ ids, rating: data.rating })
|
||||||
|
}
|
||||||
|
if (data.color_label !== undefined) {
|
||||||
|
bulkColorMutation.mutate({ ids, color: data.color_label })
|
||||||
|
}
|
||||||
|
if (data.is_discarded === true) {
|
||||||
|
bulkDiscardMutation.mutate(ids)
|
||||||
|
} else if (data.is_discarded === false) {
|
||||||
|
bulkRestoreMutation.mutate(ids)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// P key (Pick): toggle the current selection's membership in the active
|
// P key (Pick): toggle the current selection's membership in the active
|
||||||
|
|||||||
@@ -96,6 +96,26 @@ export const photos = {
|
|||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Bulk set rating (0-5). */
|
||||||
|
bulkSetRating: async (photoIds: string[], rating: number) => {
|
||||||
|
const response = await api.post('/photos/bulk', {
|
||||||
|
ids: photoIds,
|
||||||
|
action: 'set_rating',
|
||||||
|
value: rating,
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Bulk set color label (or null to clear). */
|
||||||
|
bulkSetColor: async (photoIds: string[], color: string | null) => {
|
||||||
|
const response = await api.post('/photos/bulk', {
|
||||||
|
ids: photoIds,
|
||||||
|
action: 'set_color',
|
||||||
|
value: color,
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
/** Move photos into a target folder (or source root). Returns
|
/** Move photos into a target folder (or source root). Returns
|
||||||
* { moved, errors[] }. */
|
* { moved, errors[] }. */
|
||||||
move: async (photoIds: string[], targetId: string) => {
|
move: async (photoIds: string[], targetId: string) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user