@asteby/metacore-runtime-react 39.2.0 → 39.2.2

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.
@@ -1,40 +1,52 @@
1
1
  /**
2
- * SidebarLayoutManager — admin editor for the org-wide sidebar overlay.
3
- * Transport-agnostic (loaders/mutators via props), same pattern as
4
- * PermissionsManager. Hosts wire fetchers to /api/org/sidebar-layout.
2
+ * SidebarLayoutManager — Discord-style org sidebar editor.
3
+ * The canvas IS the sidebar: sections, folders, leaves — drag, rename, nest.
5
4
  */
6
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'
7
23
  import {
8
24
  ChevronDown,
9
25
  ChevronRight,
10
26
  FolderPlus,
11
27
  GripVertical,
28
+ Pencil,
29
+ Plus,
12
30
  RotateCcw,
13
31
  Save,
14
32
  Trash2,
15
33
  Eye,
16
34
  EyeOff,
35
+ Hash,
17
36
  Folder,
18
- FileText,
19
37
  } from 'lucide-react'
20
38
  import { toast } from 'sonner'
21
39
  import {
22
40
  Button,
23
- Card,
24
- CardContent,
25
- CardDescription,
26
- CardHeader,
27
- CardTitle,
28
41
  Input,
29
- Label,
30
42
  ScrollArea,
31
- Badge,
32
43
  } from '@asteby/metacore-ui/primitives'
44
+ import { cn } from '@asteby/metacore-ui/lib'
33
45
  import {
34
- applySidebarLayout,
35
46
  collectFolderKeys,
36
47
  folderAccessCapability,
37
48
  layoutTreeFromNavGroups,
49
+ leafRefFor,
38
50
  slugifyFolderKey,
39
51
  type CatalogLeaf,
40
52
  type SidebarLayoutDoc,
@@ -45,12 +57,7 @@ export interface SidebarLayoutManagerProps {
45
57
  loadLayout: () => Promise<SidebarLayoutDoc>
46
58
  saveLayout: (doc: SidebarLayoutDoc) => Promise<void>
47
59
  resetLayout: () => Promise<void>
48
- /** Live catalog of every leaf the shell can show (core + addons). */
49
60
  catalog: CatalogLeaf[]
50
- /**
51
- * Optional: current live nav groups — used by "Partir del menú actual" to
52
- * seed a tree when the org has no layout yet.
53
- */
54
61
  currentGroups?: Array<{
55
62
  title: string
56
63
  items: Array<{
@@ -60,10 +67,98 @@ export interface SidebarLayoutManagerProps {
60
67
  items?: Array<{ title: string; url: string; icon?: string }>
61
68
  }>
62
69
  }>
70
+ /** Resolve i18n keys / raw titles to human labels. */
71
+ resolveLabel?: (title: string) => string
63
72
  title?: string
64
73
  description?: string
65
74
  }
66
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
+
67
162
  function collectUsedRefs(tree: SidebarLayoutNode[]): Set<string> {
68
163
  const used = new Set<string>()
69
164
  const walk = (nodes: SidebarLayoutNode[]) => {
@@ -76,103 +171,363 @@ function collectUsedRefs(tree: SidebarLayoutNode[]): Set<string> {
76
171
  return used
77
172
  }
78
173
 
79
- function updateAt(
80
- tree: SidebarLayoutNode[],
81
- path: number[],
82
- fn: (node: SidebarLayoutNode) => SidebarLayoutNode | null,
83
- ): SidebarLayoutNode[] {
84
- if (path.length === 0) return tree
85
- const [head, ...rest] = path
86
- return tree
87
- .map((n, i) => {
88
- if (i !== head) return n
89
- if (rest.length === 0) return fn(n)
90
- const kids = updateAt(n.children ?? [], rest, fn)
91
- return { ...n, children: kids }
92
- })
93
- .filter((n): n is SidebarLayoutNode => n != null)
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 || '…')
94
185
  }
95
186
 
96
- function removeAt(tree: SidebarLayoutNode[], path: number[]): SidebarLayoutNode[] {
97
- return updateAt(tree, path, () => null).filter(Boolean) as SidebarLayoutNode[]
98
- }
187
+ // ── Sortable row ──────────────────────────────────────────────────────────
99
188
 
100
- function moveSibling(tree: SidebarLayoutNode[], path: number[], dir: -1 | 1): SidebarLayoutNode[] {
101
- if (path.length === 0) return tree
102
- const parentPath = path.slice(0, -1)
103
- const idx = path[path.length - 1]!
104
- const getList = (nodes: SidebarLayoutNode[], p: number[]): SidebarLayoutNode[] => {
105
- if (p.length === 0) return nodes
106
- let cur: SidebarLayoutNode[] = nodes
107
- for (const i of p) {
108
- cur = cur[i]?.children ?? []
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())
109
230
  }
110
- return cur
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,
111
238
  }
112
- const list = [...getList(tree, parentPath)]
113
- const j = idx + dir
114
- if (j < 0 || j >= list.length) return tree
115
- ;[list[idx], list[j]] = [list[j]!, list[idx]!]
116
- if (parentPath.length === 0) return list
117
- return updateAt(tree, parentPath, (n) => ({ ...n, children: list }))
118
- }
119
239
 
120
- function appendChild(
121
- tree: SidebarLayoutNode[],
122
- parentPath: number[],
123
- child: SidebarLayoutNode,
124
- ): SidebarLayoutNode[] {
125
- if (parentPath.length === 0) return [...tree, child]
126
- return updateAt(tree, parentPath, (n) => ({
127
- ...n,
128
- children: [...(n.children ?? []), child],
129
- }))
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
+ )
130
386
  }
131
387
 
388
+ // ── Manager ───────────────────────────────────────────────────────────────
389
+
132
390
  export function SidebarLayoutManager({
133
391
  loadLayout,
134
392
  saveLayout,
135
393
  resetLayout,
136
394
  catalog,
137
395
  currentGroups,
396
+ resolveLabel = (s) => s,
138
397
  title = 'Menú lateral',
139
- description = 'Organizá carpetas y el orden del sidebar para toda la organización. Los permisos de cada carpeta aparecen en Permisos.',
398
+ description = 'Arrastrá, renombrá y agrupá como en Discord. Así lo verá toda la organización.',
140
399
  }: SidebarLayoutManagerProps) {
141
400
  const [tree, setTree] = React.useState<SidebarLayoutNode[]>([])
142
- const [baseline, setBaseline] = React.useState<string>('[]')
401
+ const [baseline, setBaseline] = React.useState('[]')
143
402
  const [loading, setLoading] = React.useState(true)
144
403
  const [saving, setSaving] = React.useState(false)
145
- const [newFolderTitle, setNewFolderTitle] = React.useState('')
146
- const [expanded, setExpanded] = React.useState<Record<string, boolean>>({})
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])
147
435
 
