feat: editable title, notes, and color label in RightSidebar
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) <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useMemo } from 'react'
|
import { useState, useMemo, useEffect } from 'react'
|
||||||
import {
|
import {
|
||||||
X,
|
X,
|
||||||
Star,
|
Star,
|
||||||
@@ -28,9 +28,23 @@ interface PhotoDetails {
|
|||||||
rating: number
|
rating: number
|
||||||
is_picked: boolean
|
is_picked: boolean
|
||||||
is_discarded: boolean
|
is_discarded: boolean
|
||||||
|
user_title: string | null
|
||||||
|
user_notes: string | null
|
||||||
|
color_label: string | null
|
||||||
exif_json: 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 {
|
interface ExifData {
|
||||||
Make?: string
|
Make?: string
|
||||||
Model?: string
|
Model?: string
|
||||||
@@ -102,13 +116,17 @@ export function RightSidebar() {
|
|||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Mutations for rating / pick / reject. Optimistic-ish: invalidate the
|
// Mutation for any patchable field on the active photo. Invalidates both
|
||||||
// photo query and the timeline list query so the grid re-renders too.
|
// the photo detail cache and the timeline list so the grid reflects the
|
||||||
|
// change too.
|
||||||
const updateMutation = useMutation({
|
const updateMutation = useMutation({
|
||||||
mutationFn: (data: {
|
mutationFn: (data: {
|
||||||
rating?: number
|
rating?: number
|
||||||
is_picked?: boolean
|
is_picked?: boolean
|
||||||
is_discarded?: boolean
|
is_discarded?: boolean
|
||||||
|
user_title?: string | null
|
||||||
|
user_notes?: string | null
|
||||||
|
color_label?: string | null
|
||||||
}) => photosApi.update(activePhotoId!, data),
|
}) => photosApi.update(activePhotoId!, data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['photo', activePhotoId] })
|
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])
|
const exif = useMemo(() => parseExif(photo?.exif_json ?? null), [photo?.exif_json])
|
||||||
|
|
||||||
if (selectedPhotos.length === 0) {
|
if (selectedPhotos.length === 0) {
|
||||||
@@ -133,6 +180,7 @@ export function RightSidebar() {
|
|||||||
const rating = photo?.rating ?? 0
|
const rating = photo?.rating ?? 0
|
||||||
const isPicked = photo?.is_picked ?? false
|
const isPicked = photo?.is_picked ?? false
|
||||||
const isDiscarded = photo?.is_discarded ?? false
|
const isDiscarded = photo?.is_discarded ?? false
|
||||||
|
const colorLabel = (photo?.color_label ?? null) as ColorLabel | null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col bg-surface">
|
<div className="flex h-full flex-col bg-surface">
|
||||||
@@ -154,8 +202,43 @@ export function RightSidebar() {
|
|||||||
|
|
||||||
{/* Quick Actions — operate on the active photo */}
|
{/* Quick Actions — operate on the active photo */}
|
||||||
{photo && !multipleSelected && (
|
{photo && !multipleSelected && (
|
||||||
<div className="border-b border-border p-4">
|
<div className="space-y-3 border-b border-border p-4">
|
||||||
<div className="mb-3">
|
{/* Title (editable) */}
|
||||||
|
<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="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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes (editable) */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs text-text-muted">Notes</label>
|
||||||
|
<textarea
|
||||||
|
value={notesDraft}
|
||||||
|
onChange={(e) => setNotesDraft(e.target.value)}
|
||||||
|
onBlur={commitNotes}
|
||||||
|
placeholder="Add notes…"
|
||||||
|
rows={3}
|
||||||
|
className="w-full resize-none rounded border border-border bg-bg px-2 py-1 text-sm text-text placeholder-text-faint focus:border-primary focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rating */}
|
||||||
|
<div>
|
||||||
<label className="mb-1 block text-xs text-text-muted">Rating</label>
|
<label className="mb-1 block text-xs text-text-muted">Rating</label>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
{[1, 2, 3, 4, 5].map((value) => (
|
{[1, 2, 3, 4, 5].map((value) => (
|
||||||
@@ -180,6 +263,38 @@ export function RightSidebar() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Color label */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs text-text-muted">Color label</label>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{COLOR_LABEL_OPTIONS.map(({ value, className }) => {
|
||||||
|
const active = colorLabel === value
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
onClick={() => setColor(active ? null : value)}
|
||||||
|
className={clsx(
|
||||||
|
'h-5 w-5 rounded-full ring-offset-2 ring-offset-surface transition-all',
|
||||||
|
className,
|
||||||
|
active ? 'ring-2 ring-primary' : 'opacity-60 hover:opacity-100'
|
||||||
|
)}
|
||||||
|
title={value}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{colorLabel && (
|
||||||
|
<button
|
||||||
|
onClick={() => setColor(null)}
|
||||||
|
className="ml-1 rounded p-0.5 text-text-muted hover:bg-surface-2 hover:text-text"
|
||||||
|
title="Clear color label"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Flag */}
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs text-text-muted">Flag</label>
|
<label className="mb-1 block text-xs text-text-muted">Flag</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
|||||||
@@ -57,9 +57,12 @@ export const photos = {
|
|||||||
|
|
||||||
update: async (photoId: string, data: {
|
update: async (photoId: string, data: {
|
||||||
rating?: number
|
rating?: number
|
||||||
flag?: string
|
user_title?: string | null
|
||||||
user_title?: string
|
user_notes?: string | null
|
||||||
user_notes?: string
|
color_label?: string | null
|
||||||
|
is_picked?: boolean
|
||||||
|
is_discarded?: boolean
|
||||||
|
taken_at?: string
|
||||||
}) => {
|
}) => {
|
||||||
const response = await api.patch(`/photos/${photoId}`, data)
|
const response = await api.patch(`/photos/${photoId}`, data)
|
||||||
return response.data
|
return response.data
|
||||||
|
|||||||
Reference in New Issue
Block a user