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:
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user