feat: folder CRUD with discard-or-delete dialog

The left sidebar can now create, rename, and delete folders. Each
operation is mirrored to disk through the backend.

Backend (folders router):
- POST /folders { name, parent_id } — create a sub-folder under an
  existing Folder row, mkdir on disk, insert the row, return it. Names
  are validated (no separators, no traversal).
- PATCH /folders/{id} extended — still does the display-only rename for
  SourceRoot ids, but for Folder ids it now actually moves the directory
  on disk and rewrites every descendant Folder.path + Photo.filepath
  that lived under the old prefix in a single transaction. Refuses to
  rename the source-root mount itself.
- DELETE /folders/{id}?mode=discard|permanent —
    discard: set is_discarded on every photo whose filepath lives under
             this folder. The folder, descendants, and on-disk dir are
             left intact. Recoverable from the discard pile.
    permanent: unlink each file, remove rows, rmtree the directory.
- Refuses to delete the source-root mount in either mode.

Frontend:
- New DeleteFolderDialog: two-card mode picker (Move to discard pile /
  Permanently delete) with destructive accent on the latter. Esc and
  backdrop click cancel.
- LeftSidebar: hover-revealed kebab menu on every folder row with
  New sub-folder, Rename, and Delete folder…  Inline create input
  appears below the parent row when "New sub-folder" is picked.
  All mutations invalidate ['folders'], ['photos'], and the library
  stats query so the sidebar counts stay live.
- api.ts: sourceFolders.create + sourceFolders.delete wrappers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-08 20:51:23 +02:00
parent bd904aca36
commit f05ae77ef0
4 changed files with 699 additions and 20 deletions

View File

