@edc-motor/admin-kit 0.4.24 → 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.24",
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.24",
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",
@@ -1,15 +1,26 @@
1
1
  @use "tokens" as *;
2
2
 
3
- // Configurador del menú público (doc 10 ampliado): filas tipo PageBlocks
4
- // (icono + etiqueta + badges + acciones), sin drag — el orden se mueve con
5
- // flechas, dentro de cada nivel (raíz o hijos de un grupo).
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.
6
8
  .menu-manager {
7
9
  display: grid;
8
10
  gap: $space-3;
9
11
 
10
12
  &__bar {
11
13
  display: flex;
14
+ align-items: center;
12
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;
13
24
  }
14
25
 
15
26
  &__empty { color: $text-3; margin: 0; }
@@ -29,16 +40,48 @@
29
40
  background: $surface;
30
41
  padding: $space-2 $space-3;
31
42
  min-width: 0;
32
- transition: border-color 0.15s ease;
43
+ position: relative;
44
+ transition: border-color 0.15s ease, opacity 0.15s ease;
33
45
 
34
46
  &:hover { border-color: $border-strong; }
35
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
+ }
36
69
  }
37
70
 
38
- // Hijos de un grupo: sangrados justo debajo de él (mismo lenguaje que los
39
- // bloques hijos de PageBlocks).
71
+ // Hijas de una página: sangradas justo debajo de ella (mismo lenguaje que
72
+ // los bloques hijos de PageBlocks).
40
73
  &__item.is-child { margin-left: $space-7; }
41
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
+
42
85
  &__icon {
43
86
  display: inline-flex;
44
87
  color: $text-3;
@@ -94,40 +137,5 @@
94
137
  border-color: $success;
95
138
  @include contrast-text($success);
96
139
  }
97
- &:disabled { opacity: 0.6; cursor: default; }
98
- }
99
-
100
- &__group-select {
101
- min-width: 160px;
102
-
103
- .form-field__select { min-height: 34px; }
104
- }
105
-
106
- &__group-actions {
107
- display: flex;
108
- gap: $space-1;
109
- }
110
-
111
- &__edit,
112
- &__delete {
113
- display: inline-flex;
114
- align-items: center;
115
- justify-content: center;
116
- background: none;
117
- border: none;
118
- border-radius: $radius-sm;
119
- padding: $space-1;
120
- cursor: pointer;
121
- transition: background-color 0.15s ease;
122
- }
123
-
124
- &__edit {
125
- color: $accent-500;
126
- &:hover { background: color-mix(in srgb, $accent-500 12%, transparent); }
127
- }
128
-
129
- &__delete {
130
- color: $danger;
131
- &:hover { background: color-mix(in srgb, $danger 12%, transparent); }
132
140
  }
133
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; }
@@ -8,74 +8,58 @@ import {
8
8
  Eye,
9
9
  EyeOff,
10
10
  File,
11
- Folder,
12
- Plus,
13
- SquarePen,
14
- Trash2,
11
+ GripVertical,
12
+ RotateCcw,
13
+ Save,
15
14
  } from '@lucide/vue'
16
- import {
17
- BaseButton,
18
- BaseSelect,
19
- EditModal,
20
- TranslatableInput,
21
- useConfirm,
22
- useToast,
23
- } from '@edc-motor/ui'
24
-
25
- // Configurador del menú de la web pública (doc 10 ampliado): mezcla páginas
26
- // del CRM y "rutas" propias del juego bajo un árbol de UN nivel (los grupos
27
- // son carpetas del admin; una página o ruta puede colgar de un grupo, un
28
- // grupo no puede colgar de otro). Filas tipo PageBlocks (no cards):
29
- // subir/bajar dentro de su nivel, interruptor de visibilidad, select de
30
- // grupo y, en grupos, editar label + borrar. Agnóstico de i18n (DC-29):
31
- // todos los textos por prop; `routeLabels` los pone el juego.
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).
32
27
 
33
28
  export interface MenuManagerLabels {
34
- newGroup: string
35
- newGroupTitle: string
36
- editGroupTitle: string
37
- groupLabel: string
38
- save: string
39
- cancel: string
40
- delete: string
41
- confirmDelete: string
42
29
  empty: string
43
- root: string
44
30
  hidden: string
45
31
  draft: string
46
32
  moveUp: string
47
33
  moveDown: string
48
34
  visible: string
49
- group: string
35
+ save: string
36
+ discard: string
37
+ unsaved: string
38
+ saved: string
50
39
  error: string
51
40
  }
52
41
 
