From 02fb1cd508faf562f9e1c024b37b6e09ceae78aa Mon Sep 17 00:00:00 2001 From: dtoro Date: Tue, 7 Apr 2026 23:10:01 +0200 Subject: [PATCH] feat: editable title, notes, and color label in RightSidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the missing editable fields from spec §6.6 metadata sidebar. The mutation path already existed (used by the keyboard culling shortcuts) — this just surfaces the controls. - Title: text input. Save on Enter or blur. Esc reverts. - Notes: textarea, 3 rows. Save on blur. - Color label: 6-dot picker (red/orange/yellow/green/blue/purple) with a clear button. Click an active dot to clear, or use the X. - Local "draft" state for the text fields so typing stays responsive and stale refetches don't clobber in-progress edits. Drafts re-sync on photo.id change. - Sends null for empty string so the backend stores NULL instead of an empty string (cleaner for FTS5 / future filtering). api.ts: widens photos.update() signature to match the backend PhotoUpdate schema — accepts user_title/user_notes/color_label (nullable) plus is_picked/is_discarded/taken_at, which were missing despite already being used by other call sites. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/components/layout/RightSidebar.tsx | 125 +++++++++++++++++- frontend/src/services/api.ts | 9 +- 2 files changed, 126 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/layout/RightSidebar.tsx b/frontend/src/components/layout/RightSidebar.tsx index 6207b8a..79e0867 100644 --- a/frontend/src/components/layout/RightSidebar.tsx +++ b/frontend/src/components/layout/RightSidebar.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo } from 'react' +import { useState, useMemo, useEffect } from 'react' import { X, Star, @@ -28,9 +28,23 @@ interface PhotoDetails { rating: number is_picked: boolean is_discarded: boolean + user_title: string | null + user_notes: string | null + color_label: string | null exif_json: string | null } +type ColorLabel = 'red' | 'orange' | 'yellow' | 'green' | 'blue' | 'purple' + +const COLOR_LABEL_OPTIONS: { value: ColorLabel; className: string }[] = [ + { value: 'red', className: 'bg-red-500' }, + { value: 'orange', className: 'bg-orange-500' }, + { value: 'yellow', className: 'bg-yellow-400' }, + { value: 'green', className: 'bg-green-500' }, + { value: 'blue', className: 'bg-blue-500' }, + { value: 'purple', className: 'bg-purple-500' }, +] + interface ExifData { Make?: string Model?: string @@ -102,13 +116,17 @@ export function RightSidebar() { staleTime: 60_000, }) - // Mutations for rating / pick / reject. Optimistic-ish: invalidate the - // photo query and the timeline list query so the grid re-renders too. + // Mutation for any patchable field on the active photo. Invalidates both + // the photo detail cache and the timeline list so the grid reflects the + // change too. const updateMutation = useMutation({ mutationFn: (data: { rating?: number is_picked?: boolean is_discarded?: boolean + user_title?: string | null + user_notes?: string | null + color_label?: string | null }) => photosApi.update(activePhotoId!, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['photo', activePhotoId] }) @@ -116,6 +134,35 @@ export function RightSidebar() { }, }) + // Local drafts for the editable text fields. These mirror the server value + // but stay independent while the user is typing, so we don't fight focus or + // clobber edits with stale refetches. + const [titleDraft, setTitleDraft] = useState('') + const [notesDraft, setNotesDraft] = useState('') + + useEffect(() => { + setTitleDraft(photo?.user_title ?? '') + setNotesDraft(photo?.user_notes ?? '') + }, [photo?.id, photo?.user_title, photo?.user_notes]) + + 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 ?? '' + if (next === current) return + updateMutation.mutate({ user_notes: next || null }) + } + + const setColor = (label: ColorLabel | null) => { + updateMutation.mutate({ color_label: label }) + } + const exif = useMemo(() => parseExif(photo?.exif_json ?? null), [photo?.exif_json]) if (selectedPhotos.length === 0) { @@ -133,6 +180,7 @@ export function RightSidebar() { const rating = photo?.rating ?? 0 const isPicked = photo?.is_picked ?? false const isDiscarded = photo?.is_discarded ?? false + const colorLabel = (photo?.color_label ?? null) as ColorLabel | null return (
@@ -154,8 +202,43 @@ export function RightSidebar() { {/* Quick Actions — operate on the active photo */} {photo && !multipleSelected && ( -
-
+
+ {/* Title (editable) */} +
+ + 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="w-full rounded border border-border bg-bg px-2 py-1 text-sm text-text placeholder-text-faint focus:border-primary focus:outline-none" + /> +
+ + {/* Notes (editable) */} +
+ +