diff --git a/backend/app/routers/folders.py b/backend/app/routers/folders.py index 4df4d56..a44a32b 100644 --- a/backend/app/routers/folders.py +++ b/backend/app/routers/folders.py @@ -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") diff --git a/frontend/src/components/dialogs/DeleteFolderDialog.tsx b/frontend/src/components/dialogs/DeleteFolderDialog.tsx new file mode 100644 index 0000000..7573a03 --- /dev/null +++ b/frontend/src/components/dialogs/DeleteFolderDialog.tsx @@ -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 ( +
+
+
+
+

+ Delete folder "{folderName}"? +

+

+ What should happen to {photoBlurb}? +

+ +
+ } + 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')} + /> + } + 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')} + /> +
+ +
+ + +
+
+
+
+ ) +} + +function ModeCard({ + icon, + title, + description, + selected, + destructive = false, + onClick, +}: { + icon: React.ReactNode + title: string + description: string + selected: boolean + destructive?: boolean + onClick: () => void +}) { + return ( + + ) +} diff --git a/frontend/src/components/layout/LeftSidebar.tsx b/frontend/src/components/layout/LeftSidebar.tsx index 623cee9..7987d2e 100644 --- a/frontend/src/components/layout/LeftSidebar.tsx +++ b/frontend/src/components/layout/LeftSidebar.tsx @@ -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(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(null) + const menuRef = useRef(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(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() {
) +} + +function FolderMenuItem({ + icon, + label, + onClick, + destructive = false, +}: { + icon: React.ReactNode + label: string + onClick: () => void + destructive?: boolean +}) { + return ( + + ) } \ No newline at end of file diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index a0beaaa..bcfa7bf 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -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