53
42
  const defaultLabels: MenuManagerLabels = {
54
- newGroup: 'Nuevo grupo',
55
- newGroupTitle: 'Nuevo grupo',
56
- editGroupTitle: 'Editar grupo',
57
- groupLabel: 'Nombre del grupo',
58
- save: 'Guardar',
59
- cancel: 'Cancelar',
60
- delete: 'Borrar',
61
- confirmDelete: '¿Borrar este grupo? Sus elementos pasarán a la raíz.',
62
43
  empty: 'El menú aún no tiene elementos.',
63
- root: '— Raíz —',
64
44
  hidden: 'Oculto',
65
45
  draft: 'Borrador',
66
46
  moveUp: 'Subir',
67
47
  moveDown: 'Bajar',
68
48
  visible: 'Visible',
69
- group: 'Grupo',
49
+ save: 'Guardar',
50
+ discard: 'Descartar',
51
+ unsaved: 'Cambios sin guardar',
52
+ saved: 'Menú guardado.',
70
53
  error: 'No se ha podido completar la acción.',
71
54
  }
72
55
 
73
56
  const props = withDefaults(
74
57
  defineProps<{
75
58
  api: AxiosInstance
76
- locales: { code: string; name: string }[]
77
59
  /** Etiqueta visible de cada route_key que el juego ofrece al menú. */
78
60
  routeLabels?: Record<string, string>
61
+ /** Idioma actual del admin (vue-i18n): pinta los títulos de página en él. */
62
+ displayLocale: string
79
63
  labels?: Partial<MenuManagerLabels>
80
64
  }>(),
81
65
  { routeLabels: () => ({}), labels: () => ({}) },
@@ -84,7 +68,6 @@ const props = withDefaults(
84
68
  const L = reactive({ ...defaultLabels, ...props.labels }) as MenuManagerLabels
85
69
 
86
70
  const toast = useToast()
87
- const { confirm } = useConfirm()
88
71
 
89
72
  interface MenuPageInfo {
90
73
  id: number
@@ -94,29 +77,69 @@ interface MenuPageInfo {
94
77
 
95
78
  interface MenuNode {
96
79
  id: number
97
- type: 'page' | 'route' | 'group'
80
+ type: 'page' | 'route'
98
81
  is_visible: boolean
99
82
  order: number
100
83
  route_key: string | null
101
- label: Record<string, string> | null
102
84
  page: MenuPageInfo | null
103
85
  children: MenuNode[]
104
86
  }
105
87
 
106
88
  const tree = ref<MenuNode[]>([])
107
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)
108
109
 
109
110
  async function load() {
111
+ busy.value = true
110
112
  try {
111
113
  const { data } = await props.api.get('/admin/menu')
112
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)
113
130
  } catch {
114
131
  toast.danger(L.error)
132
+ } finally {
133
+ saving.value = false
115
134
  }
116
135
  }
117
136
 
118
- // Filas aplanadas para pintar (profundidad máxima 1: hijos de un grupo,
119
- // indentados justo debajo de él — un grupo no puede colgar de otro).
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).
120
143
  interface Row {
121
144
  node: MenuNode
122
145
  depth: 0 | 1
@@ -124,170 +147,229 @@ interface Row {
124
147
  }
125
148
  const rows = computed<Row[]>(() => {
126
149
  const out: Row[] = []
127
- for (const node of tree.value) {
128
- out.push({ node, depth: 0, parentId: null })
129
- if (node.type === 'group') {
130
- for (const child of node.children) out.push({ node: child, depth: 1, parentId: node.id })
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)
131
154
  }
132
155
  }
156
+ walk(tree.value, 0, null)
133
157
  return out
134
158
  })
135
159
 
136
- const groups = computed(() => tree.value.filter((n) => n.type === 'group'))
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
+ }
137
165
 
138
- function siblingIds(parentId: number | null): number[] {
139
- if (parentId === null) return tree.value.map((n) => n.id)
140
- return groups.value.find((g) => g.id === parentId)?.children.map((c) => c.id) ?? []
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
141
173
  }
142
174
 
143
175
  function siblingIndex(row: Row): number {
144
- return siblingIds(row.parentId).indexOf(row.node.id)
176
+ return (listFor(row.parentId) ?? []).findIndex((n) => n.id === row.node.id)
145
177
  }
146
-
147
178
  function canMoveUp(row: Row): boolean {
148
179
  return siblingIndex(row) > 0
149
180
  }
150
181
  function canMoveDown(row: Row): boolean {
151
- const ids = siblingIds(row.parentId)
182
+ const list = listFor(row.parentId)
183
+ if (!list) return false
152
184
  const idx = siblingIndex(row)
153
- return idx >= 0 && idx < ids.length - 1
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
154
200
  }
