diff --git a/backend/app/routers/photos.py b/backend/app/routers/photos.py index 2784df1..9edcfb9 100644 --- a/backend/app/routers/photos.py +++ b/backend/app/routers/photos.py @@ -410,20 +410,53 @@ async def update_photo( update: PhotoUpdate, 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( select(Photo).where(Photo.id == photo_id) ) photo = result.scalar_one_or_none() - + if not photo: raise HTTPException(status_code=404, detail="Photo not found") - - # Apply updates + 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(): setattr(photo, field, value) - + await db.commit() await db.refresh(photo) diff --git a/backend/app/schemas/photos.py b/backend/app/schemas/photos.py index 4a3da4b..0af207e 100644 --- a/backend/app/schemas/photos.py +++ b/backend/app/schemas/photos.py @@ -46,6 +46,7 @@ class PhotoResponse(PhotoBase): class PhotoUpdate(BaseModel): """Photo update schema""" + filename: Optional[str] = None user_title: Optional[str] = None user_notes: Optional[str] = None rating: Optional[int] = Field(None, ge=0, le=5) diff --git a/frontend/src/components/layout/RightSidebar.tsx b/frontend/src/components/layout/RightSidebar.tsx index ce65599..a4e1061 100644 --- a/frontend/src/components/layout/RightSidebar.tsx +++ b/frontend/src/components/layout/RightSidebar.tsx @@ -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 && (
+ {/* Filename (editable, renames the file on disk) */} +
+ + 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" + /> +
+ {/* Title (editable) */}
@@ -385,7 +436,6 @@ export function RightSidebar() { onToggle={() => toggleSection('basic')} >
-