feat: editable filename in RightSidebar (renames file on disk)

The first phase-11 file operation. Lightroom-style inline rename
of a single photo, in place, in its current directory.

Backend (PATCH /photos/{id})
- PhotoUpdate schema accepts an optional `filename`.
- When set, the handler validates: non-empty, no path separators,
  no `..`/`.`, target name doesn't already exist in the directory,
  source file exists on disk.
- os.renames the file inside its current directory, then updates
  photo.filename + photo.filepath atomically. The DB only changes
  after a successful rename — a filesystem failure leaves the
  rest of the row untouched.
- Other PhotoUpdate fields still apply afterwards in the same
  request.

Frontend (RightSidebar)
- Filename is now an editable monospace input above the Title
  input. Same draft + commit pattern as title/notes (local draft,
  resync on photo.id change, on-blur or Enter commits).
- Esc reverts to the server value.
- Client-side validation mirrors the backend (rejects path
  separators and dot-segments) and shows a toast on backend
  errors with the FastAPI detail message, then rolls the draft
  back so the input matches the still-on-disk filename.
- Removed the old read-only Filename Field from the Basic Info
  section to avoid showing the same value twice.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-07 23:48:31 +02:00
parent cf7c72d437
commit 7c003bc92e
4 changed files with 92 additions and 7 deletions

View File

@@ -18,6 +18,7 @@ import { usePhotoStore } from '../../store/photoStore'
import { photos as photosApi, heaps as heapsApi } from '../../services/api'
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
import { toast } from '../ToastContainer'
interface PhotoDetails {
id: string
@@ -122,6 +123,7 @@ export function RightSidebar() {
// change too.
const updateMutation = useMutation({
mutationFn: (data: {
filename?: string
rating?: number
is_discarded?: boolean
user_title?: string | null
@@ -175,13 +177,42 @@ 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 [filenameDraft, setFilenameDraft] = useState('')
const [titleDraft, setTitleDraft] = useState('')
const [notesDraft, setNotesDraft] = useState('')
useEffect(() => {
setFilenameDraft(photo?.filename ?? '')
setTitleDraft(photo?.user_title ?? '')
setNotesDraft(photo?.user_notes ?? '')
}, [photo?.id, photo?.user_title, photo?.user_notes])
}, [photo?.id, photo?.filename, photo?.user_title, photo?.user_notes])
const commitFilename = () => {
const next = filenameDraft.trim()
const current = photo?.filename ?? ''
if (!next || next === current) {
// Reset draft if user cleared it; we never send an empty filename.
setFilenameDraft(current)
return
}
if (next.includes('/') || next.includes('\\') || next === '.' || next === '..') {
toast.error('Invalid filename', 'No path separators allowed')
setFilenameDraft(current)
return
}
updateMutation.mutate(
{ filename: next },
{
onError: (e: any) => {
toast.error(
'Rename failed',
e?.response?.data?.detail || e.message || 'Unknown error'
)
setFilenameDraft(current)
},
}
)
}
const commitTitle = () => {
const next = titleDraft.trim()
@@ -240,6 +271,26 @@ export function RightSidebar() {
{/* Quick Actions — operate on the active photo */}
{photo && !multipleSelected && (
<div className="space-y-3 border-b border-border p-4">
{/* Filename (editable, renames the file on disk) */}
<div>
<label className="mb-1 block text-xs text-text-muted">Filename</label>
<input
type="text"
value={filenameDraft}
onChange={(e) => setFilenameDraft(e.target.value)}
onBlur={commitFilename}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.currentTarget.blur()
} else if (e.key === 'Escape') {
setFilenameDraft(photo.filename ?? '')
e.currentTarget.blur()
}
}}
className="w-full rounded border border-border bg-bg px-2 py-1 font-mono text-xs text-text focus:border-primary focus:outline-none"
/>
</div>
{/* Title (editable) */}
<div>
<label className="mb-1 block text-xs text-text-muted">Title</label>
@@ -385,7 +436,6 @@ export function RightSidebar() {
onToggle={() => toggleSection('basic')}
>
<div className="grid grid-cols-2 gap-2 text-xs">
<Field label="Filename" value={photo.filename} />
<Field label="Size" value={formatFileSize(photo.file_size)} />
<Field
label="Dimensions"

View File

@@ -56,6 +56,7 @@ export const photos = {
},
update: async (photoId: string, data: {
filename?: string
rating?: number
user_title?: string | null
user_notes?: string | null