@@ -1,24 +1,48 @@
"""
Folders API router. Source roots themselves are config-driven (PHOTO_DIRS
in .env → backend bootstrap on startup) — adding or removing one is a
docker-compose change. The UI can read the list, trigger a manual rescan,
and rename the display label, but it can't change the on-disk path.
docker-compose change. Sub-folders inside a source root can be created,
renamed, and deleted from the UI; those changes are mirrored to disk.
"""
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
import logging
import os
import shutil
from typing import Literal, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import select, func, update as sql_update, delete as sql_delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import Folder, SourceRoot, Photo
logger = logging.getLogger(__name__)
router = APIRouter()
class FolderRename(BaseModel):
name: str
class FolderCreate(BaseModel):
name: str
parent_id: str # Folder.id (NOT a SourceRoot id)
def _validate_folder_name(name: str) -> str:
"""Trim + sanity-check a folder name. Rejects names that contain a
path separator or that resolve to a parent traversal — those would
let the user escape the parent directory through this endpoint.
"""
name = (name or '').strip()
if not name:
raise HTTPException(status_code=400, detail="Name cannot be empty")
if '/' in name or '\\' in name or name in ('.', '..'):
raise HTTPException(status_code=400, detail="Invalid folder name")
return name
@router.get("")
async def get_folders(db: AsyncSession = Depends(get_db)):
"""Get all source folders"""
@@ -161,20 +185,242 @@ async def rename_folder(
body: FolderRename,
db: AsyncSession = Depends(get_db),
):
"""Rename a source root's display label. Does NOT touch the on-disk
path — that's controlled by the docker mount."""
name = (body.name or '').strip()
if not name:
raise HTTPException(status_code=400, detail="Name cannot be empty")
"""Rename a folder. Two cases:
result = await db.execute(select(SourceRoot).where(SourceRoot.id == folder_id))
source_root = result.scalar_one_or_none()
if not source_root:
raise HTTPException(status_code=404, detail="Source folder not found")
- SourceRoot id → just change the display label. The on-disk path
is owned by the docker mount and never moves.
- Folder id → rename the directory on disk AND update every
descendant Folder.path + Photo.filepath that
lived under the old prefix. Refuses to rename
the source-root folder itself (= the row that
matches the SourceRoot.path) because that would
require renaming the docker mount.
"""
name = _validate_folder_name(body.name)
# Try SourceRoot first (display-only rename).
sr_result = await db.execute(
select(SourceRoot).where(SourceRoot.id == folder_id)
)
source_root = sr_result.scalar_one_or_none()
if source_root:
source_root.name = name
await db.commit()
return {
"id": source_root.id,
"name": source_root.name,
"path": source_root.path,
}
# Otherwise it's a Folder row.
folder_result = await db.execute(select(Folder).where(Folder.id == folder_id))
folder = folder_result.scalar_one_or_none()
if not folder:
raise HTTPException(status_code=404, detail="Folder not found")
# Refuse to rename the bare source root mount through here.
sr_check = await db.execute(
select(SourceRoot).where(SourceRoot.id == folder.source_root_id)
)
sr = sr_check.scalar_one_or_none()
if sr and os.path.normpath(folder.path) == os.path.normpath(sr.path):
raise HTTPException(
status_code=400,
detail="Cannot rename the source root mount; rename the docker mount instead.",
)
old_path = os.path.normpath(folder.path).rstrip(os.sep)
parent_dir = os.path.dirname(old_path)
new_path = os.path.join(parent_dir, name)
if os.path.exists(new_path):
raise HTTPException(
status_code=400,
detail=f"A folder named '{name}' already exists here",
)
try:
shutil.move(old_path, new_path)
except OSError as e:
raise HTTPException(status_code=500, detail=f"Rename failed: {e}")
# Update folder paths: this row + every descendant. SQLite REPLACE
# rewrites the prefix; we use the trailing separator on the LIKE
# pattern so a folder named "foo" doesn't accidentally match "foobar".
await db.execute(
sql_update(Folder)
.where(Folder.id == folder.id)
.values(path=new_path, name=name)
)
descendant_prefix = old_path + os.sep
descendants = await db.execute(
select(Folder).where(Folder.path.like(descendant_prefix + '%'))
)
for d in descendants.scalars().all():
d.path = new_path + d.path[len(old_path):]
# Update every photo whose filepath lives under the old prefix.
photos_result = await db.execute(
select(Photo).where(Photo.filepath.like(descendant_prefix + '%'))
)
for p in photos_result.scalars().all():
p.filepath = new_path + p.filepath[len(old_path):]
# Photos directly inside this folder (not in a subdir) won't match
# the descendant_prefix LIKE if their old path was old_path + '/file'
# — actually they DO match, since 'oldpath/file' starts with
# 'oldpath/'. So the loop above already covers them.
source_root.name = name
await db.commit()
return {"id": source_root.id, "name": source_root.name, "path": source_root.path}
return {
"id": folder.id,
"name": folder.name,
"path": folder.path,
}
@router.post("", status_code=201)
async def create_folder(body: FolderCreate, db: AsyncSession = Depends(get_db)):
"""Create a new sub-folder under an existing Folder. Mirrors the
create to disk so the next scan sees it. Body: { name, parent_id }.
parent_id MUST be an existing Folder row id (any descendant of a
source root); creating a brand-new top-level mount is a docker
operation, not a UI one.
"""
name = _validate_folder_name(body.name)
parent_result = await db.execute(
select(Folder).where(Folder.id == body.parent_id)
)
parent = parent_result.scalar_one_or_none()
if not parent:
raise HTTPException(status_code=404, detail="Parent folder not found")
new_path = os.path.join(parent.path, name)
if os.path.exists(new_path):
raise HTTPException(
status_code=400,
detail=f"A folder named '{name}' already exists here",
)
try:
os.makedirs(new_path, exist_ok=False)
except OSError as e:
raise HTTPException(status_code=500, detail=f"Create failed: {e}")
new_folder = Folder(
name=name,
path=new_path,
source_root_id=parent.source_root_id,
photo_count=0,
)
db.add(new_folder)
await db.commit()
await db.refresh(new_folder)
return {
"id": new_folder.id,
"name": new_folder.name,
"path": new_folder.path,
"parent_id": parent.id,
}
@router.delete("/{folder_id}")
async def delete_folder(
folder_id: str,
mode: Literal['discard', 'permanent'] = Query('discard'),
db: AsyncSession = Depends(get_db),
):
"""Delete a folder. Behavior depends on mode:
- mode=discard (default): mark every photo whose filepath lives
under this folder as is_discarded=true. The folder row, its
descendant rows, and the on-disk directory are LEFT INTACT —
the user can still recover photos from the discard pile, and
a re-scan won't double-import them.
- mode=permanent: unlink every photo file under this folder,
remove the photo + folder rows from the DB, and rmtree the
on-disk directory. Irreversible.
Refuses to delete the bare source-root mount in either mode (deleting
the docker mount through the UI would be a footgun).
"""
folder_result = await db.execute(select(Folder).where(Folder.id == folder_id))
folder = folder_result.scalar_one_or_none()
if not folder:
raise HTTPException(status_code=404, detail="Folder not found")
sr_check = await db.execute(
select(SourceRoot).where(SourceRoot.id == folder.source_root_id)
)
sr = sr_check.scalar_one_or_none()
if sr and os.path.normpath(folder.path) == os.path.normpath(sr.path):
raise HTTPException(
status_code=400,
detail="Cannot delete the source root mount through the UI",
)
folder_path = os.path.normpath(folder.path).rstrip(os.sep)
descendant_prefix = folder_path + os.sep
# Collect every photo under this folder OR any descendant. We match
# by filepath prefix instead of folder_id because that catches photos
# in nested subfolders without a recursive folder walk.
photos_result = await db.execute(
select(Photo).where(
(Photo.filepath == folder_path)
| (Photo.filepath.like(descendant_prefix + '%'))
)
)
photos = photos_result.scalars().all()
if mode == 'discard':
from datetime import datetime
now = datetime.utcnow()
for p in photos:
p.is_discarded = True
p.discarded_at = now
await db.commit()
return {
"status": "success",
"mode": "discard",
"discarded": len(photos),
}
# mode == 'permanent'
file_errors = 0
for p in photos:
try:
if p.filepath and os.path.exists(p.filepath):
os.unlink(p.filepath)
except OSError as e:
file_errors += 1
logger.error(f"Failed to unlink {p.filepath}: {e}")
await db.delete(p)
# Delete this folder + every descendant Folder row.
await db.execute(
sql_delete(Folder).where(
(Folder.id == folder.id)
| (Folder.path.like(descendant_prefix + '%'))
)
)
try:
if os.path.isdir(folder_path):
shutil.rmtree(folder_path)
except OSError as e:
logger.error(f"Failed to rmtree {folder_path}: {e}")
# Don't raise — DB rows are already gone, leaving an orphan
# directory is the lesser evil.
await db.commit()
return {
"status": "success",
"mode": "permanent",
"deleted_photos": len(photos),
"file_errors": file_errors,
}
@router.post("/{folder_id}/scan")

