From 696477eefd2178724fe93c4efe913801c29fbf6a Mon Sep 17 00:00:00 2001 From: dtoro Date: Wed, 8 Apr 2026 20:36:00 +0200 Subject: [PATCH] feat: heap row kebab menu with rename + duplicate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The heap row used to fan out three small icon buttons (set active, convert to folder, delete) on hover, which crowded the row and didn't leave room for new actions. Collapse the destructive / occasional ones into a kebab menu and add the missing operations. - Right-aligned action cluster: active indicator → count badge → target toggle (when not active) → kebab menu, all flex-shrink-0 so the name truncates first. - Kebab menu items: Rename, Duplicate, Move to folder…, Delete. Outside click and Escape close the popover; the trigger has aria-haspopup + aria-expanded. Delete still confirms via window.confirm. - Inline rename: double-click a heap row OR pick Rename from the menu to edit the name in place. Enter commits, Escape cancels. Mirrors the folder rename pattern in LeftSidebar. - backend: new POST /heaps/{id}/duplicate creates a copy with the same membership ("{name} (copy)") via INSERT...SELECT on heap_photos. Never marks the new heap as active so duplicating doesn't quietly steal the user's T-key destination. - api.ts: heaps.duplicate wrapper. Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/app/routers/heaps.py | 40 +++ frontend/src/components/heaps/HeapsPanel.tsx | 267 +++++++++++++++---- frontend/src/services/api.ts | 7 + 3 files changed, 260 insertions(+), 54 deletions(-) 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 => {