@edc-motor/admin-kit 0.4.14 → 0.4.16

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.14",
3
+ "version": "0.4.16",
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.14",
36
+ "@edc-motor/ui": "^0.4.16",
37
37
  "axios": "^1.7.0",
38
38
  "vue-draggable-plus": "^0.6.1"
39
39
  },
@@ -18,7 +18,14 @@
18
18
  &:hover { border-color: $border-strong; box-shadow: $shadow-md; }
19
19
  }
20
20
  &--muted { opacity: 0.65; }
21
- &.is-active { border-color: $accent-500; }
21
+ // Seleccionada: acento del tema, borde doblado con un anillo (sin mover
22
+ // el layout) y una sombra suave para despegarla de la rejilla.
23
+ &.is-active {
24
+ border-color: $accent-500;
25
+ box-shadow:
26
+ 0 0 0 1px $accent-500,
27
+ 0 2px 10px color-mix(in srgb, $accent-500 30%, transparent);
28
+ }
22
29
 
23
30
  // Borde teñido con el color de la entidad (prop accentColor, p. ej. su
24
31
  // facción). El color viene de los datos del juego, así que en reposo nunca
@@ -30,8 +37,10 @@
30
37
  &.entity-card--clickable:hover {
31
38
  border-color: color-mix(in srgb, var(--entity-card-accent) 75%, $border-strong);
32
39
  }
40
+ // La selección manda sobre el tinte de la entidad: mismo acento del
41
+ // tema que cualquier otra card seleccionada.
33
42
  &.is-active {
34
- border-color: var(--entity-card-accent);
43
+ border-color: $accent-500;
35
44
  }
36
45
  }
37
46
 
@@ -16,17 +16,20 @@
16
16
 
17
17
  &__search-icon {
18
18
  position: absolute;
19
- right: $input-padding-x;
19
+ left: $input-padding-x;
20
20
  top: 50%;
21
21
  transform: translateY(-50%);
22
22
  color: $text-3;
23
23
  pointer-events: none;
24
24
  }
25
25
 
