import { useState } from 'react' import { ShoppingBasket, Plus, Target, ChevronDown, ChevronRight, FolderOutput, MoreHorizontal, Pencil, Copy, Trash2, Users, Eye, LogOut, Download as DownloadIcon, } from 'lucide-react' import { cn } from '@/lib/utils' import { useMutation, useQueryClient } from '@tanstack/react-query' import { useHeapsQuery, HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery' import { useSharedHeapsQuery, SHARED_HEAPS_KEY, } from '../../hooks/useSharingQueries' import { heaps as heapsApi, downloads, sharing as sharingApi, type Heap, } from '../../services/api' import { Avatar } from '../sharing/Avatar' import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger, } from '@/components/ui/context-menu' import { useFilterStore } from '../../store/filterStore' import { toast } from '../ToastContainer' import { PHOTO_DRAG_MIME } from '../timeline/PhotoThumbnail' import { formatApiError } from '../../lib/apiError' import { HeapConvertDialog } from './HeapConvertDialog' import { ShareDialog } from '../sharing/ShareDialog' import { Input } from '@/components/ui/input' import { Button } from '@/components/ui/button' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' /** * 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(null) const [convertingHeap, setConvertingHeap] = useState(null) const [sharingHeap, setSharingHeap] = useState(null) const { data: sharedHeaps = [] } = useSharedHeapsQuery() // 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. Drives the trigger's // hover-visible state via DropdownMenu's open prop — Radix handles // outside-click + Escape dismissal internally. const [openMenuId, setOpenMenuId] = useState(null) 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', formatApiError(e)), }) 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', formatApiError(e)), }) 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', formatApiError(e)), }) 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', formatApiError(e)), }) 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', formatApiError(e)), }) // 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(key) const set = new Set(previous ?? []) photoIds.forEach((id) => set.add(id)) queryClient.setQueryData(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', formatApiError(e)) }, 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 (
{/* Section header — mirrors the Views/Folders section eyebrows * in LeftSidebar so Heaps sits alongside them at the same * visual tier, including the click-to-collapse chevron. */}
setExpanded((v) => !v)} aria-expanded={expanded} role="button" > Heaps
{expanded && (
{/* Inline create form */} {creating && (
)} {heaps.length === 0 && !creating && (
)} {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 (
{ 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. } }} > {/* Chevron-slot spacer — heap rows don't expand, but this * reserves the same width that leaf folder rows use so * icons and labels line up across the two hierarchies. */} ) })}
)} {/* Shared with me — mirrors the folder treatment in LeftSidebar: * owner avatar + Eye/Pencil permission icon + right-click * context menu with Open / Leave. */} {sharedHeaps.length > 0 && expanded && (
Shared with me
{sharedHeaps.map((sh) => { const isFiltered = currentSection === `heap-${sh.id}` const PermissionIcon = sh.permission === 'write' ? Pencil : Eye return (
navigateToSection(`heap-${sh.id}`, { heapId: sh.id }) } > {sh.name} {sh.photo_count > 0 && ( {sh.photo_count} )}
navigateToSection(`heap-${sh.id}`, { heapId: sh.id }) } > Open { try { await sharingApi.revokeHeapShare(sh.id, sh.share_id) queryClient.invalidateQueries({ queryKey: SHARED_HEAPS_KEY }) toast.success(`Left ${sh.name}`) } catch (err) { toast.error('Could not leave', formatApiError(err)) } }} > Leave
) })}
)} setConvertingHeap(null)} /> setSharingHeap(null)} />
) }