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