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:
@@ -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"
|
||||
|
||||
@@ -56,6 +56,7 @@ export const photos = {
|
||||
},
|
||||
|
||||
update: async (photoId: string, data: {
|
||||
filename?: string
|
||||
rating?: number
|
||||
user_title?: string | null
|
||||
user_notes?: string | null
|
||||
|
||||
Reference in New Issue
Block a user