148
436
  const reload = React.useCallback(async () => {
149
437
  setLoading(true)
150
438
  try {
151
439
  const doc = await loadLayout()
152
- const t = doc.tree ?? []
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
+ }
153
448
  setTree(t)
154
- setBaseline(JSON.stringify(t))
155
449
  } catch (e) {
156
- toast.error(e instanceof Error ? e.message : 'No se pudo cargar el layout')
450
+ toast.error(e instanceof Error ? e.message : 'No se pudo cargar el menú')
157
451
  } finally {
158
452
  setLoading(false)
159
453
  }
160
- }, [loadLayout])
454
+ }, [loadLayout, currentGroups, seedFromCurrent])
161
455
 
162
456
  React.useEffect(() => {
163
457
  void reload()
164
458
  }, [reload])
165
459
 
166
460
  const dirty = JSON.stringify(tree) !== baseline
167
- const used = React.useMemo(() => collectUsedRefs(tree), [tree])
168
- const unused = React.useMemo(
169
- () => catalog.filter((l) => !used.has(l.ref) && !(l.aliases ?? []).some((a) => used.has(a))),
170
- [catalog, used],
171
- )
172
461
  const folderKeys = React.useMemo(() => collectFolderKeys(tree), [tree])
173
- const preview = React.useMemo(() => applySidebarLayout(catalog, tree), [catalog, tree])
174
462
 
175
- const pathKey = (path: number[]) => path.join('.')
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
+ }
176
531
 
