@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 +2 -2
- package/scss/components/_entity-card.scss +11 -2
- package/scss/components/_filter-bar.scss +6 -3
- package/scss/components/_grid.scss +12 -0
- package/scss/components/_manager-card.scss +48 -5
- package/scss/components/_page-blocks.scss +24 -13
- package/src/components/ManagerCard.vue +16 -8
- package/src/content/PageBlocks.vue +99 -21
- package/src/content/SchemaFields.vue +29 -33
- package/src/content/deferredImages.ts +104 -0
- package/src/crud/BaseGrid.vue +7 -4
- package/src/crud/FilterBar.vue +5 -4
- package/src/index.ts +8 -0
- package/src/pdf/PdfManager.vue +39 -13
- package/src/previews/PreviewManager.vue +46 -14
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@edc-motor/admin-kit",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.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.
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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-
|
|
29
|
-
|
|
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
|
-
|
|
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)
|
|
56
|
+
grid-template-columns: auto auto minmax(0, 1fr);
|
|
57
57
|
grid-template-areas:
|
|
58
|
-
"grip type
|
|
59
|
-
"grip
|
|
60
|
-
"grip 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
|
|
73
|
-
grid-template-areas: "grip type summary flags
|
|
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.
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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
|
-
|
|
46
|
-
|
|
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
|
|
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">
|
|
381
|
-
<span v-if="block.is_indexable" class="chip">
|
|
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">{{
|
|
496
|
+
<p class="manager-panel__kicker">{{ L.panelTitle }}</p>
|
|
437
497
|
|
|
438
|
-
<!-- Acciones PRIMERO
|
|
498
|
+
<!-- Acciones PRIMERO (los interruptores arriba); después,
|
|
499
|
+
secciones separadas (patrón panel) -->
|
|
439
500
|
<div class="manager-detail__actions">
|
|
440
|
-
<BaseButton
|
|
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
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
<
|
|
461
|
-
|
|
462
|
-
|
|
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 (
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
/**
|
|
88
|
-
|
|
89
|
-
const
|
|
90
|
-
|
|
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
|
-
/**
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
|
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="
|
|
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="
|
|
315
|
-
:current-url="(
|
|
316
|
-
@update:model-value="(f: File | null) =>
|
|
317
|
-
@remove="
|
|
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
|
+
}
|
package/src/crud/BaseGrid.vue
CHANGED
|
@@ -10,7 +10,8 @@ const props = withDefaults(
|
|
|
10
10
|
defineProps<{
|
|
11
11
|
cols?: number | ResponsiveCols
|
|
12
12
|
gap?: 'sm' | 'md' | 'lg'
|
|
13
|
-
preset?:
|
|
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
|
-
|
|
31
|
-
|
|
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
|
package/src/crud/FilterBar.vue
CHANGED
|
@@ -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
|
|
6
|
-
//
|
|
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'
|
package/src/pdf/PdfManager.vue
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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
|
|
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
|
-
<!--
|
|
309
|
-
<template #
|
|
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
|
|
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
|
-
<!--
|
|
305
|
-
<template #
|
|
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="
|
|
381
|
+
:options="itemOptions"
|
|
350
382
|
:placeholder="L.selectItem"
|
|
351
383
|
:search-placeholder="L.searchPlaceholder"
|
|
352
384
|
:no-results="L.noResults"
|