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:
@@ -13,6 +13,7 @@ import { useHeapsQuery, HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
|
|||||||
import { heaps as heapsApi } from '../../services/api'
|
import { heaps as heapsApi } from '../../services/api'
|
||||||
import { useFilterStore } from '../../store/filterStore'
|
import { useFilterStore } from '../../store/filterStore'
|
||||||
import { toast } from '../ToastContainer'
|
import { toast } from '../ToastContainer'
|
||||||
|
import { PHOTO_DRAG_MIME } from '../timeline/PhotoThumbnail'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Heaps panel for the left sidebar. Renders the list of heaps with the
|
* 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 [expanded, setExpanded] = useState(true)
|
||||||
const [creating, setCreating] = useState(false)
|
const [creating, setCreating] = useState(false)
|
||||||
const [newName, setNewName] = useState('')
|
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 = () => {
|
const invalidate = () => {
|
||||||
queryClient.invalidateQueries({ queryKey: HEAPS_QUERY_KEY })
|
queryClient.invalidateQueries({ queryKey: HEAPS_QUERY_KEY })
|
||||||
@@ -71,6 +75,47 @@ export function HeapsPanel() {
|
|||||||
toast.error('Failed to delete heap', e.message || 'Unknown error'),
|
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 handleCreate = () => {
|
||||||
const name = newName.trim()
|
const name = newName.trim()
|
||||||
if (!name) return
|
if (!name) return
|
||||||
@@ -151,15 +196,45 @@ export function HeapsPanel() {
|
|||||||
{heaps.map((heap) => {
|
{heaps.map((heap) => {
|
||||||
const isFiltered = filterHeapId === heap.id
|
const isFiltered = filterHeapId === heap.id
|
||||||
const isActive = heap.is_active
|
const isActive = heap.is_active
|
||||||
|
const isDropTarget = dropTargetId === heap.id
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={heap.id}
|
key={heap.id}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'group flex cursor-pointer items-center gap-1 rounded px-2 py-1 text-[13px]',
|
'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' }}
|
style={{ paddingLeft: '32px' }}
|
||||||
onClick={() => setFilterHeapId(heap.id)}
|
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
|
<ShoppingBasket
|
||||||
className={clsx(
|
className={clsx(
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import { Star, ShoppingBasket, Trash2, RefreshCw, Check } from 'lucide-react'
|
|||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { photos as photosApi } from '../../services/api'
|
import { photos as photosApi } from '../../services/api'
|
||||||
import type { Photo } from '../../types/photo'
|
import type { Photo } from '../../types/photo'
|
||||||
|
import { usePhotoStore } from '../../store/photoStore'
|
||||||
|
|
||||||
|
/** Custom MIME used by HeapsPanel to recognise our drag payload. */
|
||||||
|
export const PHOTO_DRAG_MIME = 'application/x-mulita-photos'
|
||||||
|
|
||||||
// Auto-retry schedule (ms). Backend generates thumbs on-demand via Celery, so
|
// Auto-retry schedule (ms). Backend generates thumbs on-demand via Celery, so
|
||||||
// first hit often 404s. Try a few times with backoff before giving up.
|
// first hit often 404s. Try a few times with backoff before giving up.
|
||||||
@@ -94,6 +98,22 @@ export function PhotoThumbnail({
|
|||||||
return () => clearRetryTimer()
|
return () => clearRetryTimer()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Build the drag payload at fire time so multi-selection drags carry the
|
||||||
|
// current selection. If the dragged photo isn't part of the selection,
|
||||||
|
// drag just that one photo (matches Finder semantics).
|
||||||
|
const handleDragStart = (e: React.DragEvent<HTMLDivElement>) => {
|
||||||
|
const state = usePhotoStore.getState()
|
||||||
|
const ids =
|
||||||
|
state.selectedPhotos.includes(photo.id) && state.selectedPhotos.length > 0
|
||||||
|
? state.selectedPhotos
|
||||||
|
: [photo.id]
|
||||||
|
e.dataTransfer.effectAllowed = 'copy'
|
||||||
|
e.dataTransfer.setData(PHOTO_DRAG_MIME, JSON.stringify(ids))
|
||||||
|
// A plain text fallback so the OS shows something sensible if the user
|
||||||
|
// drops outside the app.
|
||||||
|
e.dataTransfer.setData('text/plain', `${ids.length} photo${ids.length > 1 ? 's' : ''}`)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
@@ -108,7 +128,9 @@ export function PhotoThumbnail({
|
|||||||
}}
|
}}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
onDoubleClick={onDoubleClick}
|
onDoubleClick={onDoubleClick}
|
||||||
title="Click to select • Double-click to open • Shift+Click for range • Ctrl+Click to add"
|
draggable
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
title="Click to select • Double-click to open • Shift+Click for range • Ctrl+Click to add • Drag onto a heap to add"
|
||||||
>
|
>
|
||||||
{/* Thumbnail Image */}
|
{/* Thumbnail Image */}
|
||||||
{!imageError ? (
|
{!imageError ? (
|
||||||
|
|||||||
Reference in New Issue
Block a user