Switch tree and heap rows from py-0.5 to a fixed h-[24px] + leading-none so the hover-only kebab buttons can't stretch the row vertically as the pointer crosses them. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
498 lines
18 KiB
TypeScript
498 lines
18 KiB
TypeScript
import { useEffect, useRef, useState } from 'react'
|
|
import {
|
|
ShoppingBasket,
|
|
Plus,
|
|
Target,
|
|
ChevronDown,
|
|
ChevronRight,
|
|
FolderOutput,
|
|
MoreHorizontal,
|
|
Pencil,
|
|
Copy,
|
|
Trash2,
|
|
} from 'lucide-react'
|
|
import clsx from 'clsx'
|
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
|
import { useHeapsQuery, HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
|
|
import { heaps as heapsApi, type Heap } from '../../services/api'
|
|
import { useFilterStore } from '../../store/filterStore'
|
|
import { toast } from '../ToastContainer'
|
|
import { PHOTO_DRAG_MIME } from '../timeline/PhotoThumbnail'
|
|
import { HeapConvertDialog } from './HeapConvertDialog'
|
|
|
|
/**
|
|
* Heaps panel for the left sidebar. Renders the list of heaps with the
|
|
* basket icon, lets the user create a new heap, click one to filter the
|
|
* timeline to its contents, set one as the "active" target for the T
|
|
* shortcut, and delete heaps.
|
|
*
|
|
* Heap state:
|
|
* - filter heapId: which heap is currently filtered to (visual)
|
|
* - heap.is_active: which heap T adds to (server-side, single per row)
|
|
*/
|
|
export function HeapsPanel() {
|
|
const { data: heaps = [] } = useHeapsQuery()
|
|
const navigateToSection = useFilterStore((s) => s.navigateToSection)
|
|
const currentSection = useFilterStore((s) => s.currentSection)
|
|
const queryClient = useQueryClient()
|
|
|
|
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<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 })
|
|
}
|
|
|
|
const createMutation = useMutation({
|
|
mutationFn: (name: string) => heapsApi.create(name),
|
|
onSuccess: () => {
|
|
invalidate()
|
|
setNewName('')
|
|
setCreating(false)
|
|
},
|
|
onError: (e: any) =>
|
|
toast.error('Failed to create heap', e.message || 'Unknown error'),
|
|
})
|
|
|
|
const setActiveMutation = useMutation({
|
|
mutationFn: (heapId: string) =>
|
|
heapsApi.update(heapId, { is_active: true }),
|
|
onSuccess: (heap) => {
|
|
invalidate()
|
|
toast.success('Active heap', `Now adding to "${heap.name}" with T`)
|
|
},
|
|
onError: (e: any) =>
|
|
toast.error('Failed to set active', e.message || 'Unknown error'),
|
|
})
|
|
|
|
const deleteMutation = useMutation({
|
|
mutationFn: (heapId: string) => heapsApi.delete(heapId),
|
|
onSuccess: (_, heapId) => {
|
|
invalidate()
|
|
// If we were viewing this heap, snap back to all-photos.
|
|
if (currentSection === `heap-${heapId}`) {
|
|
navigateToSection('all-photos', {})
|
|
}
|
|
},
|
|
onError: (e: any) =>
|
|
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.
|
|
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<string[]>(key)
|
|
const set = new Set(previous ?? [])
|
|
photoIds.forEach((id) => set.add(id))
|
|
queryClient.setQueryData<string[]>(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
|
|
createMutation.mutate(name)
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
{/* Section header — eyebrow style to match the LeftSidebar
|
|
* library/folders headers. */}
|
|
<div
|
|
className="group mt-2 flex cursor-pointer items-center gap-1 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-text-muted hover:text-text"
|
|
onClick={() => setExpanded((v) => !v)}
|
|
>
|
|
<button className="rounded p-0.5 hover:bg-surface-offset">
|
|
{expanded ? (
|
|
<ChevronDown className="h-3 w-3" />
|
|
) : (
|
|
<ChevronRight className="h-3 w-3" />
|
|
)}
|
|
</button>
|
|
<span className="flex-1 truncate">Heaps</span>
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
setCreating(true)
|
|
setExpanded(true)
|
|
}}
|
|
className="invisible rounded p-0.5 hover:bg-surface-offset hover:text-text group-hover:visible"
|
|
title="New heap"
|
|
>
|
|
<Plus className="h-3 w-3" />
|
|
</button>
|
|
</div>
|
|
|
|
{expanded && (
|
|
<div>
|
|
{/* Inline create form */}
|
|
{creating && (
|
|
<div
|
|
className="flex items-center gap-1 px-2 py-0.5"
|
|
style={{ paddingLeft: '20px' }}
|
|
>
|
|
<input
|
|
autoFocus
|
|
type="text"
|
|
value={newName}
|
|
onChange={(e) => setNewName(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') handleCreate()
|
|
if (e.key === 'Escape') {
|
|
setCreating(false)
|
|
setNewName('')
|
|
}
|
|
}}
|
|
placeholder="Heap name"
|
|
className="flex-1 rounded border border-border bg-bg px-2 py-0.5 text-[11px] text-text focus:border-primary focus:outline-none"
|
|
/>
|
|
<button
|
|
onClick={handleCreate}
|
|
disabled={!newName.trim() || createMutation.isPending}
|
|
className="rounded bg-primary px-2 py-0.5 text-[11px] text-white hover:bg-primary/80 disabled:opacity-50"
|
|
>
|
|
Add
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{heaps.length === 0 && !creating && (
|
|
<div
|
|
className="px-2 py-0.5 text-[11px] text-text-faint"
|
|
style={{ paddingLeft: '20px' }}
|
|
>
|
|
No heaps yet
|
|
</div>
|
|
)}
|
|
|
|
{heaps.map((heap) => {
|
|
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 relative flex h-[24px] cursor-pointer items-center gap-1 rounded px-2 text-[12px] leading-none',
|
|
isFiltered ? 'bg-primary/20 text-primary' : 'text-text hover:bg-surface-2',
|
|
// Active-heap row gets a soft primary wash so the
|
|
// user always knows where Pick / T will land, even
|
|
// when viewing a different section.
|
|
isActive && !isFiltered && 'bg-primary/8 text-text',
|
|
isDropTarget && 'ring-2 ring-primary bg-primary/10'
|
|
)}
|
|
style={{ paddingLeft: '20px' }}
|
|
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()
|
|
e.dataTransfer.dropEffect = 'copy'
|
|
if (dropTargetId !== heap.id) setDropTargetId(heap.id)
|
|
}
|
|
}}
|
|
onDragLeave={(e) => {
|
|
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.
|
|
}
|
|
}}
|
|
>
|
|
<ShoppingBasket
|
|
className={clsx(
|
|
'h-3.5 w-3.5 flex-shrink-0',
|
|
isActive || isFiltered ? 'text-primary' : 'text-text-muted'
|
|
)}
|
|
/>
|
|
|
|
{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('truncate', isActive && 'font-semibold')}
|
|
title={heap.name}
|
|
>
|
|
{heap.name}
|
|
</span>
|
|
{isActive && (
|
|
<span
|
|
className="ml-1 flex-shrink-0 rounded-full bg-primary/25 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wide text-primary"
|
|
title="Active heap — Pick (P) and the basket badge on photos point here"
|
|
>
|
|
Active
|
|
</span>
|
|
)}
|
|
<span className="flex-1" />
|
|
</>
|
|
)}
|
|
|
|
{/* Right cluster. Count is the rightmost element in the
|
|
* resting state — set-active and kebab use display:none
|
|
* (not invisible) so they reserve no width until hover,
|
|
* keeping the count column aligned with the rest of the
|
|
* sidebar. The active heap is signaled by font-semibold
|
|
* on the name above; the standalone Target indicator
|
|
* was making heap counts sit left of the others. */}
|
|
{heap.photo_count > 0 ? (
|
|
<span className="flex h-4 min-w-[20px] flex-shrink-0 items-center justify-center rounded bg-surface-offset px-1 text-[10px] tabular-nums text-text-muted">
|
|
{heap.photo_count}
|
|
</span>
|
|
) : (
|
|
<span className="h-4 min-w-[20px] flex-shrink-0" aria-hidden="true" />
|
|
)}
|
|
{!isActive && (
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
setActiveMutation.mutate(heap.id)
|
|
}}
|
|
className="hidden flex-shrink-0 rounded p-0.5 text-text-muted hover:bg-surface-offset hover:text-text group-hover:block"
|
|
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={clsx(
|
|
'relative flex-shrink-0',
|
|
isMenuOpen ? 'block' : 'hidden group-hover:block'
|
|
)}
|
|
>
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
setOpenMenuId(isMenuOpen ? null : heap.id)
|
|
}}
|
|
className="rounded p-0.5 text-text-muted hover:bg-surface-offset hover:text-text"
|
|
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>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
<HeapConvertDialog
|
|
heap={convertingHeap}
|
|
onClose={() => setConvertingHeap(null)}
|
|
/>
|
|
</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>
|
|
)
|
|
}
|