@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.
- package/CHANGELOG.md +6 -0
- package/dist/apply-sidebar-layout.d.ts +86 -0
- package/dist/apply-sidebar-layout.d.ts.map +1 -0
- package/dist/apply-sidebar-layout.js +226 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/sidebar-layout-manager.d.ts +36 -0
- package/dist/sidebar-layout-manager.d.ts.map +1 -0
- package/dist/sidebar-layout-manager.js +204 -0
- package/package.json +1 -1
- package/src/__tests__/apply-sidebar-layout.test.ts +74 -0
- package/src/apply-sidebar-layout.ts +290 -0
- package/src/index.ts +23 -0
- package/src/sidebar-layout-manager.tsx +522 -0
|
@@ -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.<key>.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 }
|