@edc-motor/admin-kit 0.4.23 → 0.4.25

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edc-motor/admin-kit",
3
- "version": "0.4.23",
3
+ "version": "0.4.25",
4
4
  "description": "EdC Motor — kit del panel de administración: layout, gestores CRUD, imágenes, PDF, páginas, configuración, usuarios y copias (paquete fuente: lo compila el consumidor con Vite)",
5
5
  "license": "GPL-3.0-only",
6
6
  "type": "module",
@@ -33,9 +33,8 @@
33
33
  "access": "public"
34
34
  },
35
35
  "dependencies": {
36
- "@edc-motor/ui": "^0.4.23",
37
- "axios": "^1.7.0",
38
- "vue-draggable-plus": "^0.6.1"
36
+ "@edc-motor/ui": "^0.4.25",
37
+ "axios": "^1.7.0"
39
38
  },
40
39
  "peerDependencies": {
41
40
  "@lucide/vue": "^1.0.0",
@@ -8,3 +8,4 @@
8
8
  @use "components/preview-manager";
9
9
  @use "components/pdf-manager";
10
10
  @use "components/page-blocks";
11
+ @use "components/menu-manager";
@@ -0,0 +1,141 @@
1
+ @use "tokens" as *;
2
+
3
+ // Configurador del menú público (doc 10 ampliado, rediseño sin grupos):
4
+ // filas tipo PageBlocks (asa + icono + etiqueta + badges + acciones),
5
+ // arrastrables (drag & drop nativo) además de las flechas. Nada se persiste
6
+ // hasta "Guardar": la barra superior trae el aviso de cambios sin guardar y
7
+ // Descartar/Guardar.
8
+ .menu-manager {
9
+ display: grid;
10
+ gap: $space-3;
11
+
12
+ &__bar {
13
+ display: flex;
14
+ align-items: center;
15
+ justify-content: flex-end;
16
+ gap: $space-3;
17
+ }
18
+
19
+ &__dirty {
20
+ color: $warning;
21
+ font-size: $fs-13;
22
+ font-weight: $k-fw-semibold;
23
+ margin-right: auto;
24
+ }
25
+
26
+ &__empty { color: $text-3; margin: 0; }
27
+
28
+ &__list {
29
+ display: grid;
30
+ gap: $space-2;
31
+ }
32
+
33
+ &__item {
34
+ display: flex;
35
+ align-items: center;
36
+ flex-wrap: wrap;
37
+ gap: $space-2;
38
+ border: 1px solid $border;
39
+ border-radius: $radius-md;
40
+ background: $surface;
41
+ padding: $space-2 $space-3;
42
+ min-width: 0;
43
+ position: relative;
44
+ transition: border-color 0.15s ease, opacity 0.15s ease;
45
+
46
+ &:hover { border-color: $border-strong; }
47
+ &.is-hidden { opacity: 0.65; }
48
+ &.is-dragging { opacity: 0.4; }
49
+
50
+ // Indicador de inserción: línea antes/después de la fila.
51
+ &.drop-before::before,
52
+ &.drop-after::after {
53
+ content: "";
54
+ position: absolute;
55
+ left: $space-2;
56
+ right: $space-2;
57
+ height: 2px;
58
+ background: $accent-500;
59
+ border-radius: $radius-pill;
60
+ }
61
+ &.drop-before::before { top: -$space-1; }
62
+ &.drop-after::after { bottom: -$space-1; }
63
+
64
+ // Destino válido para anidar: resalta toda la fila.
65
+ &.drop-inside {
66
+ border-color: $accent-500;
67
+ background: color-mix(in srgb, $accent-500 10%, $surface);
68
+ }
69
+ }
70
+
71
+ // Hijas de una página: sangradas justo debajo de ella (mismo lenguaje que
72
+ // los bloques hijos de PageBlocks).
73
+ &__item.is-child { margin-left: $space-7; }
74
+
75
+ &__grip {
76
+ display: inline-flex;
77
+ color: $text-3;
78
+ flex: 0 0 auto;
79
+ cursor: grab;
80
+ touch-action: none;
81
+
82
+ &:active { cursor: grabbing; }
83
+ }
84
+
85
+ &__icon {
86
+ display: inline-flex;
87
+ color: $text-3;
88
+ flex: 0 0 auto;
89
+ }
90
+
91
+ &__label {
92
+ font-weight: $k-fw-semibold;
93
+ min-width: 0;
94
+ overflow: hidden;
95
+ text-overflow: ellipsis;
96
+ white-space: nowrap;
97
+ margin-right: auto;
98
+ }
99
+
100
+ &__badges { display: flex; gap: $space-1; }
101
+
102
+ &__moves {
103
+ display: flex;
104
+ gap: 2px;
105
+ }
106
+
107
+ &__move {
108
+ display: inline-flex;
109
+ align-items: center;
110
+ justify-content: center;
111
+ background: none;
112
+ border: 1px solid $border;
113
+ border-radius: $radius-sm;
114
+ color: $text-2;
115
+ cursor: pointer;
116
+ padding: $space-1;
117
+ transition: background-color 0.15s ease;
118
+
119
+ &:hover:not(:disabled) { background: $surface-2; }
120
+ &:disabled { opacity: 0.4; cursor: default; }
121
+ }
122
+
123
+ &__visibility {
124
+ display: inline-flex;
125
+ align-items: center;
126
+ justify-content: center;
127
+ background: transparent;
128
+ border: 1px solid color-mix(in srgb, $success 45%, transparent);
129
+ border-radius: $radius-sm;
130
+ color: $success;
131
+ cursor: pointer;
132
+ padding: $space-1;
133
+ transition: background-color 0.15s ease, border-color 0.15s ease;
134
+
135
+ &.is-on {
136
+ background: $success;
137
+ border-color: $success;
138
+ @include contrast-text($success);
139
+ }
140
+ }
141
+ }
@@ -65,8 +65,9 @@
65
65
  background: $surface;
66
66
  padding: $space-2 $space-3;
67
67
  min-width: 0;
68
+ position: relative;
68
69
  cursor: pointer; // toda la fila selecciona (panel derecho)
69
- transition: border-color 0.15s ease;
70
+ transition: border-color 0.15s ease, opacity 0.15s ease;
70
71
 
71
72
  @container content (min-width: 640px) {
72
73
  grid-template-columns: auto auto auto minmax(0, 1fr) auto;
@@ -75,6 +76,28 @@
75
76
 
76
77
  &:hover { border-color: $border-strong; }
77
78
  &.is-active { border-color: $accent-500; }
79
+ &.is-dragging { opacity: 0.4; }
80
+
81
+ // Indicador de inserción (antes/después) y de destino válido (encima,
82
+ // anida bajo esa fila) del drag & drop nativo — mismo lenguaje que el
83
+ // MenuManager.
84
+ &.drop-before::before,
85
+ &.drop-after::after {
86
+ content: "";
87
+ position: absolute;
88
+ left: $space-2;
89
+ right: $space-2;
90
+ height: 2px;
91
+ background: $accent-500;
92
+ border-radius: $radius-pill;
93
+ }
94
+ &.drop-before::before { top: -$space-1; }
95
+ &.drop-after::after { bottom: -$space-1; }
96
+
97
+ &.drop-inside {
98
+ border-color: $accent-500;
99
+ background: color-mix(in srgb, $accent-500 10%, $surface);
100
+ }
78
101
  }
79
102
 
80
103
  &__grip {
@@ -141,8 +164,9 @@
141
164
  > .form-field { margin-top: $space-3; }
142
165
  }
143
166
 
144
- // Bloques hijos (parent_id): tarjeta sangrada bajo su padre.
145
- &__item.is-child { margin-left: $space-7; }
167
+ // Bloques anidados (parent_id, sin límite de niveles): sangría por
168
+ // profundidad REAL `--depth` lo pone el componente en línea.
169
+ &__item.is-child { margin-left: calc(var(--depth, 1) * #{$space-7}); }
146
170
  }
147
171
 
148
172
  .schema-fields {
@@ -196,6 +220,3 @@
196
220
  font-weight: 600;
197
221
  }
198
222
  }
199
-
200
- // Item fantasma del drag (vue-draggable-plus / SortableJS).
201
- .sortable-ghost { opacity: 0.4; }
@@ -0,0 +1,424 @@
1
+ <script setup lang="ts">
2
+ import { computed, onMounted, reactive, ref } from 'vue'
3
+ import type { AxiosInstance } from 'axios'
4
+ import {
5
+ ArrowDown,
6
+ ArrowUp,
7
+ Compass,
8
+ Eye,
9
+ EyeOff,
10
+ File,
11
+ GripVertical,
12
+ RotateCcw,
13
+ Save,
14
+ } from '@lucide/vue'
15
+ import { BaseButton, useToast } from '@edc-motor/ui'
16
+
17
+ // Configurador del menú de la web pública (doc 10 ampliado, rediseño sin
18
+ // grupos): "si quieres un grupo, haz una página" — la jerarquía SIEMPRE es
19
+ // la del CRM (una página con hijas hace de desplegable); una ruta también
20
+ // puede colgar de una página raíz. El gestor trabaja sobre una copia LOCAL
21
+ // del árbol (flechas, drag & drop nativo y el interruptor de visibilidad
22
+ // solo mutan el estado local) y no persiste NADA hasta pulsar "Guardar",
23
+ // que manda el árbol entero con `PUT /admin/menu`. "Descartar" recarga del
24
+ // servidor. Agnóstico de i18n (DC-29): textos por prop; `routeLabels` los
25
+ // pone el juego y `displayLocale` el idioma actual del admin (con fallback
26
+ // al primer valor no vacío, patrón `firstText` del AppHeader público).
27
+
28
+ export interface MenuManagerLabels {
29
+ empty: string
30
+ hidden: string
31
+ draft: string
32
+ moveUp: string
33
+ moveDown: string
34
+ visible: string
35
+ save: string
36
+ discard: string
37
+ unsaved: string
38
+ saved: string
39
+ error: string
40
+ }
41
+
42
+ const defaultLabels: MenuManagerLabels = {
43
+ empty: 'El menú aún no tiene elementos.',
44
+ hidden: 'Oculto',
45
+ draft: 'Borrador',
46
+ moveUp: 'Subir',
47
+ moveDown: 'Bajar',
48
+ visible: 'Visible',
49
+ save: 'Guardar',
50
+ discard: 'Descartar',
51
+ unsaved: 'Cambios sin guardar',
52
+ saved: 'Menú guardado.',
53
+ error: 'No se ha podido completar la acción.',
54
+ }
55
+
56
+ const props = withDefaults(
57
+ defineProps<{
58
+ api: AxiosInstance
59
+ /** Etiqueta visible de cada route_key que el juego ofrece al menú. */
60
+ routeLabels?: Record<string, string>
61
+ /** Idioma actual del admin (vue-i18n): pinta los títulos de página en él. */
62
+ displayLocale: string
63
+ labels?: Partial<MenuManagerLabels>
64
+ }>(),
65
+ { routeLabels: () => ({}), labels: () => ({}) },
66
+ )
67
+
68
+ const L = reactive({ ...defaultLabels, ...props.labels }) as MenuManagerLabels
69
+
70
+ const toast = useToast()
71
+
72
+ interface MenuPageInfo {
73
+ id: number
74
+ title: Record<string, string>
75
+ is_published: boolean
76
+ }
77
+
78
+ interface MenuNode {
79
+ id: number
80
+ type: 'page' | 'route'
81
+ is_visible: boolean
82
+ order: number
83
+ route_key: string | null
84
+ page: MenuPageInfo | null
85
+ children: MenuNode[]
86
+ }
87
+
88
+ const tree = ref<MenuNode[]>([])
89
+ const busy = ref(false)
90
+ const saving = ref(false)
91
+ // Foto del último estado GUARDADO (servidor): compara contra el árbol local
92
+ // para saber si hay cambios sin guardar (badge + aviso al salir).
93
+ const savedSnapshot = ref('')
94
+
95
+ /** Aplana el árbol (padre seguido de sus hijas) al formato del PUT. */
96
+ function flatten(
97
+ nodes: MenuNode[],
98
+ parentId: number | null,
99
+ ): { id: number; parent_id: number | null; is_visible: boolean }[] {
100
+ const out: { id: number; parent_id: number | null; is_visible: boolean }[] = []
101
+ for (const node of nodes) {
102
+ out.push({ id: node.id, parent_id: parentId, is_visible: node.is_visible })
103
+ out.push(...flatten(node.children, node.id))
104
+ }
105
+ return out
106
+ }
107
+
108
+ const isDirty = computed(() => JSON.stringify(flatten(tree.value, null)) !== savedSnapshot.value)
109
+
110
+ async function load() {
111
+ busy.value = true
112
+ try {
113
+ const { data } = await props.api.get('/admin/menu')
114
+ tree.value = data.data
115
+ savedSnapshot.value = JSON.stringify(flatten(tree.value, null))
116
+ } catch {
117
+ toast.danger(L.error)
118
+ } finally {
119
+ busy.value = false
120
+ }
121
+ }
122
+
123
+ async function save() {
124
+ saving.value = true
125
+ try {
126
+ const { data } = await props.api.put('/admin/menu', { items: flatten(tree.value, null) })
127
+ tree.value = data.data
128
+ savedSnapshot.value = JSON.stringify(flatten(tree.value, null))
129
+ toast.success(L.saved)
130
+ } catch {
131
+ toast.danger(L.error)
132
+ } finally {
133
+ saving.value = false
134
+ }
135
+ }
136
+
137
+ function discard() {
138
+ load()
139
+ }
140
+
141
+ // Filas aplanadas para pintar (profundidad máxima 1: hijas de una página
142
+ // raíz, indentadas justo debajo de ella — una ruta nunca es madre).
143
+ interface Row {
144
+ node: MenuNode
145
+ depth: 0 | 1
146
+ parentId: number | null
147
+ }
148
+ const rows = computed<Row[]>(() => {
149
+ const out: Row[] = []
150
+ function walk(nodes: MenuNode[], depth: 0 | 1, parentId: number | null) {
151
+ for (const node of nodes) {
152
+ out.push({ node, depth, parentId })
153
+ if (node.children.length) walk(node.children, 1, node.id)
154
+ }
155
+ }
156
+ walk(tree.value, 0, null)
157
+ return out
158
+ })
159
+
160
+ function listFor(parentId: number | null): MenuNode[] | null {
161
+ if (parentId === null) return tree.value
162
+ const parent = findNode(tree.value, parentId)
163
+ return parent ? parent.children : null
164
+ }
165
+
166
+ function findNode(nodes: MenuNode[], id: number): MenuNode | null {
167
+ for (const node of nodes) {
168
+ if (node.id === id) return node
169
+ const found = findNode(node.children, id)
170
+ if (found) return found
171
+ }
172
+ return null
173
+ }
174
+
175
+ function siblingIndex(row: Row): number {
176
+ return (listFor(row.parentId) ?? []).findIndex((n) => n.id === row.node.id)
177
+ }
178
+ function canMoveUp(row: Row): boolean {
179
+ return siblingIndex(row) > 0
180
+ }
181
+ function canMoveDown(row: Row): boolean {
182
+ const list = listFor(row.parentId)
183
+ if (!list) return false
184
+ const idx = siblingIndex(row)
185
+ return idx >= 0 && idx < list.length - 1
186
+ }
187
+
188
+ /** Subir/bajar: solo reordena dentro del mismo nivel (no cambia de madre). */
189
+ function move(row: Row, direction: 'up' | 'down') {
190
+ const list = listFor(row.parentId)
191
+ if (!list) return
192
+ const idx = list.findIndex((n) => n.id === row.node.id)
193
+ const swapWith = direction === 'up' ? idx - 1 : idx + 1
194
+ if (idx < 0 || swapWith < 0 || swapWith >= list.length) return
195
+ ;[list[idx], list[swapWith]] = [list[swapWith], list[idx]]
196
+ }
197
+
198
+ function toggleVisible(row: Row) {
199
+ row.node.is_visible = !row.node.is_visible
200
+ }
201
+
202
+ /** Primer texto disponible en `displayLocale`, con fallback al primero que haya. */
203
+ function displayText(map: Record<string, string> | null | undefined): string {
204
+ if (!map) return ''
205
+ return map[props.displayLocale] || Object.values(map).find(Boolean) || ''
206
+ }
207
+
208
+ function labelOf(node: MenuNode): string {
209
+ if (node.type === 'page') return node.page ? displayText(node.page.title) : ''
210
+ return props.routeLabels[node.route_key ?? ''] ?? node.route_key ?? ''
211
+ }
212
+
213
+ // --- Drag & drop nativo (HTML5, sin dependencias) --------------------------
214
+ // Fila arrastrable con asa (GripVertical): soltar en el tercio superior/
215
+ // inferior de otra fila la reordena como hermana (antes/después) en el
216
+ // nivel de la fila destino; soltar en el tercio central de una página RAÍZ
217
+ // la convierte en su madre (si es válido). Arrastrar una hija hasta el
218
+ // hueco entre filas raíz la saca a la raíz (es "antes/después" de una fila
219
+ // con depth 0). Restricciones de un nivel: una página CON hijas no puede
220
+ // pasar a ser hija de nadie; una ruta nunca es madre.
221
+ const dragged = ref<{ node: MenuNode; parentId: number | null } | null>(null)
222
+ const dropTarget = ref<{
223
+ id: number
224
+ parentId: number | null
225
+ position: 'before' | 'after' | 'inside'
226
+ } | null>(null)
227
+
228
+ function canNestInside(row: Row): boolean {
229
+ if (!dragged.value) return false
230
+ if (row.node.type !== 'page' || row.depth !== 0) return false
231
+ if (row.node.id === dragged.value.node.id) return false
232
+ return dragged.value.node.children.length === 0
233
+ }
234
+
235
+ function canPlaceSibling(row: Row): boolean {
236
+ if (!dragged.value) return false
237
+ if (row.node.id === dragged.value.node.id) return false
238
+ // Hija de nadie si el arrastrado tiene sus propias hijas (un solo nivel).
239
+ if (row.depth === 1 && dragged.value.node.children.length > 0) return false
240
+ return true
241
+ }
242
+
243
+ function onDragStart(row: Row, event: DragEvent) {
244
+ dragged.value = { node: row.node, parentId: row.parentId }
245
+ event.dataTransfer?.setData('text/plain', String(row.node.id))
246
+ if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move'
247
+ }
248
+
249
+ function onDragOver(row: Row, event: DragEvent) {
250
+ if (!dragged.value || dragged.value.node.id === row.node.id) {
251
+ dropTarget.value = null
252
+ return
253
+ }
254
+ const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
255
+ const ratio = (event.clientY - rect.top) / rect.height
256
+ const nestable = canNestInside(row)
257
+
258
+ let position: 'before' | 'after' | 'inside'
259
+ if (nestable && ratio > 0.3 && ratio < 0.7) {
260
+ position = 'inside'
261
+ } else {
262
+ if (!canPlaceSibling(row)) {
263
+ dropTarget.value = null
264
+ return
265
+ }
266
+ position = ratio < 0.5 ? 'before' : 'after'
267
+ }
268
+ dropTarget.value = { id: row.node.id, parentId: row.parentId, position }
269
+ }
270
+
271
+ function onDragLeave(row: Row, event: DragEvent) {
272
+ const related = event.relatedTarget as Node | null
273
+ const current = event.currentTarget as HTMLElement
274
+ if (related && current.contains(related)) return
275
+ if (dropTarget.value?.id === row.node.id) dropTarget.value = null
276
+ }
277
+
278
+ function onDrop(row: Row, event: DragEvent) {
279
+ event.preventDefault()
280
+ const target = dropTarget.value
281
+ const source = dragged.value
282
+ cleanupDrag()
283
+ if (!target || !source || target.id !== row.node.id) return
284
+ applyDrop(source, target)
285
+ }
286
+
287
+ function onDragEnd() {
288
+ cleanupDrag()
289
+ }
290
+
291
+ function cleanupDrag() {
292
+ dragged.value = null
293
+ dropTarget.value = null
294
+ }
295
+
296
+ function applyDrop(
297
+ source: { node: MenuNode; parentId: number | null },
298
+ target: { id: number; parentId: number | null; position: 'before' | 'after' | 'inside' },
299
+ ) {
300
+ const sourceList = listFor(source.parentId)
301
+ if (!sourceList) return
302
+ const sourceIdx = sourceList.findIndex((n) => n.id === source.node.id)
303
+ if (sourceIdx === -1) return
304
+ sourceList.splice(sourceIdx, 1)
305
+
306
+ if (target.position === 'inside') {
307
+ const targetNode = findNode(tree.value, target.id)
308
+ targetNode?.children.push(source.node)
309
+ return
310
+ }
311
+
312
+ const destList = listFor(target.parentId)
313
+ if (!destList) {
314
+ // No debería pasar (el destino existe); por si acaso, no se pierde el nodo.
315
+ sourceList.splice(sourceIdx, 0, source.node)
316
+ return
317
+ }
318
+ let destIdx = destList.findIndex((n) => n.id === target.id)
319
+ if (destIdx === -1) {
320
+ destList.push(source.node)
321
+ return
322
+ }
323
+ if (target.position === 'after') destIdx += 1
324
+ destList.splice(destIdx, 0, source.node)
325
+ }
326
+
327
+ onMounted(load)
328
+
329
+ defineExpose({ isDirty })
330
+ </script>
331
+
332
+ <template>
333
+ <div class="menu-manager">
334
+ <div class="menu-manager__bar">
335
+ <span v-if="isDirty" class="menu-manager__dirty">{{ L.unsaved }}</span>
336
+ <BaseButton variant="secondary" :disabled="!isDirty || saving || busy" @click="discard">
337
+ <template #icon><RotateCcw :size="16" /></template>
338
+ {{ L.discard }}
339
+ </BaseButton>
340
+ <BaseButton :disabled="!isDirty || saving || busy" @click="save">
341
+ <template #icon><Save :size="16" /></template>
342
+ {{ L.save }}
343
+ </BaseButton>
344
+ </div>
345
+
346
+ <p v-if="!busy && !rows.length" class="menu-manager__empty">{{ L.empty }}</p>
347
+
348
+ <div class="menu-manager__list">
349
+ <article
350
+ v-for="row in rows"
351
+ :key="row.node.id"
352
+ class="menu-manager__item"
353
+ draggable="true"
354
+ :class="{
355
+ 'is-child': row.depth === 1,
356
+ 'is-hidden': !row.node.is_visible,
357
+ 'is-dragging': dragged?.node.id === row.node.id,
358
+ 'drop-before': dropTarget?.id === row.node.id && dropTarget.position === 'before',
359
+ 'drop-after': dropTarget?.id === row.node.id && dropTarget.position === 'after',
360
+ 'drop-inside': dropTarget?.id === row.node.id && dropTarget.position === 'inside',
361
+ }"
362
+ @dragstart="onDragStart(row, $event)"
363
+ @dragover.prevent="onDragOver(row, $event)"
364
+ @dragleave="onDragLeave(row, $event)"
365
+ @drop="onDrop(row, $event)"
366
+ @dragend="onDragEnd"
367
+ >
368
+ <span class="menu-manager__grip"><GripVertical :size="15" /></span>
369
+
370
+ <span class="menu-manager__icon">
371
+ <File v-if="row.node.type === 'page'" :size="16" />
372
+ <Compass v-else :size="16" />
373
+ </span>
374
+
375
+ <span class="menu-manager__label">{{ labelOf(row.node) }}</span>
376
+
377
+ <span class="menu-manager__badges">
378
+ <span v-if="!row.node.is_visible" class="chip is-missing">{{ L.hidden }}</span>
379
+ <span
380
+ v-if="row.node.type === 'page' && row.node.page && !row.node.page.is_published"
381
+ class="chip is-missing"
382
+ >
383
+ {{ L.draft }}
384
+ </span>
385
+ </span>
386
+
387
+ <span class="menu-manager__moves">
388
+ <button
389
+ type="button"
390
+ class="menu-manager__move"
391
+ :title="L.moveUp"
392
+ :aria-label="L.moveUp"
393
+ :disabled="!canMoveUp(row)"
394
+ @click="move(row, 'up')"
395
+ >
396
+ <ArrowUp :size="14" />
397
+ </button>
398
+ <button
399
+ type="button"
400
+ class="menu-manager__move"
401
+ :title="L.moveDown"
402
+ :aria-label="L.moveDown"
403
+ :disabled="!canMoveDown(row)"
404
+ @click="move(row, 'down')"
405
+ >
406
+ <ArrowDown :size="14" />
407
+ </button>
408
+ </span>
409
+
410
+ <button
411
+ type="button"
412
+ class="menu-manager__visibility"
413
+ :class="row.node.is_visible ? 'is-on' : 'is-off'"
414
+ :aria-pressed="row.node.is_visible"
415
+ :title="L.visible"
416
+ @click="toggleVisible(row)"
417
+ >
418
+ <Eye v-if="row.node.is_visible" :size="15" />
419
+ <EyeOff v-else :size="15" />
420
+ </button>
421
+ </article>
422
+ </div>
423
+ </div>
424
+ </template>
@@ -2,7 +2,6 @@
2
2
  import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
