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

@@ -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}