Compare commits
4 Commits
485b60ff20
...
a0c41e38d3
| Author | SHA1 | Date | |
|---|---|---|---|
| a0c41e38d3 | |||
| 7d33e1688a | |||
| 16481730b7 | |||
| 066acb64ec |
@@ -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,
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
ChevronDown,
|
ChevronDown,
|
||||||
Folder,
|
Folder,
|
||||||
Image,
|
Image,
|
||||||
Calendar,
|
|
||||||
Star,
|
Star,
|
||||||
Trash2,
|
Trash2,
|
||||||
Plus,
|
Plus,
|
||||||
@@ -14,11 +13,12 @@ import {
|
|||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { AddSourceFolderDialog } from '../dialogs/AddSourceFolderDialog'
|
import { AddSourceFolderDialog } from '../dialogs/AddSourceFolderDialog'
|
||||||
import { sourceFolders, library } from '../../services/api'
|
import { sourceFolders, library, photos as photosApi } from '../../services/api'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { toast } from '../ToastContainer'
|
import { toast } from '../ToastContainer'
|
||||||
import { useFilterStore } from '../../store/filterStore'
|
import { useFilterStore } from '../../store/filterStore'
|
||||||
import { HeapsPanel } from '../heaps/HeapsPanel'
|
import { HeapsPanel } from '../heaps/HeapsPanel'
|
||||||
|
import { PHOTO_DRAG_MIME } from '../timeline/PhotoThumbnail'
|
||||||
|
|
||||||
interface TreeItem {
|
interface TreeItem {
|
||||||
id: string
|
id: string
|
||||||
@@ -41,6 +41,55 @@ export function LeftSidebar() {
|
|||||||
const setFlag = useFilterStore((s) => s.setFlag)
|
const setFlag = useFilterStore((s) => s.setFlag)
|
||||||
const setFolderId = useFilterStore((s) => s.setFolderId)
|
const setFolderId = useFilterStore((s) => s.setFolderId)
|
||||||
const filterFolderId = useFilterStore((s) => s.folderId)
|
const filterFolderId = useFilterStore((s) => s.folderId)
|
||||||
|
const [dropTargetId, setDropTargetId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Bulk discard mutation for the drag-onto-Discarded interaction.
|
||||||
|
const discardDropMutation = useMutation({
|
||||||
|
mutationFn: (photoIds: string[]) => photosApi.bulkDiscard(photoIds),
|
||||||
|
onSuccess: (_data, photoIds) => {
|
||||||
|
toast.success(
|
||||||
|
'Discarded',
|
||||||
|
`${photoIds.length} photo${photoIds.length > 1 ? 's' : ''}`
|
||||||
|
)
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||||
|
},
|
||||||
|
onError: (e: any) =>
|
||||||
|
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)
|
||||||
|
if (!raw) return null
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as string[]
|
||||||
|
return Array.isArray(parsed) && parsed.length > 0 ? parsed : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Map a library tree id to a filter-store mutation. Each "virtual node" in
|
// Map a library tree id to a filter-store mutation. Each "virtual node" in
|
||||||
// the library tree is just a saved filter preset.
|
// the library tree is just a saved filter preset.
|
||||||
@@ -66,7 +115,6 @@ export function LeftSidebar() {
|
|||||||
clearAllFilters()
|
clearAllFilters()
|
||||||
setFolderId(folderId)
|
setFolderId(folderId)
|
||||||
}
|
}
|
||||||
// 'by-date' is still visual-only.
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,7 +190,6 @@ export function LeftSidebar() {
|
|||||||
icon: <HardDrive className="h-4 w-4" />,
|
icon: <HardDrive className="h-4 w-4" />,
|
||||||
children: [
|
children: [
|
||||||
{ id: 'all-photos', label: 'All Photos', icon: <Image className="h-4 w-4" />, count: 0 },
|
{ id: 'all-photos', label: 'All Photos', icon: <Image className="h-4 w-4" />, count: 0 },
|
||||||
{ id: 'by-date', label: 'By Date', icon: <Calendar className="h-4 w-4" /> },
|
|
||||||
{ id: 'rated', label: 'Rated', icon: <Star className="h-4 w-4" />, count: 0 },
|
{ id: 'rated', label: 'Rated', icon: <Star className="h-4 w-4" />, count: 0 },
|
||||||
{ id: 'discarded', label: 'Discarded', icon: <Trash2 className="h-4 w-4" />, count: 0 },
|
{ id: 'discarded', label: 'Discarded', icon: <Trash2 className="h-4 w-4" />, count: 0 },
|
||||||
],
|
],
|
||||||
@@ -175,17 +222,38 @@ export function LeftSidebar() {
|
|||||||
return selectedItem === id
|
return selectedItem === id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Which tree items accept photo drops, and what each does on drop.
|
||||||
|
const isDropTarget = (id: string): boolean => {
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const renderTreeItem = (item: TreeItem, depth: number = 0) => {
|
const renderTreeItem = (item: TreeItem, depth: number = 0) => {
|
||||||
const hasChildren = item.children && item.children.length > 0
|
const hasChildren = item.children && item.children.length > 0
|
||||||
const isExpanded = expandedItems.has(item.id)
|
const isExpanded = expandedItems.has(item.id)
|
||||||
const isSelected = isItemActive(item.id)
|
const isSelected = isItemActive(item.id)
|
||||||
|
const acceptsDrop = isDropTarget(item.id)
|
||||||
|
const isDropHover = dropTargetId === item.id
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={item.id}>
|
<div key={item.id}>
|
||||||
<div
|
<div
|
||||||
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 && (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` }}
|
||||||
@@ -197,6 +265,24 @@ export function LeftSidebar() {
|
|||||||
applyLibraryNode(item.id)
|
applyLibraryNode(item.id)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
onDragOver={acceptsDrop ? (e) => {
|
||||||
|
if (e.dataTransfer.types.includes(PHOTO_DRAG_MIME)) {
|
||||||
|
e.preventDefault()
|
||||||
|
e.dataTransfer.dropEffect = item.id === 'discarded' ? 'move' : 'move'
|
||||||
|
if (dropTargetId !== item.id) setDropTargetId(item.id)
|
||||||
|
}
|
||||||
|
} : undefined}
|
||||||
|
onDragLeave={acceptsDrop ? (e) => {
|
||||||
|
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
|
||||||
|
if (dropTargetId === item.id) setDropTargetId(null)
|
||||||
|
}
|
||||||
|
} : undefined}
|
||||||
|
onDrop={acceptsDrop ? (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setDropTargetId(null)
|
||||||
|
const ids = readDragIds(e)
|
||||||
|
if (ids) handleDrop(item.id, ids)
|
||||||
|
} : undefined}
|
||||||
>
|
>
|
||||||
{/* Expand/Collapse Icon */}
|
{/* Expand/Collapse Icon */}
|
||||||
{hasChildren ? (
|
{hasChildren ? (
|
||||||
|
|||||||
@@ -69,19 +69,34 @@ export const photos = {
|
|||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
bulkUpdate: async (photoIds: string[], data: {
|
/** Bulk discard — matches the backend BulkAction schema. */
|
||||||
rating?: number
|
bulkDiscard: async (photoIds: string[]) => {
|
||||||
flag?: string
|
|
||||||
heap_id?: string
|
|
||||||
discard?: boolean
|
|
||||||
}) => {
|
|
||||||
const response = await api.post('/photos/bulk', {
|
const response = await api.post('/photos/bulk', {
|
||||||
photo_ids: photoIds,
|
ids: photoIds,
|
||||||
...data,
|
action: 'discard',
|
||||||
})
|
})
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Bulk restore from discarded. */
|
||||||
|
bulkRestore: async (photoIds: string[]) => {
|
||||||
|
const response = await api.post('/photos/bulk', {
|
||||||
|
ids: photoIds,
|
||||||
|
action: 'restore',
|
||||||
|
})
|
||||||
|
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}`
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user