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:
@@ -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 id = usePhotoStore.getState().activePhotoId
|
||||
if (!id) return
|
||||
updateMutation.mutate({ id, data })
|
||||
const ids = cullTargets()
|
||||
if (ids.length === 0) return
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user