@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edc-motor/admin-kit",
3
- "version": "0.4.26",
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.26",
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 de ajustes comunes: SIEMPRE visible al fondo del formulario
145
- // (antes era un <details> plegado), con su titulito discreto.
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-top: 1px dashed $border;
148
- padding-top: $space-3;
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
- // El aire de los checkboxes (bajo el selector de color) y del select del
158
- // padre va a mano, y cada checkbox en su propia línea.
159
- > .checkbox {
160
- display: flex;
161
- margin-top: $space-2;
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: 'Ajustes comunes',
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="modalType.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 PRIMERO (los interruptores arriba); después,
672
- secciones separadas (patrón panel) -->
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 fields" :key="field.key">
156
- <!-- Traducibles: text / textarea / richtext van al TranslatableInput -->
157
- <TranslatableInput
158
- v-if="field.translatable && ['text', 'textarea', 'richtext'].includes(field.type)"
159
- :model-value="translations(field)"
160
- :locales="locales"
161
- :label="label(field)"
162
- :required="field.required"
163
- :type="
164
- field.type === 'richtext' ? 'wysiwyg' : field.type === 'textarea' ? 'textarea' : 'text'
165
- "
166
- :icons="icons"
167
- :rich-labels="richLabels"
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
- :api="api"
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
- :translate="translate"
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
- <!-- Referencia a una entidad del juego: buscador sobre su endpoint -->
287
- <EntityRefSelect
288
- v-else-if="field.type === 'entity' && field.options_url"
289
- :model-value="(modelValue[field.key] as number) ?? null"
290
- :label="label(field)"
291
- :options-url="field.options_url"
292
- :api="api"
293
- @update:model-value="(v) => set(field.key, v)"
294
- />
295
-
296
- <!-- Imagen traducible: una por locale (fallback al default al renderizar).
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
- <TranslatableImage
299
- v-else-if="field.type === 'image' && field.translatable"
300
- :model-value="imageTranslations(field)"
301
- :locales="locales"
302
- :label="label(field)"
303
- :required="field.required"
304
- @update:model-value="(v) => set(field.key, v)"
305
- />
306
-
307
- <div v-else-if="field.type === 'image'" class="schema-fields__image">
308
- <span class="form-field__label">{{ label(field) }}</span>
309
- <ImageUpload
310
- :model-value="imageFile(field)"
311
- :current-url="imageCurrentUrl(field)"
312
- @update:model-value="(f: File | null) => set(field.key, f)"
313
- @remove="set(field.key, null)"
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>