/** * SidebarLayoutManager — Discord-style org sidebar editor. * The canvas IS the sidebar: sections, folders, leaves — drag, rename, nest. */ import * as React from 'react' import { DndContext, PointerSensor, closestCenter, useSensor, useSensors, type DragEndEvent, type DragStartEvent, } from '@dnd-kit/core' import { SortableContext, arrayMove, useSortable, verticalListSortingStrategy, } from '@dnd-kit/sortable' import { CSS } from '@dnd-kit/utilities' import { PortalDragOverlay } from './portal-drag-overlay' import { ChevronDown, ChevronRight, FolderPlus, GripVertical, Pencil, Plus, RotateCcw, Save, Trash2, Eye, EyeOff, Hash, Folder, } from 'lucide-react' import { toast } from 'sonner' import { Button, Input, ScrollArea, } from '@asteby/metacore-ui/primitives' import { cn } from '@asteby/metacore-ui/lib' import { collectFolderKeys, folderAccessCapability, layoutTreeFromNavGroups, leafRefFor, slugifyFolderKey, type CatalogLeaf, type SidebarLayoutDoc, type SidebarLayoutNode, } from './apply-sidebar-layout' export interface SidebarLayoutManagerProps { loadLayout: () => Promise saveLayout: (doc: SidebarLayoutDoc) => Promise resetLayout: () => Promise catalog: CatalogLeaf[] currentGroups?: Array<{ title: string items: Array<{ title: string url: string icon?: string items?: Array<{ title: string; url: string; icon?: string }> }> }> /** Resolve i18n keys / raw titles to human labels. */ resolveLabel?: (title: string) => string title?: string description?: string } type Path = number[] function pathKey(path: Path): string { return path.length ? path.join('.') : 'root' } function getAt(tree: SidebarLayoutNode[], path: Path): SidebarLayoutNode | null { let cur: SidebarLayoutNode[] = tree let node: SidebarLayoutNode | null = null for (const i of path) { node = cur[i] ?? null if (!node) return null cur = node.children ?? [] } return node } function setChildren( tree: SidebarLayoutNode[], parentPath: Path, children: SidebarLayoutNode[], ): SidebarLayoutNode[] { if (parentPath.length === 0) return children const clone = structuredClone(tree) as SidebarLayoutNode[] let cur: SidebarLayoutNode[] = clone for (let d = 0; d < parentPath.length - 1; d++) { const n = cur[parentPath[d]!] if (!n) return tree n.children = n.children ?? [] cur = n.children } const parent = cur[parentPath[parentPath.length - 1]!] if (!parent) return tree parent.children = children return clone } function removeAt(tree: SidebarLayoutNode[], path: Path): { tree: SidebarLayoutNode[] node: SidebarLayoutNode | null } { if (path.length === 0) return { tree, node: null } const parentPath = path.slice(0, -1) const idx = path[path.length - 1]! const parentKids = parentPath.length === 0 ? [...tree] : [...(getAt(tree, parentPath)?.children ?? [])] const [node] = parentKids.splice(idx, 1) return { tree: setChildren(tree, parentPath, parentKids), node: node ?? null } } function insertAt( tree: SidebarLayoutNode[], parentPath: Path, index: number, node: SidebarLayoutNode, ): SidebarLayoutNode[] { const kids = parentPath.length === 0 ? [...tree] : [...(getAt(tree, parentPath)?.children ?? [])] const i = Math.max(0, Math.min(index, kids.length)) kids.splice(i, 0, node) return setChildren(tree, parentPath, kids) } function updateAt( tree: SidebarLayoutNode[], path: Path, patch: Partial, ): SidebarLayoutNode[] { const clone = structuredClone(tree) as SidebarLayoutNode[] let cur: SidebarLayoutNode[] = clone for (let d = 0; d < path.length - 1; d++) { const n = cur[path[d]!] if (!n) return tree n.children = n.children ?? [] cur = n.children } const i = path[path.length - 1]! if (!cur[i]) return tree cur[i] = { ...cur[i]!, ...patch } return clone } function collectUsedRefs(tree: SidebarLayoutNode[]): Set { const used = new Set() const walk = (nodes: SidebarLayoutNode[]) => { for (const n of nodes) { if (n.type === 'leaf' && n.ref) used.add(n.ref) if (n.children) walk(n.children) } } walk(tree) return used } function labelOf( node: SidebarLayoutNode, catalogByRef: Map, resolveLabel: (s: string) => string, ): string { if (node.type === 'leaf') { const leaf = node.ref ? catalogByRef.get(node.ref) : undefined const raw = node.title || leaf?.title || node.ref || '…' return resolveLabel(raw) } return resolveLabel(node.title || node.key || '…') } // ── Sortable row ────────────────────────────────────────────────────────── function SortableRow({ id, depth, kind, label, hidden, collapsed, onToggleCollapse, onRename, onToggleHidden, onDelete, onAddFolder, onAddLeaf, unusedLeaves, children, }: { id: string depth: number kind: 'group' | 'folder' | 'leaf' label: string hidden?: boolean collapsed?: boolean onToggleCollapse?: () => void onRename: (next: string) => void onToggleHidden: () => void onDelete: () => void onAddFolder?: () => void onAddLeaf?: (ref: string) => void unusedLeaves?: CatalogLeaf[] children?: React.ReactNode }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id }) const [editing, setEditing] = React.useState(false) const [draft, setDraft] = React.useState(label) const inputRef = React.useRef(null) React.useEffect(() => { if (editing) { setDraft(label) requestAnimationFrame(() => inputRef.current?.select()) } }, [editing, label]) const style: React.CSSProperties = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.45 : hidden ? 0.45 : 1, paddingLeft: 8 + depth * 14, } const isCategory = kind === 'group' || kind === 'folder' const commitRename = () => { const next = draft.trim() setEditing(false) if (next && next !== label) onRename(next) } return (
{isCategory ? ( ) : ( )} {kind === 'folder' ? ( ) : null} {editing ? ( setDraft(e.target.value)} onBlur={commitRename} onKeyDown={(e) => { if (e.key === 'Enter') commitRename() if (e.key === 'Escape') setEditing(false) }} className="h-7 flex-1 text-sm" /> ) : ( )}
{kind === 'group' || kind === 'folder' ? ( <> {onAddFolder ? ( ) : null} {onAddLeaf && unusedLeaves && unusedLeaves.length > 0 ? ( ) : null} ) : null}
{!collapsed ? children : null}
) } // ── Manager ─────────────────────────────────────────────────────────────── export function SidebarLayoutManager({ loadLayout, saveLayout, resetLayout, catalog, currentGroups, resolveLabel = (s) => s, title = 'Menú lateral', description = 'Arrastrá, renombrá y agrupá como en Discord. Así lo verá toda la organización.', }: SidebarLayoutManagerProps) { const [tree, setTree] = React.useState([]) const [baseline, setBaseline] = React.useState('[]') const [loading, setLoading] = React.useState(true) const [saving, setSaving] = React.useState(false) const [collapsed, setCollapsed] = React.useState>({}) const [activeId, setActiveId] = React.useState(null) const [newCategory, setNewCategory] = React.useState('') const catalogByRef = React.useMemo(() => { const m = new Map() for (const l of catalog) { m.set(l.ref, { ...l, title: resolveLabel(l.title) }) for (const a of l.aliases ?? []) m.set(a, m.get(l.ref)!) } return m }, [catalog, resolveLabel]) const unused = React.useMemo(() => { const used = collectUsedRefs(tree) return catalog .filter((l) => !used.has(l.ref) && !(l.aliases ?? []).some((a) => used.has(a))) .map((l) => ({ ...l, title: resolveLabel(l.title) })) }, [catalog, tree, resolveLabel]) const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), ) const seedFromCurrent = React.useCallback(() => { if (!currentGroups?.length) { toast.message('No hay menú actual para copiar') return [] } return layoutTreeFromNavGroups(currentGroups, (u) => leafRefFor(u)) }, [currentGroups]) const reload = React.useCallback(async () => { setLoading(true) try { const doc = await loadLayout() let t = doc.tree ?? [] // Empty overlay → show live menu as editable draft (Discord empty-server feel). if (t.length === 0 && currentGroups?.length) { t = seedFromCurrent() setBaseline('[]') // still "unsaved overlay" } else { setBaseline(JSON.stringify(t)) } setTree(t) } catch (e) { toast.error(e instanceof Error ? e.message : 'No se pudo cargar el menú') } finally { setLoading(false) } }, [loadLayout, currentGroups, seedFromCurrent]) React.useEffect(() => { void reload() }, [reload]) const dirty = JSON.stringify(tree) !== baseline const folderKeys = React.useMemo(() => collectFolderKeys(tree), [tree]) // Flat id → path index for DnD (ids are path keys) const idToPath = React.useMemo(() => { const map = new Map() const walk = (nodes: SidebarLayoutNode[], parent: Path) => { nodes.forEach((_, i) => { const path = [...parent, i] map.set(pathKey(path), path) const n = nodes[i]! if (n.children?.length) walk(n.children, path) }) } walk(tree, []) return map }, [tree]) const handleDragStart = (e: DragStartEvent) => { setActiveId(String(e.active.id)) } const handleDragEnd = (e: DragEndEvent) => { setActiveId(null) const { active, over } = e if (!over || active.id === over.id) return const fromPath = idToPath.get(String(active.id)) const toPath = idToPath.get(String(over.id)) if (!fromPath || !toPath) return // Same parent → reorder siblings const fromParent = fromPath.slice(0, -1) const toParent = toPath.slice(0, -1) if (pathKey(fromParent) === pathKey(toParent)) { const kids = fromParent.length === 0 ? [...tree] : [...(getAt(tree, fromParent)?.children ?? [])] const oldIndex = fromPath[fromPath.length - 1]! const newIndex = toPath[toPath.length - 1]! setTree(setChildren(tree, fromParent, arrayMove(kids, oldIndex, newIndex))) return } // Different parent → move node under target's parent at target index // (or into target if it's a folder/group) const overNode = getAt(tree, toPath) let destParent = toParent let destIndex = toPath[toPath.length - 1]! if (overNode && (overNode.type === 'group' || overNode.type === 'folder')) { destParent = toPath destIndex = overNode.children?.length ?? 0 } // Prevent dropping into own descendant const fromKey = pathKey(fromPath) if (pathKey(destParent).startsWith(fromKey + '.') || pathKey(destParent) === fromKey) { return } setTree((prev) => { const { tree: without, node } = removeAt(prev, fromPath) if (!node) return prev // Recompute dest index if we removed from earlier sibling in same parent let idx = destIndex if (pathKey(fromParent) === pathKey(destParent) && fromPath[fromPath.length - 1]! < destIndex) { idx = Math.max(0, destIndex - 1) } return insertAt(without, destParent, idx, node) }) } const handleSave = async () => { setSaving(true) try { await saveLayout({ version: 1, tree }) setBaseline(JSON.stringify(tree)) toast.success('Menú guardado para toda la organización') } catch (e) { toast.error(e instanceof Error ? e.message : 'Error al guardar') } finally { setSaving(false) } } const handleReset = async () => { setSaving(true) try { await resetLayout() const seeded = seedFromCurrent() setTree(seeded) setBaseline('[]') toast.success('Overlay restaurado — mostrando el menú default') } catch (e) { toast.error(e instanceof Error ? e.message : 'Error al resetear') } finally { setSaving(false) } } const addRootCategory = () => { const name = newCategory.trim() || 'Nueva sección' const key = slugifyFolderKey(name) setTree((t) => [ ...t, { type: 'group', key: `group_${key}_${Date.now() % 10000}`, title: name, children: [], }, ]) setNewCategory('') } const renderList = (nodes: SidebarLayoutNode[], parentPath: Path): React.ReactNode => { const ids = nodes.map((_, i) => pathKey([...parentPath, i])) return ( {nodes.map((node, i) => { const path = [...parentPath, i] const id = pathKey(path) const depth = parentPath.length const isOpen = !(collapsed[id] ?? false) const lbl = labelOf(node, catalogByRef, resolveLabel) return ( ) })} ) } const activeLabel = activeId ? (() => { const p = idToPath.get(activeId) if (!p) return null const n = getAt(tree, p) return n ? labelOf(n, catalogByRef, resolveLabel) : null })() : null return (

{title}

{description}

{loading ? (

Cargando menú…

) : (
{/* Discord-like sidebar canvas */}
Vista del menú {baseline === '[]' && dirty ? ( Sin guardar · borrador del menú actual ) : dirty ? ( Cambios sin guardar ) : ( {folderKeys.length ? `${folderKeys.length} carpeta(s) con permiso` : 'Layout activo'} )}
{tree.length === 0 ? (

Menú vacío. Creá una sección o recargá el menú actual.

) : ( renderList(tree, []) )} {activeLabel ? (
{activeLabel}
) : null}
setNewCategory(e.target.value)} placeholder="Nombre de sección…" className="h-8 text-sm" onKeyDown={(e) => { if (e.key === 'Enter') addRootCategory() }} />
{/* Unused pool */}

Sin colocar

Agregalos desde el menú + de cada sección

    {unused.length === 0 ? (
  • Todo está en el menú
  • ) : ( unused.map((l) => (
  • )) )}
{folderKeys.length > 0 ? (
Permisos:{' '} {folderKeys.map((k) => folderAccessCapability(k)).join(', ')}
) : null}
)}
) } export type { CatalogLeaf, SidebarLayoutDoc, SidebarLayoutNode }