diff --git a/backend/app/routers/photos.py b/backend/app/routers/photos.py
index dbdbab4..0af894f 100644
--- a/backend/app/routers/photos.py
+++ b/backend/app/routers/photos.py
@@ -1252,6 +1252,18 @@ async def bulk_action(
elif action.action == 'set_color':
for photo in photos:
photo.color_label = action.value
+ elif action.action == 'set_notes':
+ # value is the replacement notes string (empty string clears).
+ # Sent verbatim — no whitespace trimming, callers can pre-trim
+ # client-side if they want.
+ if action.value is not None and not isinstance(action.value, str):
+ raise HTTPException(
+ status_code=400,
+ detail="set_notes requires a string value (or null to clear)",
+ )
+ new_notes = action.value or None # empty string -> null
+ for photo in photos:
+ photo.user_notes = new_notes
elif action.action in ('set_taken_at', 'set_taken_at_map'):
# Two shapes share one code path:
# set_taken_at → value is one ISO datetime, applied to every id
diff --git a/frontend/src/components/layout/RightSidebar.tsx b/frontend/src/components/layout/RightSidebar.tsx
index 3d4a60d..c92e8d6 100644
--- a/frontend/src/components/layout/RightSidebar.tsx
+++ b/frontend/src/components/layout/RightSidebar.tsx
@@ -20,6 +20,7 @@ import { BulkTagsEditor } from '../sidebar/BulkTagsEditor'
import { COLOR_LABEL_OPTIONS } from '../../constants/colorLabels'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
+import { Textarea } from '@/components/ui/textarea'
import { useBulkPhotoMutations } from '../../hooks/useBulkPhotoMutations'
import { formatApiError } from '../../lib/apiError'
@@ -36,6 +37,7 @@ export function RightSidebar() {
const {
bulkRating: bulkRatingMutation,
bulkColor: bulkColorMutation,
+ bulkNotes: bulkNotesMutation,
invalidatePhotoQueries,
} = useBulkPhotoMutations()
@@ -144,6 +146,10 @@ export function RightSidebar() {
const { data: allTags = [] } = useTagsQuery()
const [tagInput, setTagInput] = useState('')
+ // Local draft for the bulk notes textarea. Reset on selection-size
+ // changes (and inside the apply handler) so a leftover note from a
+ // previous selection doesn't haunt the next bulk action.
+ const [bulkNotesDraft, setBulkNotesDraft] = useState('')
// Active heap membership for the bulk Select toggle.
const { activeHeap, memberIds: activeHeapMembers } = useActiveHeapMembers()
@@ -422,6 +428,49 @@ export function RightSidebar() {
/>
+ {/* Bulk Notes — replaces every selected photo's notes with the
+ * same string. Click Apply to commit; nothing fires on every
+ * keystroke (each keystroke would otherwise PATCH all N rows).
+ * Empty + Apply clears the field across the selection. */}
+
+
+
+
{/* Bulk Date Taken — lets an operator repair the capture date on
* a whole selection at once, either by applying one date to
* everything or by inferring a per-photo date from each file's
diff --git a/frontend/src/components/sidebar/PhotoInfoPanel.tsx b/frontend/src/components/sidebar/PhotoInfoPanel.tsx
index a9dadf7..5253dd0 100644
--- a/frontend/src/components/sidebar/PhotoInfoPanel.tsx
+++ b/frontend/src/components/sidebar/PhotoInfoPanel.tsx
@@ -258,13 +258,11 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
// Local drafts for the text fields. Mirror the server value but stay
// independent while typing so we don't fight focus or clobber edits.
const [filenameDraft, setFilenameDraft] = useState('')
- const [titleDraft, setTitleDraft] = useState('')
const [notesDraft, setNotesDraft] = useState('')
const [takenAtDraft, setTakenAtDraft] = useState('')
useEffect(() => {
setFilenameDraft(photo?.filename ?? '')
- setTitleDraft(photo?.user_title ?? '')
setNotesDraft(photo?.user_notes ?? '')
setTakenAtDraft(
photo?.taken_at ? toDatetimeLocalValue(new Date(photo.taken_at)) : ''
@@ -272,7 +270,6 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
}, [
photo?.id,
photo?.filename,
- photo?.user_title,
photo?.user_notes,
photo?.taken_at,
])
@@ -303,13 +300,6 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
)
}
- const commitTitle = () => {
- const next = titleDraft.trim()
- const current = photo?.user_title ?? ''
- if (next === current) return
- updateMutation.mutate({ user_title: next || null })
- }
-
const commitNotes = () => {
const next = notesDraft
const current = photo?.user_notes ?? ''
@@ -472,26 +462,6 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
/>
-