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