@edc-motor/admin-kit 0.4.26 → 0.4.28
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 +2 -2
- package/scss/components/_menu-manager.scss +7 -1
- package/scss/components/_page-blocks.scss +101 -11
- package/src/content/PageBlocks.vue +106 -34
- package/src/content/SchemaFields.vue +250 -153
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.28",
|
|
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,7 +33,7 @@
|
|
|
33
33
|
"access": "public"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@edc-motor/ui": "^0.4.
|
|
36
|
+
"@edc-motor/ui": "^0.4.28",
|
|
37
37
|
"axios": "^1.7.0"
|
|
38
38
|
},
|
|
39
39
|
"peerDependencies": {
|
|
@@ -43,9 +43,13 @@
|
|
|
43
43
|
position: relative;
|
|
44
44
|
transition: border-color 0.15s ease, opacity 0.15s ease;
|
|
45
45
|
|
|
46
|
+
// Filas NO clickables (no seleccionan panel, solo se arrastran): el
|
|
47
|
+
// cursor invita a agarrar en cualquier punto de la fila.
|
|
48
|
+
cursor: grab;
|
|
49
|
+
|
|
46
50
|
&:hover { border-color: $border-strong; }
|
|
47
51
|
&.is-hidden { opacity: 0.65; }
|
|
48
|
-
&.is-dragging { opacity: 0.4; }
|
|
52
|
+
&.is-dragging { opacity: 0.4; cursor: grabbing; }
|
|
49
53
|
|
|
50
54
|
// Indicador de inserción: línea antes/después de la fila.
|
|
51
55
|
&.drop-before::before,
|
|
@@ -78,7 +82,9 @@
|
|
|
78
82
|
flex: 0 0 auto;
|
|
79
83
|
cursor: grab;
|
|
80
84
|
touch-action: none;
|
|
85
|
+
transition: color 0.15s ease;
|
|
81
86
|
|
|
87
|
+
&:hover { color: $accent-500; }
|
|
82
88
|
&:active { cursor: grabbing; }
|
|
83
89
|
}
|
|
84
90
|
|
|
@@ -105,7 +105,9 @@
|
|
|
105
105
|
display: inline-flex;
|
|
106
106
|
color: $text-3;
|
|
107
107
|
cursor: grab;
|
|
108
|
+
transition: color 0.15s ease;
|
|
108
109
|
|
|
110
|
+
&:hover { color: $accent-500; }
|
|
109
111
|
&:active { cursor: grabbing; }
|
|
110
112
|
}
|
|
111
113
|
|
|
@@ -141,11 +143,13 @@
|
|
|
141
143
|
|
|
142
144
|
&__flags { grid-area: flags; display: flex; gap: $space-1; }
|
|
143
145
|
|
|
144
|
-
// Sección
|
|
145
|
-
//
|
|
146
|
+
// Sección "General": ARRIBA del formulario (antes al fondo, como "Ajustes
|
|
147
|
+
// comunes", y pasaba desapercibida), con su titulito discreto — fila 1:
|
|
148
|
+
// anchura + bloque padre; fila 2: los interruptores; fila 3: color de
|
|
149
|
+
// fondo + alineación general del bloque. Los campos del TIPO van después.
|
|
146
150
|
&__common {
|
|
147
|
-
border-
|
|
148
|
-
padding-
|
|
151
|
+
border-bottom: 1px dashed $border;
|
|
152
|
+
padding-bottom: $space-4;
|
|
149
153
|
display: grid;
|
|
150
154
|
gap: $space-3;
|
|
151
155
|
|
|
@@ -153,15 +157,21 @@
|
|
|
153
157
|
color: $text-2;
|
|
154
158
|
font-size: $fs-13;
|
|
155
159
|
}
|
|
160
|
+
}
|
|
156
161
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
+
// Cada fila de General: dos columnas iguales, apiladas en un modal
|
|
163
|
+
// angosto (container query sobre el cuerpo del modal, no el viewport).
|
|
164
|
+
&__common-row {
|
|
165
|
+
display: grid;
|
|
166
|
+
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
|
167
|
+
gap: $space-3;
|
|
168
|
+
align-items: start;
|
|
169
|
+
|
|
170
|
+
> .checkbox { display: flex; }
|
|
171
|
+
|
|
172
|
+
@container modal-body (max-width: #{$bp-sm - 1px}) {
|
|
173
|
+
grid-template-columns: 1fr;
|
|
162
174
|
}
|
|
163
|
-
> .checkbox:first-of-type { margin-top: $space-4; }
|
|
164
|
-
> .form-field { margin-top: $space-3; }
|
|
165
175
|
}
|
|
166
176
|
|
|
167
177
|
// Bloques anidados (parent_id, sin límite de niveles): sangría por
|
|
@@ -173,6 +183,86 @@
|
|
|
173
183
|
display: grid;
|
|
174
184
|
gap: $space-4;
|
|
175
185
|
|
|
186
|
+
// Envoltorio de CADA campo (doc 03 ampliado): normalmente transparente
|
|
187
|
+
// (una columna, como siempre) — gana forma cuando el campo se agrupa con
|
|
188
|
+
// su alineación o con los ajustes de la imagen.
|
|
189
|
+
&__field { min-width: 0; }
|
|
190
|
+
|
|
191
|
+
&__field-main { min-width: 0; }
|
|
192
|
+
|
|
193
|
+
// Alineación junto a su campo: input flexible + select de ancho
|
|
194
|
+
// contenido (~180px). En un modal angosto (container query sobre el
|
|
195
|
+
// cuerpo del modal, no el viewport) se apilan, el select debajo.
|
|
196
|
+
&__field--row {
|
|
197
|
+
display: flex;
|
|
198
|
+
// ARRIBA (no al fondo): con un textarea alto (p. ej. el subtítulo) el
|
|
199
|
+
// select de alineación debe quedar a la altura de las etiquetas, no
|
|
200
|
+
// descolgado al fondo de la fila.
|
|
201
|
+
align-items: flex-start;
|
|
202
|
+
gap: $space-3;
|
|
203
|
+
|
|
204
|
+
.schema-fields__field-main { flex: 1 1 0; }
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Ancho justo para "Centrado"/"Izquierda" + etiqueta en es/en/eu sin
|
|
208
|
+
// cortarse (180px sobraba de aire).
|
|
209
|
+
&__field-align { flex: 0 0 140px; }
|
|
210
|
+
|
|
211
|
+
// Imagen a dos columnas: el input a la izquierda, sus ajustes (posición,
|
|
212
|
+
// escalado, reparto de columnas) apilados a la derecha. `stretch` (no
|
|
213
|
+
// `start`) para que la columna de la imagen pueda llenar el alto de la
|
|
214
|
+
// fila — la cadena de contenedores hasta el cuadro de arrastre lleva
|
|
215
|
+
// `flex: 1` para que la dropzone crezca con ella.
|
|
216
|
+
&__field--image-group {
|
|
217
|
+
display: grid;
|
|
218
|
+
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
|
219
|
+
gap: $space-3;
|
|
220
|
+
align-items: stretch;
|
|
221
|
+
|
|
222
|
+
.schema-fields__field-main {
|
|
223
|
+
display: flex;
|
|
224
|
+
flex-direction: column;
|
|
225
|
+
min-width: 0;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// El campo imagen (label + ImageUpload) y el propio ImageUpload
|
|
229
|
+
// (`@edc-motor/ui`) se apuntan al `flex: 1` de la columna; SUELTO
|
|
230
|
+
// (form-modals normales, TranslatableImage) el componente no vive en
|
|
231
|
+
// esta cadena y conserva su tamaño natural (160×160).
|
|
232
|
+
.schema-fields__image {
|
|
233
|
+
display: flex;
|
|
234
|
+
flex-direction: column;
|
|
235
|
+
gap: $space-1;
|
|
236
|
+
flex: 1;
|
|
237
|
+
min-height: 0;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
.image-upload {
|
|
241
|
+
flex: 1;
|
|
242
|
+
min-height: 0;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
.image-upload__zone {
|
|
246
|
+
flex: 1;
|
|
247
|
+
width: 100%;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
&__field-image-settings {
|
|
252
|
+
display: grid;
|
|
253
|
+
gap: $space-3;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
@container modal-body (max-width: #{$bp-sm - 1px}) {
|
|
257
|
+
&__field--row {
|
|
258
|
+
flex-direction: column;
|
|
259
|
+
align-items: stretch;
|
|
260
|
+
}
|
|
261
|
+
&__field-align { flex-basis: auto; }
|
|
262
|
+
|
|
263
|
+
&__field--image-group { grid-template-columns: 1fr; }
|
|
264
|
+
}
|
|
265
|
+
|
|
176
266
|
&__color,
|
|
177
267
|
&__image {
|
|
178
268
|
display: grid;
|
|
@@ -52,6 +52,9 @@ export interface PageBlocksLabels {
|
|
|
52
52
|
panelContent: string
|
|
53
53
|
parent: string
|
|
54
54
|
parentNone: string
|
|
55
|
+
/** Título de la sección de interruptores (PDF/Índice) del panel, debajo
|
|
56
|
+
* de las acciones de verdad (editar, borrar…). */
|
|
57
|
+
stateKicker: string
|
|
55
58
|
}
|
|
56
59
|
|
|
57
60
|
const defaultLabels: PageBlocksLabels = {
|
|
@@ -67,7 +70,7 @@ const defaultLabels: PageBlocksLabels = {
|
|
|
67
70
|
indexableShort: 'Índice',
|
|
68
71
|
yes: 'Sí',
|
|
69
72
|
no: 'No',
|
|
70
|
-
common: '
|
|
73
|
+
common: 'General',
|
|
71
74
|
confirmDelete: '¿Borrar este bloque?',
|
|
72
75
|
error: 'No se ha podido completar la acción.',
|
|
73
76
|
panelTitle: 'Bloque',
|
|
@@ -75,6 +78,7 @@ const defaultLabels: PageBlocksLabels = {
|
|
|
75
78
|
panelContent: 'Contenido',
|
|
76
79
|
parent: 'Bloque padre (índices indentados)',
|
|
77
80
|
parentNone: '— Ninguno —',
|
|
81
|
+
stateKicker: 'Estado',
|
|
78
82
|
}
|
|
79
83
|
|
|
80
84
|
const props = withDefaults(
|
|
@@ -181,6 +185,39 @@ const formPrintable = ref(true)
|
|
|
181
185
|
const formIndexable = ref(true)
|
|
182
186
|
const formParent = ref<number | null>(null)
|
|
183
187
|
|
|
188
|
+
// --- Formulario del modal: General ARRIBA + alineaciones junto a su campo -
|
|
189
|
+
// `title_align`/`subtitle_align` son campos COMUNES del motor pero
|
|
190
|
+
// title/subtitle son del TIPO: se inyectan aquí junto a su campo (SchemaFields
|
|
191
|
+
// ya sabe emparejar un `<base>_align` con `<base>` con que ambos estén en la
|
|
192
|
+
// misma lista) y se retiran de la sección general (author_align→author,
|
|
193
|
+
// label_align→label y button_align→button_text ya viven en el propio tipo,
|
|
194
|
+
// SchemaFields los empareja sin ayuda). El `align` general no tiene campo
|
|
195
|
+
// objetivo: se queda en General, junto al color de fondo.
|
|
196
|
+
function commonField(key: string): FieldSchema | undefined {
|
|
197
|
+
return modalType.value?.common.find((f) => f.key === key)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const typeFieldsWithAligns = computed<FieldSchema[]>(() => {
|
|
201
|
+
if (!modalType.value) return []
|
|
202
|
+
const injected = (['title_align', 'subtitle_align'] as const)
|
|
203
|
+
.map((key) => commonField(key))
|
|
204
|
+
.filter((f): f is FieldSchema => !!f)
|
|
205
|
+
return [...modalType.value.fields, ...injected]
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
const generalWidthFields = computed<FieldSchema[]>(() => {
|
|
209
|
+
const field = commonField('width')
|
|
210
|
+
return field ? [field] : []
|
|
211
|
+
})
|
|
212
|
+
const generalBackgroundFields = computed<FieldSchema[]>(() => {
|
|
213
|
+
const field = commonField('background')
|
|
214
|
+
return field ? [field] : []
|
|
215
|
+
})
|
|
216
|
+
const generalAlignFields = computed<FieldSchema[]>(() => {
|
|
217
|
+
const field = commonField('align')
|
|
218
|
+
return field ? [field] : []
|
|
219
|
+
})
|
|
220
|
+
|
|
184
221
|
// Padres elegibles: CUALQUIER bloque de la página (sin límite de niveles),
|
|
185
222
|
// salvo uno mismo o uno de sus propios descendientes (crearía un ciclo).
|
|
186
223
|
// Etiqueta con prefijo de rayas según su profundidad, para ver la jerarquía
|
|
@@ -614,46 +651,74 @@ defineExpose({ reload: load })
|
|
|
614
651
|
</article>
|
|
615
652
|
</div>
|
|
616
653
|
|
|
617
|
-
<!-- Modal generado desde el esquema del tipo
|
|
654
|
+
<!-- Modal generado desde el esquema del tipo: más ancho que el resto de
|
|
655
|
+
formularios del admin (más columnas por la sección de imagen y las
|
|
656
|
+
filas de alineación). -->
|
|
618
657
|
<EditModal
|
|
619
658
|
v-model="modalOpen"
|
|
620
659
|
:title="modalType ? typeName(modalType.key) : ''"
|
|
660
|
+
size="wide"
|
|
621
661
|
:submit-label="L.save"
|
|
622
662
|
:cancel-label="L.cancel"
|
|
623
663
|
:loading="busy"
|
|
624
664
|
@submit="save"
|
|
625
665
|
>
|
|
626
666
|
<template v-if="modalType">
|
|
667
|
+
<!-- General ARRIBA del todo (antes iba al fondo, como "Ajustes
|
|
668
|
+
comunes", y pasaba desapercibida): anchura + bloque padre;
|
|
669
|
+
los interruptores; color de fondo + alineación general del
|
|
670
|
+
bloque. Los campos del tipo van DESPUÉS. -->
|
|
671
|
+
<div class="page-blocks__common">
|
|
672
|
+
<span class="page-blocks__common-title">{{ L.common }}</span>
|
|
673
|
+
|
|
674
|
+
<div class="page-blocks__common-row">
|
|
675
|
+
<SchemaFields
|
|
676
|
+
v-model="form"
|
|
677
|
+
:fields="generalWidthFields"
|
|
678
|
+
:locales="locales"
|
|
679
|
+
:api="api"
|
|
680
|
+
:translate="translate"
|
|
681
|
+
/>
|
|
682
|
+
<BaseSelect
|
|
683
|
+
:model-value="formParent === null ? '' : String(formParent)"
|
|
684
|
+
:label="L.parent"
|
|
685
|
+
:options="[{ value: '', label: L.parentNone }, ...parentOptions]"
|
|
686
|
+
@update:model-value="(v: string) => (formParent = v ? Number(v) : null)"
|
|
687
|
+
/>
|
|
688
|
+
</div>
|
|
689
|
+
|
|
690
|
+
<div class="page-blocks__common-row">
|
|
691
|
+
<BaseCheckbox v-model="formPrintable" :label="L.printable" />
|
|
692
|
+
<BaseCheckbox v-model="formIndexable" :label="L.indexable" />
|
|
693
|
+
</div>
|
|
694
|
+
|
|
695
|
+
<div class="page-blocks__common-row">
|
|
696
|
+
<SchemaFields
|
|
697
|
+
v-model="form"
|
|
698
|
+
:fields="generalBackgroundFields"
|
|
699
|
+
:locales="locales"
|
|
700
|
+
:api="api"
|
|
701
|
+
:translate="translate"
|
|
702
|
+
/>
|
|
703
|
+
<SchemaFields
|
|
704
|
+
v-model="form"
|
|
705
|
+
:fields="generalAlignFields"
|
|
706
|
+
:locales="locales"
|
|
707
|
+
:api="api"
|
|
708
|
+
:translate="translate"
|
|
709
|
+
/>
|
|
710
|
+
</div>
|
|
711
|
+
</div>
|
|
712
|
+
|
|
627
713
|
<SchemaFields
|
|
628
714
|
v-model="form"
|
|
629
|
-
:fields="
|
|
715
|
+
:fields="typeFieldsWithAligns"
|
|
630
716
|
:locales="locales"
|
|
631
717
|
:api="api"
|
|
632
718
|
:icons="icons"
|
|
633
719
|
:rich-labels="richLabels"
|
|
634
720
|
:translate="translate"
|
|
635
721
|
/>
|
|
636
|
-
|
|
637
|
-
<!-- Sección SIEMPRE visible al fondo del formulario (antes era un
|
|
638
|
-
details plegado y los ajustes pasaban desapercibidos) -->
|
|
639
|
-
<div class="page-blocks__common">
|
|
640
|
-
<span class="page-blocks__common-title">{{ L.common }}</span>
|
|
641
|
-
<SchemaFields
|
|
642
|
-
v-model="form"
|
|
643
|
-
:fields="modalType.common"
|
|
644
|
-
:locales="locales"
|
|
645
|
-
:api="api"
|
|
646
|
-
:translate="translate"
|
|
647
|
-
/>
|
|
648
|
-
<BaseCheckbox v-model="formPrintable" :label="L.printable" />
|
|
649
|
-
<BaseCheckbox v-model="formIndexable" :label="L.indexable" />
|
|
650
|
-
<BaseSelect
|
|
651
|
-
:model-value="formParent === null ? '' : String(formParent)"
|
|
652
|
-
:label="L.parent"
|
|
653
|
-
:options="[{ value: '', label: L.parentNone }, ...parentOptions]"
|
|
654
|
-
@update:model-value="(v: string) => (formParent = v ? Number(v) : null)"
|
|
655
|
-
/>
|
|
656
|
-
</div>
|
|
657
722
|
</template>
|
|
658
723
|
</EditModal>
|
|
659
724
|
|
|
@@ -668,8 +733,23 @@ defineExpose({ reload: load })
|
|
|
668
733
|
<template v-else>
|
|
669
734
|
<p class="manager-panel__kicker">{{ L.panelTitle }}</p>
|
|
670
735
|
|
|
671
|
-
<!-- Acciones
|
|
672
|
-
|
|
736
|
+
<!-- Acciones de verdad (patrón panel): los interruptores
|
|
737
|
+
PDF/Índice van en su propia sección, debajo -->
|
|
738
|
+
<div class="manager-detail__actions">
|
|
739
|
+
<BaseButton variant="info" :disabled="busy" @click="openEdit(selected)">
|
|
740
|
+
<template #icon><SquarePen :size="14" /></template>
|
|
741
|
+
{{ L.edit }}
|
|
742
|
+
</BaseButton>
|
|
743
|
+
<BaseButton variant="danger" :disabled="busy" @click="remove(selected)">
|
|
744
|
+
<template #icon><Trash2 :size="14" /></template>
|
|
745
|
+
{{ L.delete }}
|
|
746
|
+
</BaseButton>
|
|
747
|
+
</div>
|
|
748
|
+
|
|
749
|
+
<!-- Estado: los interruptores (flags), separados de las
|
|
750
|
+
acciones de verdad -->
|
|
751
|
+
<hr class="manager-panel__divider" />
|
|
752
|
+
<p class="manager-panel__kicker">{{ L.stateKicker }}</p>
|
|
673
753
|
<div class="manager-detail__actions">
|
|
674
754
|
<BaseButton
|
|
675
755
|
variant="success"
|
|
@@ -690,14 +770,6 @@ defineExpose({ reload: load })
|
|
|
690
770
|
<template #icon><List :size="14" /></template>
|
|
691
771
|
{{ L.indexableShort }}
|
|
692
772
|
</BaseButton>
|
|
693
|
-
<BaseButton variant="info" :disabled="busy" @click="openEdit(selected)">
|
|
694
|
-
<template #icon><SquarePen :size="14" /></template>
|
|
695
|
-
{{ L.edit }}
|
|
696
|
-
</BaseButton>
|
|
697
|
-
<BaseButton variant="danger" :disabled="busy" @click="remove(selected)">
|
|
698
|
-
<template #icon><Trash2 :size="14" /></template>
|
|
699
|
-
{{ L.delete }}
|
|
700
|
-
</BaseButton>
|
|
701
773
|
</div>
|
|
702
774
|
|
|
703
775
|
<hr class="manager-panel__divider" />
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue'
|
|
2
3
|
import type { AxiosInstance } from 'axios'
|
|
3
4
|
import { ArrowDown, ArrowUp, Plus, X } from '@lucide/vue'
|
|
4
5
|
import {
|
|
@@ -62,6 +63,64 @@ const props = withDefaults(
|
|
|
62
63
|
|
|
63
64
|
const emit = defineEmits<{ 'update:modelValue': [value: Record<string, unknown>] }>()
|
|
64
65
|
|
|
66
|
+
// Convenciones genéricas de agrupado (doc 03 ampliado):
|
|
67
|
+
//
|
|
68
|
+
// 1) Alineaciones junto a su campo: un select `<base>_align` cuyo campo
|
|
69
|
+
// `<base>` (o `<base>_text`, caso de `button_align`/`button_text`) esté
|
|
70
|
+
// en la MISMA lista se pinta junto a él, en una fila de dos columnas
|
|
71
|
+
// (PageBlocks inyecta title_align/subtitle_align —comunes del motor—
|
|
72
|
+
// junto a title/subtitle antes de pasar la lista aquí). El `align`
|
|
73
|
+
// general del bloque no tiene campo objetivo ("align" no acaba en
|
|
74
|
+
// "_align" con un prefijo no vacío) y se queda fuera de esta pareja.
|
|
75
|
+
// 2) Imagen a dos columnas: el campo `image` con alguno de sus ajustes
|
|
76
|
+
// (`image_position`, `image_fit`, `image_columns`) en la misma lista se
|
|
77
|
+
// agrupa con ellos — columna 1 la imagen, columna 2 los ajustes
|
|
78
|
+
// apilados.
|
|
79
|
+
const IMAGE_SETTING_KEYS = ['image_position', 'image_fit', 'image_columns']
|
|
80
|
+
|
|
81
|
+
interface LayoutItem {
|
|
82
|
+
field: FieldSchema
|
|
83
|
+
/** Select `<base>_align` que viaja junto a `field` en la misma fila. */
|
|
84
|
+
align?: FieldSchema
|
|
85
|
+
/** Ajustes de imagen (image_position/fit/columns) que viajan con `field`. */
|
|
86
|
+
imageSettings?: FieldSchema[]
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const layout = computed<LayoutItem[]>(() => {
|
|
90
|
+
const byKey = new Map(props.fields.map((f) => [f.key, f]))
|
|
91
|
+
const alignOf = new Map<string, FieldSchema>() // clave del campo "base" => su _align
|
|
92
|
+
const consumed = new Set<string>() // claves ya emparejadas con otro campo
|
|
93
|
+
|
|
94
|
+
for (const field of props.fields) {
|
|
95
|
+
if (field.type !== 'select' || !field.key.endsWith('_align')) continue
|
|
96
|
+
const base = field.key.slice(0, -'_align'.length)
|
|
97
|
+
if (!base) continue // el "align" general no tiene prefijo
|
|
98
|
+
const baseKey = byKey.has(base) ? base : byKey.has(`${base}_text`) ? `${base}_text` : null
|
|
99
|
+
if (!baseKey) continue
|
|
100
|
+
alignOf.set(baseKey, field)
|
|
101
|
+
consumed.add(field.key)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const imageSettingsOf = new Map<string, FieldSchema[]>()
|
|
105
|
+
for (const field of props.fields) {
|
|
106
|
+
if (field.key !== 'image' || field.type !== 'image') continue
|
|
107
|
+
const settings = IMAGE_SETTING_KEYS.map((key) => byKey.get(key)).filter(
|
|
108
|
+
(f): f is FieldSchema => !!f,
|
|
109
|
+
)
|
|
110
|
+
if (!settings.length) continue
|
|
111
|
+
imageSettingsOf.set(field.key, settings)
|
|
112
|
+
settings.forEach((f) => consumed.add(f.key))
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return props.fields
|
|
116
|
+
.filter((f) => !consumed.has(f.key))
|
|
117
|
+
.map((f) => ({
|
|
118
|
+
field: f,
|
|
119
|
+
align: alignOf.get(f.key),
|
|
120
|
+
imageSettings: imageSettingsOf.get(f.key),
|
|
121
|
+
}))
|
|
122
|
+
})
|
|
123
|
+
|
|
65
124
|
function label(field: FieldSchema): string {
|
|
66
125
|
return props.translate?.(`blockFields.${field.key}`, field.label) ?? field.label
|
|
67
126
|
}
|
|
@@ -152,166 +211,204 @@ function addLabel(): string {
|
|
|
152
211
|
|
|
153
212
|
<template>
|
|
154
213
|
<div class="schema-fields">
|
|
155
|
-
<template v-for="field in
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
@update:model-value="(v) => set(field.key, v)"
|
|
169
|
-
/>
|
|
170
|
-
|
|
171
|
-
<BaseInput
|
|
172
|
-
v-else-if="field.type === 'text'"
|
|
173
|
-
:model-value="(modelValue[field.key] as string) ?? ''"
|
|
174
|
-
:label="label(field)"
|
|
175
|
-
:required="field.required"
|
|
176
|
-
@update:model-value="(v) => set(field.key, v)"
|
|
177
|
-
/>
|
|
178
|
-
|
|
179
|
-
<BaseTextarea
|
|
180
|
-
v-else-if="field.type === 'textarea'"
|
|
181
|
-
:model-value="(modelValue[field.key] as string) ?? ''"
|
|
182
|
-
:label="label(field)"
|
|
183
|
-
@update:model-value="(v: string) => set(field.key, v)"
|
|
184
|
-
/>
|
|
185
|
-
|
|
186
|
-
<RichTextInput
|
|
187
|
-
v-else-if="field.type === 'richtext'"
|
|
188
|
-
:model-value="(modelValue[field.key] as string) ?? ''"
|
|
189
|
-
:icons="icons"
|
|
190
|
-
:labels="richLabels"
|
|
191
|
-
@update:model-value="(v: string) => set(field.key, v)"
|
|
192
|
-
/>
|
|
193
|
-
|
|
194
|
-
<NumericInput
|
|
195
|
-
v-else-if="field.type === 'number'"
|
|
196
|
-
:model-value="(modelValue[field.key] as number) ?? (field.default as number) ?? 0"
|
|
197
|
-
:label="label(field)"
|
|
198
|
-
:min="field.min ?? 0"
|
|
199
|
-
:max="field.max ?? undefined"
|
|
200
|
-
@update:model-value="(v) => set(field.key, v)"
|
|
201
|
-
/>
|
|
202
|
-
|
|
203
|
-
<BaseCheckbox
|
|
204
|
-
v-else-if="field.type === 'boolean'"
|
|
205
|
-
:model-value="Boolean(modelValue[field.key] ?? field.default)"
|
|
206
|
-
:label="label(field)"
|
|
207
|
-
@update:model-value="(v) => set(field.key, v)"
|
|
208
|
-
/>
|
|
209
|
-
|
|
210
|
-
<BaseSelect
|
|
211
|
-
v-else-if="field.type === 'select'"
|
|
212
|
-
:model-value="(modelValue[field.key] as string) ?? (field.default as string) ?? ''"
|
|
213
|
-
:label="label(field)"
|
|
214
|
-
:options="selectOptions(field)"
|
|
215
|
-
@update:model-value="(v) => set(field.key, v)"
|
|
216
|
-
/>
|
|
217
|
-
|
|
218
|
-
<div v-else-if="field.type === 'color'" class="schema-fields__color">
|
|
219
|
-
<span class="form-field__label">{{ label(field) }}</span>
|
|
220
|
-
<PaletteColorPicker
|
|
221
|
-
:model-value="(modelValue[field.key] as string) ?? null"
|
|
222
|
-
@update:model-value="(v) => set(field.key, v)"
|
|
223
|
-
/>
|
|
224
|
-
</div>
|
|
225
|
-
|
|
226
|
-
<!-- Grupo: objeto {subclave: valor} con los subcampos anidados -->
|
|
227
|
-
<fieldset v-else-if="field.type === 'group'" class="schema-fields__group">
|
|
228
|
-
<legend class="form-field__label">{{ label(field) }}</legend>
|
|
229
|
-
<SchemaFields
|
|
230
|
-
:fields="field.fields ?? []"
|
|
231
|
-
:model-value="groupValue(field)"
|
|
232
|
-
:locales="locales"
|
|
233
|
-
:api="api"
|
|
234
|
-
:icons="icons"
|
|
235
|
-
:rich-labels="richLabels"
|
|
236
|
-
:translate="translate"
|
|
237
|
-
@update:model-value="(v) => set(field.key, v)"
|
|
238
|
-
/>
|
|
239
|
-
</fieldset>
|
|
240
|
-
|
|
241
|
-
<!-- Repeater: filas con los mismos subcampos (añadir/quitar/reordenar) -->
|
|
242
|
-
<div v-else-if="field.type === 'repeater'" class="schema-fields__repeater">
|
|
243
|
-
<span class="form-field__label">{{ label(field) }}</span>
|
|
244
|
-
<fieldset v-for="(row, index) in rows(field)" :key="index" class="schema-fields__row">
|
|
245
|
-
<div class="schema-fields__row-bar">
|
|
246
|
-
<span class="schema-fields__row-index">{{ index + 1 }}</span>
|
|
247
|
-
<IconButton
|
|
248
|
-
v-if="index > 0"
|
|
249
|
-
variant="neutral"
|
|
250
|
-
:title="'↑'"
|
|
251
|
-
@click="moveRow(field, index, -1)"
|
|
252
|
-
><ArrowUp :size="14"
|
|
253
|
-
/></IconButton>
|
|
254
|
-
<IconButton
|
|
255
|
-
v-if="index < rows(field).length - 1"
|
|
256
|
-
variant="neutral"
|
|
257
|
-
:title="'↓'"
|
|
258
|
-
@click="moveRow(field, index, 1)"
|
|
259
|
-
><ArrowDown :size="14"
|
|
260
|
-
/></IconButton>
|
|
261
|
-
<IconButton variant="danger" :title="'×'" @click="removeRow(field, index)"
|
|
262
|
-
><X :size="14"
|
|
263
|
-
/></IconButton>
|
|
264
|
-
</div>
|
|
265
|
-
<SchemaFields
|
|
266
|
-
:fields="field.fields ?? []"
|
|
267
|
-
:model-value="row"
|
|
214
|
+
<template v-for="{ field, align, imageSettings } in layout" :key="field.key">
|
|
215
|
+
<div
|
|
216
|
+
class="schema-fields__field"
|
|
217
|
+
:class="{
|
|
218
|
+
'schema-fields__field--row': !!align,
|
|
219
|
+
'schema-fields__field--image-group': !!imageSettings,
|
|
220
|
+
}"
|
|
221
|
+
>
|
|
222
|
+
<div class="schema-fields__field-main">
|
|
223
|
+
<!-- Traducibles: text / textarea / richtext van al TranslatableInput -->
|
|
224
|
+
<TranslatableInput
|
|
225
|
+
v-if="field.translatable && ['text', 'textarea', 'richtext'].includes(field.type)"
|
|
226
|
+
:model-value="translations(field)"
|
|
268
227
|
:locales="locales"
|
|
269
|
-
:
|
|
228
|
+
:label="label(field)"
|
|
229
|
+
:required="field.required"
|
|
230
|
+
:type="
|
|
231
|
+
field.type === 'richtext'
|
|
232
|
+
? 'wysiwyg'
|
|
233
|
+
: field.type === 'textarea'
|
|
234
|
+
? 'textarea'
|
|
235
|
+
: 'text'
|
|
236
|
+
"
|
|
270
237
|
:icons="icons"
|
|
271
238
|
:rich-labels="richLabels"
|
|
272
|
-
:
|
|
273
|
-
@update:model-value="(v) => setRow(field, index, v)"
|
|
239
|
+
@update:model-value="(v) => set(field.key, v)"
|
|
274
240
|
/>
|
|
275
|
-
</fieldset>
|
|
276
|
-
<BaseButton
|
|
277
|
-
v-if="field.max === null || rows(field).length < field.max"
|
|
278
|
-
variant="text"
|
|
279
|
-
@click="addRow(field)"
|
|
280
|
-
>
|
|
281
|
-
<template #icon><Plus :size="14" /></template>
|
|
282
|
-
{{ addLabel() }}
|
|
283
|
-
</BaseButton>
|
|
284
|
-
</div>
|
|
285
241
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
242
|
+
<BaseInput
|
|
243
|
+
v-else-if="field.type === 'text'"
|
|
244
|
+
:model-value="(modelValue[field.key] as string) ?? ''"
|
|
245
|
+
:label="label(field)"
|
|
246
|
+
:required="field.required"
|
|
247
|
+
@update:model-value="(v) => set(field.key, v)"
|
|
248
|
+
/>
|
|
249
|
+
|
|
250
|
+
<BaseTextarea
|
|
251
|
+
v-else-if="field.type === 'textarea'"
|
|
252
|
+
:model-value="(modelValue[field.key] as string) ?? ''"
|
|
253
|
+
:label="label(field)"
|
|
254
|
+
@update:model-value="(v: string) => set(field.key, v)"
|
|
255
|
+
/>
|
|
256
|
+
|
|
257
|
+
<RichTextInput
|
|
258
|
+
v-else-if="field.type === 'richtext'"
|
|
259
|
+
:model-value="(modelValue[field.key] as string) ?? ''"
|
|
260
|
+
:icons="icons"
|
|
261
|
+
:labels="richLabels"
|
|
262
|
+
@update:model-value="(v: string) => set(field.key, v)"
|
|
263
|
+
/>
|
|
264
|
+
|
|
265
|
+
<NumericInput
|
|
266
|
+
v-else-if="field.type === 'number'"
|
|
267
|
+
:model-value="(modelValue[field.key] as number) ?? (field.default as number) ?? 0"
|
|
268
|
+
:label="label(field)"
|
|
269
|
+
:min="field.min ?? 0"
|
|
270
|
+
:max="field.max ?? undefined"
|
|
271
|
+
@update:model-value="(v) => set(field.key, v)"
|
|
272
|
+
/>
|
|
273
|
+
|
|
274
|
+
<BaseCheckbox
|
|
275
|
+
v-else-if="field.type === 'boolean'"
|
|
276
|
+
:model-value="Boolean(modelValue[field.key] ?? field.default)"
|
|
277
|
+
:label="label(field)"
|
|
278
|
+
@update:model-value="(v) => set(field.key, v)"
|
|
279
|
+
/>
|
|
280
|
+
|
|
281
|
+
<BaseSelect
|
|
282
|
+
v-else-if="field.type === 'select'"
|
|
283
|
+
:model-value="(modelValue[field.key] as string) ?? (field.default as string) ?? ''"
|
|
284
|
+
:label="label(field)"
|
|
285
|
+
:options="selectOptions(field)"
|
|
286
|
+
@update:model-value="(v) => set(field.key, v)"
|
|
287
|
+
/>
|
|
288
|
+
|
|
289
|
+
<div v-else-if="field.type === 'color'" class="schema-fields__color">
|
|
290
|
+
<span class="form-field__label">{{ label(field) }}</span>
|
|
291
|
+
<PaletteColorPicker
|
|
292
|
+
:model-value="(modelValue[field.key] as string) ?? null"
|
|
293
|
+
@update:model-value="(v) => set(field.key, v)"
|
|
294
|
+
/>
|
|
295
|
+
</div>
|
|
296
|
+
|
|
297
|
+
<!-- Grupo: objeto {subclave: valor} con los subcampos anidados -->
|
|
298
|
+
<fieldset v-else-if="field.type === 'group'" class="schema-fields__group">
|
|
299
|
+
<legend class="form-field__label">{{ label(field) }}</legend>
|
|
300
|
+
<SchemaFields
|
|
301
|
+
:fields="field.fields ?? []"
|
|
302
|
+
:model-value="groupValue(field)"
|
|
303
|
+
:locales="locales"
|
|
304
|
+
:api="api"
|
|
305
|
+
:icons="icons"
|
|
306
|
+
:rich-labels="richLabels"
|
|
307
|
+
:translate="translate"
|
|
308
|
+
@update:model-value="(v) => set(field.key, v)"
|
|
309
|
+
/>
|
|
310
|
+
</fieldset>
|
|
311
|
+
|
|
312
|
+
<!-- Repeater: filas con los mismos subcampos (añadir/quitar/reordenar) -->
|
|
313
|
+
<div v-else-if="field.type === 'repeater'" class="schema-fields__repeater">
|
|
314
|
+
<span class="form-field__label">{{ label(field) }}</span>
|
|
315
|
+
<fieldset v-for="(row, index) in rows(field)" :key="index" class="schema-fields__row">
|
|
316
|
+
<div class="schema-fields__row-bar">
|
|
317
|
+
<span class="schema-fields__row-index">{{ index + 1 }}</span>
|
|
318
|
+
<IconButton
|
|
319
|
+
v-if="index > 0"
|
|
320
|
+
variant="neutral"
|
|
321
|
+
:title="'↑'"
|
|
322
|
+
@click="moveRow(field, index, -1)"
|
|
323
|
+
><ArrowUp :size="14"
|
|
324
|
+
/></IconButton>
|
|
325
|
+
<IconButton
|
|
326
|
+
v-if="index < rows(field).length - 1"
|
|
327
|
+
variant="neutral"
|
|
328
|
+
:title="'↓'"
|
|
329
|
+
@click="moveRow(field, index, 1)"
|
|
330
|
+
><ArrowDown :size="14"
|
|
331
|
+
/></IconButton>
|
|
332
|
+
<IconButton variant="danger" :title="'×'" @click="removeRow(field, index)"
|
|
333
|
+
><X :size="14"
|
|
334
|
+
/></IconButton>
|
|
335
|
+
</div>
|
|
336
|
+
<SchemaFields
|
|
337
|
+
:fields="field.fields ?? []"
|
|
338
|
+
:model-value="row"
|
|
339
|
+
:locales="locales"
|
|
340
|
+
:api="api"
|
|
341
|
+
:icons="icons"
|
|
342
|
+
:rich-labels="richLabels"
|
|
343
|
+
:translate="translate"
|
|
344
|
+
@update:model-value="(v) => setRow(field, index, v)"
|
|
345
|
+
/>
|
|
346
|
+
</fieldset>
|
|
347
|
+
<BaseButton
|
|
348
|
+
v-if="field.max === null || rows(field).length < field.max"
|
|
349
|
+
variant="text"
|
|
350
|
+
@click="addRow(field)"
|
|
351
|
+
>
|
|
352
|
+
<template #icon><Plus :size="14" /></template>
|
|
353
|
+
{{ addLabel() }}
|
|
354
|
+
</BaseButton>
|
|
355
|
+
</div>
|
|
356
|
+
|
|
357
|
+
<!-- Referencia a una entidad del juego: buscador sobre su endpoint -->
|
|
358
|
+
<EntityRefSelect
|
|
359
|
+
v-else-if="field.type === 'entity' && field.options_url"
|
|
360
|
+
:model-value="(modelValue[field.key] as number) ?? null"
|
|
361
|
+
:label="label(field)"
|
|
362
|
+
:options-url="field.options_url"
|
|
363
|
+
:api="api"
|
|
364
|
+
@update:model-value="(v) => set(field.key, v)"
|
|
365
|
+
/>
|
|
366
|
+
|
|
367
|
+
<!-- Imagen traducible: una por locale (fallback al default al renderizar).
|
|
297
368
|
Diferida: el mapa lleva File pendientes hasta el guardar -->
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
369
|
+
<TranslatableImage
|
|
370
|
+
v-else-if="field.type === 'image' && field.translatable"
|
|
371
|
+
:model-value="imageTranslations(field)"
|
|
372
|
+
:locales="locales"
|
|
373
|
+
:label="label(field)"
|
|
374
|
+
:required="field.required"
|
|
375
|
+
@update:model-value="(v) => set(field.key, v)"
|
|
376
|
+
/>
|
|
377
|
+
|
|
378
|
+
<div v-else-if="field.type === 'image'" class="schema-fields__image">
|
|
379
|
+
<span class="form-field__label">{{ label(field) }}</span>
|
|
380
|
+
<ImageUpload
|
|
381
|
+
:model-value="imageFile(field)"
|
|
382
|
+
:current-url="imageCurrentUrl(field)"
|
|
383
|
+
@update:model-value="(f: File | null) => set(field.key, f)"
|
|
384
|
+
@remove="set(field.key, null)"
|
|
385
|
+
/>
|
|
386
|
+
</div>
|
|
387
|
+
</div>
|
|
388
|
+
|
|
389
|
+
<!-- Alineación del campo (title_align/subtitle_align/author_align/…),
|
|
390
|
+
en la misma fila que su campo (columna de ancho contenido). -->
|
|
391
|
+
<BaseSelect
|
|
392
|
+
v-if="align"
|
|
393
|
+
class="schema-fields__field-align"
|
|
394
|
+
:model-value="(modelValue[align.key] as string) ?? (align.default as string) ?? ''"
|
|
395
|
+
:label="label(align)"
|
|
396
|
+
:options="selectOptions(align)"
|
|
397
|
+
@update:model-value="(v) => set(align!.key, v)"
|
|
314
398
|
/>
|
|
399
|
+
|
|
400
|
+
<!-- Ajustes de la imagen (image_position/image_fit/image_columns),
|
|
401
|
+
apilados en la segunda columna junto al input de imagen. -->
|
|
402
|
+
<div v-if="imageSettings" class="schema-fields__field-image-settings">
|
|
403
|
+
<BaseSelect
|
|
404
|
+
v-for="setting in imageSettings"
|
|
405
|
+
:key="setting.key"
|
|
406
|
+
:model-value="(modelValue[setting.key] as string) ?? (setting.default as string) ?? ''"
|
|
407
|
+
:label="label(setting)"
|
|
408
|
+
:options="selectOptions(setting)"
|
|
409
|
+
@update:model-value="(v) => set(setting.key, v)"
|
|
410
|
+
/>
|
|
411
|
+
</div>
|
|
315
412
|
</div>
|
|
316
413
|
</template>
|
|
317
414
|
</div>
|