@asteby/metacore-runtime-react 39.1.1 → 39.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,833 @@
1
+ /**
2
+ * SidebarLayoutManager — Discord-style org sidebar editor.
3
+ * The canvas IS the sidebar: sections, folders, leaves — drag, rename, nest.
4
+ */
5
+ import * as React from 'react'
6
+ import {
7
+ DndContext,
8
+ DragOverlay,
9
+ PointerSensor,
10
+ closestCenter,
11
+ useSensor,
12
+ useSensors,
13
+ type DragEndEvent,
14
+ type DragStartEvent,
15
+ } from '@dnd-kit/core'
16
+ import {
17
+ SortableContext,
18
+ arrayMove,
19
+ useSortable,
20
+ verticalListSortingStrategy,
21
+ } from '@dnd-kit/sortable'
22
+ import { CSS } from '@dnd-kit/utilities'
23
+ import {
24
+ ChevronDown,
25
+ ChevronRight,
26
+ FolderPlus,
27
+ GripVertical,
28
+ Pencil,
29
+ Plus,
30
+ RotateCcw,
31
+ Save,
32
+ Trash2,
33
+ Eye,
34
+ EyeOff,
35
+ Hash,
36
+ Folder,
37
+ } from 'lucide-react'
38
+ import { toast } from 'sonner'
39
+ import {
40
+ Button,
41
+ Input,
42
+ ScrollArea,
43
+ } from '@asteby/metacore-ui/primitives'
44
+ import { cn } from '@asteby/metacore-ui/lib'
45
+ import {
46
+ collectFolderKeys,
47
+ folderAccessCapability,
48
+ layoutTreeFromNavGroups,
49
+ leafRefFor,
50
+ slugifyFolderKey,
51
+ type CatalogLeaf,
52
+ type SidebarLayoutDoc,
53
+ type SidebarLayoutNode,
54
+ } from './apply-sidebar-layout'
55
+
56
+ export interface SidebarLayoutManagerProps {
57
+ loadLayout: () => Promise<SidebarLayoutDoc>
58
+ saveLayout: (doc: SidebarLayoutDoc) => Promise<void>
59
+ resetLayout: () => Promise<void>
60
+ catalog: CatalogLeaf[]
61
+ currentGroups?: Array<{
62
+ title: string
63
+ items: Array<{
64
+ title: string
65
+ url: string
66
+ icon?: string
67
+ items?: Array<{ title: string; url: string; icon?: string }>
68
+ }>
69
+ }>
70
+ /** Resolve i18n keys / raw titles to human labels. */
71
+ resolveLabel?: (title: string) => string
72
+ title?: string
73
+ description?: string
74
+ }
75
+
76
+ type Path = number[]
77
+
78
+ function pathKey(path: Path): string {
79
+ return path.length ? path.join('.') : 'root'
80
+ }
81
+
82
+ function getAt(tree: SidebarLayoutNode[], path: Path): SidebarLayoutNode | null {
83
+ let cur: SidebarLayoutNode[] = tree
84
+ let node: SidebarLayoutNode | null = null
85
+ for (const i of path) {
86
+ node = cur[i] ?? null
87
+ if (!node) return null
88
+ cur = node.children ?? []
89
+ }
90
+ return node
91
+ }
92
+
93
+ function setChildren(
94
+ tree: SidebarLayoutNode[],
95
+ parentPath: Path,
96
+ children: SidebarLayoutNode[],
97
+ ): SidebarLayoutNode[] {
98
+ if (parentPath.length === 0) return children
99
+ const clone = structuredClone(tree) as SidebarLayoutNode[]
100
+ let cur: SidebarLayoutNode[] = clone
101
+ for (let d = 0; d < parentPath.length - 1; d++) {
102
+ const n = cur[parentPath[d]!]
103
+ if (!n) return tree
104
+ n.children = n.children ?? []
105
+ cur = n.children
106
+ }
107
+ const parent = cur[parentPath[parentPath.length - 1]!]
108
+ if (!parent) return tree
109
+ parent.children = children
110
+ return clone
111
+ }
112
+
113
+ function removeAt(tree: SidebarLayoutNode[], path: Path): {
114
+ tree: SidebarLayoutNode[]
115
+ node: SidebarLayoutNode | null
116
+ } {
117
+ if (path.length === 0) return { tree, node: null }
118
+ const parentPath = path.slice(0, -1)
119
+ const idx = path[path.length - 1]!
120
+ const parentKids =
121
+ parentPath.length === 0
122
+ ? [...tree]
123
+ : [...(getAt(tree, parentPath)?.children ?? [])]
124
+ const [node] = parentKids.splice(idx, 1)
125
+ return { tree: setChildren(tree, parentPath, parentKids), node: node ?? null }
126
+ }
127
+
128
+ function insertAt(
129
+ tree: SidebarLayoutNode[],
130
+ parentPath: Path,
131
+ index: number,
132
+ node: SidebarLayoutNode,
133
+ ): SidebarLayoutNode[] {
134
+ const kids =
135
+ parentPath.length === 0
136
+ ? [...tree]
137
+ : [...(getAt(tree, parentPath)?.children ?? [])]
138
+ const i = Math.max(0, Math.min(index, kids.length))
139
+ kids.splice(i, 0, node)
140
+ return setChildren(tree, parentPath, kids)
141
+ }
142
+
143
+ function updateAt(
144
+ tree: SidebarLayoutNode[],
145
+ path: Path,
146
+ patch: Partial<SidebarLayoutNode>,
147
+ ): SidebarLayoutNode[] {
148
+ const clone = structuredClone(tree) as SidebarLayoutNode[]
149
+ let cur: SidebarLayoutNode[] = clone
150
+ for (let d = 0; d < path.length - 1; d++) {
151
+ const n = cur[path[d]!]
152
+ if (!n) return tree
153
+ n.children = n.children ?? []
154
+ cur = n.children
155
+ }
156
+ const i = path[path.length - 1]!
157
+ if (!cur[i]) return tree
158
+ cur[i] = { ...cur[i]!, ...patch }
159
+ return clone
160
+ }
161
+
162
+ function collectUsedRefs(tree: SidebarLayoutNode[]): Set<string> {
163
+ const used = new Set<string>()
164
+ const walk = (nodes: SidebarLayoutNode[]) => {
165
+ for (const n of nodes) {
166
+ if (n.type === 'leaf' && n.ref) used.add(n.ref)
167
+ if (n.children) walk(n.children)
168
+ }
169
+ }
170
+ walk(tree)
171
+ return used
172
+ }
173
+
174
+ function labelOf(
175
+ node: SidebarLayoutNode,
176
+ catalogByRef: Map<string, CatalogLeaf>,
177
+ resolveLabel: (s: string) => string,
178
+ ): string {
179
+ if (node.type === 'leaf') {
180
+ const leaf = node.ref ? catalogByRef.get(node.ref) : undefined
181
+ const raw = node.title || leaf?.title || node.ref || '…'
182
+ return resolveLabel(raw)
183
+ }
184
+ return resolveLabel(node.title || node.key || '…')
185
+ }
186
+
187
+ // ── Sortable row ──────────────────────────────────────────────────────────
188
+
189
+ function SortableRow({
190
+ id,
191
+ depth,
192
+ kind,
193
+ label,
194
+ hidden,
195
+ collapsed,
196
+ onToggleCollapse,
197
+ onRename,
198
+ onToggleHidden,
199
+ onDelete,
200
+ onAddFolder,
201
+ onAddLeaf,
202
+ unusedLeaves,
203
+ children,
204
+ }: {
205
+ id: string
206
+ depth: number
207
+ kind: 'group' | 'folder' | 'leaf'
208
+ label: string
209
+ hidden?: boolean
210
+ collapsed?: boolean
211
+ onToggleCollapse?: () => void
212
+ onRename: (next: string) => void
213
+ onToggleHidden: () => void
214
+ onDelete: () => void
215
+ onAddFolder?: () => void
216
+ onAddLeaf?: (ref: string) => void
217
+ unusedLeaves?: CatalogLeaf[]
218
+ children?: React.ReactNode
219
+ }) {
220
+ const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
221
+ useSortable({ id })
222
+ const [editing, setEditing] = React.useState(false)
223
+ const [draft, setDraft] = React.useState(label)
224
+ const inputRef = React.useRef<HTMLInputElement>(null)
225
+
226
+ React.useEffect(() => {
227
+ if (editing) {
228
+ setDraft(label)
229
+ requestAnimationFrame(() => inputRef.current?.select())
230
+ }
231
+ }, [editing, label])
232
+
233
+ const style: React.CSSProperties = {
234
+ transform: CSS.Transform.toString(transform),
235
+ transition,
236
+ opacity: isDragging ? 0.45 : hidden ? 0.45 : 1,
237
+ paddingLeft: 8 + depth * 14,
238
+ }
239
+
240
+ const isCategory = kind === 'group' || kind === 'folder'
241
+
242
+ const commitRename = () => {
243
+ const next = draft.trim()
244
+ setEditing(false)
245
+ if (next && next !== label) onRename(next)
246
+ }
247
+
248
+ return (
249
+ <div ref={setNodeRef} style={style} className="group/row">
250
+ <div
251
+ className={cn(
252
+ 'flex items-center gap-1 rounded-md py-1 pr-1',
253
+ isCategory ? 'mt-2 first:mt-0' : 'hover:bg-muted/50',
254
+ isDragging && 'bg-muted/80',
255
+ )}
256
+ >
257
+ <button
258
+ type="button"
259
+ className="text-muted-foreground hover:text-foreground cursor-grab px-0.5 active:cursor-grabbing"
260
+ aria-label="Arrastrar"
261
+ {...attributes}
262
+ {...listeners}
263
+ >
264
+ <GripVertical className="size-3.5 opacity-40 group-hover/row:opacity-100" />
265
+ </button>
266
+
267
+ {isCategory ? (
268
+ <button
269
+ type="button"
270
+ className="text-muted-foreground hover:text-foreground p-0.5"
271
+ onClick={onToggleCollapse}
272
+ >
273
+ {collapsed ? (
274
+ <ChevronRight className="size-3.5" />
275
+ ) : (
276
+ <ChevronDown className="size-3.5" />
277
+ )}
278
+ </button>
279
+ ) : (
280
+ <Hash className="text-muted-foreground size-3.5 shrink-0 opacity-60" />
281
+ )}
282
+
283
+ {kind === 'folder' ? (
284
+ <Folder className="size-3.5 shrink-0 text-amber-500/90" />
285
+ ) : null}
286
+
287
+ {editing ? (
288
+ <Input
289
+ ref={inputRef}
290
+ value={draft}
291
+ onChange={(e) => setDraft(e.target.value)}
292
+ onBlur={commitRename}
293
+ onKeyDown={(e) => {
294
+ if (e.key === 'Enter') commitRename()
295
+ if (e.key === 'Escape') setEditing(false)
296
+ }}
297
+ className="h-7 flex-1 text-sm"
298
+ />
299
+ ) : (
300
+ <button
301
+ type="button"
302
+ className={cn(
303
+ 'min-w-0 flex-1 truncate text-left text-sm',
304
+ isCategory
305
+ ? 'text-muted-foreground text-[11px] font-semibold uppercase tracking-wider'
306
+ : 'font-medium',
307
+ )}
308
+ onDoubleClick={() => setEditing(true)}
309
+ title="Doble clic para renombrar"
310
+ >
311
+ {label}
312
+ </button>
313
+ )}
314
+
315
+ <div className="flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover/row:opacity-100 focus-within:opacity-100">
316
+ {kind === 'group' || kind === 'folder' ? (
317
+ <>
318
+ {onAddFolder ? (
319
+ <Button
320
+ type="button"
321
+ size="icon"
322
+ variant="ghost"
323
+ className="size-6"
324
+ title="Nueva carpeta"
325
+ onClick={onAddFolder}
326
+ >
327
+ <FolderPlus className="size-3" />
328
+ </Button>
329
+ ) : null}
330
+ {onAddLeaf && unusedLeaves && unusedLeaves.length > 0 ? (
331
+ <select
332
+ className="border-input bg-background h-6 max-w-[110px] rounded border px-1 text-[10px]"
333
+ defaultValue=""
334
+ title="Agregar ítem"
335
+ onChange={(e) => {
336
+ const ref = e.target.value
337
+ e.target.value = ''
338
+ if (ref) onAddLeaf(ref)
339
+ }}
340
+ >
341
+ <option value="">+ ítem</option>
342
+ {unusedLeaves.map((l) => (
343
+ <option key={l.ref} value={l.ref}>
344
+ {l.title}
345
+ </option>
346
+ ))}
347
+ </select>
348
+ ) : null}
349
+ </>
350
+ ) : null}
351
+ <Button
352
+ type="button"
353
+ size="icon"
354
+ variant="ghost"
355
+ className="size-6"
356
+ title="Renombrar"
357
+ onClick={() => setEditing(true)}
358
+ >
359
+ <Pencil className="size-3" />
360
+ </Button>
361
+ <Button
362
+ type="button"
363
+ size="icon"
364
+ variant="ghost"
365
+ className="size-6"
366
+ title={hidden ? 'Mostrar' : 'Ocultar'}
367
+ onClick={onToggleHidden}
368
+ >
369
+ {hidden ? <EyeOff className="size-3" /> : <Eye className="size-3" />}
370
+ </Button>
371
+ <Button
372
+ type="button"
373
+ size="icon"
374
+ variant="ghost"
375
+ className="text-destructive size-6"
376
+ title="Quitar"
377
+ onClick={onDelete}
378
+ >
379
+ <Trash2 className="size-3" />
380
+ </Button>
381
+ </div>
382
+ </div>
383
+ {!collapsed ? children : null}
384
+ </div>
385
+ )
386
+ }
387
+
388
+ // ── Manager ───────────────────────────────────────────────────────────────
389
+
390
+ export function SidebarLayoutManager({
391
+ loadLayout,
392
+ saveLayout,
393
+ resetLayout,
394
+ catalog,
395
+ currentGroups,
396
+ resolveLabel = (s) => s,
397
+ title = 'Menú lateral',
398
+ description = 'Arrastrá, renombrá y agrupá como en Discord. Así lo verá toda la organización.',
399
+ }: SidebarLayoutManagerProps) {
400
+ const [tree, setTree] = React.useState<SidebarLayoutNode[]>([])
401
+ const [baseline, setBaseline] = React.useState('[]')
402
+ const [loading, setLoading] = React.useState(true)
403
+ const [saving, setSaving] = React.useState(false)
404
+ const [collapsed, setCollapsed] = React.useState<Record<string, boolean>>({})
405
+ const [activeId, setActiveId] = React.useState<string | null>(null)
406
+ const [newCategory, setNewCategory] = React.useState('')
407
+
408
+ const catalogByRef = React.useMemo(() => {
409
+ const m = new Map<string, CatalogLeaf>()
410
+ for (const l of catalog) {
411
+ m.set(l.ref, { ...l, title: resolveLabel(l.title) })
412
+ for (const a of l.aliases ?? []) m.set(a, m.get(l.ref)!)
413
+ }
414
+ return m
415
+ }, [catalog, resolveLabel])
416
+
417
+ const unused = React.useMemo(() => {
418
+ const used = collectUsedRefs(tree)
419
+ return catalog
420
+ .filter((l) => !used.has(l.ref) && !(l.aliases ?? []).some((a) => used.has(a)))
421
+ .map((l) => ({ ...l, title: resolveLabel(l.title) }))
422
+ }, [catalog, tree, resolveLabel])
423
+
424
+ const sensors = useSensors(
425
+ useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
426
+ )
427
+
428
+ const seedFromCurrent = React.useCallback(() => {
429
+ if (!currentGroups?.length) {
430
+ toast.message('No hay menú actual para copiar')
431
+ return []
432
+ }
433
+ return layoutTreeFromNavGroups(currentGroups, (u) => leafRefFor(u))
434
+ }, [currentGroups])
435
+
436
+ const reload = React.useCallback(async () => {
437
+ setLoading(true)
438
+ try {
439
+ const doc = await loadLayout()
440
+ let t = doc.tree ?? []
441
+ // Empty overlay → show live menu as editable draft (Discord empty-server feel).
442
+ if (t.length === 0 && currentGroups?.length) {
443
+ t = seedFromCurrent()
444
+ setBaseline('[]') // still "unsaved overlay"
445
+ } else {
446
+ setBaseline(JSON.stringify(t))
447
+ }
448
+ setTree(t)
449
+ } catch (e) {
450
+ toast.error(e instanceof Error ? e.message : 'No se pudo cargar el menú')
451
+ } finally {
452
+ setLoading(false)
453
+ }
454
+ }, [loadLayout, currentGroups, seedFromCurrent])
455
+
456
+ React.useEffect(() => {
457
+ void reload()
458
+ }, [reload])
459
+
460
+ const dirty = JSON.stringify(tree) !== baseline
461
+ const folderKeys = React.useMemo(() => collectFolderKeys(tree), [tree])
462
+
463
+ // Flat id → path index for DnD (ids are path keys)
464
+ const idToPath = React.useMemo(() => {
465
+ const map = new Map<string, Path>()
466
+ const walk = (nodes: SidebarLayoutNode[], parent: Path) => {
467
+ nodes.forEach((_, i) => {
468
+ const path = [...parent, i]
469
+ map.set(pathKey(path), path)
470
+ const n = nodes[i]!
471
+ if (n.children?.length) walk(n.children, path)
472
+ })
473
+ }
474
+ walk(tree, [])
475
+ return map
476
+ }, [tree])
477
+
478
+ const handleDragStart = (e: DragStartEvent) => {
479
+ setActiveId(String(e.active.id))
480
+ }
481
+
482
+ const handleDragEnd = (e: DragEndEvent) => {
483
+ setActiveId(null)
484
+ const { active, over } = e
485
+ if (!over || active.id === over.id) return
486
+ const fromPath = idToPath.get(String(active.id))
487
+ const toPath = idToPath.get(String(over.id))
488
+ if (!fromPath || !toPath) return
489
+
490
+ // Same parent → reorder siblings
491
+ const fromParent = fromPath.slice(0, -1)
492
+ const toParent = toPath.slice(0, -1)
493
+ if (pathKey(fromParent) === pathKey(toParent)) {
494
+ const kids =
495
+ fromParent.length === 0
496
+ ? [...tree]
497
+ : [...(getAt(tree, fromParent)?.children ?? [])]
498
+ const oldIndex = fromPath[fromPath.length - 1]!
499
+ const newIndex = toPath[toPath.length - 1]!
500
+ setTree(setChildren(tree, fromParent, arrayMove(kids, oldIndex, newIndex)))
501
+ return
502
+ }
503
+
504
+ // Different parent → move node under target's parent at target index
505
+ // (or into target if it's a folder/group)
506
+ const overNode = getAt(tree, toPath)
507
+ let destParent = toParent
508
+ let destIndex = toPath[toPath.length - 1]!
509
+ if (overNode && (overNode.type === 'group' || overNode.type === 'folder')) {
510
+ destParent = toPath
511
+ destIndex = overNode.children?.length ?? 0
512
+ }
513
+
514
+ // Prevent dropping into own descendant
515
+ const fromKey = pathKey(fromPath)
516
+ if (pathKey(destParent).startsWith(fromKey + '.') || pathKey(destParent) === fromKey) {
517
+ return
518
+ }
519
+
520
+ setTree((prev) => {
521
+ const { tree: without, node } = removeAt(prev, fromPath)
522
+ if (!node) return prev
523
+ // Recompute dest index if we removed from earlier sibling in same parent
524
+ let idx = destIndex
525
+ if (pathKey(fromParent) === pathKey(destParent) && fromPath[fromPath.length - 1]! < destIndex) {
526
+ idx = Math.max(0, destIndex - 1)
527
+ }
528
+ return insertAt(without, destParent, idx, node)
529
+ })
530
+ }
531
+
532
+ const handleSave = async () => {
533
+ setSaving(true)
534
+ try {
535
+ await saveLayout({ version: 1, tree })
536
+ setBaseline(JSON.stringify(tree))
537
+ toast.success('Menú guardado para toda la organización')
538
+ } catch (e) {
539
+ toast.error(e instanceof Error ? e.message : 'Error al guardar')
540
+ } finally {
541
+ setSaving(false)
542
+ }
543
+ }
544
+
545
+ const handleReset = async () => {
546
+ setSaving(true)
547
+ try {
548
+ await resetLayout()
549
+ const seeded = seedFromCurrent()
550
+ setTree(seeded)
551
+ setBaseline('[]')
552
+ toast.success('Overlay restaurado — mostrando el menú default')
553
+ } catch (e) {
554
+ toast.error(e instanceof Error ? e.message : 'Error al resetear')
555
+ } finally {
556
+ setSaving(false)
557
+ }
558
+ }
559
+
560
+ const addRootCategory = () => {
561
+ const name = newCategory.trim() || 'Nueva sección'
562
+ const key = slugifyFolderKey(name)
563
+ setTree((t) => [
564
+ ...t,
565
+ {
566
+ type: 'group',
567
+ key: `group_${key}_${Date.now() % 10000}`,
568
+ title: name,
569
+ children: [],
570
+ },
571
+ ])
572
+ setNewCategory('')
573
+ }
574
+
575
+ const renderList = (nodes: SidebarLayoutNode[], parentPath: Path): React.ReactNode => {
576
+ const ids = nodes.map((_, i) => pathKey([...parentPath, i]))
577
+ return (
578
+ <SortableContext items={ids} strategy={verticalListSortingStrategy}>
579
+ {nodes.map((node, i) => {
580
+ const path = [...parentPath, i]
581
+ const id = pathKey(path)
582
+ const depth = parentPath.length
583
+ const isOpen = !(collapsed[id] ?? false)
584
+ const lbl = labelOf(node, catalogByRef, resolveLabel)
585
+
586
+ return (
587
+ <SortableRow
588
+ key={id}
589
+ id={id}
590
+ depth={depth}
591
+ kind={node.type}
592
+ label={lbl}
593
+ hidden={node.hidden}
594
+ collapsed={!isOpen}
595
+ onToggleCollapse={() =>
596
+ setCollapsed((c) => ({ ...c, [id]: !(c[id] ?? false) }))
597
+ }
598
+ onRename={(next) => setTree((t) => updateAt(t, path, { title: next }))}
599
+ onToggleHidden={() =>
600
+ setTree((t) => updateAt(t, path, { hidden: !node.hidden }))
601
+ }
602
+ onDelete={() => setTree((t) => removeAt(t, path).tree)}
603
+ onAddFolder={
604
+ node.type === 'group' || node.type === 'folder'
605
+ ? () => {
606
+ const key = slugifyFolderKey(`carpeta_${Date.now() % 1000}`)
607
+ setTree((t) =>
608
+ insertAt(t, path, node.children?.length ?? 0, {
609
+ type: 'folder',
610
+ key,
611
+ title: 'Nueva carpeta',
612
+ icon: 'Folder',
613
+ children: [],
614
+ }),
615
+ )
616
+ setCollapsed((c) => ({ ...c, [id]: false }))
617
+ }
618
+ : undefined
619
+ }
620
+ onAddLeaf={
621
+ node.type === 'group' || node.type === 'folder'
622
+ ? (ref) => {
623
+ const leaf = catalog.find((l) => l.ref === ref)
624
+ setTree((t) =>
625
+ insertAt(t, path, node.children?.length ?? 0, {
626
+ type: 'leaf',
627
+ ref,
628
+ title: leaf?.title,
629
+ icon: leaf?.icon,
630
+ }),
631
+ )
632
+ }
633
+ : undefined
634
+ }
635
+ unusedLeaves={unused}
636
+ >
637
+ {node.type !== 'leaf' && isOpen
638
+ ? renderList(node.children ?? [], path)
639
+ : null}
640
+ </SortableRow>
641
+ )
642
+ })}
643
+ </SortableContext>
644
+ )
645
+ }
646
+
647
+ const activeLabel = activeId
648
+ ? (() => {
649
+ const p = idToPath.get(activeId)
650
+ if (!p) return null
651
+ const n = getAt(tree, p)
652
+ return n ? labelOf(n, catalogByRef, resolveLabel) : null
653
+ })()
654
+ : null
655
+
656
+ return (
657
+ <div className="space-y-4">
658
+ <div className="flex flex-wrap items-start justify-between gap-3">
659
+ <div className="space-y-1">
660
+ <h2 className="text-xl font-semibold tracking-tight">{title}</h2>
661
+ <p className="text-muted-foreground max-w-xl text-sm">{description}</p>
662
+ </div>
663
+ <div className="flex flex-wrap gap-2">
664
+ <Button
665
+ type="button"
666
+ variant="outline"
667
+ size="sm"
668
+ disabled={saving}
669
+ onClick={() => {
670
+ setTree(seedFromCurrent())
671
+ toast.message('Árbol recargado desde el menú actual')
672
+ }}
673
+ >
674
+ Recargar menú actual
675
+ </Button>
676
+ <Button
677
+ type="button"
678
+ variant="outline"
679
+ size="sm"
680
+ disabled={saving}
681
+ onClick={() => void handleReset()}
682
+ >
683
+ <RotateCcw className="mr-1 size-3.5" />
684
+ Restaurar default
685
+ </Button>
686
+ <Button
687
+ type="button"
688
+ size="sm"
689
+ disabled={!dirty || saving}
690
+ onClick={() => void handleSave()}
691
+ >
692
+ <Save className="mr-1 size-3.5" />
693
+ {saving ? 'Guardando…' : dirty ? 'Guardar cambios' : 'Guardado'}
694
+ </Button>
695
+ </div>
696
+ </div>
697
+
698
+ {loading ? (
699
+ <p className="text-muted-foreground text-sm">Cargando menú…</p>
700
+ ) : (
701
+ <div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_240px]">
702
+ {/* Discord-like sidebar canvas */}
703
+ <div className="bg-muted/20 border-border overflow-hidden rounded-xl border">
704
+ <div className="border-border/60 flex items-center justify-between border-b px-3 py-2">
705
+ <span className="text-muted-foreground text-[11px] font-semibold uppercase tracking-wider">
706
+ Vista del menú
707
+ </span>
708
+ {baseline === '[]' && dirty ? (
709
+ <span className="text-amber-600 text-[11px]">Sin guardar · borrador del menú actual</span>
710
+ ) : dirty ? (
711
+ <span className="text-amber-600 text-[11px]">Cambios sin guardar</span>
712
+ ) : (
713
+ <span className="text-muted-foreground text-[11px]">
714
+ {folderKeys.length
715
+ ? `${folderKeys.length} carpeta(s) con permiso`
716
+ : 'Layout activo'}
717
+ </span>
718
+ )}
719
+ </div>
720
+
721
+ <ScrollArea className="h-[min(70vh,640px)]">
722
+ <div className="p-2 pb-4">
723
+ <DndContext
724
+ sensors={sensors}
725
+ collisionDetection={closestCenter}
726
+ onDragStart={handleDragStart}
727
+ onDragEnd={handleDragEnd}
728
+ >
729
+ {tree.length === 0 ? (
730
+ <p className="text-muted-foreground px-3 py-8 text-center text-sm">
731
+ Menú vacío. Creá una sección o recargá el menú actual.
732
+ </p>
733
+ ) : (
734
+ renderList(tree, [])
735
+ )}
736
+ <DragOverlay>
737
+ {activeLabel ? (
738
+ <div className="bg-background border-border rounded-md border px-3 py-1.5 text-sm shadow-lg">
739
+ {activeLabel}
740
+ </div>
741
+ ) : null}
742
+ </DragOverlay>
743
+ </DndContext>
744
+
745
+ <div className="mt-3 flex gap-2 px-2">
746
+ <Input
747
+ value={newCategory}
748
+ onChange={(e) => setNewCategory(e.target.value)}
749
+ placeholder="Nombre de sección…"
750
+ className="h-8 text-sm"
751
+ onKeyDown={(e) => {
752
+ if (e.key === 'Enter') addRootCategory()
753
+ }}
754
+ />
755
+ <Button type="button" size="sm" className="h-8 shrink-0" onClick={addRootCategory}>
756
+ <Plus className="mr-1 size-3.5" />
757
+ Sección
758
+ </Button>
759
+ </div>
760
+ </div>
761
+ </ScrollArea>
762
+ </div>
763
+
764
+ {/* Unused pool */}
765
+ <div className="bg-muted/10 border-border flex flex-col overflow-hidden rounded-xl border">
766
+ <div className="border-border/60 border-b px-3 py-2">
767
+ <p className="text-muted-foreground text-[11px] font-semibold uppercase tracking-wider">
768
+ Sin colocar
769
+ </p>
770
+ <p className="text-muted-foreground mt-0.5 text-[11px]">
771
+ Agregalos desde el menú + de cada sección
772
+ </p>
773
+ </div>
774
+ <ScrollArea className="flex-1">
775
+ <ul className="space-y-1 p-2">
776
+ {unused.length === 0 ? (
777
+ <li className="text-muted-foreground px-1 py-4 text-center text-xs">
778
+ Todo está en el menú
779
+ </li>
780
+ ) : (
781
+ unused.map((l) => (
782
+ <li key={l.ref}>
783
+ <button
784
+ type="button"
785
+ className="hover:bg-muted/60 flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs"
786
+ title={l.ref}
787
+ onClick={() => {
788
+ if (tree.length === 0) {
789
+ setTree([
790
+ {
791
+ type: 'group',
792
+ key: 'sidebar.general',
793
+ title: 'General',
794
+ children: [
795
+ { type: 'leaf', ref: l.ref, title: l.title, icon: l.icon },
796
+ ],
797
+ },
798
+ ])
799
+ } else {
800
+ setTree((t) =>
801
+ insertAt(t, [0], t[0]?.children?.length ?? 0, {
802
+ type: 'leaf',
803
+ ref: l.ref,
804
+ title: l.title,
805
+ icon: l.icon,
806
+ }),
807
+ )
808
+ }
809
+ }}
810
+ >
811
+ <Hash className="text-muted-foreground size-3 shrink-0" />
812
+ <span className="truncate font-medium">{l.title}</span>
813
+ <Plus className="text-muted-foreground ml-auto size-3 shrink-0 opacity-50" />
814
+ </button>
815
+ </li>
816
+ ))
817
+ )}
818
+ </ul>
819
+ </ScrollArea>
820
+ {folderKeys.length > 0 ? (
821
+ <div className="border-border/60 text-muted-foreground border-t px-3 py-2 text-[10px] leading-relaxed">
822
+ Permisos:{' '}
823
+ {folderKeys.map((k) => folderAccessCapability(k)).join(', ')}
824
+ </div>
825
+ ) : null}
826
+ </div>
827
+ </div>
828
+ )}
829
+ </div>
830
+ )
831
+ }
832
+
833
+ export type { CatalogLeaf, SidebarLayoutDoc, SidebarLayoutNode }