feat: drag photos onto a heap row to add them

Lightroom-style direct manipulation: pick up a photo (or a multi-
selection) and drop it on a heap to add it. Complements the P
shortcut without replacing it.

PhotoThumbnail
- Becomes draggable. onDragStart reads the current selection from
  the photo store at fire time: if the dragged photo is part of the
  selection, the payload is the whole selection; otherwise it's
  just that one photo. Matches Finder semantics.
- Payload uses a custom MIME (application/x-mulita-photos) so the
  drop target can recognise our drags vs. arbitrary file drags from
  the OS. Also sets text/plain so dropping outside the app shows a
  sensible "N photos" string.

HeapsPanel
- Each heap row is now a drop target. onDragOver previews the drop
  effect and highlights the row with a primary ring and a faint
  background tint. onDragLeave only clears the highlight if the
  cursor actually left the row (not just moved over a child).
- New dropMutation handles the drop: optimistic membership cache
  update so the basket affordance flips immediately, rollback on
  error from a captured `previous`, success toast naming the heap
  and the count of newly-added photos, onSettled invalidation of
  heaps + heap-photo-ids + photos so server truth re-syncs.

PhotoThumbnail's title attribute now mentions the drag affordance
alongside click/double-click/shift+click/ctrl+click hints.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-07 23:59:29 +02:00
parent 9729391dcc
commit 485b60ff20
2 changed files with 99 additions and 2 deletions

View File

@@ -13,6 +13,7 @@ import { useHeapsQuery, HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
import { heaps as heapsApi } from '../../services/api'
import { useFilterStore } from '../../store/filterStore'
import { toast } from '../ToastContainer'
import { PHOTO_DRAG_MIME } from '../timeline/PhotoThumbnail'
/**
* Heaps panel for the left sidebar. Renders the list of heaps with the
@@ -33,6 +34,9 @@ export function HeapsPanel() {
const [expanded, setExpanded] = useState(true)
const [creating, setCreating] = useState(false)
const [newName, setNewName] = useState('')
// Which heap row is currently being hovered with a drag — used to render
// the drop highlight ring. Only one heap can be the target at a time.
const [dropTargetId, setDropTargetId] = useState<string | null>(null)
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: HEAPS_QUERY_KEY })
@@ -71,6 +75,47 @@ export function HeapsPanel() {
toast.error('Failed to delete heap', e.message || 'Unknown error'),
})
// Drop handler: add the dragged photos to the target heap. Optimistically
// updates the membership cache so the basket affordance flips immediately,
// mirroring the keyboard P-toggle pattern.
const dropMutation = useMutation({
mutationFn: ({ heapId, photoIds }: { heapId: string; photoIds: string[] }) =>
heapsApi.addPhotos(heapId, photoIds),
onMutate: ({ heapId, photoIds }) => {
const key = ['heap-photo-ids', heapId] as const
const previous = queryClient.getQueryData<string[]>(key)
const set = new Set(previous ?? [])
photoIds.forEach((id) => set.add(id))
queryClient.setQueryData<string[]>(key, Array.from(set))
return { previous }
},
onError: (e: any, vars, ctx) => {
if (ctx?.previous) {
queryClient.setQueryData(['heap-photo-ids', vars.heapId], ctx.previous)
}
toast.error('Failed to add to heap', e.message || 'Unknown error')
},
onSuccess: (data, vars) => {
const heap = heaps.find((h) => h.id === vars.heapId)
const heapName = heap?.name ?? 'heap'
const added = data?.added ?? 0
const already = data?.already_present ?? 0
if (added > 0) {
toast.success(
`Added to ${heapName}`,
`${added} photo${added > 1 ? 's' : ''}${already > 0 ? ` (${already} already present)` : ''}`
)
} else if (already > 0) {
toast.info(`Already in ${heapName}`, `${already} photo${already > 1 ? 's' : ''}`)
}
},
onSettled: (_d, _e, vars) => {
queryClient.invalidateQueries({ queryKey: HEAPS_QUERY_KEY })
queryClient.invalidateQueries({ queryKey: ['heap-photo-ids', vars.heapId] })
queryClient.invalidateQueries({ queryKey: ['photos'] })
},
})
const handleCreate = () => {
const name = newName.trim()
if (!name) return
@@ -151,15 +196,45 @@ export function HeapsPanel() {
{heaps.map((heap) => {
const isFiltered = filterHeapId === heap.id
const isActive = heap.is_active
const isDropTarget = dropTargetId === heap.id
return (
<div
key={heap.id}
className={clsx(
'group flex cursor-pointer items-center gap-1 rounded px-2 py-1 text-[13px]',
isFiltered ? 'bg-primary/20 text-primary' : 'text-text hover:bg-surface-2'
isFiltered ? 'bg-primary/20 text-primary' : 'text-text hover:bg-surface-2',
isDropTarget && 'ring-2 ring-primary bg-primary/10'
)}
style={{ paddingLeft: '32px' }}
onClick={() => setFilterHeapId(heap.id)}
onDragOver={(e) => {
if (e.dataTransfer.types.includes(PHOTO_DRAG_MIME)) {
e.preventDefault()
e.dataTransfer.dropEffect = 'copy'
if (dropTargetId !== heap.id) setDropTargetId(heap.id)
}
}}
onDragLeave={(e) => {
// Only clear if we're actually leaving this row, not just
// moving over a child element.
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
if (dropTargetId === heap.id) setDropTargetId(null)
}
}}
onDrop={(e) => {
e.preventDefault()
setDropTargetId(null)
const raw = e.dataTransfer.getData(PHOTO_DRAG_MIME)
if (!raw) return
try {
const ids = JSON.parse(raw) as string[]
if (Array.isArray(ids) && ids.length > 0) {
dropMutation.mutate({ heapId: heap.id, photoIds: ids })
}
} catch {
// Bad payload — ignore.
}
}}
>
<ShoppingBasket
className={clsx(