155
201
 
156
- /** Primer texto disponible (agnóstico de locale, patrón EntityRefSelect). */
157
- function firstText(map: Record<string, string> | null | undefined): string {
202
+ /** Primer texto disponible en `displayLocale`, con fallback al primero que haya. */
203
+ function displayText(map: Record<string, string> | null | undefined): string {
158
204
  if (!map) return ''
159
- return Object.values(map).find(Boolean) ?? ''
205
+ return map[props.displayLocale] || Object.values(map).find(Boolean) || ''
160
206
  }
161
207
 
162
208
  function labelOf(node: MenuNode): string {
163
- if (node.type === 'page') return node.page ? firstText(node.page.title) : ''
164
- if (node.type === 'route') return props.routeLabels[node.route_key ?? ''] ?? node.route_key ?? ''
165
- return firstText(node.label)
209
+ if (node.type === 'page') return node.page ? displayText(node.page.title) : ''
210
+ return props.routeLabels[node.route_key ?? ''] ?? node.route_key ?? ''
166
211
  }
167
212
 
168
- async function move(row: Row, direction: 'up' | 'down') {
169
- const ids = siblingIds(row.parentId)
170
- const idx = ids.indexOf(row.node.id)
171
- const swapWith = direction === 'up' ? idx - 1 : idx + 1
172
- if (idx < 0 || swapWith < 0 || swapWith >= ids.length) return
173
- const next = [...ids]
174
- ;[next[idx], next[swapWith]] = [next[swapWith], next[idx]]
175
- busy.value = true
176
- try {
177
- await props.api.post('/admin/menu/reorder', { ids: next })
178
- await load()
179
- } catch {
180
- toast.danger(L.error)
181
- } finally {
182
- busy.value = false
183
- }
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
184
233
  }
185
234
 
186
- async function toggleVisible(row: Row) {
187
- busy.value = true
188
- try {
189
- await props.api.patch(`/admin/menu/${row.node.id}`, { is_visible: !row.node.is_visible })
190
- await load()
191
- } catch {
192
- toast.danger(L.error)
193
- } finally {
194
- busy.value = false
195
- }
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
196
241
  }
197
242
 
198
- async function setGroup(row: Row, value: string) {
199
- busy.value = true
200
- try {
201
- await props.api.patch(`/admin/menu/${row.node.id}`, {
202
- parent_id: value ? Number(value) : null,
203
- })
204
- await load()
205
- } catch {
206
- toast.danger(L.error)
207
- } finally {
208
- busy.value = false
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
209
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 }
210
269
  }
211
270
 
212
- // Modal de grupo (crear / editar label), compartido.
213
- const modalOpen = ref(false)
214
- const editingGroup = ref<MenuNode | null>(null)
215
- const groupLabelForm = ref<Record<string, string>>({})
216
- const saving = ref(false)
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
+ }
217
277
 
218
- function openCreateGroup() {
219
- editingGroup.value = null
220
- groupLabelForm.value = {}
221
- modalOpen.value = true
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)
222
285
  }
223
286
 
224
- function openEditGroup(node: MenuNode) {
225
- editingGroup.value = node
226
- groupLabelForm.value = { ...(node.label ?? {}) }
227
- modalOpen.value = true
287
+ function onDragEnd() {
288
+ cleanupDrag()
228
289
  }
229
290
 
230
- async function saveGroup() {
231
- saving.value = true
232
- try {
233
- if (editingGroup.value) {
234
- await props.api.patch(`/admin/menu/${editingGroup.value.id}`, { label: groupLabelForm.value })
235
- } else {
236
- await props.api.post('/admin/menu/groups', { label: groupLabelForm.value })
237
- }
238
- modalOpen.value = false
239
- await load()
240
- } catch {
241
- toast.danger(L.error)
242
- } finally {
243
- saving.value = false
244
- }
291
+ function cleanupDrag() {
292
+ dragged.value = null
293
+ dropTarget.value = null
245
294
  }
246
295
 
247
- async function removeGroup(node: MenuNode) {
248
- const ok = await confirm({
249
- message: L.confirmDelete,
250
- confirmLabel: L.delete,
251
- cancelLabel: L.cancel,
252
- variant: 'danger',
253
- })
254
- if (!ok) return
255
- busy.value = true
256
- try {
257
- await props.api.delete(`/admin/menu/${node.id}`)
258
- await load()
259
- } catch {
260
- toast.danger(L.error)
261
- } finally {
262
- busy.value = false
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
263
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)
264
325
  }
265
326
 
266
327
  onMounted(load)
328
+
329
+ defineExpose({ isDirty })
267
330
  </script>
268
331
 
269
332
  <template>
270
333
  <div class="menu-manager">
271
334
  <div class="menu-manager__bar">
272
- <BaseButton :disabled="busy" @click="openCreateGroup">
273
- <template #icon><Plus :size="16" /></template>
274
- {{ L.newGroup }}
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 }}
275
343
  </BaseButton>
