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:
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
/>
|
||||
</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
|
||||
* a whole selection at once, either by applying one date to
|
||||
* everything or by inferring a per-photo date from each file's
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
</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
|
||||
photo={photo}
|
||||
draft={takenAtDraft}
|
||||
|
||||
@@ -172,5 +172,22 @@ export function useBulkPhotoMutations() {
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -204,6 +204,17 @@ export const photos = {
|
||||
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.
|
||||
* Backend rewrites EXIF on disk per-photo and surfaces per-photo errors
|
||||
* in the `errors` array so the UI can report a partial apply. */
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface Photo {
|
||||
taken_at_source?: string | null
|
||||
rating: number
|
||||
color_label?: string | null
|
||||
user_notes?: string | null
|
||||
is_discarded: boolean
|
||||
is_duplicate: boolean
|
||||
needs_review?: boolean
|
||||
|
||||
Reference in New Issue
Block a user