@edc-motor/admin-kit 0.4.25 → 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.25",
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.25",
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 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
+ }
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
- // 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;
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: 'Ajustes comunes',
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="modalType.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 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>