feat: drag photos onto a folder to move them

Bulk-move via drag-and-drop. Drop a photo (or multi-selection) on
any folder row in the LeftSidebar and the files move on disk +
photo.folder_id updates atomically.

Backend
- New POST /photos/move accepting { photo_ids, target_id }. The
  target_id can be either a Folder id OR a SourceRoot id (the
  sidebar exposes source roots today, so the same drag target
  needs to resolve either).
- Resolves source roots to their on-disk path and looks up / creates
  the canonical Folder row via the existing scan get_or_create_folder
  helper, so dedupe + path normalization stay consistent with the
  scanner.
- Per-photo loop with shutil.move; per-file failures (target name
  collision, missing source, OS error) are collected into a
  structured `errors` array and don't abort the batch.
- Skips photos that are already in the target folder so re-drops
  are a no-op.

Frontend
- New photos.move(ids, targetId) helper in api.ts.
- LeftSidebar grows a moveDropMutation alongside the existing
  discard one. handleDrop dispatches by id prefix:
  'discarded' → discard, 'folder-{id}' → move.
- Folder rows now report acceptsDrop and get the same drag-over
  highlight as heap drops, in primary tint instead of reject.
- onSuccess invalidates both the photos query and the folders
  query so the new folder counts in the sidebar refresh.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-08 00:13:04 +02:00
parent 066acb64ec
commit 16481730b7
3 changed files with 137 additions and 3 deletions

View File

@@ -6,6 +6,7 @@ from datetime import datetime
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Query, Response from fastapi import APIRouter, Depends, HTTPException, Query, Response
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from pydantic import BaseModel
from sqlalchemy import select, and_, or_, func from sqlalchemy import select, and_, or_, func
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
import json import json
@@ -485,6 +486,100 @@ async def discard_photo(
return {"status": "success", "message": "Photo discarded"} 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") @router.post("/bulk")
async def bulk_action( async def bulk_action(
action: BulkAction, action: BulkAction,

View File

@@ -58,6 +58,28 @@ export function LeftSidebar() {
toast.error('Discard failed', e?.message || 'Unknown error'), 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. // 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)
@@ -204,12 +226,17 @@ export function LeftSidebar() {
// Which tree items accept photo drops, and what each does on drop. // Which tree items accept photo drops, and what each does on drop.
const isDropTarget = (id: string): boolean => { const isDropTarget = (id: string): boolean => {
return id === 'discarded' return id === 'discarded' || id.startsWith('folder-')
} }
const handleDrop = (id: string, ids: string[]) => { const handleDrop = (id: string, ids: string[]) => {
if (id === 'discarded') { if (id === 'discarded') {
discardDropMutation.mutate(ids) 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( className={clsx(
'group flex cursor-pointer items-center gap-1 rounded px-2 py-1 text-sm', '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', 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]' depth > 0 && 'text-[13px]'
)} )}
style={{ paddingLeft: `${8 + depth * 16}px` }} style={{ paddingLeft: `${8 + depth * 16}px` }}
@@ -241,7 +270,7 @@ export function LeftSidebar() {
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 = 'move' e.dataTransfer.dropEffect = item.id === 'discarded' ? 'move' : 'move'
if (dropTargetId !== item.id) setDropTargetId(item.id) if (dropTargetId !== item.id) setDropTargetId(item.id)
} }
} : undefined} } : undefined}

View File

@@ -87,6 +87,16 @@ export const photos = {
return response.data 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') => { getThumbnailUrl: (photoId: string, size: 'small' | 'medium' | 'large' = 'medium') => {
return `${API_BASE_URL}/photos/${photoId}/thumb/${size}` return `${API_BASE_URL}/photos/${photoId}/thumb/${size}`
}, },