diff --git a/backend/app/routers/heaps.py b/backend/app/routers/heaps.py index 3d6dc0d..142221a 100644 --- a/backend/app/routers/heaps.py +++ b/backend/app/routers/heaps.py @@ -137,6 +137,46 @@ async def update_heap( } +@router.post("/{heap_id}/duplicate", status_code=201) +async def duplicate_heap(heap_id: str, db: AsyncSession = Depends(get_db)): + """Create a new heap with the same membership as an existing one. The + new heap is named "{original} (copy)" and is never the active target — + duplicating shouldn't quietly steal the user's T-key destination. + """ + result = await db.execute(select(Heap).where(Heap.id == heap_id)) + source = result.scalar_one_or_none() + if not source: + raise HTTPException(status_code=404, detail="Heap not found") + + new_heap = Heap(name=f"{source.name} (copy)", is_active=False) + db.add(new_heap) + await db.flush() # populate new_heap.id without committing yet + + # Bulk-copy the membership rows. SELECT photo_id FROM heap_photos WHERE + # heap_id = :src — INSERT each into the new heap. Done as a single + # INSERT...SELECT to avoid round-tripping ids through Python. + member_rows = await db.execute( + select(heap_photos.c.photo_id).where(heap_photos.c.heap_id == heap_id) + ) + photo_ids = [row[0] for row in member_rows.all()] + if photo_ids: + await db.execute( + insert(heap_photos), + [{"heap_id": new_heap.id, "photo_id": pid} for pid in photo_ids], + ) + + await db.commit() + await db.refresh(new_heap) + return { + "id": new_heap.id, + "name": new_heap.name, + "is_active": False, + "photo_count": len(photo_ids), + "created_at": new_heap.created_at, + "updated_at": new_heap.updated_at, + } + + @router.delete("/{heap_id}", status_code=204) async def delete_heap(heap_id: str, db: AsyncSession = Depends(get_db)): """Delete a heap. Photos themselves are unaffected — only the membership diff --git a/frontend/src/components/heaps/HeapsPanel.tsx b/frontend/src/components/heaps/HeapsPanel.tsx index bf67ab6..947651b 100644 --- a/frontend/src/components/heaps/HeapsPanel.tsx +++ b/frontend/src/components/heaps/HeapsPanel.tsx @@ -1,12 +1,15 @@ -import { useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { ShoppingBasket, Plus, Target, - X, ChevronDown, ChevronRight, FolderOutput, + MoreHorizontal, + Pencil, + Copy, + Trash2, } from 'lucide-react' import clsx from 'clsx' import { useMutation, useQueryClient } from '@tanstack/react-query' @@ -40,6 +43,32 @@ export function HeapsPanel() { // the drop highlight ring. Only one heap can be the target at a time. const [dropTargetId, setDropTargetId] = useState(null) const [convertingHeap, setConvertingHeap] = useState(null) + // Inline rename state for heap rows: stores the heap id being edited and + // the draft name. Mirrors the folder rename pattern in LeftSidebar. + const [renamingId, setRenamingId] = useState(null) + const [renameDraft, setRenameDraft] = useState('') + // Which heap's burger menu is currently open. null when no menu is open. + // The popover closes on outside click and Escape via the effect below. + const [openMenuId, setOpenMenuId] = useState(null) + const menuRef = useRef(null) + + useEffect(() => { + if (!openMenuId) return + const onDown = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + setOpenMenuId(null) + } + } + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') setOpenMenuId(null) + } + document.addEventListener('mousedown', onDown) + document.addEventListener('keydown', onKey) + return () => { + document.removeEventListener('mousedown', onDown) + document.removeEventListener('keydown', onKey) + } + }, [openMenuId]) const invalidate = () => { queryClient.invalidateQueries({ queryKey: HEAPS_QUERY_KEY }) @@ -80,6 +109,24 @@ export function HeapsPanel() { toast.error('Failed to delete heap', e.message || 'Unknown error'), }) + const renameMutation = useMutation({ + mutationFn: ({ heapId, name }: { heapId: string; name: string }) => + heapsApi.update(heapId, { name }), + onSuccess: () => invalidate(), + onError: (e: any) => + toast.error('Failed to rename heap', e.message || 'Unknown error'), + }) + + const duplicateMutation = useMutation({ + mutationFn: (heapId: string) => heapsApi.duplicate(heapId), + onSuccess: (heap) => { + invalidate() + toast.success('Heap duplicated', heap.name) + }, + onError: (e: any) => + toast.error('Failed to duplicate 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. @@ -202,18 +249,35 @@ export function HeapsPanel() { const isFiltered = currentSection === `heap-${heap.id}` const isActive = heap.is_active const isDropTarget = dropTargetId === heap.id + const isRenaming = renamingId === heap.id + const isMenuOpen = openMenuId === heap.id + + const commitRename = () => { + const next = renameDraft.trim() + if (next && next !== heap.name) { + renameMutation.mutate({ heapId: heap.id, name: next }) + } + setRenamingId(null) + } + return (
+ onClick={() => { + if (isRenaming) return navigateToSection(`heap-${heap.id}`, { heapId: heap.id }) - } + }} + onDoubleClick={(e) => { + e.stopPropagation() + setRenamingId(heap.id) + setRenameDraft(heap.name) + }} onDragOver={(e) => { if (e.dataTransfer.types.includes(PHOTO_DRAG_MIME)) { e.preventDefault() @@ -222,8 +286,6 @@ export function HeapsPanel() { } }} 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) } @@ -249,63 +311,132 @@ export function HeapsPanel() { isFiltered ? 'text-primary' : 'text-text-muted' )} /> - - {heap.name} - + + {isRenaming ? ( + setRenameDraft(e.target.value)} + onClick={(e) => e.stopPropagation()} + onBlur={commitRename} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.currentTarget.blur() + } else if (e.key === 'Escape') { + setRenamingId(null) + } + }} + className="flex-1 rounded border border-border bg-bg px-1 py-0 text-[13px] text-text focus:border-primary focus:outline-none" + /> + ) : ( + + {heap.name} + + )} + + {/* Right-aligned action cluster: active indicator, count, + * target toggle, kebab menu. The flex-1 on the name span + * pushes everything below to the right edge of the row. */} {isActive && ( )} {heap.photo_count > 0 && ( - + {heap.photo_count} )} - + )} + + {/* Kebab menu — collects rename / duplicate / convert / + * delete so the row stays compact. */} +
+ + + {isMenuOpen && ( +
e.stopPropagation()} + > + } + label="Rename" + onClick={() => { + setOpenMenuId(null) + setRenamingId(heap.id) + setRenameDraft(heap.name) + }} + /> + } + label="Duplicate" + onClick={() => { + setOpenMenuId(null) + duplicateMutation.mutate(heap.id) + }} + /> + } + label="Move to folder…" + onClick={() => { + setOpenMenuId(null) + setConvertingHeap(heap) + }} + /> +
+ } + label="Delete" + destructive + onClick={() => { + setOpenMenuId(null) + if ( + confirm( + `Delete heap "${heap.name}"? Photos are not affected.` + ) + ) { + deleteMutation.mutate(heap.id) + } + }} + /> +
)} - title="Set as active heap (T target)" - > - - - - +
) })} @@ -319,3 +450,31 @@ export function HeapsPanel() {
) } + +function MenuItem({ + icon, + label, + onClick, + destructive = false, +}: { + icon: React.ReactNode + label: string + onClick: () => void + destructive?: boolean +}) { + return ( + + ) +} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 75d6afa..9024d6d 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -229,6 +229,13 @@ export const heaps = { await api.delete(`/heaps/${heapId}`) }, + /** Duplicate a heap, copying its membership but never marking the new + * one as active. The new heap is named "{name} (copy)". */ + duplicate: async (heapId: string): Promise => { + const response = await api.post(`/heaps/${heapId}/duplicate`) + return response.data + }, + /** Lightweight: just the photo ids in a heap, for client-side membership * lookups (the basket affordance on thumbnails). */ photoIds: async (heapId: string): Promise => {