@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 +3 -4
- package/scss/_admin-kit.scss +1 -0
- package/scss/components/_menu-manager.scss +141 -0
- package/scss/components/_page-blocks.scss +27 -6
- package/src/content/MenuManager.vue +424 -0
- package/src/content/PageBlocks.vue +199 -28
- package/src/index.ts +3 -0
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",
|
package/scss/_admin-kit.scss
CHANGED
|
@@ -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
|
|
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; }
|
|
@@ -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
|
|
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
|
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 {
|