feat: drag photos onto Discarded sidebar node to discard them

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) <noreply@anthropic.com>
This commit is contained in:
2026-04-08 00:11:23 +02:00
parent 485b60ff20
commit 066acb64ec
2 changed files with 75 additions and 11 deletions

View File

@@ -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<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'),
})
// 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 (
<div key={item.id}>
<div
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',
depth > 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 ? (