@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 +3 -4
- package/scss/components/_menu-manager.scss +49 -41
- package/scss/components/_page-blocks.scss +27 -6
- package/src/content/MenuManager.vue +236 -203
- package/src/content/PageBlocks.vue +199 -28
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@edc-motor/admin-kit",
|
|
3
|
-
"version": "0.4.
|
|
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.
|
|
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
|
|
4
|
-
// (icono + etiqueta + badges + acciones),
|
|
5
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
145
|
-
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
Trash2,
|
|
11
|
+
GripVertical,
|
|
12
|
+
RotateCcw,
|
|
13
|
+
Save,
|
|
15
14
|
} from '@lucide/vue'
|
|
16
|
-
import {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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'
|
|
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
|
-
|
|
119
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
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
|
|
139
|
-
|
|
140
|
-
|
|
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
|
|
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
|
|
182
|
+
const list = listFor(row.parentId)
|
|
183
|
+
if (!list) return false
|
|
152
184
|
const idx = siblingIndex(row)
|
|
153
|
-
return idx >= 0 && idx <
|
|
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
|
|
157
|
-
function
|
|
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 ?
|
|
164
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
213
|
-
const
|
|
214
|
-
const
|
|
215
|
-
|
|
216
|
-
|
|
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
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
|
225
|
-
|
|
226
|
-
groupLabelForm.value = { ...(node.label ?? {}) }
|
|
227
|
-
modalOpen.value = true
|
|
287
|
+
function onDragEnd() {
|
|
288
|
+
cleanupDrag()
|
|
228
289
|
}
|
|
229
290
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
if (
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
<
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
|
|
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
|
|
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="
|
|
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="
|
|
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
|
|
23
|
-
// esquema de campos del tipo. Añadir un tipo de
|
|
24
|
-
//
|
|
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:
|
|
174
|
-
//
|
|
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) =>
|
|
179
|
-
.map((b) => ({
|
|
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 =
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
263
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
<
|
|
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
|
-
|
|
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
|
-
</
|
|
615
|
+
</div>
|
|
445
616
|
|
|
446
617
|
<!-- Modal generado desde el esquema del tipo -->
|
|
447
618
|
<EditModal
|