177
532
  const handleSave = async () => {
178
533
  setSaving(true)
@@ -191,9 +546,10 @@ export function SidebarLayoutManager({
191
546
  setSaving(true)
192
547
  try {
193
548
  await resetLayout()
194
- setTree([])
549
+ const seeded = seedFromCurrent()
550
+ setTree(seeded)
195
551
  setBaseline('[]')
196
- toast.success('Menú restaurado al default (addons + core)')
552
+ toast.success('Overlay restaurado — mostrando el menú default')
197
553
  } catch (e) {
198
554
  toast.error(e instanceof Error ? e.message : 'Error al resetear')
199
555
  } finally {
@@ -201,318 +557,273 @@ export function SidebarLayoutManager({
201
557
  }
202
558
  }
203
559
 
204
- const seedFromCurrent = () => {
205
- if (!currentGroups?.length) {
206
- toast.message('No hay menú actual para copiar')
207
- return
208
- }
209
- const seeded = layoutTreeFromNavGroups(currentGroups)
210
- setTree(seeded)
211
- toast.message('Árbol partido del menú actual — guardá para aplicar')
212
- }
213
-
214
- const addRootFolder = () => {
215
- const titleText = newFolderTitle.trim() || 'Nueva carpeta'
216
- const key = slugifyFolderKey(titleText)
560
+ const addRootCategory = () => {
561
+ const name = newCategory.trim() || 'Nueva sección'
562
+ const key = slugifyFolderKey(name)
217
563
  setTree((t) => [
218
564
  ...t,
219
565
  {
220
566
  type: 'group',
221
- key: `group_${key}`,
222
- title: titleText,
223
- children: [{ type: 'folder', key, title: titleText, icon: 'Folder', children: [] }],
567
+ key: `group_${key}_${Date.now() % 10000}`,
568
+ title: name,
569
+ children: [],
224
570
  },
225
571
  ])
226
- setNewFolderTitle('')
227
- }
228
-
229
- const addLeafTo = (parentPath: number[], leaf: CatalogLeaf) => {
230
- setTree((t) =>
231
- appendChild(t, parentPath, {
232
- type: 'leaf',
233
- ref: leaf.ref,
234
- title: leaf.title,
235
- icon: leaf.icon,
236
- }),
237
- )
572
+ setNewCategory('')
238
573
  }
239
574
 
240
- const renderNode = (node: SidebarLayoutNode, path: number[]): React.ReactNode => {
241
- const k = pathKey(path)
242
- const isOpen = expanded[k] ?? true
243
- const isFolderish = node.type === 'group' || node.type === 'folder'
244
-
575
+ const renderList = (nodes: SidebarLayoutNode[], parentPath: Path): React.ReactNode => {
576
+ const ids = nodes.map((_, i) => pathKey([...parentPath, i]))
245
577
  return (
246
- <div key={k} className="border-border/60 rounded-md border bg-background/50">
247
- <div className="flex items-center gap-1 px-2 py-1.5">
248
- {isFolderish ? (
249
- <button
250
- type="button"
251
- className="text-muted-foreground hover:text-foreground p-0.5"
252
- onClick={() => setExpanded((e) => ({ ...e, [k]: !isOpen }))}
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}
253
636
  >
254
- {isOpen ? <ChevronDown className="size-4" /> : <ChevronRight className="size-4" />}
255
- </button>
256
- ) : (
257
- <GripVertical className="text-muted-foreground size-4 shrink-0 opacity-40" />
258
- )}
259
- {node.type === 'folder' ? (
260
- <Folder className="text-amber-600 size-4 shrink-0" />
261
- ) : node.type === 'leaf' ? (
262
- <FileText className="text-muted-foreground size-4 shrink-0" />
263
- ) : null}
264
- <span className="min-w-0 flex-1 truncate text-sm font-medium">
265
- {node.title || node.key || node.ref}
266
- </span>
267
- {node.type === 'folder' && node.key ? (
268
- <Badge variant="outline" className="font-mono text-[10px]">
269
- {folderAccessCapability(node.key)}
270
- </Badge>
271
- ) : null}
272
- <Button
273
- type="button"
274
- size="icon"
275
- variant="ghost"
276
- className="size-7"
277
- title={node.hidden ? 'Mostrar' : 'Ocultar'}
278
- onClick={() =>
279
- setTree((t) => updateAt(t, path, (n) => ({ ...n, hidden: !n.hidden })))
280
- }
281
- >
282
- {node.hidden ? <EyeOff className="size-3.5" /> : <Eye className="size-3.5" />}
283
- </Button>
284
- <Button
285
- type="button"
286
- size="icon"
287
- variant="ghost"
288
- className="size-7"
289
- onClick={() => setTree((t) => moveSibling(t, path, -1))}
290
- >
291
- ↑
292
- </Button>
293
- <Button
294
- type="button"
295
- size="icon"
296
- variant="ghost"
297
- className="size-7"
298
- onClick={() => setTree((t) => moveSibling(t, path, 1))}
299
- >
300
- ↓
301
- </Button>
302
- <Button
303
- type="button"
304
- size="icon"
305
- variant="ghost"
306
- className="text-destructive size-7"
307
- onClick={() => setTree((t) => removeAt(t, path))}
308
- >
309
- <Trash2 className="size-3.5" />
310
- </Button>
311
- </div>
312
- {isFolderish && isOpen ? (
313
- <div className="border-border/40 space-y-1 border-t px-2 py-2 pl-6">
314
- {(node.children ?? []).map((child, i) => renderNode(child, [...path, i]))}
315
- {unused.length > 0 ? (
316
- <select
317
- className="border-input bg-background w-full rounded-md border px-2 py-1 text-xs"
318
- defaultValue=""
319
- onChange={(e) => {
320
- const ref = e.target.value
321
- e.target.value = ''
322
- const leaf = catalog.find((l) => l.ref === ref)
323
- if (leaf) addLeafTo(path, leaf)
324
- }}
325
- >
326
- <option value="">+ Agregar ítem…</option>
327
- {unused.map((l) => (
328
- <option key={l.ref} value={l.ref}>
329
- {l.title} ({l.ref})
330
- </option>
331
- ))}
332
- </select>
333
- ) : null}
334
- {node.type === 'group' ? (
335
- <Button
336
- type="button"
337
- size="sm"
338
- variant="outline"
339
- className="h-7 text-xs"
340
- onClick={() => {
341
- const titleText = 'Nueva carpeta'
342
- const key = slugifyFolderKey(`${titleText}_${path.join('')}_${Date.now() % 1000}`)
343
- setTree((t) =>
344
- appendChild(t, path, {
345
- type: 'folder',
346
- key,
347
- title: titleText,
348
- icon: 'Folder',
349
- children: [],
350
- }),
351
- )
352
- }}
353
- >
354
- <FolderPlus className="mr-1 size-3.5" />
355
- Subcarpeta
356
- </Button>
357
- ) : null}
358
- </div>
359
- ) : null}
360
- </div>
637
+ {node.type !== 'leaf' && isOpen
638
+ ? renderList(node.children ?? [], path)
639
+ : null}
640
+ </SortableRow>
641
+ )
642
+ })}
643
+ </SortableContext>
361
644
  )
362
645
  }
363
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
+
364
656
  return (
365
657
  <div className="space-y-4">
366
658
  <div className="flex flex-wrap items-start justify-between gap-3">
367
659
  <div className="space-y-1">
368
660
  <h2 className="text-xl font-semibold tracking-tight">{title}</h2>
369
- <p className="text-muted-foreground max-w-2xl text-sm">{description}</p>
661
+ <p className="text-muted-foreground max-w-xl text-sm">{description}</p>
370
662
  </div>
371
663
  <div className="flex flex-wrap gap-2">
372
- <Button type="button" variant="outline" size="sm" onClick={seedFromCurrent}>
373
- Partir del menú actual
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
374
675
  </Button>
375
- <Button type="button" variant="outline" size="sm" disabled={saving} onClick={() => void handleReset()}>
676
+ <Button
677
+ type="button"
678
+ variant="outline"
679
+ size="sm"
680
+ disabled={saving}
681
+ onClick={() => void handleReset()}
682
+ >
376
683
  <RotateCcw className="mr-1 size-3.5" />
377
684
  Restaurar default
378
685
  </Button>
379
- <Button type="button" size="sm" disabled={!dirty || saving} onClick={() => void handleSave()}>
686
+ <Button
687
+ type="button"
688
+ size="sm"
689
+ disabled={!dirty || saving}
690
+ onClick={() => void handleSave()}
691
+ >
380
692
  <Save className="mr-1 size-3.5" />
381
- {saving ? 'Guardando…' : dirty ? 'Guardar' : 'Guardado'}
693
+ {saving ? 'Guardando…' : dirty ? 'Guardar cambios' : 'Guardado'}
382
694
  </Button>
383
695
  </div>
384
696
  </div>
385
697
 
386
698
  {loading ? (
387
- <p className="text-muted-foreground text-sm">Cargando…</p>
699
+ <p className="text-muted-foreground text-sm">Cargando menú…</p>
388
700
  ) : (
389
- <div className="grid gap-4 lg:grid-cols-2">
390
- <Card>
391
- <CardHeader className="pb-3">
392
- <CardTitle className="text-base">Árbol del menú</CardTitle>
393
- <CardDescription>
394
- Carpetas nuevas otorgan el permiso{' '}
395
- <code className="text-xs">folder.&lt;key&gt;.access</code>.
396
- </CardDescription>
397
- </CardHeader>
398
- <CardContent className="space-y-3">
399
- <div className="flex gap-2">
400
- <div className="flex-1 space-y-1">
401
- <Label className="text-xs">Nueva sección + carpeta</Label>
402
- <Input
403
- value={newFolderTitle}
404
- onChange={(e) => setNewFolderTitle(e.target.value)}
405
- placeholder="Configuración"
406
- />
407
- </div>
408
- <Button type="button" className="mt-5" size="sm" onClick={addRootFolder}>
409
- <FolderPlus className="mr-1 size-3.5" />
410
- Crear
411
- </Button>
412
- </div>
413
- <ScrollArea className="h-[420px] pr-2">
414
- <div className="space-y-2">
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
+ >
415
729
  {tree.length === 0 ? (
416
- <p className="text-muted-foreground text-sm">
417
- Sin overlay — el sidebar usa el merge default. Partí del menú actual o
418
- creá una carpeta.
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.
419
732
  </p>
420
733
  ) : (
421
- tree.map((n, i) => renderNode(n, [i]))
734
+ renderList(tree, [])
422
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>
423
759
  </div>
424
- </ScrollArea>
425
- {folderKeys.length > 0 ? (
426
- <p className="text-muted-foreground text-xs">
427
- Carpetas con permiso: {folderKeys.map((k) => folderAccessCapability(k)).join(', ')}
428
- </p>
429
- ) : null}
430
- </CardContent>
431
- </Card>
432
-
433
- <Card>
434
- <CardHeader className="pb-3">
435
- <CardTitle className="text-base">Catálogo sin usar / vista previa</CardTitle>
436
- <CardDescription>
437
- Ítems disponibles para colocar. La vista previa aplica el overlay sobre el
438
- catálogo vivo.
439
- </CardDescription>
440
- </CardHeader>
441
- <CardContent className="space-y-4">
442
- <ScrollArea className="h-[200px] rounded-md border p-2">
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">
443
776
  {unused.length === 0 ? (
444
- <p className="text-muted-foreground text-xs">Todos los ítems están en el árbol.</p>
445
- ) : (
446
- <ul className="space-y-1">
447
- {unused.map((l) => (
448
- <li key={l.ref} className="flex items-center justify-between gap-2 text-xs">
449
- <span className="truncate">
450
- {l.title}{' '}
451
- <span className="text-muted-foreground font-mono">{l.ref}</span>
452
- </span>
453
- <Button
454
- type="button"
455
- size="sm"
456
- variant="ghost"
457
- className="h-6 shrink-0 text-xs"
458
- onClick={() => {
459
- if (tree.length === 0) {
460
- setTree([
461
- {
462
- type: 'group',
463
- key: 'sidebar.general',
464
- title: 'sidebar.general',
465
- children: [
466
- { type: 'leaf', ref: l.ref, title: l.title, icon: l.icon },
467
- ],
468
- },
469
- ])
470
- } else {
471
- addLeafTo([0], l)
472
- }
473
- }}
474
- >
475
- +
476
- </Button>
477
- </li>
478
- ))}
479
- </ul>
480
- )}
481
- </ScrollArea>
482
- <div>
483
- <p className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
484
- Preview
485
- </p>
486
- {!preview ? (
487
- <p className="text-muted-foreground text-sm">Default merge (sin overlay).</p>
777
+ <li className="text-muted-foreground px-1 py-4 text-center text-xs">
778
+ Todo está en el menú
779
+ </li>
488
780
  ) : (
489
- <ul className="space-y-2 text-sm">
490
- {preview.map((g) => (
491
- <li key={g.title}>
492
- <div className="text-muted-foreground text-xs font-semibold uppercase tracking-wide">
493
- {g.title}
494
- </div>
495
- <ul className="mt-1 space-y-0.5 pl-2">
496
- {g.items.map((it) => (
497
- <li key={`${it.title}-${it.url}`}>
498
- {it.folderKey ? `📁 ${it.title}` : it.title}
499
- {it.items?.length ? (
500
- <ul className="text-muted-foreground pl-4 text-xs">
501
- {it.items.map((c) => (
502
- <li key={c.ref || c.url}>{c.title}</li>
503
- ))}
504
- </ul>
505
- ) : null}
506
- </li>
507
- ))}
508
- </ul>
509
- </li>
510
- ))}
511
- </ul>
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
+ ))
512
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(', ')}
513
824
  </div>
514
- </CardContent>
515
- </Card>
825
+ ) : null}
826
+ </div>
516
827
  </div>
517
828
  )}
518
829
  </div>