View File

@@ -0,0 +1,164 @@
import { useEffect, useState } from 'react'
import clsx from 'clsx'
import { Trash2, Archive } from 'lucide-react'
interface DeleteFolderDialogProps {
isOpen: boolean
folderName: string
/** Number of photos under this folder, including descendants. Surfaced
* in the dialog copy so the user understands the blast radius. */
photoCount?: number
onClose: () => void
/** Called with the chosen mode when the user confirms. */
onConfirm: (mode: 'discard' | 'permanent') => void
}
/**
* Two-mode folder delete dialog:
*
* - Move to discard pile (default, soft, recoverable)
* - Permanently delete (destructive, irreversible)
*
* The user picks a mode via the radio cards then clicks Delete. Esc /
* backdrop click cancels.
*/
export function DeleteFolderDialog({
isOpen,
folderName,
photoCount,
onClose,
onConfirm,
}: DeleteFolderDialogProps) {
const [mode, setMode] = useState<'discard' | 'permanent'>('discard')
// Reset mode when re-opening so the safe option is always the default.
useEffect(() => {
if (isOpen) setMode('discard')
}, [isOpen])
// Esc to close.
useEffect(() => {
if (!isOpen) return
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [isOpen, onClose])
if (!isOpen) return null
const photoBlurb =
photoCount === undefined
? 'photos in this folder'
: photoCount === 0
? 'this empty folder'
: `${photoCount} photo${photoCount === 1 ? '' : 's'} in this folder`
return (
<div className="fixed inset-0 z-50">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
<div className="relative z-10 w-[420px] rounded-lg border border-border bg-surface p-5 shadow-2xl">
<h2 className="mb-1 text-base font-semibold text-text">
Delete folder "{folderName}"?
</h2>
<p className="mb-4 text-sm text-text-muted">
What should happen to {photoBlurb}?
</p>
<div className="space-y-2">
<ModeCard
icon={<Archive className="h-4 w-4" />}
title="Move photos to discard pile"
description="Photos can be restored later from Discarded. The folder and files stay on disk."
selected={mode === 'discard'}
onClick={() => setMode('discard')}
/>
<ModeCard
icon={<Trash2 className="h-4 w-4" />}
title="Permanently delete folder and photos"
description="Removes the folder, every photo inside it, and the directory from disk. This cannot be undone."
selected={mode === 'permanent'}
destructive
onClick={() => setMode('permanent')}
/>
</div>
<div className="mt-5 flex justify-end gap-2">
<button
onClick={onClose}
className="rounded border border-border px-3 py-1.5 text-sm text-text hover:bg-surface-2"
>
Cancel
</button>
<button
onClick={() => onConfirm(mode)}
className={clsx(
'rounded px-3 py-1.5 text-sm font-medium text-white',
mode === 'permanent'
? 'bg-reject hover:bg-reject/80'
: 'bg-primary hover:bg-primary/80'
)}
>
{mode === 'permanent' ? 'Delete forever' : 'Move to discard pile'}
</button>
</div>
</div>
</div>
</div>
)
}
function ModeCard({
icon,
title,
description,
selected,
destructive = false,
onClick,
}: {
icon: React.ReactNode
title: string
description: string
selected: boolean
destructive?: boolean
onClick: () => void
}) {
return (
<button
onClick={onClick}
className={clsx(
'flex w-full gap-3 rounded-lg border p-3 text-left transition-colors',
selected
? destructive
? 'border-reject/60 bg-reject/10'
: 'border-primary/60 bg-primary/10'
: 'border-border bg-surface-2 hover:bg-surface-offset'
)}
>
<div
className={clsx(
'mt-0.5 flex-shrink-0',
selected ? (destructive ? 'text-reject' : 'text-primary') : 'text-text-muted'
)}
>
{icon}
</div>
<div className="flex-1">
<div
className={clsx(
'text-sm font-medium',
selected ? (destructive ? 'text-reject' : 'text-primary') : 'text-text'
)}
>
{title}
</div>
<div className="mt-0.5 text-xs text-text-muted">{description}</div>
</div>
</button>
)
}

View File

@@ -1,8 +1,9 @@
import { useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import {
ChevronRight,
ChevronDown,
Folder,
FolderPlus,
Image,
Star,
Trash2,
@@ -11,6 +12,8 @@ import {
Copy,
Tag as TagIcon,
Layers2,
MoreHorizontal,
Pencil,
} from 'lucide-react'
import clsx from 'clsx'
import { sourceFolders, library, photos as photosApi, type FolderTreeNode } from '../../services/api'
@@ -27,6 +30,7 @@ import {
} from '../../hooks/useLibraryStatsQuery'
import { registerUndoable } from '../../store/undoStore'
import type { Photo } from '../../types/photo'
import { DeleteFolderDialog } from '../dialogs/DeleteFolderDialog'
interface TreeItem {
id: string
@@ -52,6 +56,40 @@ export function LeftSidebar() {
const { data: stats } = useLibraryStatsQuery()
const [dropTargetId, setDropTargetId] = useState<string | null>(null)
// Per-folder kebab menu open state. Stores the tree-item id ("folder-..."
// or "folders" for the section header). Outside-click + Escape close.
const [openMenuId, setOpenMenuId] = useState<string | null>(null)
const menuRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!openMenuId) return
const onDown = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setOpenMenuId(null)
}
}
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpenMenuId(null)
}
document.addEventListener('mousedown', onDown)
document.addEventListener('keydown', onKey)
return () => {
document.removeEventListener('mousedown', onDown)
document.removeEventListener('keydown', onKey)
}
}, [openMenuId])
// "Create new folder under {parent}" inline state. parentId is the
// Folder.id (no "folder-" prefix).
const [creatingUnder, setCreatingUnder] = useState<string | null>(null)
const [createDraft, setCreateDraft] = useState('')
// Folder being deleted, drives the DeleteFolderDialog mounted below.
const [deletingFolder, setDeletingFolder] = useState<{
id: string
name: string
photoCount?: number
} | null>(null)
// Bulk discard mutation for the drag-onto-Discarded interaction.
const discardDropMutation = useMutation({
mutationFn: (photoIds: string[]) => photosApi.bulkDiscard(photoIds),
@@ -217,11 +255,55 @@ export function LeftSidebar() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['folders'] })
queryClient.invalidateQueries({ queryKey: ['folders', 'tree'] })
queryClient.invalidateQueries({ queryKey: ['photos'] })
},
onError: (e: any) =>
toast.error('Rename failed', e?.response?.data?.detail || e.message || 'Unknown error'),
})
const createFolderMutation = useMutation({
mutationFn: ({ parentId, name }: { parentId: string; name: string }) =>
sourceFolders.create(parentId, name),
onSuccess: (data) => {
toast.success('Folder created', data.name)
queryClient.invalidateQueries({ queryKey: ['folders'] })
queryClient.invalidateQueries({ queryKey: ['folders', 'tree'] })
setCreatingUnder(null)
setCreateDraft('')
},
onError: (e: any) =>
toast.error('Create failed', e?.response?.data?.detail || e.message || 'Unknown error'),
})
const deleteFolderMutation = useMutation({
mutationFn: ({ id, mode }: { id: string; mode: 'discard' | 'permanent' }) =>
sourceFolders.delete(id, mode),
onSuccess: (data) => {
if (data.mode === 'discard') {
toast.success(
'Folder photos discarded',
`${data.discarded ?? 0} moved to discard pile`
)
} else {
toast.success(
'Folder deleted',
`${data.deleted_photos ?? 0} photos removed from disk`
)
}
queryClient.invalidateQueries({ queryKey: ['folders'] })
queryClient.invalidateQueries({ queryKey: ['folders', 'tree'] })
queryClient.invalidateQueries({ queryKey: ['photos'] })
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
// If we were viewing the deleted folder, snap back to all-photos.
if (deletingFolder && currentSection === `folder-${deletingFolder.id}`) {
navigateToSection('all-photos', {})
}
setDeletingFolder(null)
},
onError: (e: any) =>
toast.error('Delete failed', e?.response?.data?.detail || e.message || 'Unknown error'),
})
// Mutation for scanning all folders
const scanLibraryMutation = useMutation({
mutationFn: library.scan,
@@ -455,8 +537,127 @@ export function LeftSidebar() {
<span className="h-5 min-w-[24px] flex-shrink-0" aria-hidden="true" />
)}
{/* Folder kebab menu — only on folder rows. Hidden until hover
* (or when its menu is open) so the count column stays aligned
* in the resting state. */}
{item.id.startsWith('folder-') &&
(() => {
const folderId = item.id.slice('folder-'.length)
const isMenuOpen = openMenuId === item.id
return (
<div className="relative flex-shrink-0">
<button
onClick={(e) => {
e.stopPropagation()
setOpenMenuId(isMenuOpen ? null : item.id)
}}
className={clsx(
'rounded p-0.5 text-text-muted hover:bg-surface-offset hover:text-text',
isMenuOpen ? 'visible' : 'invisible group-hover:visible'
)}
title="More actions"
aria-label="More folder actions"
aria-haspopup="menu"
aria-expanded={isMenuOpen}
>
<MoreHorizontal className="h-3.5 w-3.5" />
</button>
{isMenuOpen && (
<div
ref={menuRef}
role="menu"
onClick={(e) => e.stopPropagation()}
className="absolute right-0 top-full z-30 mt-1 min-w-[180px] overflow-hidden rounded-lg border border-border bg-surface py-1 text-sm shadow-xl"
>
<FolderMenuItem
icon={<FolderPlus className="h-3.5 w-3.5" />}
label="New sub-folder"
onClick={() => {
setOpenMenuId(null)
setCreatingUnder(folderId)
setCreateDraft('')
// Make sure the parent is expanded so the new
// input is visible.
if (!expandedItems.has(item.id)) {
toggleExpanded(item.id)
}
}}
/>
<FolderMenuItem
icon={<Pencil className="h-3.5 w-3.5" />}
label="Rename"
onClick={() => {
setOpenMenuId(null)
setRenamingId(item.id)
setRenameDraft(item.label)
}}
/>
<div className="my-1 h-px bg-border" />
<FolderMenuItem
icon={<Trash2 className="h-3.5 w-3.5" />}
label="Delete folder…"
destructive
onClick={() => {
setOpenMenuId(null)
setDeletingFolder({
id: folderId,
name: item.label,
photoCount: item.count,
})
}}
/>
</div>
)}
</div>
)
})()}
</div>
{/* Inline "create new sub-folder" input. Renders just below the
* parent row when its create state is active. */}
{item.id.startsWith('folder-') &&
creatingUnder === item.id.slice('folder-'.length) && (
<div
className="flex items-center gap-1 px-2 py-1"
style={{ paddingLeft: `${8 + (depth + 1) * 16 + 4}px` }}
>
<FolderPlus className="h-3 w-3 flex-shrink-0 text-text-muted" />
<input
autoFocus
type="text"
value={createDraft}
placeholder="New folder name"
onChange={(e) => setCreateDraft(e.target.value)}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === 'Enter') {
const name = createDraft.trim()
if (name) {
createFolderMutation.mutate({
parentId: item.id.slice('folder-'.length),
name,
})
}
} else if (e.key === 'Escape') {
setCreatingUnder(null)
setCreateDraft('')
}
}}
onBlur={() => {
// Don't auto-commit on blur — empty/escaped renames
// close the input but don't fire the request.
if (!createFolderMutation.isPending) {
setCreatingUnder(null)
setCreateDraft('')
}
}}
className="flex-1 rounded border border-border bg-bg px-1 py-0 text-[13px] text-text focus:border-primary focus:outline-none"
/>
</div>
)}
{/* Render Children */}
{hasChildren && isExpanded && (
<div>
@@ -488,6 +689,46 @@ export function LeftSidebar() {
</button>
</div>
)}
<DeleteFolderDialog
isOpen={!!deletingFolder}
folderName={deletingFolder?.name ?? ''}
photoCount={deletingFolder?.photoCount}
onClose={() => setDeletingFolder(null)}
onConfirm={(mode) => {
if (deletingFolder) {
deleteFolderMutation.mutate({ id: deletingFolder.id, mode })
}
}}
/>
</div>
)
}
function FolderMenuItem({
icon,
label,
onClick,
destructive = false,
}: {
icon: React.ReactNode
label: string
onClick: () => void
destructive?: boolean
}) {
return (
<button
role="menuitem"
onClick={onClick}
className={clsx(
'flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs transition-colors',
destructive
? 'text-reject hover:bg-reject/10'
: 'text-text hover:bg-surface-2'
)}
>
<span className="text-text-muted">{icon}</span>
{label}
</button>
)
}

