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:
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}`
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user