@edc-motor/admin-kit 0.4.26 → 0.4.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@edc-motor/admin-kit",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.27",
|
|
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.27",
|
|
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
|
+
}
|
|
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;
|
|
156
169
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
margin-top: $space-2;
|
|
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,50 @@
|
|
|
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
|
+
align-items: flex-end;
|
|
199
|
+
gap: $space-3;
|
|
200
|
+
|
|
201
|
+
.schema-fields__field-main { flex: 1 1 0; }
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
&__field-align { flex: 0 0 180px; }
|
|
205
|
+
|
|
206
|
+
// Imagen a dos columnas: el input a la izquierda, sus ajustes (posición,
|
|
207
|
+
// escalado, reparto de columnas) apilados a la derecha.
|
|
208
|
+
&__field--image-group {
|
|
209
|
+
display: grid;
|
|
210
|
+
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
|
211
|
+
gap: $space-3;
|
|
212
|
+
align-items: start;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
&__field-image-settings {
|
|
216
|
+
display: grid;
|
|
217
|
+
gap: $space-3;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
@container modal-body (max-width: #{$bp-sm - 1px}) {
|
|
221
|
+
&__field--row {
|
|
222
|
+
flex-direction: column;
|
|
223
|
+
align-items: stretch;
|
|
224
|
+
}
|
|
225
|
+
&__field-align { flex-basis: auto; }
|
|
226
|
+
|
|
227
|
+
&__field--image-group { grid-template-columns: 1fr; }
|
|
228
|
+
}
|
|
229
|
+
|
|
176
230
|
&__color,
|
|
177
231
|
&__image {
|
|
178
232
|
display: grid;
|
|
@@ -67,7 +67,7 @@ const defaultLabels: PageBlocksLabels = {
|
|
|
67
67
|
indexableShort: 'Índice',
|
|
68
68
|
yes: 'Sí',
|
|
69
69
|
no: 'No',
|
|
70
|
-
common: '
|
|
70
|
+
common: 'General',
|
|
71
71
|
confirmDelete: '¿Borrar este bloque?',
|
|
72
72
|
error: 'No se ha podido completar la acción.',
|
|
73
73
|
panelTitle: 'Bloque',
|
|
@@ -181,6 +181,39 @@ const formPrintable = ref(true)
|
|
|
181
181
|
const formIndexable = ref(true)
|
|
182
182
|
const formParent = ref<number | null>(null)
|
|
183
183
|
|
|
184
|
+
// --- Formulario del modal: General ARRIBA + alineaciones junto a su campo -
|
|
185
|
+
// `title_align`/`subtitle_align` son campos COMUNES del motor pero
|
|
186
|
+
// title/subtitle son del TIPO: se inyectan aquí junto a su campo (SchemaFields
|
|
187
|
+
// ya sabe emparejar un `<base>_align` con `<base>` con que ambos estén en la
|
|
188
|
+
// misma lista) y se retiran de la sección general (author_align→author,
|
|
189
|
+
// label_align→label y button_align→button_text ya viven en el propio tipo,
|
|
190
|
+
// SchemaFields los empareja sin ayuda). El `align` general no tiene campo
|
|
191
|
+
// objetivo: se queda en General, junto al color de fondo.
|
|
192
|
+
function commonField(key: string): FieldSchema | undefined {
|
|
193
|
+
return modalType.value?.common.find((f) => f.key === key)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const typeFieldsWithAligns = computed<FieldSchema[]>(() => {
|
|
197
|
+
if (!modalType.value) return []
|
|
198
|
+
const injected = (['title_align', 'subtitle_align'] as const)
|
|
199
|
+
.map((key) => commonField(key))
|
|
200
|
+
.filter((f): f is FieldSchema => !!f)
|
|
201
|
+
return [...modalType.value.fields, ...injected]
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
const generalWidthFields = computed<FieldSchema[]>(() => {
|
|
205
|
+
const field = commonField('width')
|
|
206
|
+
return field ? [field] : []
|
|
207
|
+
})
|
|
208
|
+
const generalBackgroundFields = computed<FieldSchema[]>(() => {
|
|
209
|
+
const field = commonField('background')
|
|
210
|
+
return field ? [field] : []
|
|
211
|
+
})
|
|
212
|
+
const generalAlignFields = computed<FieldSchema[]>(() => {
|
|
213
|
+
const field = commonField('align')
|
|
214
|
+
return field ? [field] : []
|
|
215
|
+
})
|
|
216
|
+
|
|
184
217
|
// Padres elegibles: CUALQUIER bloque de la página (sin límite de niveles),
|
|
185
218
|
// salvo uno mismo o uno de sus propios descendientes (crearía un ciclo).
|
|
186
219
|
// Etiqueta con prefijo de rayas según su profundidad, para ver la jerarquía
|
|
@@ -614,46 +647,74 @@ defineExpose({ reload: load })
|
|
|
614
647
|
</article>
|
|
615
648
|
</div>
|
|
616
649
|
|
|
617
|
-
<!-- Modal generado desde el esquema del tipo
|
|
650
|
+
<!-- Modal generado desde el esquema del tipo: más ancho que el resto de
|
|
651
|
+
formularios del admin (más columnas por la sección de imagen y las
|
|
652
|
+
filas de alineación). -->
|
|
618
653
|
<EditModal
|
|
619
654
|
v-model="modalOpen"
|
|
620
655
|
:title="modalType ? typeName(modalType.key) : ''"
|
|
656
|
+
size="wide"
|
|
621
657
|
:submit-label="L.save"
|
|
622
658
|
:cancel-label="L.cancel"
|
|
623
659
|
:loading="busy"
|
|
624
660
|
@submit="save"
|
|
625
661
|
>
|
|
626
662
|
<template v-if="modalType">
|
|
663
|
+
<!-- General ARRIBA del todo (antes iba al fondo, como "Ajustes
|
|
664
|
+
comunes", y pasaba desapercibida): anchura + bloque padre;
|
|
665
|
+
los interruptores; color de fondo + alineación general del
|
|
666
|
+
bloque. Los campos del tipo van DESPUÉS. -->
|
|
667
|
+
<div class="page-blocks__common">
|
|
668
|
+
<span class="page-blocks__common-title">{{ L.common }}</span>
|
|
669
|
+
|
|
670
|
+
<div class="page-blocks__common-row">
|
|
671
|
+
<SchemaFields
|
|
672
|
+
v-model="form"
|
|
673
|
+
:fields="generalWidthFields"
|
|
674
|
+
:locales="locales"
|
|
675
|
+
:api="api"
|
|
676
|
+
:translate="translate"
|
|
677
|
+
/>
|
|
678
|
+
<BaseSelect
|
|
679
|
+
:model-value="formParent === null ? '' : String(formParent)"
|
|
680
|
+
:label="L.parent"
|
|
681
|
+
:options="[{ value: '', label: L.parentNone }, ...parentOptions]"
|
|
682
|
+
@update:model-value="(v: string) => (formParent = v ? Number(v) : null)"
|
|
683
|
+
/>
|
|
684
|
+
</div>
|
|
685
|
+
|
|
686
|
+
<div class="page-blocks__common-row">
|
|
687
|
+
<BaseCheckbox v-model="formPrintable" :label="L.printable" />
|
|
688
|
+
<BaseCheckbox v-model="formIndexable" :label="L.indexable" />
|
|
689
|
+
</div>
|
|
690
|
+
|
|
691
|
+
<div class="page-blocks__common-row">
|
|
692
|
+
<SchemaFields
|
|
693
|
+
v-model="form"
|
|
694
|
+
:fields="generalBackgroundFields"
|
|
695
|
+
:locales="locales"
|
|
696
|
+
:api="api"
|
|
697
|
+
:translate="translate"
|
|
698
|
+
/>
|
|
699
|
+
<SchemaFields
|
|
700
|
+
v-model="form"
|
|
701
|
+
:fields="generalAlignFields"
|
|
702
|
+
:locales="locales"
|
|
703
|
+
:api="api"
|
|
704
|
+
:translate="translate"
|
|
705
|
+
/>
|
|
706
|
+
</div>
|
|
707
|
+
</div>
|
|
708
|
+
|
|
627
709
|
<SchemaFields
|
|
628
710
|
v-model="form"
|
|
629
|
-
:fields="
|
|
711
|
+
:fields="typeFieldsWithAligns"
|
|
630
712
|
:locales="locales"
|
|
631
713
|
:api="api"
|
|
632
714
|
:icons="icons"
|
|
633
715
|
:rich-labels="richLabels"
|
|
634
716
|
:translate="translate"
|
|
635
717
|
/>
|
|
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
718
|
</template>
|
|
658
719
|
</EditModal>
|
|
659
720
|
|
|
@@ -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>
|