3
3
  import type { AxiosInstance } from 'axios'
4
4
  import { GripVertical, List, Plus, Printer, SquarePen, Trash2 } from '@lucide/vue'
5
- import { VueDraggable } from 'vue-draggable-plus'
6
5
  import {
7
6
  BaseButton,
8
7
  BaseCheckbox,
@@ -19,9 +18,18 @@ import { useRightSidebar } from '../composables/useRightSidebar'
19
18
  import { useCardDeselect } from '../composables/useCardDeselect'
20
19
 
21
20
  // Gestor de bloques de una página (doc 03): paleta de tipos (motor + juego),
22
- // lista reordenable con drag (DC-17) y modal de edición GENERADO desde el
23
- // esquema de campos del tipo. Añadir un tipo de bloque no toca este código.
24
- // Agnóstico de i18n (DC-29): textos por prop; gancho translate para nombres.
21
+ // lista reordenable con drag & drop NATIVO (HTML5, sin dependencias) y modal
22
+ // de edición GENERADO desde el esquema de campos del tipo. Añadir un tipo de
23
+ // bloque no toca este código. Anidado en VARIOS niveles, sin límite (solo se
24
+ // prohíben los ciclos): el drag persiste al soltar (sin botón Guardar, a
25
+ // diferencia del MenuManager) — soltar entre filas reordena
26
+ // (`POST .../blocks/reorder`, la lista aplanada en preorden); soltar ENCIMA
27
+ // de cualquier fila anida el bloque bajo ella (con sus propios
28
+ // descendientes, que se mueven en bloque) vía `PUT /admin/blocks/{id}`,
29
+ // mismas reglas que el select "Bloque padre": nunca uno mismo ni un
30
+ // descendiente propio. Agnóstico de i18n (DC-29): textos por prop; gancho
31
+ // translate para nombres; `displayLocale` (idioma actual del admin) para
32
+ // los textos traducibles del resumen/campos.
25
33
 
26
34
  export interface PageBlocksLabels {
27
35
  add: string
@@ -74,6 +82,9 @@ const props = withDefaults(
74
82
  api: AxiosInstance
75
83
  pageId: number
76
84
  locales: { code: string; name: string }[]
85
+ /** Idioma actual del admin (vue-i18n): textos traducibles en él, con
86
+ * fallback al primer valor no vacío (patrón `firstText` del AppHeader). */
87
+ displayLocale: string
77
88
  icons?: RichIcon[]
78
89
  richLabels?: Partial<RichTextLabels>
79
90
  labels?: Partial<PageBlocksLabels>
@@ -170,13 +181,18 @@ const formPrintable = ref(true)
170
181
  const formIndexable = ref(true)
171
182
  const formParent = ref<number | null>(null)
172
183
 
173
- // Padres elegibles: bloques raíz de la página (un solo nivel de anidado),
174
- // nunca uno mismo.
184
+ // Padres elegibles: CUALQUIER bloque de la página (sin límite de niveles),
185
+ // salvo uno mismo o uno de sus propios descendientes (crearía un ciclo).
186
+ // Etiqueta con prefijo de rayas según su profundidad, para ver la jerarquía
187
+ // en un <select> nativo (no admite sangría real por opción).
175
188
  const parentOptions = computed(() => {
176
189
  const self = editing.value?.id
177
190
  return blocks.value
178
- .filter((b) => !b.parent_id && b.id !== self)
179
- .map((b) => ({ value: String(b.id), label: `${typeName(b.type)} — ${summary(b) || b.id}` }))
191
+ .filter((b) => b.id !== self && !(self !== undefined && isDescendant(b.id, self)))
192
+ .map((b) => ({
193
+ value: String(b.id),
194
+ label: `${'— '.repeat(depthOf(b))}${typeName(b.type)} — ${summary(b) || b.id}`,
195
+ }))
180
196
  })
181
197
 
182
198
  function typeName(key: string): string {
@@ -185,6 +201,12 @@ function typeName(key: string): string {
185
201
  return props.translate?.(`blockTypes.${key}`, fallback) ?? fallback
186
202
  }
187
203
 
204
+ /** Texto en `displayLocale`, con fallback al primer valor no vacío. */
205
+ function displayText(map: Record<string, string> | null | undefined): string {
206
+ if (!map) return ''
207
+ return map[props.displayLocale] || Object.values(map).find(Boolean) || ''
208
+ }
209
+
188
210
  /** Resumen del bloque en la lista: el primer texto traducible con valor. */
189
211
  function summary(block: BlockRow): string {
190
212
  const type = types.value.find((t) => t.key === block.type)
@@ -192,18 +214,18 @@ function summary(block: BlockRow): string {
192
214
  if (!['text', 'textarea', 'richtext'].includes(field.type)) continue
193
215
  const value = block.settings?.[field.key]
194
216
  if (field.translatable && value && typeof value === 'object') {
195
- const text = Object.values(value as Record<string, string>).find(Boolean)
217
+ const text = displayText(value as Record<string, string>)
196
218
  if (text) return text.replace(/<[^>]*>/g, '').slice(0, 80)
197
219
  }
198
220
  }
199
221
  return ''
200
222
  }
201
223
 
202
- /** URL de un campo imagen (traducible o no): la primera con valor. */
224
+ /** URL de un campo imagen (traducible o no): la del locale actual. */
203
225
  function imageUrl(field: FieldSchema, block: BlockRow): string {
204
226
  const raw = block.settings?.[field.key]
205
227
  if (raw && typeof raw === 'object') {
206
- return Object.values(raw as Record<string, string>).find(Boolean) ?? ''
228
+ return displayText(raw as Record<string, string>)
207
229
  }
208
230
  return raw ? String(raw) : ''
209
231
  }
@@ -218,7 +240,7 @@ function fieldValue(field: FieldSchema, block: BlockRow): string {
218
240
  const raw = block.settings?.[field.key]
219
241
  if (raw === null || raw === undefined || raw === '') return ''
220
242
  if (field.translatable && typeof raw === 'object') {
221
- const text = Object.values(raw as Record<string, string>).find(Boolean) ?? ''
243
+ const text = displayText(raw as Record<string, string>)
222
244
  return text
223
245
  .replace(/<[^>]*>/g, ' ')
224
246
  .replace(/\s+/g, ' ')
@@ -255,18 +277,58 @@ const selectedFields = computed(() => {
255
277
  .filter((entry) => entry.value)
256
278
  })
257
279
 
258
- /** Hijos justo debajo de su padre (en su orden relativo): anidado visible. */
280
+ /** Preorden del árbol (sin límite de niveles): cada bloque, seguido AL
281
+ * MOMENTO de todos sus descendientes (recursivo), en su orden relativo. El
282
+ * reorder del servidor persiste exactamente este orden aplanado, así que
283
+ * `order` ya es un preorden — el índice público (IndexBlock) no necesita
284
+ * reordenar, solo calcular la profundidad. */
259
285
  function arrange(list: BlockRow[]): BlockRow[] {
260
- const parents = list.filter((b) => !b.parent_id)
286
+ const byParent = new Map<number | null, BlockRow[]>()
287
+ for (const block of list) {
288
+ const key = block.parent_id
289
+ if (!byParent.has(key)) byParent.set(key, [])
290
+ byParent.get(key)!.push(block)
291
+ }
261
292
  const out: BlockRow[] = []
262
- for (const parent of parents) {
263
- out.push(parent, ...list.filter((b) => b.parent_id === parent.id))
293
+ function walk(parentId: number | null) {
294
+ for (const block of byParent.get(parentId) ?? []) {
295
+ out.push(block)
296
+ walk(block.id)
297
+ }
264
298
  }
265
- // Huérfanos (padre borrado en otra sesión): al final, sin perderse.
299
+ walk(null)
300
+ // Huérfanos (padre borrado en otra sesión, o un ciclo residual): al final.
266
301
  out.push(...list.filter((b) => !out.includes(b)))
267
302
  return out
268
303
  }
269
304
 
305
+ /** Profundidad real del bloque, subiendo por la cadena de padres. */
306
+ function depthOf(block: BlockRow): number {
307
+ let depth = 0
308
+ let current: BlockRow | undefined = block
309
+ const seen = new Set<number>()
310
+ while (current?.parent_id) {
311
+ if (seen.has(current.id)) break
312
+ seen.add(current.id)
313
+ current = blocks.value.find((b) => b.id === current!.parent_id)
314
+ if (current) depth++
315
+ }
316
+ return depth
317
+ }
318
+
319
+ /** ¿`candidateId` cuelga (a cualquier profundidad) de `ancestorId`? */
320
+ function isDescendant(candidateId: number, ancestorId: number): boolean {
321
+ let current = blocks.value.find((b) => b.id === candidateId)
322
+ const seen = new Set<number>()
323
+ while (current?.parent_id) {
324
+ if (current.parent_id === ancestorId) return true
325
+ if (seen.has(current.id)) break
326
+ seen.add(current.id)
327
+ current = blocks.value.find((b) => b.id === current!.parent_id)
328
+ }
329
+ return false
330
+ }
331
+
270
332
  async function load() {
271
333
  try {
272
334
  const [palette, list] = await Promise.all([
@@ -364,8 +426,7 @@ async function remove(block: BlockRow) {
364
426
  }
365
427
  }
366
428
 
367
- /** El drag reordena en cliente; los hijos se recolocan bajo su padre y se
368
- * persiste la lista de ids resultante. */
429
+ /** Persiste el orden actual (la lista completa de ids, ya reagrupada). */
369
430
  async function persistOrder() {
370
431
  blocks.value = arrange(blocks.value)
371
432
  try {
@@ -378,6 +439,108 @@ async function persistOrder() {
378
439
  }
379
440
  }
380
441
 
442
+ // --- Drag & drop nativo (HTML5, sin dependencias) --------------------------
443
+ // Fila arrastrable con asa (GripVertical): soltar en el tercio superior/
444
+ // inferior de otra fila reordena como hermana; soltar en el tercio central
445
+ // de CUALQUIER fila la convierte en su padre — sin límite de niveles, solo
446
+ // se prohíbe soltar sobre uno mismo o un descendiente propio (ciclo). Al
447
+ // mover un bloque se mueve con él TODO su subárbol (persiste al momento,
448
+ // sin botón Guardar).
449
+ const dragged = ref<BlockRow | null>(null)
450
+ const dropTarget = ref<{ id: number; position: 'before' | 'after' | 'inside' } | null>(null)
451
+
452
+ /** Único requisito para soltar sobre `target`: no ser el arrastrado ni un descendiente suyo. */
453
+ function canDropOn(target: BlockRow): boolean {
454
+ if (!dragged.value) return false
455
+ if (target.id === dragged.value.id) return false
456
+ return !isDescendant(target.id, dragged.value.id)
457
+ }
458
+
459
+ function onDragStart(block: BlockRow, event: DragEvent) {
460
+ dragged.value = block
461
+ event.dataTransfer?.setData('text/plain', String(block.id))
462
+ if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move'
463
+ }
464
+
465
+ function onDragOver(block: BlockRow, event: DragEvent) {
466
+ if (!canDropOn(block)) {
467
+ dropTarget.value = null
468
+ return
469
+ }
470
+ const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
471
+ const ratio = (event.clientY - rect.top) / rect.height
472
+ const position: 'before' | 'after' | 'inside' =
473
+ ratio > 0.3 && ratio < 0.7 ? 'inside' : ratio < 0.5 ? 'before' : 'after'
474
+ dropTarget.value = { id: block.id, position }
475
+ }
476
+
477
+ function onDragLeave(block: BlockRow, event: DragEvent) {
478
+ const related = event.relatedTarget as Node | null
479
+ const current = event.currentTarget as HTMLElement
480
+ if (related && current.contains(related)) return
481
+ if (dropTarget.value?.id === block.id) dropTarget.value = null
482
+ }
483
+
484
+ function onDragEnd() {
485
+ dragged.value = null
486
+ dropTarget.value = null
487
+ }
488
+
489
+ async function onDrop(block: BlockRow, event: DragEvent) {
490
+ event.preventDefault()
491
+ const target = dropTarget.value
492
+ const source = dragged.value
493
+ dragged.value = null
494
+ dropTarget.value = null
495
+ if (!target || !source || target.id !== block.id) return
496
+ if (source.id === target.id || isDescendant(target.id, source.id)) return
497
+
498
+ const oldParentId = source.parent_id
499
+ const sourceIdx = blocks.value.findIndex((b) => b.id === source.id)
500
+ if (sourceIdx === -1) return
501
+ const sourceDepth = depthOf(source)
502
+
503
+ // Extrae el subárbol completo (el bloque + todos sus descendientes: la
504
+ // tirada contigua que le sigue con más profundidad que él, ya que el
505
+ // array se mantiene en preorden) para moverlo en bloque.
506
+ let end = sourceIdx + 1
507
+ while (end < blocks.value.length && depthOf(blocks.value[end]) > sourceDepth) end++
508
+ const subtree = blocks.value.splice(sourceIdx, end - sourceIdx)
509
+
510
+ let newParentId: number | null
511
+ if (target.position === 'inside') {
512
+ newParentId = target.id
513
+ const targetBlock = blocks.value.find((b) => b.id === target.id)
514
+ const targetDepth = targetBlock ? depthOf(targetBlock) : 0
515
+ let insertAt = blocks.value.findIndex((b) => b.id === target.id) + 1
516
+ while (insertAt < blocks.value.length && depthOf(blocks.value[insertAt]) > targetDepth) {
517
+ insertAt++
518
+ }
519
+ subtree[0].parent_id = newParentId
520
+ blocks.value.splice(insertAt, 0, ...subtree)
521
+ } else {
522
+ const targetIdx = blocks.value.findIndex((b) => b.id === target.id)
523
+ newParentId = targetIdx >= 0 ? blocks.value[targetIdx].parent_id : null
524
+ subtree[0].parent_id = newParentId
525
+ const insertAt = target.position === 'after' ? targetIdx + 1 : targetIdx
526
+ blocks.value.splice(insertAt < 0 ? blocks.value.length : insertAt, 0, ...subtree)
527
+ }
528
+
529
+ busy.value = true
530
+ try {
531
+ if (newParentId !== oldParentId) {
532
+ await props.api.put(`/admin/blocks/${source.id}`, { parent_id: newParentId })
533
+ }
534
+ await persistOrder()
535
+ await load()
536
+ } catch {
537
+ toast.danger(L.error)
538
+ await load()
539
+ } finally {
540
+ busy.value = false
541
+ }
542
+ }
543
+
381
544
  const presentation = computed(() => types.value.filter((t) => t.category === 'presentation'))
382
545
  const dataTypes = computed(() => types.value.filter((t) => t.category !== 'presentation'))
383
546
 
@@ -410,19 +573,27 @@ defineExpose({ reload: load })
410
573
 
411
574
  <p v-if="!blocks.length" class="page-blocks__empty">{{ L.empty }}</p>
412
575
 
413
- <VueDraggable
414
- v-model="blocks"
415
- class="page-blocks__list"
416
- handle=".page-blocks__grip"
417
- :animation="150"
418
- @end="persistOrder"
419
- >
576
+ <div class="page-blocks__list">
420
577
  <article
421
578
  v-for="block in blocks"
422
579
  :key="block.id"
423
580
  class="page-blocks__item"
424
- :class="{ 'is-active': selectedId === block.id, 'is-child': block.parent_id }"
581
+ draggable="true"
582
+ :style="{ '--depth': depthOf(block) }"
583
+ :class="{
584
+ 'is-active': selectedId === block.id,
585
+ 'is-child': depthOf(block) > 0,
586
+ 'is-dragging': dragged?.id === block.id,
587
+ 'drop-before': dropTarget?.id === block.id && dropTarget.position === 'before',
588
+ 'drop-after': dropTarget?.id === block.id && dropTarget.position === 'after',
589
+ 'drop-inside': dropTarget?.id === block.id && dropTarget.position === 'inside',
590
+ }"
425
591
  @click="(e) => selectBlock(block, e)"
592
+ @dragstart="onDragStart(block, $event)"
593
+ @dragover.prevent="onDragOver(block, $event)"
594
+ @dragleave="onDragLeave(block, $event)"
595
+ @drop="onDrop(block, $event)"
596
+ @dragend="onDragEnd"
426
597
  >
427
598
  <span class="page-blocks__grip"><GripVertical :size="16" /></span>
428
599
  <button
@@ -441,7 +612,7 @@ defineExpose({ reload: load })
441
612
  <span v-if="block.is_indexable" class="chip">{{ L.indexableShort }}</span>
442
613
  </span>
443
614
  </article>
444
- </VueDraggable>
615
+ </div>
445
616
 
446
617
  <!-- Modal generado desde el esquema del tipo -->
447
618
  <EditModal
package/src/index.ts CHANGED
@@ -24,6 +24,9 @@ export { useResource, type ResourceMeta } from './crud/useResource'
24
24
  // CRM de páginas y bloques (doc 03): editor dirigido por esquema.
25
25
  export { default as SchemaFields, type FieldSchema } from './content/SchemaFields.vue'
26
26
  export { default as PageBlocks, type PageBlocksLabels } from './content/PageBlocks.vue'
27
+ // Configurador del menú público (doc 10 ampliado): páginas del CRM + rutas
28
+ // del juego, reordenables y agrupables. Agnóstico de i18n (DC-29).
29
+ export { default as MenuManager, type MenuManagerLabels } from './content/MenuManager.vue'
27
30
  // Subidas de imagen diferidas al guardar (uploads de contenido sin huérfanos):
28
31
  // las usan PageBlocks y las vistas del cascarón (Ajustes, form de página).
29
32
  export {