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':
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user