From 066acb64ec8675d17c8d89dafdbddb8f230d8953 Mon Sep 17 00:00:00 2001 From: dtoro Date: Wed, 8 Apr 2026 00:11:23 +0200 Subject: [PATCH] feat: drag photos onto Discarded sidebar node to discard them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same drag pattern as the heap drop, but the target is the Discarded library node. The dropped photos go to is_discarded=true via a single bulk request. Backend: the /photos/bulk endpoint already had a 'discard' action branch; the missing piece was a frontend client that sent the right shape. The previous photos.bulkUpdate sent { photo_ids, discard: true } against a backend that wanted { ids, action } — silently broken since day one. Replaced with two narrow helpers that match BulkAction exactly: photos.bulkDiscard(ids) and photos.bulkRestore(ids). Frontend: LeftSidebar grows a small dnd state machine — dropTargetId for the hovered row, isDropTarget(id) for which library nodes accept drops, handleDrop(id, ids) for the dispatch. Today only the 'discarded' node is wired; folder rows for bulk move come next. Drop highlight uses the reject ring/tint to match the destructive nature of the action. Toast confirms; photos query is invalidated so the timeline immediately drops them. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/components/layout/LeftSidebar.tsx | 65 ++++++++++++++++++- frontend/src/services/api.ts | 21 +++--- 2 files changed, 75 insertions(+), 11 deletions(-) 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 },