View File

@@ -42,12 +42,40 @@ export const sourceFolders = {
return response.data
},
/** Rename the display label only — the on-disk path is controlled by
* the docker mount and cannot be changed from the UI. */
/** Rename a folder. SourceRoot ids only update the display label;
* Folder ids actually move the directory on disk and update every
* descendant photo's filepath. */
rename: async (folderId: string, name: string) => {
const response = await api.patch(`/folders/${folderId}`, { name })
return response.data
},
/** Create a new sub-folder under an existing Folder. parent_id MUST
* be a Folder row id (not a SourceRoot id). */
create: async (parentId: string, name: string) => {
const response = await api.post('/folders', {
name,
parent_id: parentId,
})
return response.data as { id: string; name: string; path: string; parent_id: string }
},
/** Delete a folder. mode=discard moves all photos under it to the
* discard pile (recoverable) and leaves the folder + on-disk dir
* alone. mode=permanent unlinks files, removes folder rows, and
* rmtrees the directory — irreversible. */
delete: async (folderId: string, mode: 'discard' | 'permanent') => {
const response = await api.delete(`/folders/${folderId}`, {
params: { mode },
})
return response.data as {
status: string
mode: string
discarded?: number
deleted_photos?: number
file_errors?: number
}
},
}
// Photos API