From 8fd8bfe3de5ca4f8cd228b3ce2951776a2872c60 Mon Sep 17 00:00:00 2001 From: dtoro Date: Wed, 8 Apr 2026 12:19:53 +0200 Subject: [PATCH] feat: bulk rating / color / discard / pick from selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../src/components/layout/RightSidebar.tsx | 199 ++++++++++++------ frontend/src/hooks/useKeyboardShortcuts.ts | 70 +++++- frontend/src/services/api.ts | 20 ++ 3 files changed, 217 insertions(+), 72 deletions(-) diff --git a/frontend/src/components/layout/RightSidebar.tsx b/frontend/src/components/layout/RightSidebar.tsx index 767d61f..9aa98b0 100644 --- a/frontend/src/components/layout/RightSidebar.tsx +++ b/frontend/src/components/layout/RightSidebar.tsx @@ -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(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(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() { - {/* Quick Actions — operate on the active photo */} - {photo && !multipleSelected && ( + {/* Quick Actions */} + {photo && (
- {/* Filename (editable, renames the file on disk) */} -
- - 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" - /> -
+ {multipleSelected && ( +

+ Rating, color, and flag apply to all {selectedPhotos.length} selected. +

+ )} - {/* Title (editable) */} -
- - 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" - /> -
+ {/* Per-photo fields — only meaningful for a single selection */} + {!multipleSelected && ( + <> +
+ + 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" + /> +
- {/* Notes (editable) */} -
- -