26
- &__search-input {
26
+ // Doble clase: gana al input[type="search"] genérico de _forms.scss
27
+ // (0,1,1) sin depender del orden de carga — si no, el padding de la lupa
28
+ // no se aplica y el texto se monta con el icono.
29
+ &__search #{&}__search-input {
27
30
  width: 100%;
28
31
  height: $input-height;
29
- padding: 0 34px 0 $input-padding-x; // hueco a la derecha para la lupa
32
+ padding: 0 $input-padding-x 0 34px; // hueco a la izquierda para la lupa
30
33
  background: $surface-2;
31
34
  color: $text-1;
32
35
  border: 1px solid $border;
@@ -43,4 +43,16 @@
43
43
  @container content (min-width: #{$bp-lg}) { grid-template-columns: repeat(4, 1fr); }
44
44
  @container content (min-width: #{$bp-xl}) { grid-template-columns: repeat(5, 1fr); }
45
45
  }
46
+
47
+ // Preset `cards-dense` (piezas pequeñas, p. ej. el gestor de iconos): el
48
+ // DOBLE de columnas que `cards` en todos los breakpoints — 2 → 4 → 6 → 8
49
+ // → 10 con los mismos escalones del contenedor `content`.
50
+ &--cards-dense {
51
+ grid-template-columns: repeat(2, 1fr);
52
+
53
+ @container content (min-width: #{$bp-sm}) { grid-template-columns: repeat(4, 1fr); }
54
+ @container content (min-width: #{$bp-md}) { grid-template-columns: repeat(6, 1fr); }
55
+ @container content (min-width: #{$bp-lg}) { grid-template-columns: repeat(8, 1fr); }
56
+ @container content (min-width: #{$bp-xl}) { grid-template-columns: repeat(10, 1fr); }
57
+ }
46
58
  }
@@ -19,15 +19,18 @@
19
19
  flex-wrap: wrap;
20
20
  }
21
21
 
22
+ // Mismos escalones que el preset `cards` de BaseGrid (1 → 2 → 3 → 4 → 5 con
23
+ // los breakpoints canónicos del contenedor `content`).
22
24
  .manager-grid {
23
25
  display: grid;
24
26
  grid-template-columns: minmax(0, 1fr);
25
27
  gap: $space-4;
26
28
  align-items: start;
27
29
 
28
- @container content (min-width: #{$bp-md}) {
29
- grid-template-columns: repeat(2, minmax(0, 1fr));
30
- }
30
+ @container content (min-width: #{$bp-sm}) { grid-template-columns: repeat(2, minmax(0, 1fr)); }
31
+ @container content (min-width: #{$bp-md}) { grid-template-columns: repeat(3, minmax(0, 1fr)); }
32
+ @container content (min-width: #{$bp-lg}) { grid-template-columns: repeat(4, minmax(0, 1fr)); }
33
+ @container content (min-width: #{$bp-xl}) { grid-template-columns: repeat(5, minmax(0, 1fr)); }
31
34
  }
32
35
 
33
36
  .manager-card {
@@ -58,14 +61,19 @@
58
61
 
59
62
  &.is-active {
60
63
  border-color: $accent-500;
64
+ box-shadow:
65
+ 0 0 0 1px $accent-500,
66
+ 0 2px 10px color-mix(in srgb, $accent-500 30%, transparent);
61
67
  }
62
68
 
69
+ // Cabecera con divisoria, como la de EntityCard.
63
70
  &__head {
64
71
  display: flex;
65
72
  align-items: center;
66
73
  gap: $space-2;
67
74
  color: $text-1;
68
- padding: $space-3 $space-4;
75
+ padding: $space-3 $space-4 $space-2;
76
+ border-bottom: 1px solid $border;
69
77
  min-width: 0;
70
78
  }
71
79
 
@@ -90,12 +98,27 @@
90
98
  .manager-card__chip + & { margin-left: 0; }
91
99
  }
92
100
 
101
+ // Contenido como el de EntityCard: badges (chips) arriba, meta debajo.
102
+ &__content {
103
+ display: flex;
104
+ flex-direction: column;
105
+ gap: $space-2;
106
+ padding: $space-2 $space-4 $space-3;
107
+ }
108
+
109
+ &__badges {
110
+ display: flex;
111
+ flex-wrap: wrap;
112
+ gap: $space-1;
113
+ }
114
+
93
115
  &__meta {
94
116
  display: flex;
95
117
  flex-wrap: wrap;
96
118
  align-items: center;
97
119
  gap: $space-1 $space-3;
98
- padding: 0 $space-4 $space-3;
120
+ font-size: $fs-13;
121
+ color: $text-3;
99
122
  }
100
123
 
101
124
  &__body {
@@ -185,6 +208,13 @@
185
208
  border-top: 1px dashed $border;
186
209
  padding-top: $space-3;
187
210
 
211
+ // Tras un divider del panel, la divisoria dashed propia sobra (quedaba un
212
+ // doble separador — línea + "puntos suspensivos"): fuera.
213
+ .manager-panel__divider + & {
214
+ border-top: 0;
215
+ padding-top: 0;
216
+ }
217
+
188
218
  &__title {
189
219
  margin: 0;
190
220
  font-size: $fs-16;
@@ -272,6 +302,19 @@
272
302
  border-color: $color;
273
303
  color: $text-1;
274
304
  }
305
+
306
+ // Interruptores (aria-pressed): encendido = RELLENO del color (como
307
+ // el hover); apagado = contorno ATENUADO — el estado se ve de un
308
+ // vistazo junto a los botones de acción normales.
309
+ &.is-on {
310
+ background: $color;
311
+ border-color: $color;
312
+ color: $text-1;
313
+ }
314
+
315
+ &.is-off {
316
+ border-color: color-mix(in srgb, $color 45%, transparent);
317
+ }
275
318
  }
276
319
  }
277
320
  }
@@ -53,11 +53,11 @@
53
53
  // cambian a la vez a un layout apilado (grid-areas por container query).
54
54
  &__item {
55
55
  display: grid;
56
- grid-template-columns: auto minmax(0, 1fr) auto;
56
+ grid-template-columns: auto auto minmax(0, 1fr);
57
57
  grid-template-areas:
58
- "grip type buttons"
59
- "grip summary summary"
60
- "grip flags flags";
58
+ "grip edit type"
59
+ "grip edit summary"
60
+ "grip edit flags";
61
61
  align-items: center;
62
62
  gap: $space-1 $space-2;
63
63
  border: 1px solid $border;
@@ -69,8 +69,8 @@
69
69
  transition: border-color 0.15s ease;
70
70
 
71
71
  @container content (min-width: 640px) {
72
- grid-template-columns: auto auto minmax(0, 1fr) auto auto;
73
- grid-template-areas: "grip type summary flags buttons";
72
+ grid-template-columns: auto auto auto minmax(0, 1fr) auto;
73
+ grid-template-areas: "grip edit type summary flags";
74
74
  }
75
75
 
76
76
  &:hover { border-color: $border-strong; }
@@ -86,6 +86,24 @@
86
86
  &:active { cursor: grabbing; }
87
87
  }
88
88
 
89
+ // Editar a la IZQUIERDA de cada fila: abre el form del bloque sin pasar
90
+ // por el panel (mismo lenguaje que .card-enter del cascarón).
91
+ &__edit {
92
+ grid-area: edit;
93
+ display: inline-flex;
94
+ align-items: center;
95
+ justify-content: center;
96
+ background: none;
97
+ border: none;
98
+ border-radius: $radius-sm;
99
+ padding: $space-1;
100
+ color: $accent-500;
101
+ cursor: pointer;
102
+ transition: background-color 0.15s ease;
103
+
104
+ &:hover { background: color-mix(in srgb, $accent-500 12%, transparent); }
105
+ }
106
+
89
107
  &__type { grid-area: type; font-weight: $k-fw-semibold; white-space: nowrap; }
90
108
 
91
109
  &__summary {
@@ -100,13 +118,6 @@
100
118
 
101
119
  &__flags { grid-area: flags; display: flex; gap: $space-1; }
102
120
 
103
- &__buttons {
104
- grid-area: buttons;
105
- margin-left: auto;
106
- display: flex;
107
- gap: $space-1;
108
- }
109
-
110
121
  &__common {
111
122
  border-top: 1px dashed $border;
112
123
  padding-top: $space-3;
@@ -1,12 +1,14 @@
1
1
  <script setup lang="ts">
2
2
  import { PanelRight } from '@lucide/vue'
3
3
 
4
- // Tarjeta de los gestores del admin (previews y PDF): NO colapsa. Muestra el
5
- // título (+ chip opcional), el resumen (slot meta) y las acciones "de todas"
6
- // en el pie (slot actions). TODA la tarjeta selecciona (excepto los botones,
7
- // enlaces e inputs interiores): su detalle —selector de elementos, etc.— se
8
- // abre en el panel derecho del layout.
9
- // Se coloca dentro de .manager-grid (1-2 columnas según el contenedor).
4
+ // Tarjeta de los gestores del admin (previews y PDF): NO colapsa. Mismo
5
+ // lenguaje visual que EntityCard: cabecera con el título (+ chip opcional) y
6
+ // divisoria; debajo, las BADGES (chips de estado, slot badges) y el meta
7
+ // (datos secundarios) en ese orden; acciones "de todas" en el pie (slot
8
+ // actions). TODA la tarjeta selecciona (excepto los botones, enlaces e
9
+ // inputs interiores): su detalle —selector de elementos, etc.— se abre en el
10
+ // panel derecho del layout.
11
+ // Se coloca dentro de .manager-grid (preset cards: 1 → 5 columnas).
10
12
 
11
13
  defineProps<{
12
14
  title: string
@@ -42,8 +44,14 @@ function onClick(event: MouseEvent) {
42
44
  <PanelRight :size="16" class="manager-card__hint" />
43
45
  </div>
44
46
 
45
- <div v-if="$slots.meta" class="manager-card__meta">
46
- <slot name="meta" />
47
+ <!-- Como EntityCard: badges (chips) arriba, meta debajo -->
48
+ <div v-if="$slots.badges || $slots.meta" class="manager-card__content">
49
+ <div v-if="$slots.badges" class="manager-card__badges">
50
+ <slot name="badges" />
51
+ </div>
52
+ <div v-if="$slots.meta" class="manager-card__meta">
53
+ <slot name="meta" />
54
+ </div>
47
55
  </div>
48
56
 
49
57
  <div v-if="$slots.default" class="manager-card__body">
@@ -1,7 +1,7 @@
1
1
  <script setup lang="ts">
2
- import { computed, onMounted, reactive, ref } from 'vue'
2
+ import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
3
3
  import type { AxiosInstance } from 'axios'
4
- import { GripVertical, Plus, SquarePen, Trash2 } from '@lucide/vue'
4
+ import { GripVertical, List, Plus, Printer, SquarePen, Trash2 } from '@lucide/vue'
5
5
  import { VueDraggable } from 'vue-draggable-plus'
6
6
  import {
7
7
  BaseButton,
@@ -14,7 +14,9 @@ import {
14
14
  type RichTextLabels,
15
15
  } from '@edc-motor/ui'
16
16
  import SchemaFields, { type FieldSchema } from './SchemaFields.vue'
17
+ import { collectImageUrls, deleteContentImage, uploadPendingImages } from './deferredImages'
17
18
  import { useRightSidebar } from '../composables/useRightSidebar'
19
+ import { useCardDeselect } from '../composables/useCardDeselect'
18
20
 
19
21
  // Gestor de bloques de una página (doc 03): paleta de tipos (motor + juego),
20
22
  // lista reordenable con drag (DC-17) y modal de edición GENERADO desde el
@@ -29,7 +31,11 @@ export interface PageBlocksLabels {
29
31
  cancel: string
30
32
  empty: string
31
33
  printable: string
34
+ printableShort: string
32
35
  indexable: string
36
+ indexableShort: string
37
+ yes: string
38
+ no: string
33
39
  common: string
34
40
  confirmDelete: string
35
41
  error: string
@@ -48,7 +54,11 @@ const defaultLabels: PageBlocksLabels = {
48
54
  cancel: 'Cancelar',
49
55
  empty: 'La página aún no tiene bloques.',
50
56
  printable: 'Entra en el PDF de la página',
57
+ printableShort: 'PDF',
51
58
  indexable: 'Aparece en el índice',
59
+ indexableShort: 'Índice',
60
+ yes: 'Sí',
61
+ no: 'No',
52
62
  common: 'Ajustes comunes',
53
63
  confirmDelete: '¿Borrar este bloque?',
54
64
  error: 'No se ha podido completar la acción.',
@@ -114,6 +124,10 @@ function selectBlock(block: BlockRow, event: MouseEvent) {
114
124
  sidebar.reveal()
115
125
  }
116
126
 
127
+ // Click en la zona vacía del contenido (fuera de una fila o control):
128
+ // deselecciona, como en los index (patrón useCardDeselect).
129
+ useCardDeselect(() => (selectedId.value = null), '.page-blocks__item, .page-blocks__menu')
130
+
117
131
  /** Acción rápida del panel: alterna un flag sin abrir el modal. */
118
132
  async function toggleFlag(flag: 'is_printable' | 'is_indexable', value: boolean) {
119
133
  if (!selected.value) return
@@ -127,6 +141,26 @@ async function toggleFlag(flag: 'is_printable' | 'is_indexable', value: boolean)
127
141
  const busy = ref(false)
128
142
  const paletteOpen = ref(false)
129
143
 
144
+ // La paleta de "añadir bloque" se cierra con Escape y clicando fuera (los
145
+ // clicks dentro — el propio botón incluido — no la tocan).
146
+ function onDocumentClick(event: MouseEvent) {
147
+ if (!paletteOpen.value) return
148
+ const target = event.target instanceof Element ? event.target : null
149
+ if (target?.closest('.page-blocks__palette')) return
150
+ paletteOpen.value = false
151
+ }
152
+ function onDocumentKeydown(event: KeyboardEvent) {
153
+ if (event.key === 'Escape' && paletteOpen.value) paletteOpen.value = false
154
+ }
155
+ onMounted(() => {
156
+ document.addEventListener('click', onDocumentClick)
157
+ document.addEventListener('keydown', onDocumentKeydown)
158
+ })
159
+ onBeforeUnmount(() => {
160
+ document.removeEventListener('click', onDocumentClick)
161
+ document.removeEventListener('keydown', onDocumentKeydown)
162
+ })
163
+
130
164
  // Modal de edición (crear/editar comparten formulario).
131
165
  const modalOpen = ref(false)
132
166
  const editing = ref<BlockRow | null>(null)
@@ -271,13 +305,22 @@ function openEdit(block: BlockRow) {
271
305
  modalOpen.value = true
272
306
  }
273
307
 
308
+ // Guardado DIFERIDO de las imágenes del bloque: los inputs dejan el File en
309
+ // `form` y NADA viaja hasta aquí — se suben los pendientes, se persiste el
310
+ // bloque con las URLs resueltas y solo entonces se borran del disco las
311
+ // imágenes que el bloque ya no referencia. Si algo falla, las subidas nuevas
312
+ // se deshacen (el form conserva los File para reintentar) y cancelar el
313
+ // modal no deja rastro en el servidor.
274
314
  async function save() {
275
315
  if (!modalType.value) return
276
316
  busy.value = true
317
+ const fields = [...modalType.value.fields, ...modalType.value.common]
318
+ const uploaded: string[] = []
277
319
  try {
320
+ const settings = await uploadPendingImages(props.api, fields, form.value, uploaded)
278
321
  const payload = {
279
322
  type: modalType.value.key,
280
- settings: form.value,
323
+ settings,
281
324
  is_printable: formPrintable.value,
282
325
  is_indexable: formIndexable.value,
283
326
  parent_id: formParent.value,
@@ -287,9 +330,17 @@ async function save() {
287
330
  } else {
288
331
  await props.api.post(`/admin/pages/${props.pageId}/blocks`, payload)
289
332
  }
333
+ // Guardado en firme: fuera del disco lo que el bloque ya no referencia
334
+ // (sustituidas y quitadas), robusto ante filas de repeater reordenadas.
335
+ const kept = new Set(collectImageUrls(fields, settings))
336
+ const before = collectImageUrls(fields, editing.value?.settings ?? {})
337
+ await Promise.all(
338
+ before.filter((url) => !kept.has(url)).map((url) => deleteContentImage(props.api, url)),
339
+ )
290
340
  modalOpen.value = false
291
341
  await load()
292
342
  } catch {
343
+ await Promise.all(uploaded.map((url) => deleteContentImage(props.api, url)))
293
344
  toast.danger(L.error)
294
345
  } finally {
295
346
  busy.value = false
@@ -374,11 +425,20 @@ defineExpose({ reload: load })
374
425
  @click="(e) => selectBlock(block, e)"
375
426
  >
376
427
  <span class="page-blocks__grip"><GripVertical :size="16" /></span>
428
+ <button
429
+ type="button"
430
+ class="page-blocks__edit"
431
+ :title="L.edit"
432
+ :aria-label="L.edit"
433
+ @click="openEdit(block)"
434
+ >
435
+ <SquarePen :size="15" />
436
+ </button>
377
437
  <span class="page-blocks__type">{{ typeName(block.type) }}</span>
378
438
  <span class="page-blocks__summary">{{ summary(block) }}</span>
379
439
  <span class="page-blocks__flags">
380
- <span v-if="block.is_printable" class="chip is-ok">PDF</span>
381
- <span v-if="block.is_indexable" class="chip">IDX</span>
440
+ <span v-if="block.is_printable" class="chip is-ok">{{ L.printableShort }}</span>
441
+ <span v-if="block.is_indexable" class="chip">{{ L.indexableShort }}</span>
382
442
  </span>
383
443
  </article>
384
444
  </VueDraggable>
@@ -433,11 +493,31 @@ defineExpose({ reload: load })
433
493
  <p class="manager-panel__empty">{{ L.panelEmpty }}</p>
434
494
  </slot>
435
495
  <template v-else>
436
- <p class="manager-panel__kicker">{{ typeName(selected.type) }}</p>
496
+ <p class="manager-panel__kicker">{{ L.panelTitle }}</p>
437
497
 
438
- <!-- Acciones PRIMERO; después, secciones separadas (patrón panel) -->
498
+ <!-- Acciones PRIMERO (los interruptores arriba); después,
499
+ secciones separadas (patrón panel) -->
439
500
  <div class="manager-detail__actions">
440
- <BaseButton :disabled="busy" @click="openEdit(selected)">
501
+ <BaseButton
502
+ variant="success"
503
+ :class="selected.is_printable ? 'is-on' : 'is-off'"
504
+ :aria-pressed="selected.is_printable"
505
+ :disabled="busy"
506
+ @click="toggleFlag('is_printable', !selected.is_printable)"
507
+ >
508
+ <template #icon><Printer :size="14" /></template>
509
+ {{ L.printableShort }}
510
+ </BaseButton>
511
+ <BaseButton
512
+ :class="selected.is_indexable ? 'is-on' : 'is-off'"
513
+ :aria-pressed="selected.is_indexable"
514
+ :disabled="busy"
515
+ @click="toggleFlag('is_indexable', !selected.is_indexable)"
516
+ >
517
+ <template #icon><List :size="14" /></template>
518
+ {{ L.indexableShort }}
519
+ </BaseButton>
520
+ <BaseButton variant="info" :disabled="busy" @click="openEdit(selected)">
441
521
  <template #icon><SquarePen :size="14" /></template>
442
522
  {{ L.edit }}
443
523
  </BaseButton>
@@ -449,19 +529,17 @@ defineExpose({ reload: load })
449
529
 
450
530
  <hr class="manager-panel__divider" />
451
531
 
452
- <h3 class="manager-detail__title">{{ summary(selected) || typeName(selected.type) }}</h3>
453
-
454
- <!-- Acciones rápidas sin modal -->
455
- <BaseCheckbox
456
- :model-value="selected.is_printable"
457
- :label="L.printable"
458
- @update:model-value="(v) => toggleFlag('is_printable', v)"
459
- />
460
- <BaseCheckbox
461
- :model-value="selected.is_indexable"
462
- :label="L.indexable"
463
- @update:model-value="(v) => toggleFlag('is_indexable', v)"
464
- />
532
+ <!-- El título del panel es el TIPO del bloque (el contenido va en
533
+ su sección, abajo) -->
534
+ <h3 class="manager-detail__title">{{ typeName(selected.type) }}</h3>
535
+
536
+ <!-- Estado de los interruptores, en texto -->
537
+ <p class="manager-detail__meta">
538
+ <strong>{{ L.printable }}</strong> {{ selected.is_printable ? L.yes : L.no }}
539
+ </p>
540
+ <p class="manager-detail__meta">
541
+ <strong>{{ L.indexable }}</strong> {{ selected.is_indexable ? L.yes : L.no }}
542
+ </p>
465
543
 
466
544
  <!-- Contenido: cada campo del bloque con su valor (truncado) -->
467
545
  <hr v-if="selectedFields.length" class="manager-panel__divider" />
@@ -49,7 +49,7 @@ const props = withDefaults(
49
49
  fields: FieldSchema[]
50
50
  modelValue: Record<string, unknown>
51
51
  locales: { code: string; name: string }[]
52
- /** Cliente de la API del admin (sube las imágenes de contenido). */
52
+ /** Cliente de la API del admin (opciones de los campos `entity`). */
53
53
  api: AxiosInstance
54
54
  /** Iconos del juego para el WYSIWYG. */
55
55
  icons?: RichIcon[]
@@ -77,36 +77,33 @@ function translations(field: FieldSchema): Record<string, string> {
77
77
  return value as Record<string, string>
78
78
  }
79
79
 
80
- function selectOptions(field: FieldSchema): SelectOption[] {
81
- return Object.entries(field.options ?? {}).map(([value, text]) => ({
82
- value,
83
- label: props.translate?.(`blockOptions.${field.key}.${value}`, text) ?? text,
84
- }))
80
+ // --- Imágenes DIFERIDAS: elegir fichero deja el File en el modelo (URL al
81
+ // guardar: PageBlocks los sube en el submit con uploadPendingImages) ---
82
+
83
+ /** File pendiente de un campo imagen no traducible (si lo hay). */
84
+ function imageFile(field: FieldSchema): File | null {
85
+ const value = props.modelValue[field.key]
86
+ return value instanceof File ? value : null
85
87
  }
86
88
 
87
- /** Sube la imagen al momento; en settings queda la URL pública. */
88
- async function upload(file: File, replaces?: string | null): Promise<string> {
89
- const form = new FormData()
90
- form.append('image', file)
91
- // El backend borra el fichero sustituido: sin huérfanos.
92
- if (replaces) form.append('replaces', replaces)
93
- const { data } = await props.api.post('/admin/content/uploads', form)
94
- return data.url
89
+ /** URL guardada de un campo imagen no traducible (si la hay). */
90
+ function imageCurrentUrl(field: FieldSchema): string | null {
91
+ const value = props.modelValue[field.key]
92
+ return typeof value === 'string' && value ? value : null
95
93
  }
96
94
 
97
- /** Borra la subida del disco (el botón "quitar"); en silencio si falla. */
98
- async function removeUpload(url: string): Promise<void> {
99
- await props.api.delete('/admin/content/uploads', { data: { url } }).catch(() => {})
95
+ /** Mapa locale => URL guardada o File pendiente de una imagen traducible. */
96
+ function imageTranslations(field: FieldSchema): Record<string, string | File> {
97
+ const value = props.modelValue[field.key]
98
+ if (!value || typeof value !== 'object') return {}
99
+ return value as Record<string, string | File>
100
100
  }
101
101
 
102
- async function uploadImage(field: FieldSchema, file: File | null) {
103
- const current = (props.modelValue[field.key] as string | null) ?? null
104
- if (!file) {
105
- if (current) removeUpload(current)
106
- set(field.key, null)
107
- return
108
- }
109
- set(field.key, await upload(file, current))
102
+ function selectOptions(field: FieldSchema): SelectOption[] {
103
+ return Object.entries(field.options ?? {}).map(([value, text]) => ({
104
+ value,
105
+ label: props.translate?.(`blockOptions.${field.key}.${value}`, text) ?? text,
106
+ }))
110
107
  }
111
108
 
112
109
  // --- Anidados (group / repeater) ---
@@ -296,25 +293,24 @@ function addLabel(): string {
296
293
  @update:model-value="(v) => set(field.key, v)"
297
294
  />
298
295
 
299
- <!-- Imagen traducible: una URL por locale (fallback al default al renderizar) -->
296
+ <!-- Imagen traducible: una por locale (fallback al default al renderizar).
297
+ Diferida: el mapa lleva File pendientes hasta el guardar -->
300
298
  <TranslatableImage
301
299
  v-else-if="field.type === 'image' && field.translatable"
302
- :model-value="translations(field)"
300
+ :model-value="imageTranslations(field)"
303
301
  :locales="locales"
304
302
  :label="label(field)"
305
303
  :required="field.required"
306
- :upload="upload"
307
- :remove-file="removeUpload"
308
304
  @update:model-value="(v) => set(field.key, v)"
309
305
  />
310
306
 
311
307
  <div v-else-if="field.type === 'image'" class="schema-fields__image">
312
308
  <span class="form-field__label">{{ label(field) }}</span>
313
309
  <ImageUpload
314
- :model-value="null"
315
- :current-url="(modelValue[field.key] as string) ?? null"
316
- @update:model-value="(f: File | null) => uploadImage(field, f)"
317
- @remove="uploadImage(field, null)"
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)"
318
314
  />
319
315
  </div>
320
316
  </template>
@@ -0,0 +1,104 @@
1
+ import type { AxiosInstance } from 'axios'
2
+ import type { FieldSchema } from './SchemaFields.vue'
3
+
4
+ // Subidas de imagen DIFERIDAS (CRM y configuración): los inputs de imagen
5
+ // retienen el File en el estado del formulario y NADA viaja al servidor hasta
6
+ // el submit. Al guardar: (1) se suben los File pendientes, (2) se persiste el
7
+ // formulario con las URLs resueltas y (3) SOLO si el guardado fue bien se
8
+ // borran del disco las imágenes que el registro ya no referencia. Si el
9
+ // guardado falla, se borran las subidas recién hechas (sin huérfanos de
10
+ // formularios cancelados o fallidos).
11
+
12
+ /** Sube una imagen de contenido (`POST /admin/content/uploads`) y devuelve su URL pública. */
13
+ export async function uploadContentImage(api: AxiosInstance, file: File): Promise<string> {
14
+ const form = new FormData()
15
+ form.append('image', file)
16
+ const { data } = await api.post('/admin/content/uploads', form)
17
+ return data.url as string
18
+ }
19
+
20
+ /** Borra una subida de contenido del disco; en silencio si falla. */
21
+ export async function deleteContentImage(api: AxiosInstance, url: string): Promise<void> {
22
+ await api.delete('/admin/content/uploads', { data: { url } }).catch(() => {})
23
+ }
24
+
25
+ /**
26
+ * Sube los File pendientes de los campos imagen de unos settings (recursivo
27
+ * en group/repeater) y devuelve el valor resuelto, con URLs donde había
28
+ * Files. Las URLs recién subidas se van APUNTANDO en `uploaded` (parámetro
29
+ * de salida): si una subida o el guardado posterior fallan, el llamador
30
+ * puede deshacerlas aunque el proceso quedara a medias.
31
+ */
32
+ export async function uploadPendingImages(
33
+ api: AxiosInstance,
34
+ fields: FieldSchema[],
35
+ value: Record<string, unknown>,
36
+ uploaded: string[],
37
+ ): Promise<Record<string, unknown>> {
38
+ const out: Record<string, unknown> = { ...value }
39
+ for (const field of fields) {
40
+ const raw = out[field.key]
41
+ if (field.type === 'image') {
42
+ if (raw instanceof File) {
43
+ const url = await uploadContentImage(api, raw)
44
+ uploaded.push(url)
45
+ out[field.key] = url
46
+ } else if (field.translatable && raw && typeof raw === 'object') {
47
+ const map: Record<string, string> = {}
48
+ for (const [code, entry] of Object.entries(raw as Record<string, string | File>)) {
49
+ if (entry instanceof File) {
50
+ const url = await uploadContentImage(api, entry)
51
+ uploaded.push(url)
52
+ map[code] = url
53
+ } else if (entry) {
54
+ map[code] = entry
55
+ }
56
+ }
57
+ out[field.key] = map
58
+ }
59
+ } else if (field.type === 'group' && raw && typeof raw === 'object' && !Array.isArray(raw)) {
60
+ out[field.key] = await uploadPendingImages(
61
+ api,
62
+ field.fields ?? [],
63
+ raw as Record<string, unknown>,
64
+ uploaded,
65
+ )
66
+ } else if (field.type === 'repeater' && Array.isArray(raw)) {
67
+ const rows: Record<string, unknown>[] = []
68
+ for (const row of raw as Record<string, unknown>[]) {
69
+ rows.push(await uploadPendingImages(api, field.fields ?? [], row ?? {}, uploaded))
70
+ }
71
+ out[field.key] = rows
72
+ }
73
+ }
74
+ return out
75
+ }
76
+
77
+ /**
78
+ * URLs de imagen presentes en unos settings según su esquema (recursivo).
79
+ * Comparar las de ANTES de guardar con las de DESPUÉS da las que borrar del
80
+ * disco (robusto ante reordenar/quitar filas de un repeater: una URL que siga
81
+ * en cualquier posición no se toca).
82
+ */
83
+ export function collectImageUrls(fields: FieldSchema[], value: Record<string, unknown>): string[] {
84
+ const urls: string[] = []
85
+ for (const field of fields) {
86
+ const raw = value?.[field.key]
87
+ if (field.type === 'image') {
88
+ if (typeof raw === 'string' && raw) {
89
+ urls.push(raw)
90
+ } else if (raw && typeof raw === 'object' && !(raw instanceof File)) {
91
+ for (const entry of Object.values(raw as Record<string, unknown>)) {
92
+ if (typeof entry === 'string' && entry) urls.push(entry)
93
+ }
94
+ }
95
+ } else if (field.type === 'group' && raw && typeof raw === 'object' && !Array.isArray(raw)) {
96
+ urls.push(...collectImageUrls(field.fields ?? [], raw as Record<string, unknown>))
97
+ } else if (field.type === 'repeater' && Array.isArray(raw)) {
98
+ for (const row of raw as Record<string, unknown>[]) {
99
+ urls.push(...collectImageUrls(field.fields ?? [], row ?? {}))
100
+ }
101
+ }
102
+ }
103
+ return urls
104
+ }
@@ -10,7 +10,8 @@ const props = withDefaults(
10
10
  defineProps<{
11
11
  cols?: number | ResponsiveCols
12
12
  gap?: 'sm' | 'md' | 'lg'
13
- preset?: 'cards' | 'cards-wide' | 'cards-narrow' | 'cards-full' | 'halves' | 'thirds'
13
+ preset?:
14
+ 'cards' | 'cards-dense' | 'cards-wide' | 'cards-narrow' | 'cards-full' | 'halves' | 'thirds'
14
15
  }>(),
15
16
  { cols: () => ({ base: 1, sm: 2, lg: 3 }), gap: 'md' },
16
17
  )
@@ -26,9 +27,11 @@ const presetCols: Record<string, ResponsiveCols> = {
26
27
  const gridClasses = computed(() => {
27
28
  const classes = ['grid', `grid--gap-${props.gap}`]
28
29
  // Los index de entidades escalan 1 → 2 → 3 → 4 → 5 con los breakpoints
29
- // canónicos del contenedor `content` (ver .grid--cards en _grid.scss).
30
- if (props.preset === 'cards') {
31
- classes.push('grid--cards')
30
+ // canónicos del contenedor `content` (ver .grid--cards en _grid.scss);
31
+ // `cards-dense` (piezas pequeñas, p. ej. iconos) dobla cada escalón:
32
+ // 2 → 4 → 6 → 8 → 10.
33
+ if (props.preset === 'cards' || props.preset === 'cards-dense') {
34
+ classes.push(`grid--${props.preset}`)
32
35
  return classes
33
36
  }
34
37
  const cols = props.preset ? presetCols[props.preset] : props.cols
@@ -1,9 +1,10 @@
1
1
  <script setup lang="ts">
2
2
  import { Search } from '@lucide/vue'
3
3
 
4
- // Barra de filtros (estilo kontuan): caja de búsqueda con icono de lupa.
5
- // Controlada por v-model; se compone por encima de las tabs. Slot por defecto
6
- // para filtros extra (selects, fechas, …) a la derecha de la búsqueda.
4
+ // Barra de filtros (estilo kontuan): caja de búsqueda con icono de lupa a la
5
+ // izquierda (el texto empieza tras ella). Controlada por v-model; se compone
6
+ // por encima de las tabs. Slot por defecto para filtros extra (selects,
7
+ // fechas, …) a la derecha de la búsqueda.
7
8
  defineProps<{
8
9
  modelValue: string
9
10
  placeholder?: string
@@ -14,6 +15,7 @@ defineEmits<{ 'update:modelValue': [value: string] }>()
14
15
  <template>
15
16
  <div class="filter-bar">
16
17
  <div class="filter-bar__search">
18
+ <Search :size="16" class="filter-bar__search-icon" />
17
19
  <input
18
20
  type="search"
19
21
  :value="modelValue"
@@ -21,7 +23,6 @@ defineEmits<{ 'update:modelValue': [value: string] }>()
21
23
  class="filter-bar__search-input"
22
24
  @input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)"
23
25
  />
24
- <Search :size="16" class="filter-bar__search-icon" />
25
26
  </div>
26
27
  <div v-if="$slots.default" class="filter-bar__extra"><slot /></div>
27
28
  </div>
package/src/index.ts CHANGED
@@ -24,3 +24,11 @@ export { useResource, type ResourceMeta } from './crud/useResource'
24
24
  // CRM de páginas y bloques (doc 03): editor dirigido por esquema.
25
25
  export { default as SchemaFields, type FieldSchema } from './content/SchemaFields.vue'
26
26
  export { default as PageBlocks, type PageBlocksLabels } from './content/PageBlocks.vue'
27
+ // Subidas de imagen diferidas al guardar (uploads de contenido sin huérfanos):
28
+ // las usan PageBlocks y las vistas del cascarón (Ajustes, form de página).
29
+ export {
30
+ uploadContentImage,
31
+ deleteContentImage,
32
+ uploadPendingImages,
33
+ collectImageUrls,
34
+ } from './content/deferredImages'
@@ -127,6 +127,12 @@ function typeName(exp: ExportInfo): string {
127
127
  return props.typeLabels[exp.type] ?? exp.type
128
128
  }
129
129
 
130
+ // Criterio de los selects del admin: sin un orden explícito, ALFABÉTICO.
131
+ // Vale también para las tarjetas, que hacen de selector de export.
132
+ const sortedExports = computed(() =>
133
+ [...exports.value].sort((a, b) => typeName(a).localeCompare(typeName(b))),
134
+ )
135
+
130
136
  /** Quedan piezas por generar (algún idioma por debajo del total). */
131
137
  function hasMissing(exp: ExportInfo): boolean {
132
138
  return Object.values(exp.stats.locales).some((ready) => ready < exp.stats.total)
@@ -134,13 +140,17 @@ function hasMissing(exp: ExportInfo): boolean {
134
140
 
135
141
  const activeExport = computed(() => exports.value.find((e) => e.type === activeType.value) ?? null)
136
142
 
137
- /** Fuentes filtradas por el buscador del panel (filtro en cliente). */
143
+ /**
144
+ * Fuentes del combobox del panel: filtradas por el buscador (en cliente) y
145
+ * ALFABÉTICAS (criterio general de los selects del admin sin orden propio).
146
+ */
138
147
  const filteredSources = computed(() => {
139
148
  if (!activeExport.value) return []
140
149
  const q = sourceSearch.value.trim().toLowerCase()
141
- return q
150
+ const sources = q
142
151
  ? activeExport.value.sources.filter((s) => s.label.toLowerCase().includes(q))
143
152
  : activeExport.value.sources
153
+ return [...sources].sort((a, b) => a.label.localeCompare(b.label))
144
154
  })
145
155
 
146
156
  /** Filas visibles en el panel: las del export global o las de la dueña elegida. */
@@ -169,6 +179,9 @@ async function loadCatalog() {
169
179
  exports.value = data.data
170
180
  // Los globales cargan sus filas ya (el panel las pinta directamente).
171
181
  await Promise.all(exports.value.filter((e) => e.global).map((e) => loadRows(e.type, null)))
182
+ // El selector de export (las tarjetas) arranca con el PRIMERO seleccionado,
183
+ // no vacío (sin abrir el panel: eso lo hace el click del usuario).
184
+ if (!activeType.value && sortedExports.value.length) activate(sortedExports.value[0])
172
185
  } catch {
173
186
  toast.danger(L.error)
174
187
  } finally {
@@ -176,12 +189,23 @@ async function loadCatalog() {
176
189
  }
177
190
  }
178
191
 
192
+ /**
193
+ * Activa un export: resetea el buscador y, en los exports por entidad, deja
194
+ * el select de la dueña con el PRIMER elemento (alfabético) seleccionado —
195
+ * nunca vacío — cargando sus filas.
196
+ */
197
+ function activate(exp: ExportInfo) {
198
+ activeType.value = exp.type
199
+ sourceSearch.value = ''
200
+ selectedSourceId.value = null
201
+ const first = exp.global
202
+ ? null
203
+ : [...exp.sources].sort((a, b) => a.label.localeCompare(b.label))[0]
204
+ if (first) selectSource(first.id)
205
+ }
206
+
179
207
  function select(exp: ExportInfo) {
180
- if (activeType.value !== exp.type) {
181
- activeType.value = exp.type
182
- sourceSearch.value = ''
183
- selectedSourceId.value = null
184
- }
208
+ if (activeType.value !== exp.type) activate(exp)
185
209
  sidebar.reveal()
186
210
  }
187
211
 
@@ -298,18 +322,15 @@ defineExpose({ refreshAll })
298
322
 
299
323
  <div class="manager-grid">
300
324
  <ManagerCard
301
- v-for="exp in exports"
325
+ v-for="exp in sortedExports"
302
326
  :key="exp.type"
303
327
  :title="typeName(exp)"
304
328
  :chip="exp.layout"
305
329
  :active="activeType === exp.type"
306
330
  @select="select(exp)"
307
331
  >
308
- <!-- Resumen como el de las previews: total + listos por idioma -->
309
- <template #meta>
310
- <span class="manager-stat"
311
- >{{ L.total }} <strong>{{ exp.stats.total }}</strong></span
312
- >
332
+ <!-- Como EntityCard: badges (listos por idioma) arriba, meta (total) debajo -->
333
+ <template #badges>
313
334
  <span
314
335
  v-for="(ready, locale) in exp.stats.locales"
315
336
  :key="locale"
@@ -317,6 +338,11 @@ defineExpose({ refreshAll })
317
338
  >{{ String(locale).toUpperCase() }} {{ ready }}/{{ exp.stats.total }}</span
318
339
  >
319
340
  </template>
341
+ <template #meta>
342
+ <span class="manager-stat"
343
+ >{{ L.total }} <strong>{{ exp.stats.total }}</strong></span
344
+ >
345
+ </template>
320
346
  </ManagerCard>
321
347
  </div>
322
348
 
@@ -109,6 +109,12 @@ function typeName(type: TypeStatus): string {
109
109
  return props.typeLabels[type.key] ?? type.model
110
110
  }
111
111
 
112
+ // Criterio de los selects del admin: sin un orden explícito, ALFABÉTICO.
113
+ // Vale también para las tarjetas, que hacen de selector de tipo.
114
+ const sortedTypes = computed(() =>
115
+ [...types.value].sort((a, b) => typeName(a).localeCompare(typeName(b))),
116
+ )
117
+
112
118
  // Tipo activo (tarjeta seleccionada) y su selector en el panel.
113
119
  const activeType = ref<string | null>(null)
114
120
  const search = ref('')
@@ -116,6 +122,14 @@ const items = ref<PreviewItem[]>([])
116
122
  const page = ref({ current: 1, last: 1 })
117
123
  const selectedId = ref<number | null>(null)
118
124
 
125
+ // Opciones del combobox del panel: el servidor manda por id; aquí salen
126
+ // ALFABÉTICAS (criterio general de los selects del admin sin orden propio).
127
+ const itemOptions = computed(() =>
128
+ items.value
129
+ .map((i) => ({ id: i.id, label: i.label }))
130
+ .sort((a, b) => a.label.localeCompare(b.label)),
131
+ )
132
+
119
133
  const activeStatus = computed(() => types.value.find((t) => t.key === activeType.value) ?? null)
120
134
  const selectedItem = computed(() => items.value.find((i) => i.id === selectedId.value) ?? null)
121
135
  const missingLocales = computed(() =>
@@ -131,6 +145,9 @@ async function loadStatus() {
131
145
  try {
132
146
  const { data } = await props.api.get('/admin/previews')
133
147
  types.value = data.data
148
+ // El selector de tipo (las tarjetas) arranca con el PRIMERO seleccionado,
149
+ // no vacío (sin abrir el panel: eso lo hace el click del usuario).
150
+ if (!activeType.value && sortedTypes.value.length) activate(sortedTypes.value[0].key)
134
151
  } catch {
135
152
  toast.danger(L.error)
136
153
  } finally {
@@ -146,19 +163,32 @@ async function loadItems(pageNumber = 1) {
146
163
  })
147
164
  items.value = pageNumber === 1 ? data.data : [...items.value, ...data.data]
148
165
  page.value = { current: data.meta.current_page, last: data.meta.last_page }
166
+ // El select de elemento arranca con el PRIMERO (alfabético) seleccionado,
167
+ // no vacío; buscando o con algo ya elegido, no se toca.
168
+ if (
169
+ pageNumber === 1 &&
170
+ !search.value &&
171
+ selectedId.value === null &&
172
+ itemOptions.value.length
173
+ ) {
174
+ selectedId.value = Number(itemOptions.value[0].id)
175
+ }
149
176
  } catch {
150
177
  toast.danger(L.error)
151
178
  }
152
179
  }
153
180
 
181
+ /** Activa un tipo: resetea búsqueda y selección y carga sus elementos. */
182
+ function activate(key: string) {
183
+ activeType.value = key
184
+ search.value = ''
185
+ items.value = []
186
+ selectedId.value = null
187
+ loadItems()
188
+ }
189
+
154
190
  function select(type: TypeStatus) {
155
- if (activeType.value !== type.key) {
156
- activeType.value = type.key
157
- search.value = ''
158
- items.value = []
159
- selectedId.value = null
160
- loadItems()
161
- }
191
+ if (activeType.value !== type.key) activate(type.key)
162
192
  sidebar.reveal()
163
193
  }
164
194
 
@@ -295,17 +325,14 @@ defineExpose({ refreshAll })
295
325
 
296
326
  <div class="manager-grid">
297
327
  <ManagerCard
298
- v-for="type in types"
328
+ v-for="type in sortedTypes"
299
329
  :key="type.key"
300
330
  :title="typeName(type)"
301
331
  :active="activeType === type.key"
302
332
  @select="select(type)"
303
333
  >
304
- <!-- Total + generadas por idioma, de un vistazo -->
305
- <template #meta>
306
- <span class="manager-stat"
307
- >{{ L.total }} <strong>{{ type.total }}</strong></span
308
- >
334
+ <!-- Como EntityCard: badges (generadas por idioma) arriba, meta (total) debajo -->
335
+ <template #badges>
309
336
  <span
310
337
  v-for="(count, locale) in type.locales"
311
338
  :key="locale"
@@ -313,6 +340,11 @@ defineExpose({ refreshAll })
313
340
  >{{ String(locale).toUpperCase() }} {{ count }}/{{ type.total }}</span
314
341
  >
315
342
  </template>
343
+ <template #meta>
344
+ <span class="manager-stat"
345
+ >{{ L.total }} <strong>{{ type.total }}</strong></span
346
+ >
347
+ </template>
316
348
  </ManagerCard>
317
349
  </div>
318
350
 
@@ -346,7 +378,7 @@ defineExpose({ refreshAll })
346
378
 
347
379
  <SearchSelect
348
380
  :model-value="selectedId"
349
- :options="items.map((i) => ({ id: i.id, label: i.label }))"
381
+ :options="itemOptions"
350
382
  :placeholder="L.selectItem"
351
383
  :search-placeholder="L.searchPlaceholder"
352
384
  :no-results="L.noResults"