diff --git a/frontend/src/components/layout/LeftSidebar.tsx b/frontend/src/components/layout/LeftSidebar.tsx index 9e0e627..5fbc79f 100644 --- a/frontend/src/components/layout/LeftSidebar.tsx +++ b/frontend/src/components/layout/LeftSidebar.tsx @@ -14,11 +14,12 @@ import { } from 'lucide-react' import clsx from 'clsx' 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 { toast } from '../ToastContainer' import { useFilterStore } from '../../store/filterStore' import { HeapsPanel } from '../heaps/HeapsPanel' +import { PHOTO_DRAG_MIME } from '../timeline/PhotoThumbnail' interface TreeItem { id: string @@ -41,6 +42,33 @@ export function LeftSidebar() { const setFlag = useFilterStore((s) => s.setFlag) const setFolderId = useFilterStore((s) => s.setFolderId) const filterFolderId = useFilterStore((s) => s.folderId) + const [dropTargetId, setDropTargetId] = useState(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'), + }) + + // 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 // the library tree is just a saved filter preset. @@ -66,7 +94,6 @@ export function LeftSidebar() { clearAllFilters() setFolderId(folderId) } - // 'by-date' is still visual-only. } } @@ -175,17 +202,31 @@ export function LeftSidebar() { return selectedItem === id } + // Which tree items accept photo drops, and what each does on drop. + const isDropTarget = (id: string): boolean => { + return id === 'discarded' + } + + const handleDrop = (id: string, ids: string[]) => { + if (id === 'discarded') { + discardDropMutation.mutate(ids) + } + } + const renderTreeItem = (item: TreeItem, depth: number = 0) => { const hasChildren = item.children && item.children.length > 0 const isExpanded = expandedItems.has(item.id) const isSelected = isItemActive(item.id) - + const acceptsDrop = isDropTarget(item.id) + const isDropHover = dropTargetId === item.id + return (
0 && 'text-[13px]' )} style={{ paddingLeft: `${8 + depth * 16}px` }} @@ -197,6 +238,24 @@ export function LeftSidebar() { applyLibraryNode(item.id) } }} + onDragOver={acceptsDrop ? (e) => { + if (e.dataTransfer.types.includes(PHOTO_DRAG_MIME)) { + e.preventDefault() + e.dataTransfer.dropEffect = '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 */} {hasChildren ? ( diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index ad475df..3c1fbe0 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -69,15 +69,20 @@ export const photos = { return response.data }, - bulkUpdate: async (photoIds: string[], data: { - rating?: number - flag?: string - heap_id?: string - discard?: boolean - }) => { + /** Bulk discard — matches the backend BulkAction schema. */ + bulkDiscard: async (photoIds: string[]) => { const response = await api.post('/photos/bulk', { - photo_ids: photoIds, - ...data, + ids: photoIds, + action: 'discard', + }) + 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 },