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:
@@ -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<string | 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 = () => {
|
||||
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 (
|
||||
<div
|
||||
key={heap.id}
|
||||
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',
|
||||
isDropTarget && 'ring-2 ring-primary bg-primary/10'
|
||||
)}
|
||||
style={{ paddingLeft: '32px' }}
|
||||
onClick={() =>
|
||||
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'
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={clsx(
|
||||
'flex-1 truncate',
|
||||
isActive && 'font-semibold'
|
||||
)}
|
||||
title={heap.name}
|
||||
>
|
||||
{heap.name}
|
||||
</span>
|
||||
|
||||
{isRenaming ? (
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
value={renameDraft}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
) : (
|
||||
<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 && (
|
||||
<Target
|
||||
className="h-3 w-3 text-primary"
|
||||
className="h-3 w-3 flex-shrink-0 text-primary"
|
||||
aria-label="Active heap (T target)"
|
||||
/>
|
||||
)}
|
||||
{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}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (!isActive) setActiveMutation.mutate(heap.id)
|
||||
}}
|
||||
className={clsx(
|
||||
'rounded p-0.5 hover:bg-surface-offset hover:text-text',
|
||||
isActive
|
||||
? 'invisible'
|
||||
: 'invisible text-text-muted group-hover:visible'
|
||||
{!isActive && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setActiveMutation.mutate(heap.id)
|
||||
}}
|
||||
className="invisible flex-shrink-0 rounded p-0.5 text-text-muted hover:bg-surface-offset hover:text-text group-hover:visible"
|
||||
title="Set as active heap (T target)"
|
||||
aria-label="Set as active heap"
|
||||
>
|
||||
<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)"
|
||||
>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<Heap> => {
|
||||
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<string[]> => {
|
||||
|
||||
Reference in New Issue
Block a user