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

@@ -410,20 +410,53 @@ async def update_photo(
update: PhotoUpdate, update: PhotoUpdate,
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
"""Update photo metadata""" """Update photo metadata. If `filename` is included, also rename the
file on disk in its current directory (no cross-folder moves through
this endpoint).
"""
result = await db.execute( result = await db.execute(
select(Photo).where(Photo.id == photo_id) select(Photo).where(Photo.id == photo_id)
) )
photo = result.scalar_one_or_none() photo = result.scalar_one_or_none()
if not photo: if not photo:
raise HTTPException(status_code=404, detail="Photo not found") raise HTTPException(status_code=404, detail="Photo not found")
# Apply updates
update_data = update.dict(exclude_unset=True) update_data = update.dict(exclude_unset=True)
# Filename rename: validate, rename on disk, then update both filename
# and filepath atomically. Done before any other field changes so a
# filesystem failure leaves the rest of the row untouched.
if 'filename' in update_data:
new_name = (update_data.pop('filename') or '').strip()
if not new_name:
raise HTTPException(status_code=400, detail="Filename cannot be empty")
# Reject path separators and parent traversal — same-directory only.
if '/' in new_name or '\\' in new_name or new_name in ('.', '..'):
raise HTTPException(status_code=400, detail="Invalid filename")
if new_name != photo.filename:
current_dir = os.path.dirname(photo.filepath)
new_path = os.path.join(current_dir, new_name)
if not os.path.exists(photo.filepath):
raise HTTPException(status_code=404, detail="Source file missing on disk")
if os.path.exists(new_path):
raise HTTPException(status_code=409, detail="A file with that name already exists")
try:
os.rename(photo.filepath, new_path)
except OSError as e:
logger.error(f"Failed to rename {photo.filepath} -> {new_path}: {e}")
raise HTTPException(status_code=500, detail=f"Rename failed: {e}")
photo.filename = new_name
photo.filepath = new_path
# Apply remaining updates
for field, value in update_data.items(): for field, value in update_data.items():
setattr(photo, field, value) setattr(photo, field, value)
await db.commit() await db.commit()
await db.refresh(photo) await db.refresh(photo)

View File

@@ -46,6 +46,7 @@ class PhotoResponse(PhotoBase):
class PhotoUpdate(BaseModel): class PhotoUpdate(BaseModel):
"""Photo update schema""" """Photo update schema"""
filename: Optional[str] = None
user_title: Optional[str] = None user_title: Optional[str] = None
user_notes: Optional[str] = None user_notes: Optional[str] = None
rating: Optional[int] = Field(None, ge=0, le=5) rating: Optional[int] = Field(None, ge=0, le=5)

View File

@@ -18,6 +18,7 @@ import { usePhotoStore } from '../../store/photoStore'
import { photos as photosApi, heaps as heapsApi } from '../../services/api' import { photos as photosApi, heaps as heapsApi } from '../../services/api'
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery' import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery' import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
import { toast } from '../ToastContainer'
interface PhotoDetails { interface PhotoDetails {
id: string id: string
@@ -122,6 +123,7 @@ export function RightSidebar() {
// change too. // change too.
const updateMutation = useMutation({ const updateMutation = useMutation({
mutationFn: (data: { mutationFn: (data: {
filename?: string
rating?: number rating?: number
is_discarded?: boolean is_discarded?: boolean
user_title?: string | null user_title?: string | null
@@ -175,13 +177,42 @@ export function RightSidebar() {
// Local drafts for the editable text fields. These mirror the server value // 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 // but stay independent while the user is typing, so we don't fight focus or
// clobber edits with stale refetches. // clobber edits with stale refetches.
const [filenameDraft, setFilenameDraft] = useState('')
const [titleDraft, setTitleDraft] = useState('') const [titleDraft, setTitleDraft] = useState('')
const [notesDraft, setNotesDraft] = useState('') const [notesDraft, setNotesDraft] = useState('')
useEffect(() => { useEffect(() => {
setFilenameDraft(photo?.filename ?? '')
setTitleDraft(photo?.user_title ?? '') setTitleDraft(photo?.user_title ?? '')
setNotesDraft(photo?.user_notes ?? '') 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 commitTitle = () => {
const next = titleDraft.trim() const next = titleDraft.trim()
@@ -240,6 +271,26 @@ export function RightSidebar() {
{/* Quick Actions — operate on the active photo */} {/* Quick Actions — operate on the active photo */}
{photo && !multipleSelected && ( {photo && !multipleSelected && (
<div className="space-y-3 border-b border-border p-4"> <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) */} {/* Title (editable) */}
<div> <div>
<label className="mb-1 block text-xs text-text-muted">Title</label> <label className="mb-1 block text-xs text-text-muted">Title</label>
@@ -385,7 +436,6 @@ export function RightSidebar() {
onToggle={() => toggleSection('basic')} onToggle={() => toggleSection('basic')}
> >
<div className="grid grid-cols-2 gap-2 text-xs"> <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="Size" value={formatFileSize(photo.file_size)} />
<Field <Field
label="Dimensions" label="Dimensions"

View File

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