ui(sidebar): drop Title field, add bulk notes editor

The Title (user_title) field hadn't earned its place in the sidebar
form — the underlying column stays on the model but the editable
row + its draft state + commit handler are gone.

Bulk Notes: a textarea in the multi-photo bulk panel that replaces
user_notes across the whole selection with one string. Apply commits;
Clear empties the draft without committing. New backend bulk action
'set_notes' validates the value is a string (or null/empty to clear)
and writes to every photo in the selection in one go. Wired through
the standard useBulkPhotoMutations optimistic-patch path, so the
photo cache flips immediately and rolls back on error.

user_notes added to the shared Photo type so patchPhotos accepts the
field; previously it was only on PhotoInfoPanel's local PhotoDetails.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claudio
2026-05-11 00:07:02 +02:00
parent c69322a89d
commit f743733edd
6 changed files with 91 additions and 31 deletions

View File

@@ -1252,6 +1252,18 @@ async def bulk_action(
elif action.action == 'set_color': elif action.action == 'set_color':
for photo in photos: for photo in photos:
photo.color_label = action.value 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'): elif action.action in ('set_taken_at', 'set_taken_at_map'):
# Two shapes share one code path: # Two shapes share one code path:
# set_taken_at → value is one ISO datetime, applied to every id # set_taken_at → value is one ISO datetime, applied to every id

View File

@@ -20,6 +20,7 @@ import { BulkTagsEditor } from '../sidebar/BulkTagsEditor'
import { COLOR_LABEL_OPTIONS } from '../../constants/colorLabels' import { COLOR_LABEL_OPTIONS } from '../../constants/colorLabels'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { useBulkPhotoMutations } from '../../hooks/useBulkPhotoMutations' import { useBulkPhotoMutations } from '../../hooks/useBulkPhotoMutations'
import { formatApiError } from '../../lib/apiError' import { formatApiError } from '../../lib/apiError'
@@ -36,6 +37,7 @@ export function RightSidebar() {
const { const {
bulkRating: bulkRatingMutation, bulkRating: bulkRatingMutation,
bulkColor: bulkColorMutation, bulkColor: bulkColorMutation,
bulkNotes: bulkNotesMutation,
invalidatePhotoQueries, invalidatePhotoQueries,
} = useBulkPhotoMutations() } = useBulkPhotoMutations()
@@ -144,6 +146,10 @@ export function RightSidebar() {
const { data: allTags = [] } = useTagsQuery() const { data: allTags = [] } = useTagsQuery()
const [tagInput, setTagInput] = useState('') 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. // Active heap membership for the bulk Select toggle.
const { activeHeap, memberIds: activeHeapMembers } = useActiveHeapMembers() const { activeHeap, memberIds: activeHeapMembers } = useActiveHeapMembers()
@@ -422,6 +428,49 @@ export function RightSidebar() {
/> />
</div> </div>
{/* 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. */}
<div>
<Label className="mb-1 block">Notes</Label>
<Textarea
value={bulkNotesDraft}
onChange={(e) => setBulkNotesDraft(e.target.value)}
placeholder={`Replace notes on ${selectedPhotos.length} photos…`}
rows={2}
disabled={bulkNotesMutation.isPending}
className="resize-none text-xs"
/>
<div className="mt-1 flex items-center justify-end gap-2">
<button
type="button"
onClick={() => setBulkNotesDraft('')}
disabled={bulkNotesMutation.isPending || !bulkNotesDraft}
className="rounded px-2 py-0.5 text-[11px] text-text-muted hover:bg-surface-2 hover:text-text disabled:cursor-not-allowed disabled:opacity-50"
>
Clear
</button>
<Button
size="sm"
onClick={() => {
bulkNotesMutation.mutate(
{ ids: selectedPhotos, notes: bulkNotesDraft },
{ onSuccess: () => setBulkNotesDraft('') },
)
}}
disabled={bulkNotesMutation.isPending}
title={
bulkNotesDraft
? `Replace notes on all ${selectedPhotos.length} selected photos`
: `Clear notes on all ${selectedPhotos.length} selected photos`
}
>
{bulkNotesMutation.isPending ? 'Applying…' : 'Apply'}
</Button>
</div>
</div>
{/* Bulk Date Taken — lets an operator repair the capture date on {/* Bulk Date Taken — lets an operator repair the capture date on
* a whole selection at once, either by applying one date to * a whole selection at once, either by applying one date to
* everything or by inferring a per-photo date from each file's * everything or by inferring a per-photo date from each file's

View File

@@ -258,13 +258,11 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
// Local drafts for the text fields. Mirror the server value but stay // Local drafts for the text fields. Mirror the server value but stay
// independent while typing so we don't fight focus or clobber edits. // independent while typing so we don't fight focus or clobber edits.
const [filenameDraft, setFilenameDraft] = useState('') const [filenameDraft, setFilenameDraft] = useState('')
const [titleDraft, setTitleDraft] = useState('')
const [notesDraft, setNotesDraft] = useState('') const [notesDraft, setNotesDraft] = useState('')
const [takenAtDraft, setTakenAtDraft] = useState('') const [takenAtDraft, setTakenAtDraft] = useState('')
useEffect(() => { useEffect(() => {
setFilenameDraft(photo?.filename ?? '') setFilenameDraft(photo?.filename ?? '')
setTitleDraft(photo?.user_title ?? '')
setNotesDraft(photo?.user_notes ?? '') setNotesDraft(photo?.user_notes ?? '')
setTakenAtDraft( setTakenAtDraft(
photo?.taken_at ? toDatetimeLocalValue(new Date(photo.taken_at)) : '' photo?.taken_at ? toDatetimeLocalValue(new Date(photo.taken_at)) : ''
@@ -272,7 +270,6 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
}, [ }, [
photo?.id, photo?.id,
photo?.filename, photo?.filename,
photo?.user_title,
photo?.user_notes, photo?.user_notes,
photo?.taken_at, 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 commitNotes = () => {
const next = notesDraft const next = notesDraft
const current = photo?.user_notes ?? '' const current = photo?.user_notes ?? ''
@@ -472,26 +462,6 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
/> />
</div> </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={inputClass}
/>
</div>
<TakenAtEditor <TakenAtEditor
photo={photo} photo={photo}
draft={takenAtDraft} draft={takenAtDraft}

View File

@@ -172,5 +172,22 @@ export function useBulkPhotoMutations() {
onSuccess: invalidatePhotoQueries, onSuccess: invalidatePhotoQueries,
}) })
return { bulkRating, bulkColor, invalidatePhotoQueries } const bulkNotes = useMutation({
mutationFn: ({ ids, notes }: { ids: string[]; notes: string }) =>
photosApi.bulkSetNotes(ids, notes),
onMutate: ({ ids, notes }) => {
const snapshot = snapshotPhotos(ids)
// Mirror the server's empty-string -> null collapse so the
// optimistic cache matches what the API returns.
patchPhotos(ids, { user_notes: notes || null })
return { snapshot }
},
onError: (e, _vars, ctx) => {
if (ctx?.snapshot) restoreFromSnapshot(ctx.snapshot)
toast.error('Notes update failed', formatApiError(e))
},
onSuccess: invalidatePhotoQueries,
})
return { bulkRating, bulkColor, bulkNotes, invalidatePhotoQueries }
} }

View File

@@ -204,6 +204,17 @@ export const photos = {
return response.data return response.data
}, },
/** Bulk set user_notes — replaces every selected photo's notes with
* the same string. Empty string clears (server collapses to null). */
bulkSetNotes: async (photoIds: string[], notes: string) => {
const response = await api.post('/photos/bulk', {
ids: photoIds,
action: 'set_notes',
value: notes,
})
return response.data
},
/** Bulk set taken_at — one ISO datetime applied to every listed photo. /** Bulk set taken_at — one ISO datetime applied to every listed photo.
* Backend rewrites EXIF on disk per-photo and surfaces per-photo errors * Backend rewrites EXIF on disk per-photo and surfaces per-photo errors
* in the `errors` array so the UI can report a partial apply. */ * in the `errors` array so the UI can report a partial apply. */

View File

@@ -15,6 +15,7 @@ export interface Photo {
taken_at_source?: string | null taken_at_source?: string | null
rating: number rating: number
color_label?: string | null color_label?: string | null
user_notes?: string | null
is_discarded: boolean is_discarded: boolean
is_duplicate: boolean is_duplicate: boolean
needs_review?: boolean needs_review?: boolean