feat: watcher source-root resolution, folder rename, alt-drag copy
Three small phase-11 follow-ups in one commit since they all touch the
same surface area.
1. Watcher source-root resolution
The watch_folders task previously called scan_folder.delay(parent_dir)
when files arrived, with no source_root_id. scan_folder would then
auto-create a fresh SourceRoot for that arbitrary subdir, polluting
the source_root list. Now the watcher loads (path, id) pairs at
startup, defines find_source_root_for() that walks the parent chain,
and dispatches with the resolved id. Events under no known root are
logged at debug and ignored instead of creating stale rows.
2. Folder rename via UI
- Backend: PATCH /folders/{id} accepts { name } and updates the
SourceRoot display label only. The on-disk path is controlled by
the docker mount and intentionally not editable from the UI.
- Frontend: double-click a folder row in the LeftSidebar to start
editing; Enter or blur commits, Esc reverts. New renamingId /
renameDraft local state and a renameMutation that invalidates
['folders']. The click handler ignores clicks while the row is
in edit mode so it doesn't navigate.
- api.ts: new sourceFolders.rename(id, name) helper.
3. Bulk copy via Alt-drag onto folder
- Backend: new POST /photos/copy that mirrors /photos/move but uses
shutil.copy2 and creates fresh Photo rows with is_duplicate=true.
Name collisions are resolved by appending " (copy)", " (copy 2)",
etc., up to 100 tries before erroring. Same target_id resolution
as /move (folder id or source root id).
- Frontend: photos.copy(ids, targetId) helper. LeftSidebar's
handleDrop now takes a `copy` flag derived from e.altKey on the
drop event; folder targets dispatch copyDropMutation when held,
moveDropMutation otherwise. The drop-effect cursor flips to
'copy' on dragover when Alt is pressed so the user gets visual
confirmation. Discard target ignores the modifier.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,11 @@
|
|||||||
"""
|
"""
|
||||||
Folders API router. Source roots are config-driven (PHOTO_DIRS in .env →
|
Folders API router. Source roots themselves are config-driven (PHOTO_DIRS
|
||||||
backend bootstrap on startup); this router only exposes read access and a
|
in .env → backend bootstrap on startup) — adding or removing one is a
|
||||||
manual rescan trigger. Adding/removing source roots happens by editing
|
docker-compose change. The UI can read the list, trigger a manual rescan,
|
||||||
docker-compose.yml + .env and restarting the stack.
|
and rename the display label, but it can't change the on-disk path.
|
||||||
"""
|
"""
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
import os
|
import os
|
||||||
@@ -14,6 +15,10 @@ from app.models import Folder, SourceRoot
|
|||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class FolderRename(BaseModel):
|
||||||
|
name: str
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
async def get_folders(db: AsyncSession = Depends(get_db)):
|
async def get_folders(db: AsyncSession = Depends(get_db)):
|
||||||
"""Get all source folders"""
|
"""Get all source folders"""
|
||||||
@@ -39,6 +44,28 @@ async def get_folders(db: AsyncSession = Depends(get_db)):
|
|||||||
|
|
||||||
return {"folders": folders_list}
|
return {"folders": folders_list}
|
||||||
|
|
||||||
|
@router.patch("/{folder_id}")
|
||||||
|
async def rename_folder(
|
||||||
|
folder_id: str,
|
||||||
|
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")
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
source_root.name = name
|
||||||
|
await db.commit()
|
||||||
|
return {"id": source_root.id, "name": source_root.name, "path": source_root.path}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{folder_id}/scan")
|
@router.post("/{folder_id}/scan")
|
||||||
async def scan_folder(folder_id: str, db: AsyncSession = Depends(get_db)):
|
async def scan_folder(folder_id: str, db: AsyncSession = Depends(get_db)):
|
||||||
"""Trigger manual re-scan of source root folder"""
|
"""Trigger manual re-scan of source root folder"""
|
||||||
|
|||||||
@@ -491,6 +491,129 @@ class MoveRequest(BaseModel):
|
|||||||
target_id: str # folder id OR source root id
|
target_id: str # folder id OR source root id
|
||||||
|
|
||||||
|
|
||||||
|
class CopyRequest(BaseModel):
|
||||||
|
photo_ids: list[str]
|
||||||
|
target_id: str # folder id OR source root id
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/copy")
|
||||||
|
async def copy_photos(
|
||||||
|
body: CopyRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Copy photos into a target folder. Same target resolution as /move
|
||||||
|
(folder id or source root id), but uses shutil.copy2 and creates new
|
||||||
|
Photo rows for each copied file. Original photos are unaffected.
|
||||||
|
|
||||||
|
Each new row gets is_duplicate=true so the user can spot the
|
||||||
|
duplicates later. The new file's name is suffixed with " (copy)" if
|
||||||
|
a name collision would otherwise happen, and " (copy 2)", etc., for
|
||||||
|
further conflicts.
|
||||||
|
"""
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
# Resolve target_id → (target_dir, target_folder)
|
||||||
|
sr_check = await db.execute(
|
||||||
|
select(SourceRoot).where(SourceRoot.id == body.target_id)
|
||||||
|
)
|
||||||
|
source_root = sr_check.scalar_one_or_none()
|
||||||
|
|
||||||
|
if source_root is not None:
|
||||||
|
target_dir = source_root.path
|
||||||
|
from app.tasks.scan import get_or_create_folder
|
||||||
|
target_folder = await get_or_create_folder(db, target_dir, source_root.id)
|
||||||
|
else:
|
||||||
|
folder_check = await db.execute(
|
||||||
|
select(Folder).where(Folder.id == body.target_id)
|
||||||
|
)
|
||||||
|
target_folder = folder_check.scalar_one_or_none()
|
||||||
|
if target_folder is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Target folder not found")
|
||||||
|
target_dir = target_folder.path
|
||||||
|
|
||||||
|
if not os.path.isdir(target_dir):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Target directory does not exist: {target_dir}",
|
||||||
|
)
|
||||||
|
|
||||||
|
if not body.photo_ids:
|
||||||
|
return {"status": "success", "copied": 0, "errors": []}
|
||||||
|
|
||||||
|
photos_result = await db.execute(
|
||||||
|
select(Photo).where(Photo.id.in_(body.photo_ids))
|
||||||
|
)
|
||||||
|
photos_to_copy = photos_result.scalars().all()
|
||||||
|
|
||||||
|
copied = 0
|
||||||
|
errors: list[dict] = []
|
||||||
|
|
||||||
|
def _unique_target_name(directory: str, filename: str) -> Optional[str]:
|
||||||
|
"""Find a non-colliding filename in `directory` based on `filename`,
|
||||||
|
suffixing " (copy)", " (copy 2)", ... if needed. Gives up after 100
|
||||||
|
attempts."""
|
||||||
|
if not os.path.exists(os.path.join(directory, filename)):
|
||||||
|
return filename
|
||||||
|
stem, ext = os.path.splitext(filename)
|
||||||
|
for i in range(1, 100):
|
||||||
|
candidate = f"{stem} (copy{'' if i == 1 else f' {i}'}){ext}"
|
||||||
|
if not os.path.exists(os.path.join(directory, candidate)):
|
||||||
|
return candidate
|
||||||
|
return None
|
||||||
|
|
||||||
|
for photo in photos_to_copy:
|
||||||
|
if not os.path.exists(photo.filepath):
|
||||||
|
errors.append({"id": photo.id, "error": "source file missing"})
|
||||||
|
continue
|
||||||
|
|
||||||
|
new_name = _unique_target_name(target_dir, photo.filename)
|
||||||
|
if new_name is None:
|
||||||
|
errors.append({"id": photo.id, "error": "too many name collisions"})
|
||||||
|
continue
|
||||||
|
|
||||||
|
new_path = os.path.join(target_dir, new_name)
|
||||||
|
|
||||||
|
try:
|
||||||
|
shutil.copy2(photo.filepath, new_path)
|
||||||
|
except OSError as e:
|
||||||
|
errors.append({"id": photo.id, "error": str(e)})
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Create a new Photo row pointing at the copy. Most metadata is
|
||||||
|
# copied verbatim; the file_hash stays so the duplicate flag does
|
||||||
|
# the right thing across the library.
|
||||||
|
new_photo = Photo(
|
||||||
|
filepath=new_path,
|
||||||
|
filename=new_name,
|
||||||
|
folder_id=target_folder.id,
|
||||||
|
file_hash=photo.file_hash,
|
||||||
|
media_type=photo.media_type,
|
||||||
|
original_format=photo.original_format,
|
||||||
|
width=photo.width,
|
||||||
|
height=photo.height,
|
||||||
|
file_size=photo.file_size,
|
||||||
|
taken_at=photo.taken_at,
|
||||||
|
taken_at_source=photo.taken_at_source,
|
||||||
|
user_title=photo.user_title,
|
||||||
|
user_notes=photo.user_notes,
|
||||||
|
rating=photo.rating,
|
||||||
|
color_label=photo.color_label,
|
||||||
|
exif_json=photo.exif_json,
|
||||||
|
is_duplicate=True,
|
||||||
|
processing_status='pending',
|
||||||
|
)
|
||||||
|
db.add(new_photo)
|
||||||
|
copied += 1
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"copied": copied,
|
||||||
|
"errors": errors,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/move")
|
@router.post("/move")
|
||||||
async def move_photos(
|
async def move_photos(
|
||||||
body: MoveRequest,
|
body: MoveRequest,
|
||||||
|
|||||||
@@ -361,42 +361,61 @@ def watch_folders():
|
|||||||
from watchfiles import watch
|
from watchfiles import watch
|
||||||
|
|
||||||
# Read source roots from the DB instead of the (now-removed) YAML
|
# Read source roots from the DB instead of the (now-removed) YAML
|
||||||
# config. Synchronous lookup is fine here — this runs once at task
|
# config. We need both the path and the id so we can dispatch
|
||||||
# start, not on every event.
|
# scan_folder with the source_root_id when an event fires.
|
||||||
paths: list[str] = []
|
roots: list[tuple[str, str]] = []
|
||||||
try:
|
try:
|
||||||
async def _load_paths():
|
async def _load_roots():
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(SourceRoot).where(SourceRoot.is_active == True) # noqa: E712
|
select(SourceRoot).where(SourceRoot.is_active == True) # noqa: E712
|
||||||
)
|
)
|
||||||
return [
|
return [
|
||||||
sr.path for sr in result.scalars().all()
|
(os.path.normpath(sr.path), sr.id)
|
||||||
|
for sr in result.scalars().all()
|
||||||
if os.path.exists(sr.path)
|
if os.path.exists(sr.path)
|
||||||
]
|
]
|
||||||
paths = asyncio.run(_load_paths())
|
roots = asyncio.run(_load_roots())
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"watch_folders could not load source roots: {e}")
|
logger.error(f"watch_folders could not load source roots: {e}")
|
||||||
return
|
return
|
||||||
|
|
||||||
if not paths:
|
if not roots:
|
||||||
logger.warning("No valid source roots to watch")
|
logger.warning("No valid source roots to watch")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
paths = [p for p, _ in roots]
|
||||||
logger.info(f"Starting folder watcher for: {paths}")
|
logger.info(f"Starting folder watcher for: {paths}")
|
||||||
|
|
||||||
|
def find_source_root_for(path: str) -> Optional[str]:
|
||||||
|
"""Return the source_root id whose path contains `path`, or None."""
|
||||||
|
normalized = os.path.normpath(path)
|
||||||
|
for root_path, root_id in roots:
|
||||||
|
if normalized == root_path or normalized.startswith(root_path + os.sep):
|
||||||
|
return root_id
|
||||||
|
return None
|
||||||
|
|
||||||
for changes in watch(*paths):
|
for changes in watch(*paths):
|
||||||
for change_type, filepath in changes:
|
for change_type, filepath in changes:
|
||||||
filepath = str(filepath)
|
filepath = str(filepath)
|
||||||
|
|
||||||
# Check if it's a supported file type
|
# Check if it's a supported file type
|
||||||
if Path(filepath).suffix.lower() not in SUPPORTED_EXTENSIONS:
|
if Path(filepath).suffix.lower() not in SUPPORTED_EXTENSIONS:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if change_type == 'added' or change_type == 'modified':
|
if change_type == 'added' or change_type == 'modified':
|
||||||
# Queue scan for the parent folder
|
# Queue scan for the parent folder, with the source_root_id
|
||||||
|
# resolved by ancestor lookup so scan_folder doesn't
|
||||||
|
# auto-create a new SourceRoot for an arbitrary subdir.
|
||||||
parent_dir = str(Path(filepath).parent)
|
parent_dir = str(Path(filepath).parent)
|
||||||
scan_folder.delay(parent_dir)
|
source_root_id = find_source_root_for(parent_dir)
|
||||||
|
if source_root_id is None:
|
||||||
|
logger.debug(
|
||||||
|
f"watcher event for {filepath}: parent {parent_dir} "
|
||||||
|
f"not under any active source root, ignoring"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
scan_folder.delay(parent_dir, source_root_id)
|
||||||
logger.info(f"File {change_type}: {filepath}, queued scan for {parent_dir}")
|
logger.info(f"File {change_type}: {filepath}, queued scan for {parent_dir}")
|
||||||
elif change_type == 'deleted':
|
elif change_type == 'deleted':
|
||||||
# Handle file deletion
|
# Handle file deletion
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ export function LeftSidebar() {
|
|||||||
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set(['library', 'folders', 'heaps']))
|
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set(['library', 'folders', 'heaps']))
|
||||||
const [selectedItem, setSelectedItem] = useState<string | null>('all-photos')
|
const [selectedItem, setSelectedItem] = useState<string | null>('all-photos')
|
||||||
const [isScanning, setIsScanning] = useState(false)
|
const [isScanning, setIsScanning] = useState(false)
|
||||||
|
// Inline rename state for source-root rows. Stores the id being edited
|
||||||
|
// and the draft name. Double-click a folder row to start.
|
||||||
|
const [renamingId, setRenamingId] = useState<string | null>(null)
|
||||||
|
const [renameDraft, setRenameDraft] = useState('')
|
||||||
|
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const clearAllFilters = useFilterStore((s) => s.clearAll)
|
const clearAllFilters = useFilterStore((s) => s.clearAll)
|
||||||
@@ -76,6 +80,28 @@ export function LeftSidebar() {
|
|||||||
toast.error('Move failed', e?.response?.data?.detail || e.message || 'Unknown error'),
|
toast.error('Move failed', e?.response?.data?.detail || e.message || 'Unknown error'),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Bulk copy mutation — Alt-drag uses this instead of move.
|
||||||
|
const copyDropMutation = useMutation({
|
||||||
|
mutationFn: ({ targetId, photoIds }: { targetId: string; photoIds: string[] }) =>
|
||||||
|
photosApi.copy(photoIds, targetId),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
const copied = data?.copied ?? 0
|
||||||
|
const errCount = data?.errors?.length ?? 0
|
||||||
|
if (copied > 0) {
|
||||||
|
toast.success(
|
||||||
|
'Copied',
|
||||||
|
`${copied} photo${copied > 1 ? 's' : ''}${errCount ? ` (${errCount} skipped)` : ''}`
|
||||||
|
)
|
||||||
|
} else if (errCount > 0) {
|
||||||
|
toast.error('Copy failed', `${errCount} file${errCount > 1 ? 's' : ''} could not be copied`)
|
||||||
|
}
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['folders'] })
|
||||||
|
},
|
||||||
|
onError: (e: any) =>
|
||||||
|
toast.error('Copy failed', e?.response?.data?.detail || e.message || 'Unknown error'),
|
||||||
|
})
|
||||||
|
|
||||||
// Reads the dragged ids out of a drop event payload.
|
// Reads the dragged ids out of a drop event payload.
|
||||||
const readDragIds = (e: React.DragEvent): string[] | null => {
|
const readDragIds = (e: React.DragEvent): string[] | null => {
|
||||||
const raw = e.dataTransfer.getData(PHOTO_DRAG_MIME)
|
const raw = e.dataTransfer.getData(PHOTO_DRAG_MIME)
|
||||||
@@ -121,6 +147,16 @@ export function LeftSidebar() {
|
|||||||
queryFn: sourceFolders.list,
|
queryFn: sourceFolders.list,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const renameMutation = useMutation({
|
||||||
|
mutationFn: ({ id, name }: { id: string; name: string }) =>
|
||||||
|
sourceFolders.rename(id, name),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['folders'] })
|
||||||
|
},
|
||||||
|
onError: (e: any) =>
|
||||||
|
toast.error('Rename failed', e?.response?.data?.detail || e.message || 'Unknown error'),
|
||||||
|
})
|
||||||
|
|
||||||
// Mutation for scanning all folders
|
// Mutation for scanning all folders
|
||||||
const scanLibraryMutation = useMutation({
|
const scanLibraryMutation = useMutation({
|
||||||
mutationFn: library.scan,
|
mutationFn: library.scan,
|
||||||
@@ -199,14 +235,18 @@ export function LeftSidebar() {
|
|||||||
return id === 'discarded' || id.startsWith('folder-')
|
return id === 'discarded' || id.startsWith('folder-')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDrop = (id: string, ids: string[]) => {
|
const handleDrop = (id: string, ids: string[], copy: boolean) => {
|
||||||
if (id === 'discarded') {
|
if (id === 'discarded') {
|
||||||
discardDropMutation.mutate(ids)
|
discardDropMutation.mutate(ids)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (id.startsWith('folder-')) {
|
if (id.startsWith('folder-')) {
|
||||||
const targetId = id.slice('folder-'.length)
|
const targetId = id.slice('folder-'.length)
|
||||||
moveDropMutation.mutate({ targetId, photoIds: ids })
|
if (copy) {
|
||||||
|
copyDropMutation.mutate({ targetId, photoIds: ids })
|
||||||
|
} else {
|
||||||
|
moveDropMutation.mutate({ targetId, photoIds: ids })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,6 +270,7 @@ export function LeftSidebar() {
|
|||||||
)}
|
)}
|
||||||
style={{ paddingLeft: `${8 + depth * 16}px` }}
|
style={{ paddingLeft: `${8 + depth * 16}px` }}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
if (renamingId === item.id) return
|
||||||
setSelectedItem(item.id)
|
setSelectedItem(item.id)
|
||||||
if (hasChildren) {
|
if (hasChildren) {
|
||||||
toggleExpanded(item.id)
|
toggleExpanded(item.id)
|
||||||
@@ -237,10 +278,22 @@ export function LeftSidebar() {
|
|||||||
applyLibraryNode(item.id)
|
applyLibraryNode(item.id)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
onDoubleClick={
|
||||||
|
item.id.startsWith('folder-')
|
||||||
|
? (e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setRenamingId(item.id)
|
||||||
|
setRenameDraft(item.label)
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
onDragOver={acceptsDrop ? (e) => {
|
onDragOver={acceptsDrop ? (e) => {
|
||||||
if (e.dataTransfer.types.includes(PHOTO_DRAG_MIME)) {
|
if (e.dataTransfer.types.includes(PHOTO_DRAG_MIME)) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.dataTransfer.dropEffect = item.id === 'discarded' ? 'move' : 'move'
|
// Alt held → copy (only meaningful for folder targets;
|
||||||
|
// discarding doesn't copy).
|
||||||
|
const wantCopy = e.altKey && item.id.startsWith('folder-')
|
||||||
|
e.dataTransfer.dropEffect = wantCopy ? 'copy' : 'move'
|
||||||
if (dropTargetId !== item.id) setDropTargetId(item.id)
|
if (dropTargetId !== item.id) setDropTargetId(item.id)
|
||||||
}
|
}
|
||||||
} : undefined}
|
} : undefined}
|
||||||
@@ -253,7 +306,7 @@ export function LeftSidebar() {
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setDropTargetId(null)
|
setDropTargetId(null)
|
||||||
const ids = readDragIds(e)
|
const ids = readDragIds(e)
|
||||||
if (ids) handleDrop(item.id, ids)
|
if (ids) handleDrop(item.id, ids, e.altKey)
|
||||||
} : undefined}
|
} : undefined}
|
||||||
>
|
>
|
||||||
{/* Expand/Collapse Icon */}
|
{/* Expand/Collapse Icon */}
|
||||||
@@ -282,8 +335,34 @@ export function LeftSidebar() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Label */}
|
{/* Label (or inline rename input for folder rows) */}
|
||||||
<span className="flex-1 truncate">{item.label}</span>
|
{renamingId === item.id ? (
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
type="text"
|
||||||
|
value={renameDraft}
|
||||||
|
onChange={(e) => setRenameDraft(e.target.value)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onBlur={() => {
|
||||||
|
const next = renameDraft.trim()
|
||||||
|
const id = item.id.slice('folder-'.length)
|
||||||
|
if (next && next !== item.label) {
|
||||||
|
renameMutation.mutate({ id, name: next })
|
||||||
|
}
|
||||||
|
setRenamingId(null)
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.currentTarget.blur()
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
setRenamingId(null)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="flex-1 rounded border border-border bg-bg px-1 py-0 text-[13px] text-text focus:border-primary focus:outline-none"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="flex-1 truncate">{item.label}</span>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Count Badge */}
|
{/* Count Badge */}
|
||||||
{item.count !== undefined && item.count > 0 && (
|
{item.count !== undefined && item.count > 0 && (
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ const api = axios.create({
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Source Folders API. Source roots are config-driven now (PHOTO_DIRS in
|
// Source Folders API. Source roots are config-driven now (PHOTO_DIRS in
|
||||||
// .env → bootstrap on backend startup), so the UI only reads them.
|
// .env → bootstrap on backend startup), so the UI only reads them and
|
||||||
|
// optionally renames the display label.
|
||||||
export const sourceFolders = {
|
export const sourceFolders = {
|
||||||
list: async () => {
|
list: async () => {
|
||||||
const response = await api.get('/folders')
|
const response = await api.get('/folders')
|
||||||
@@ -21,6 +22,13 @@ export const sourceFolders = {
|
|||||||
const response = await api.post(`/folders/${folderId}/scan`)
|
const response = await api.post(`/folders/${folderId}/scan`)
|
||||||
return response.data
|
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: async (folderId: string, name: string) => {
|
||||||
|
const response = await api.patch(`/folders/${folderId}`, { name })
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Photos API
|
// Photos API
|
||||||
@@ -84,6 +92,16 @@ export const photos = {
|
|||||||
return response.data as { status: string; moved: number; errors: Array<{ id: string; error: string }> }
|
return response.data as { status: string; moved: number; errors: Array<{ id: string; error: string }> }
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Copy photos into a target folder. Originals are unaffected; new
|
||||||
|
* rows are created with is_duplicate=true. */
|
||||||
|
copy: async (photoIds: string[], targetId: string) => {
|
||||||
|
const response = await api.post('/photos/copy', {
|
||||||
|
photo_ids: photoIds,
|
||||||
|
target_id: targetId,
|
||||||
|
})
|
||||||
|
return response.data as { status: string; copied: number; errors: Array<{ id: string; error: string }> }
|
||||||
|
},
|
||||||
|
|
||||||
getThumbnailUrl: (photoId: string, size: 'small' | 'medium' | 'large' = 'medium') => {
|
getThumbnailUrl: (photoId: string, size: 'small' | 'medium' | 'large' = 'medium') => {
|
||||||
return `${API_BASE_URL}/photos/${photoId}/thumb/${size}`
|
return `${API_BASE_URL}/photos/${photoId}/thumb/${size}`
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user