276
344
  </div>
277
345
 
278
- <p v-if="!rows.length" class="menu-manager__empty">{{ L.empty }}</p>
346
+ <p v-if="!busy && !rows.length" class="menu-manager__empty">{{ L.empty }}</p>
279
347
 
280
348
  <div class="menu-manager__list">
281
349
  <article
282
350
  v-for="row in rows"
283
351
  :key="row.node.id"
284
352
  class="menu-manager__item"
285
- :class="{ 'is-child': row.depth === 1, 'is-hidden': !row.node.is_visible }"
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"
286
367
  >
368
+ <span class="menu-manager__grip"><GripVertical :size="15" /></span>
369
+
287
370
  <span class="menu-manager__icon">
288
371
  <File v-if="row.node.type === 'page'" :size="16" />
289
- <Compass v-else-if="row.node.type === 'route'" :size="16" />
290
- <Folder v-else :size="16" />
372
+ <Compass v-else :size="16" />
291
373
  </span>
292
374
 
293
375
  <span class="menu-manager__label">{{ labelOf(row.node) }}</span>
@@ -308,7 +390,7 @@ onMounted(load)
308
390
  class="menu-manager__move"
309
391
  :title="L.moveUp"
310
392
  :aria-label="L.moveUp"
311
- :disabled="busy || !canMoveUp(row)"
393
+ :disabled="!canMoveUp(row)"
312
394
  @click="move(row, 'up')"
313
395
  >
314
396
  <ArrowUp :size="14" />
@@ -318,7 +400,7 @@ onMounted(load)
318
400
  class="menu-manager__move"
319
401
  :title="L.moveDown"
320
402
  :aria-label="L.moveDown"
321
- :disabled="busy || !canMoveDown(row)"
403
+ :disabled="!canMoveDown(row)"
322
404
  @click="move(row, 'down')"
323
405
  >
324
406
  <ArrowDown :size="14" />
@@ -331,61 +413,12 @@ onMounted(load)
331
413
  :class="row.node.is_visible ? 'is-on' : 'is-off'"
332
414
  :aria-pressed="row.node.is_visible"
333
415
  :title="L.visible"
334
- :disabled="busy"
335
416
  @click="toggleVisible(row)"
336
417
  >
337
418
  <Eye v-if="row.node.is_visible" :size="15" />
338
419
  <EyeOff v-else :size="15" />
339
420
  </button>
340
-
341
- <BaseSelect
342
- v-if="row.node.type !== 'group'"
343
- class="menu-manager__group-select"
344
- :model-value="row.parentId === null ? '' : String(row.parentId)"
345
- :options="[
346
- { value: '', label: L.root },
347
- ...groups.map((g) => ({ value: String(g.id), label: labelOf(g) })),
348
- ]"
349
- @update:model-value="(v: string) => setGroup(row, v)"
350
- />
351
-
352
- <span v-if="row.node.type === 'group'" class="menu-manager__group-actions">
353
- <button
354
- type="button"
355
- class="menu-manager__edit"
356
- :title="L.editGroupTitle"
357
- :aria-label="L.editGroupTitle"
358
- @click="openEditGroup(row.node)"
359
- >
360
- <SquarePen :size="15" />
361
- </button>
362
- <button
363
- type="button"
364
- class="menu-manager__delete"
365
- :title="L.delete"
366
- :aria-label="L.delete"
367
- @click="removeGroup(row.node)"
368
- >
369
- <Trash2 :size="15" />
370
- </button>
371
- </span>
372
421
  </article>
373
422
  </div>
374
-
375
- <EditModal
376
- v-model="modalOpen"
377
- :title="editingGroup ? L.editGroupTitle : L.newGroupTitle"
378
- :submit-label="L.save"
379
- :cancel-label="L.cancel"
380
- :loading="saving"
381
- @submit="saveGroup"
382
- >
383
- <TranslatableInput
384
- v-model="groupLabelForm"
385
- :locales="locales"
386
- :label="L.groupLabel"
387
- required
388
- />
389
- </EditModal>
390
423
  </div>
391
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