@asteby/metacore-runtime-react 39.1.1 → 39.2.0

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,522 @@
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.
5
+ */
6
+ import * as React from 'react'
7
+ import {
8
+ ChevronDown,
9
+ ChevronRight,
10
+ FolderPlus,
11
+ GripVertical,
12
+ RotateCcw,
13
+ Save,
14
+ Trash2,
15
+ Eye,
16
+ EyeOff,
17
+ Folder,
18
+ FileText,
19
+ } from 'lucide-react'
20
+ import { toast } from 'sonner'
21
+ import {
22
+ Button,
23
+ Card,
24
+ CardContent,
25
+ CardDescription,
26
+ CardHeader,
27
+ CardTitle,
28
+ Input,
29
+ Label,
30
+ ScrollArea,
31
+ Badge,
32
+ } from '@asteby/metacore-ui/primitives'
33
+ import {
34
+ applySidebarLayout,
35
+ collectFolderKeys,
36
+ folderAccessCapability,
37
+ layoutTreeFromNavGroups,
38
+ slugifyFolderKey,
39
+ type CatalogLeaf,
40
+ type SidebarLayoutDoc,
41
+ type SidebarLayoutNode,
42
+ } from './apply-sidebar-layout'
43
+
44
+ export interface SidebarLayoutManagerProps {
45
+ loadLayout: () => Promise<SidebarLayoutDoc>
46
+ saveLayout: (doc: SidebarLayoutDoc) => Promise<void>
47
+ resetLayout: () => Promise<void>
48
+ /** Live catalog of every leaf the shell can show (core + addons). */
49
+ 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
+ currentGroups?: Array<{
55
+ title: string
56
+ items: Array<{
57
+ title: string
58
+ url: string
59
+ icon?: string
60
+ items?: Array<{ title: string; url: string; icon?: string }>
61
+ }>
62
+ }>
63
+ title?: string
64
+ description?: string
65
+ }
66
+
67
+ function collectUsedRefs(tree: SidebarLayoutNode[]): Set<string> {
68
+ const used = new Set<string>()
69
+ const walk = (nodes: SidebarLayoutNode[]) => {
70
+ for (const n of nodes) {
71
+ if (n.type === 'leaf' && n.ref) used.add(n.ref)
72
+ if (n.children) walk(n.children)
73
+ }
74
+ }
75
+ walk(tree)
76
+ return used
77
+ }
78
+
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)
94
+ }
95
+
96
+ function removeAt(tree: SidebarLayoutNode[], path: number[]): SidebarLayoutNode[] {
97
+ return updateAt(tree, path, () => null).filter(Boolean) as SidebarLayoutNode[]
98
+ }
99
+
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 ?? []
109
+ }
110
+ return cur
111
+ }
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
+
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
+ }))
130
+ }
131
+
132
+ export function SidebarLayoutManager({
133
+ loadLayout,
134
+ saveLayout,
135
+ resetLayout,
136
+ catalog,
137
+ currentGroups,
138
+ 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.',
140
+ }: SidebarLayoutManagerProps) {
141
+ const [tree, setTree] = React.useState<SidebarLayoutNode[]>([])
142
+ const [baseline, setBaseline] = React.useState<string>('[]')
143
+ const [loading, setLoading] = React.useState(true)
144
+ const [saving, setSaving] = React.useState(false)
145
+ const [newFolderTitle, setNewFolderTitle] = React.useState('')
146
+ const [expanded, setExpanded] = React.useState<Record<string, boolean>>({})
147
+
148
+ const reload = React.useCallback(async () => {
149
+ setLoading(true)
150
+ try {
151
+ const doc = await loadLayout()
152
+ const t = doc.tree ?? []
153
+ setTree(t)
154
+ setBaseline(JSON.stringify(t))
155
+ } catch (e) {
156
+ toast.error(e instanceof Error ? e.message : 'No se pudo cargar el layout')
157
+ } finally {
158
+ setLoading(false)
159
+ }
160
+ }, [loadLayout])
161
+
162
+ React.useEffect(() => {
163
+ void reload()
164
+ }, [reload])
165
+
166
+ 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
+ const folderKeys = React.useMemo(() => collectFolderKeys(tree), [tree])
173
+ const preview = React.useMemo(() => applySidebarLayout(catalog, tree), [catalog, tree])
174
+
175
+ const pathKey = (path: number[]) => path.join('.')
176
+
177
+ const handleSave = async () => {
178
+ setSaving(true)
179
+ try {
180
+ await saveLayout({ version: 1, tree })
181
+ setBaseline(JSON.stringify(tree))
182
+ toast.success('Menú guardado para toda la organización')
183
+ } catch (e) {
184
+ toast.error(e instanceof Error ? e.message : 'Error al guardar')
185
+ } finally {
186
+ setSaving(false)
187
+ }
188
+ }
189
+
190
+ const handleReset = async () => {
191
+ setSaving(true)
192
+ try {
193
+ await resetLayout()
194
+ setTree([])
195
+ setBaseline('[]')
196
+ toast.success('Menú restaurado al default (addons + core)')
197
+ } catch (e) {
198
+ toast.error(e instanceof Error ? e.message : 'Error al resetear')
199
+ } finally {
200
+ setSaving(false)
201
+ }
202
+ }
203
+
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)
217
+ setTree((t) => [
218
+ ...t,
219
+ {
220
+ type: 'group',
221
+ key: `group_${key}`,
222
+ title: titleText,
223
+ children: [{ type: 'folder', key, title: titleText, icon: 'Folder', children: [] }],
224
+ },
225
+ ])
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
+ )
238
+ }
239
+
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
+
245
+ 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 }))}
253
+ >
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>
361
+ )
362
+ }
363
+
364
+ return (
365
+ <div className="space-y-4">
366
+ <div className="flex flex-wrap items-start justify-between gap-3">
367
+ <div className="space-y-1">
368
+ <h2 className="text-xl font-semibold tracking-tight">{title}</h2>
369
+ <p className="text-muted-foreground max-w-2xl text-sm">{description}</p>
370
+ </div>
371
+ <div className="flex flex-wrap gap-2">
372
+ <Button type="button" variant="outline" size="sm" onClick={seedFromCurrent}>
373
+ Partir del menú actual
374
+ </Button>
375
+ <Button type="button" variant="outline" size="sm" disabled={saving} onClick={() => void handleReset()}>
376
+ <RotateCcw className="mr-1 size-3.5" />
377
+ Restaurar default
378
+ </Button>
379
+ <Button type="button" size="sm" disabled={!dirty || saving} onClick={() => void handleSave()}>
380
+ <Save className="mr-1 size-3.5" />
381
+ {saving ? 'Guardando…' : dirty ? 'Guardar' : 'Guardado'}
382
+ </Button>
383
+ </div>
384
+ </div>
385
+
386
+ {loading ? (
387
+ <p className="text-muted-foreground text-sm">Cargando…</p>
388
+ ) : (
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">
415
+ {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.
419
+ </p>
420
+ ) : (
421
+ tree.map((n, i) => renderNode(n, [i]))
422
+ )}
423
+ </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">
443
+ {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>
488
+ ) : (
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>
512
+ )}
513
+ </div>
514
+ </CardContent>
515
+ </Card>
516
+ </div>
517
+ )}
518
+ </div>
519
+ )
520
+ }
521
+
522
+ export type { CatalogLeaf, SidebarLayoutDoc, SidebarLayoutNode }