From 485b60ff20e595c6618cc6cce59f6c612e900bba Mon Sep 17 00:00:00 2001 From: dtoro Date: Tue, 7 Apr 2026 23:59:29 +0200 Subject: [PATCH] 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) --- frontend/src/components/heaps/HeapsPanel.tsx | 77 ++++++++++++++++++- .../components/timeline/PhotoThumbnail.tsx | 24 +++++- 2 files changed, 99 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/heaps/HeapsPanel.tsx b/frontend/src/components/heaps/HeapsPanel.tsx index 664b575..655e0b0 100644 --- a/frontend/src/components/heaps/HeapsPanel.tsx +++ b/frontend/src/components/heaps/HeapsPanel.tsx @@ -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(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(key) + const set = new Set(previous ?? []) + photoIds.forEach((id) => set.add(id)) + queryClient.setQueryData(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 (
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. + } + }} > 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) => { + 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 (
{/* Thumbnail Image */} {!imageError ? (