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:
@@ -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,10 +202,23 @@ 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}>
|
||||
@@ -186,6 +226,7 @@ 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',
|
||||
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 ? (
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user