feat: heap row kebab menu with rename + duplicate

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) <noreply@anthropic.com>
This commit is contained in:
2026-04-08 20:36:00 +02:00
parent fc63b1f69d
commit 696477eefd
3 changed files with 260 additions and 54 deletions

View File

@@ -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) @router.delete("/{heap_id}", status_code=204)
async def delete_heap(heap_id: str, db: AsyncSession = Depends(get_db)): async def delete_heap(heap_id: str, db: AsyncSession = Depends(get_db)):
"""Delete a heap. Photos themselves are unaffected — only the membership """Delete a heap. Photos themselves are unaffected — only the membership

View File

@@ -1,12 +1,15 @@
import { useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { import {
ShoppingBasket, ShoppingBasket,
Plus, Plus,
Target, Target,
X,
ChevronDown, ChevronDown,
ChevronRight, ChevronRight,
FolderOutput, FolderOutput,
MoreHorizontal,
Pencil,
Copy,
Trash2,
} from 'lucide-react' } from 'lucide-react'
import clsx from 'clsx' import clsx from 'clsx'
import { useMutation, useQueryClient } from '@tanstack/react-query' 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. // the drop highlight ring. Only one heap can be the target at a time.
const [dropTargetId, setDropTargetId] = useState<string | null>(null) const [dropTargetId, setDropTargetId] = useState<string | null>(null)
const [convertingHeap, setConvertingHeap] = useState<Heap | null>(null) const [convertingHeap, setConvertingHeap] = useState<Heap | null>(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<string | null>(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<string | null>(null)
const menuRef = useRef<HTMLDivElement>(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 = () => { const invalidate = () => {
queryClient.invalidateQueries({ queryKey: HEAPS_QUERY_KEY }) queryClient.invalidateQueries({ queryKey: HEAPS_QUERY_KEY })
@@ -80,6 +109,24 @@ export function HeapsPanel() {
toast.error('Failed to delete heap', e.message || 'Unknown error'), 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 // Drop handler: add the dragged photos to the target heap. Optimistically
// updates the membership cache so the basket affordance flips immediately, // updates the membership cache so the basket affordance flips immediately,
// mirroring the keyboard P-toggle pattern. // mirroring the keyboard P-toggle pattern.
@@ -202,18 +249,35 @@ export function HeapsPanel() {
const isFiltered = currentSection === `heap-${heap.id}` const isFiltered = currentSection === `heap-${heap.id}`
const isActive = heap.is_active const isActive = heap.is_active
const isDropTarget = dropTargetId === heap.id 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 ( 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 relative 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' isDropTarget && 'ring-2 ring-primary bg-primary/10'
)} )}
style={{ paddingLeft: '32px' }} style={{ paddingLeft: '32px' }}
onClick={() => onClick={() => {
if (isRenaming) return
navigateToSection(`heap-${heap.id}`, { heapId: heap.id }) navigateToSection(`heap-${heap.id}`, { heapId: heap.id })
} }}
onDoubleClick={(e) => {
e.stopPropagation()
setRenamingId(heap.id)
setRenameDraft(heap.name)
}}
onDragOver={(e) => { onDragOver={(e) => {
if (e.dataTransfer.types.includes(PHOTO_DRAG_MIME)) { if (e.dataTransfer.types.includes(PHOTO_DRAG_MIME)) {
e.preventDefault() e.preventDefault()
@@ -222,8 +286,6 @@ export function HeapsPanel() {
} }
}} }}
onDragLeave={(e) => { 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 (!e.currentTarget.contains(e.relatedTarget as Node)) {
if (dropTargetId === heap.id) setDropTargetId(null) if (dropTargetId === heap.id) setDropTargetId(null)
} }
@@ -249,63 +311,132 @@ export function HeapsPanel() {
isFiltered ? 'text-primary' : 'text-text-muted' isFiltered ? 'text-primary' : 'text-text-muted'
)} )}
/> />
<span
className={clsx( {isRenaming ? (
'flex-1 truncate', <input
isActive && 'font-semibold' autoFocus
)} type="text"
title={heap.name} value={renameDraft}
> onChange={(e) => setRenameDraft(e.target.value)}
{heap.name} onClick={(e) => e.stopPropagation()}
</span> 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"
/>
) : (
<span
className={clsx('flex-1 truncate', isActive && 'font-semibold')}
title={heap.name}
>
{heap.name}
</span>
)}
{/* 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 && ( {isActive && (
<Target <Target
className="h-3 w-3 text-primary" className="h-3 w-3 flex-shrink-0 text-primary"
aria-label="Active heap (T target)" aria-label="Active heap (T target)"
/> />
)} )}
{heap.photo_count > 0 && ( {heap.photo_count > 0 && (
<span className="rounded bg-surface-offset px-1.5 py-0.5 text-xs text-text-muted"> <span className="flex-shrink-0 rounded bg-surface-offset px-1.5 py-0.5 text-xs text-text-muted">
{heap.photo_count} {heap.photo_count}
</span> </span>
)} )}
<button {!isActive && (
onClick={(e) => { <button
e.stopPropagation() onClick={(e) => {
if (!isActive) setActiveMutation.mutate(heap.id) e.stopPropagation()
}} setActiveMutation.mutate(heap.id)
className={clsx( }}
'rounded p-0.5 hover:bg-surface-offset hover:text-text', className="invisible flex-shrink-0 rounded p-0.5 text-text-muted hover:bg-surface-offset hover:text-text group-hover:visible"
isActive title="Set as active heap (T target)"
? 'invisible' aria-label="Set as active heap"
: 'invisible text-text-muted group-hover:visible' >
<Target className="h-3 w-3" />
</button>
)}
{/* Kebab menu — collects rename / duplicate / convert /
* delete so the row stays compact. */}
<div className="relative flex-shrink-0">
<button
onClick={(e) => {
e.stopPropagation()
setOpenMenuId(isMenuOpen ? null : heap.id)
}}
className={clsx(
'rounded p-0.5 text-text-muted hover:bg-surface-offset hover:text-text',
isMenuOpen ? 'visible' : 'invisible group-hover:visible'
)}
title="More actions"
aria-label="More heap actions"
aria-haspopup="menu"
aria-expanded={isMenuOpen}
>
<MoreHorizontal className="h-3.5 w-3.5" />
</button>
{isMenuOpen && (
<div
ref={menuRef}
role="menu"
className="absolute right-0 top-full z-30 mt-1 min-w-[160px] overflow-hidden rounded-lg border border-border bg-surface py-1 text-sm shadow-xl"
onClick={(e) => e.stopPropagation()}
>
<MenuItem
icon={<Pencil className="h-3.5 w-3.5" />}
label="Rename"
onClick={() => {
setOpenMenuId(null)
setRenamingId(heap.id)
setRenameDraft(heap.name)
}}
/>
<MenuItem
icon={<Copy className="h-3.5 w-3.5" />}
label="Duplicate"
onClick={() => {
setOpenMenuId(null)
duplicateMutation.mutate(heap.id)
}}
/>
<MenuItem
icon={<FolderOutput className="h-3.5 w-3.5" />}
label="Move to folder…"
onClick={() => {
setOpenMenuId(null)
setConvertingHeap(heap)
}}
/>
<div className="my-1 h-px bg-border" />
<MenuItem
icon={<Trash2 className="h-3.5 w-3.5" />}
label="Delete"
destructive
onClick={() => {
setOpenMenuId(null)
if (
confirm(
`Delete heap "${heap.name}"? Photos are not affected.`
)
) {
deleteMutation.mutate(heap.id)
}
}}
/>
</div>
)} )}
title="Set as active heap (T target)" </div>
>
<Target className="h-3 w-3" />
</button>
<button
onClick={(e) => {
e.stopPropagation()
setConvertingHeap(heap)
}}
className="invisible rounded p-0.5 text-text-muted hover:bg-surface-offset hover:text-text group-hover:visible"
title="Convert to folder…"
>
<FolderOutput className="h-3 w-3" />
</button>
<button
onClick={(e) => {
e.stopPropagation()
if (confirm(`Delete heap "${heap.name}"? Photos are not affected.`)) {
deleteMutation.mutate(heap.id)
}
}}
className="invisible rounded p-0.5 text-text-muted hover:bg-surface-offset hover:text-reject group-hover:visible"
title="Delete heap"
>
<X className="h-3 w-3" />
</button>
</div> </div>
) )
})} })}
@@ -319,3 +450,31 @@ export function HeapsPanel() {
</div> </div>
) )
} }
function MenuItem({
icon,
label,
onClick,
destructive = false,
}: {
icon: React.ReactNode
label: string
onClick: () => void
destructive?: boolean
}) {
return (
<button
role="menuitem"
onClick={onClick}
className={clsx(
'flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs transition-colors',
destructive
? 'text-reject hover:bg-reject/10'
: 'text-text hover:bg-surface-2'
)}
>
<span className="text-text-muted">{icon}</span>
{label}
</button>
)
}

View File

@@ -229,6 +229,13 @@ export const heaps = {
await api.delete(`/heaps/${heapId}`) 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<Heap> => {
const response = await api.post(`/heaps/${heapId}/duplicate`)
return response.data
},
/** Lightweight: just the photo ids in a heap, for client-side membership /** Lightweight: just the photo ids in a heap, for client-side membership
* lookups (the basket affordance on thumbnails). */ * lookups (the basket affordance on thumbnails). */
photoIds: async (heapId: string): Promise<string[]> => { photoIds: async (heapId: string): Promise<string[]> => {