diff --git a/backend/app/routers/photos.py b/backend/app/routers/photos.py index 9edcfb9..91f51e5 100644 --- a/backend/app/routers/photos.py +++ b/backend/app/routers/photos.py @@ -6,6 +6,7 @@ from datetime import datetime from pathlib import Path from fastapi import APIRouter, Depends, HTTPException, Query, Response from fastapi.responses import FileResponse +from pydantic import BaseModel from sqlalchemy import select, and_, or_, func from sqlalchemy.ext.asyncio import AsyncSession import json @@ -485,6 +486,100 @@ async def discard_photo( return {"status": "success", "message": "Photo discarded"} +class MoveRequest(BaseModel): + photo_ids: list[str] + target_id: str # folder id OR source root id + + +@router.post("/move") +async def move_photos( + body: MoveRequest, + db: AsyncSession = Depends(get_db), +): + """Move photos into a target folder. The target can be either a Folder + id or a SourceRoot id (since the LeftSidebar only exposes source roots + today). The handler resolves the target to an on-disk directory, calls + shutil.move for each photo, and updates photo.filepath + folder_id. + + Per-file failures (target name collision, missing source) are collected + and returned in the response so a single bad photo doesn't abort the + batch. + """ + 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 + # We need a Folder row to point photo.folder_id at. Reuse the + # scanner's get_or_create helper so we don't duplicate the dedupe + # / normalization logic. + 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", "moved": 0, "errors": []} + + # Fetch the photo rows + photos_result = await db.execute( + select(Photo).where(Photo.id.in_(body.photo_ids)) + ) + photos_to_move = photos_result.scalars().all() + + moved = 0 + errors: list[dict] = [] + + for photo in photos_to_move: + # Skip if already in the target folder. + if photo.folder_id == target_folder.id: + continue + + new_path = os.path.join(target_dir, photo.filename) + + if not os.path.exists(photo.filepath): + errors.append({"id": photo.id, "error": "source file missing"}) + continue + if os.path.exists(new_path): + errors.append({"id": photo.id, "error": f"name already exists in target: {photo.filename}"}) + continue + + try: + shutil.move(photo.filepath, new_path) + except OSError as e: + errors.append({"id": photo.id, "error": str(e)}) + continue + + photo.filepath = new_path + photo.folder_id = target_folder.id + moved += 1 + + await db.commit() + + return { + "status": "success", + "moved": moved, + "errors": errors, + } + + @router.post("/bulk") async def bulk_action( action: BulkAction, diff --git a/frontend/src/components/layout/LeftSidebar.tsx b/frontend/src/components/layout/LeftSidebar.tsx index 5fbc79f..ba3363c 100644 --- a/frontend/src/components/layout/LeftSidebar.tsx +++ b/frontend/src/components/layout/LeftSidebar.tsx @@ -58,6 +58,28 @@ export function LeftSidebar() { toast.error('Discard failed', e?.message || 'Unknown error'), }) + // Bulk move mutation for the drag-onto-folder interaction. + const moveDropMutation = useMutation({ + mutationFn: ({ targetId, photoIds }: { targetId: string; photoIds: string[] }) => + photosApi.move(photoIds, targetId), + onSuccess: (data) => { + const moved = data?.moved ?? 0 + const errCount = (data?.errors?.length ?? 0) + if (moved > 0) { + toast.success( + 'Moved', + `${moved} photo${moved > 1 ? 's' : ''}${errCount ? ` (${errCount} skipped)` : ''}` + ) + } else if (errCount > 0) { + toast.error('Move failed', `${errCount} file${errCount > 1 ? 's' : ''} could not be moved`) + } + queryClient.invalidateQueries({ queryKey: ['photos'] }) + queryClient.invalidateQueries({ queryKey: ['folders'] }) + }, + onError: (e: any) => + toast.error('Move failed', e?.response?.data?.detail || e.message || 'Unknown error'), + }) + // Reads the dragged ids out of a drop event payload. const readDragIds = (e: React.DragEvent): string[] | null => { const raw = e.dataTransfer.getData(PHOTO_DRAG_MIME) @@ -204,12 +226,17 @@ export function LeftSidebar() { // Which tree items accept photo drops, and what each does on drop. const isDropTarget = (id: string): boolean => { - return id === 'discarded' + return id === 'discarded' || id.startsWith('folder-') } const handleDrop = (id: string, ids: string[]) => { if (id === 'discarded') { discardDropMutation.mutate(ids) + return + } + if (id.startsWith('folder-')) { + const targetId = id.slice('folder-'.length) + moveDropMutation.mutate({ targetId, photoIds: ids }) } } @@ -226,7 +253,9 @@ export function LeftSidebar() { className={clsx( 'group flex cursor-pointer items-center gap-1 rounded px-2 py-1 text-sm', isSelected ? 'bg-primary/20 text-primary' : 'text-text hover:bg-surface-2', - isDropHover && 'ring-2 ring-reject bg-reject/10', + isDropHover && (item.id === 'discarded' + ? 'ring-2 ring-reject bg-reject/10' + : 'ring-2 ring-primary bg-primary/10'), depth > 0 && 'text-[13px]' )} style={{ paddingLeft: `${8 + depth * 16}px` }} @@ -241,7 +270,7 @@ export function LeftSidebar() { onDragOver={acceptsDrop ? (e) => { if (e.dataTransfer.types.includes(PHOTO_DRAG_MIME)) { e.preventDefault() - e.dataTransfer.dropEffect = 'move' + e.dataTransfer.dropEffect = item.id === 'discarded' ? 'move' : 'move' if (dropTargetId !== item.id) setDropTargetId(item.id) } } : undefined} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 3c1fbe0..b534b58 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -87,6 +87,16 @@ export const photos = { return response.data }, + /** Move photos into a target folder (or source root). Returns + * { moved, errors[] }. */ + move: async (photoIds: string[], targetId: string) => { + const response = await api.post('/photos/move', { + photo_ids: photoIds, + target_id: targetId, + }) + return response.data as { status: string; moved: number; errors: Array<{ id: string; error: string }> } + }, + getThumbnailUrl: (photoId: string, size: 'small' | 'medium' | 'large' = 'medium') => { return `${API_BASE_URL}/photos/${photoId}/thumb/${size}` },