@asteby/metacore-runtime-react 39.2.0 → 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.
|
@@ -1,40 +1,52 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* SidebarLayoutManager —
|
|
3
|
-
*
|
|
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
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
):
|
|
84
|
-
if (
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
97
|
-
return updateAt(tree, path, () => null).filter(Boolean) as SidebarLayoutNode[]
|
|
98
|
-
}
|
|
187
|
+
// ── Sortable row ──────────────────────────────────────────────────────────
|
|
99
188
|
|
|
100
|
-
function
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
)
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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 = '
|
|
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
|
|
401
|
+
const [baseline, setBaseline] = React.useState('[]')
|
|
143
402
|
const [loading, setLoading] = React.useState(true)
|
|
144
403
|
const [saving, setSaving] = React.useState(false)
|
|
145
|
-
const [
|
|
146
|
-
const [
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
549
|
+
const seeded = seedFromCurrent()
|
|
550
|
+
setTree(seeded)
|
|
195
551
|
setBaseline('[]')
|
|
196
|
-
toast.success('
|
|
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
|
|
205
|
-
|
|
206
|
-
|
|
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:
|
|
223
|
-
children: [
|
|
567
|
+
key: `group_${key}_${Date.now() % 10000}`,
|
|
568
|
+
title: name,
|
|
569
|
+
children: [],
|
|
224
570
|
},
|
|
225
571
|
])
|
|
226
|
-
|
|
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
|
|
241
|
-
const
|
|
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
|
-
<
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
{
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
)
|
|
259
|
-
|
|
260
|
-
|
|
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-
|
|
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
|
|
373
|
-
|
|
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
|
|
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
|
|
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
|
|
699
|
+
<p className="text-muted-foreground text-sm">Cargando menú…</p>
|
|
388
700
|
) : (
|
|
389
|
-
<div className="grid gap-4 lg:grid-cols-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
<
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
</
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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
|
-
<
|
|
445
|
-
|
|
446
|
-
|
|
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
|
-
|
|
490
|
-
{
|
|
491
|
-
<
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
{
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
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
|
-
|
|
515
|
-
</
|
|
825
|
+
) : null}
|
|
826
|
+
</div>
|
|
516
827
|
</div>
|
|
517
828
|
)}
|
|
518
829
|
</div>
|