@edc-motor/ui 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 +1 -1
- package/scss/components/_edit-modal.scss +16 -0
- package/scss/components/_form-locale-switch.scss +38 -0
- package/scss/components/_image-upload.scss +11 -0
- package/scss/components/_index-toolbar.scss +6 -3
- package/scss/components/_index.scss +1 -0
- package/src/components/EditModal.vue +19 -0
- package/src/components/FormLocaleSwitch.vue +55 -0
- package/src/components/ImageUpload.vue +39 -4
- package/src/components/IndexToolbar.vue +2 -2
- package/src/components/PaletteColorPicker.vue +12 -12
- package/src/components/TranslatableImage.vue +31 -32
- package/src/components/TranslatableInput.vue +10 -0
- package/src/composables/useFormLocale.ts +106 -0
- package/src/index.ts +11 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@edc-motor/ui",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.16",
|
|
4
4
|
"description": "EdC Motor — componentes públicos Vue 3 + tokens SCSS para webs de juegos de mesa (paquete fuente: lo compila el consumidor con Vite)",
|
|
5
5
|
"license": "GPL-3.0-only",
|
|
6
6
|
"type": "module",
|
|
@@ -3,6 +3,22 @@
|
|
|
3
3
|
.edit-modal {
|
|
4
4
|
&__form { display: contents; }
|
|
5
5
|
|
|
6
|
+
// Cabecera propia: título + selector de locale global de los campos
|
|
7
|
+
// traducibles (solo aparece si el formulario contiene alguno).
|
|
8
|
+
&__header {
|
|
9
|
+
display: flex;
|
|
10
|
+
align-items: center;
|
|
11
|
+
gap: $space-3;
|
|
12
|
+
min-width: 0;
|
|
13
|
+
flex: 1;
|
|
14
|
+
|
|
15
|
+
.modal__title {
|
|
16
|
+
overflow: hidden;
|
|
17
|
+
text-overflow: ellipsis;
|
|
18
|
+
white-space: nowrap;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
6
22
|
&__body {
|
|
7
23
|
display: flex;
|
|
8
24
|
flex-direction: column;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
@use "tokens" as *;
|
|
2
|
+
|
|
3
|
+
// Selector compacto de locale GLOBAL de un formulario (FormLocaleSwitch):
|
|
4
|
+
// segmentado de códigos, mismo lenguaje visual que el trigger de locale de
|
|
5
|
+
// los campos traducibles (_translatable-input.scss). Vive en la cabecera del
|
|
6
|
+
// EditModal (solo si el formulario contiene campos traducibles).
|
|
7
|
+
.form-locale-switch {
|
|
8
|
+
display: inline-flex;
|
|
9
|
+
align-items: stretch;
|
|
10
|
+
background: $surface-2;
|
|
11
|
+
border: 1px solid $border;
|
|
12
|
+
border-radius: $radius-sm;
|
|
13
|
+
overflow: hidden;
|
|
14
|
+
flex-shrink: 0;
|
|
15
|
+
|
|
16
|
+
&__option {
|
|
17
|
+
padding: 2px $space-2;
|
|
18
|
+
border: none;
|
|
19
|
+
background: transparent;
|
|
20
|
+
color: $text-3;
|
|
21
|
+
font-size: $fs-11;
|
|
22
|
+
font-weight: $k-fw-bold;
|
|
23
|
+
letter-spacing: $k-track-wide;
|
|
24
|
+
cursor: pointer;
|
|
25
|
+
transition: background-color 0.15s, color 0.15s;
|
|
26
|
+
|
|
27
|
+
& + & { border-left: 1px solid $border; }
|
|
28
|
+
|
|
29
|
+
&:hover { background: $surface-3; color: $text-1; }
|
|
30
|
+
|
|
31
|
+
// Resalta la ÚLTIMA elección global (los tabs individuales pueden haberse
|
|
32
|
+
// movido después: esto es un mando, no un espejo).
|
|
33
|
+
&.is-active {
|
|
34
|
+
background: $accent-500;
|
|
35
|
+
color: $text-1;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -80,6 +80,17 @@
|
|
|
80
80
|
|
|
81
81
|
&__icon { width: 32px; height: 32px; color: $text-3; }
|
|
82
82
|
&__text { font-size: $fs-12; color: $text-2; }
|
|
83
|
+
|
|
84
|
+
// Nombre del fichero (pendiente o guardado) bajo la miniatura.
|
|
85
|
+
&__name {
|
|
86
|
+
max-width: 160px;
|
|
87
|
+
overflow: hidden;
|
|
88
|
+
font-size: $fs-12;
|
|
89
|
+
color: $text-3;
|
|
90
|
+
text-overflow: ellipsis;
|
|
91
|
+
white-space: nowrap;
|
|
92
|
+
}
|
|
93
|
+
|
|
83
94
|
&__hint { font-size: $fs-12; color: $text-3; }
|
|
84
95
|
&__error { font-size: $fs-12; color: $danger; }
|
|
85
96
|
}
|
|
@@ -21,17 +21,20 @@
|
|
|
21
21
|
|
|
22
22
|
&__search-icon {
|
|
23
23
|
position: absolute;
|
|
24
|
-
|
|
24
|
+
left: $input-padding-x;
|
|
25
25
|
top: 50%;
|
|
26
26
|
transform: translateY(-50%);
|
|
27
27
|
color: $text-3;
|
|
28
28
|
pointer-events: none;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
// Doble clase: gana al input[type="search"] genérico de _forms.scss
|
|
32
|
+
// (0,1,1) sin depender del orden de carga — si no, el padding de la lupa
|
|
33
|
+
// no se aplica y el texto se monta con el icono.
|
|
34
|
+
&__search #{&}__search-input {
|
|
32
35
|
width: 100%;
|
|
33
36
|
height: $input-height;
|
|
34
|
-
padding: 0
|
|
37
|
+
padding: 0 $input-padding-x 0 34px; // hueco a la izquierda para la lupa
|
|
35
38
|
background: $input-bg;
|
|
36
39
|
color: $input-text;
|
|
37
40
|
border: 1px solid $input-border;
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
import BaseModal from './BaseModal.vue'
|
|
3
3
|
import { Save, X } from '@lucide/vue'
|
|
4
4
|
import BaseButton from './BaseButton.vue'
|
|
5
|
+
import FormLocaleSwitch from './FormLocaleSwitch.vue'
|
|
6
|
+
import { provideFormLocale } from '../composables/useFormLocale'
|
|
5
7
|
|
|
6
8
|
// Modal de formulario (portado de kontuan): BaseModal + pie con Cancelar/Guardar.
|
|
7
9
|
// Agnóstico de i18n: las etiquetas se pasan por props (la app las traduce).
|
|
@@ -14,6 +16,8 @@ const props = withDefaults(
|
|
|
14
16
|
submitLabel?: string
|
|
15
17
|
cancelLabel?: string
|
|
16
18
|
submitVariant?: 'primary' | 'secondary' | 'danger' | 'success'
|
|
19
|
+
/** Texto accesible del selector de locale global (DC-29). */
|
|
20
|
+
localeSwitchLabel?: string
|
|
17
21
|
}>(),
|
|
18
22
|
{
|
|
19
23
|
size: 'md',
|
|
@@ -21,11 +25,17 @@ const props = withDefaults(
|
|
|
21
25
|
submitLabel: 'Guardar',
|
|
22
26
|
cancelLabel: 'Cancelar',
|
|
23
27
|
submitVariant: 'primary',
|
|
28
|
+
localeSwitchLabel: undefined,
|
|
24
29
|
},
|
|
25
30
|
)
|
|
26
31
|
|
|
27
32
|
const emit = defineEmits<{ 'update:modelValue': [boolean]; submit: [] }>()
|
|
28
33
|
|
|
34
|
+
// Locale global del formulario: los campos traducibles del slot (Translatable*
|
|
35
|
+
// del ui, también dentro de SchemaFields/PageBlocks) se suscriben SOLOS por
|
|
36
|
+
// inject; el selector de la cabecera solo se pinta si hay alguno.
|
|
37
|
+
provideFormLocale()
|
|
38
|
+
|
|
29
39
|
function close() {
|
|
30
40
|
if (props.loading) return
|
|
31
41
|
emit('update:modelValue', false)
|
|
@@ -39,6 +49,15 @@ function close() {
|
|
|
39
49
|
:size="size"
|
|
40
50
|
@update:model-value="(v) => !loading && emit('update:modelValue', v)"
|
|
41
51
|
>
|
|
52
|
+
<!-- Cabecera propia: título + selector de locale global (si hay campos
|
|
53
|
+
traducibles); el botón de cerrar lo sigue poniendo BaseModal. -->
|
|
54
|
+
<template #header>
|
|
55
|
+
<div class="edit-modal__header">
|
|
56
|
+
<h3 class="modal__title">{{ title }}</h3>
|
|
57
|
+
<FormLocaleSwitch :title="localeSwitchLabel" />
|
|
58
|
+
</div>
|
|
59
|
+
</template>
|
|
60
|
+
|
|
42
61
|
<form class="edit-modal__form" @submit.prevent="emit('submit')">
|
|
43
62
|
<div class="edit-modal__body"><slot /></div>
|
|
44
63
|
</form>
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { inject, ref, watch } from 'vue'
|
|
3
|
+
import { FormLocaleKey } from '../composables/useFormLocale'
|
|
4
|
+
|
|
5
|
+
// Selector COMPACTO de locale global de un formulario: botones segmentados
|
|
6
|
+
// con los códigos (unión de los locales de los campos traducibles suscritos
|
|
7
|
+
// vía provide/inject, ver useFormLocale). Al pulsar uno cambia el tab activo
|
|
8
|
+
// de TODOS los campos a la vez; los tabs individuales siguen funcionando por
|
|
9
|
+
// su cuenta (por eso el resaltado marca la ÚLTIMA elección global, no un
|
|
10
|
+
// estado que los campos puedan desmentir). Solo se pinta si el formulario
|
|
11
|
+
// contiene campos traducibles. Agnóstico de i18n (DC-29): texto por prop.
|
|
12
|
+
|
|
13
|
+
withDefaults(defineProps<{ title?: string }>(), { title: 'Idioma de todos los campos' })
|
|
14
|
+
|
|
15
|
+
const context = inject(FormLocaleKey, null)
|
|
16
|
+
|
|
17
|
+
// Última elección global (para el resaltado); se reinicia si cambia el set
|
|
18
|
+
// de locales (formularios que se rellenan en diferido).
|
|
19
|
+
const current = ref<string | null>(null)
|
|
20
|
+
if (context) {
|
|
21
|
+
watch(context.locales, () => {
|
|
22
|
+
if (current.value && !context.locales.value.some((l) => l.code === current.value)) {
|
|
23
|
+
current.value = null
|
|
24
|
+
}
|
|
25
|
+
})
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function setAll(code: string) {
|
|
29
|
+
if (!context) return
|
|
30
|
+
current.value = code
|
|
31
|
+
context.setAll(code)
|
|
32
|
+
}
|
|
33
|
+
</script>
|
|
34
|
+
|
|
35
|
+
<template>
|
|
36
|
+
<div
|
|
37
|
+
v-if="context && context.hasFields.value && context.locales.value.length > 1"
|
|
38
|
+
class="form-locale-switch"
|
|
39
|
+
role="group"
|
|
40
|
+
:title="title"
|
|
41
|
+
:aria-label="title"
|
|
42
|
+
>
|
|
43
|
+
<button
|
|
44
|
+
v-for="locale in context.locales.value"
|
|
45
|
+
:key="locale.code"
|
|
46
|
+
type="button"
|
|
47
|
+
class="form-locale-switch__option"
|
|
48
|
+
:class="{ 'is-active': locale.code === current }"
|
|
49
|
+
:title="locale.name"
|
|
50
|
+
@click="setAll(locale.code)"
|
|
51
|
+
>
|
|
52
|
+
{{ locale.code.toUpperCase() }}
|
|
53
|
+
</button>
|
|
54
|
+
</div>
|
|
55
|
+
</template>
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { computed, onBeforeUnmount, ref } from 'vue'
|
|
2
|
+
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
|
3
3
|
import { Trash2 } from '@lucide/vue'
|
|
4
4
|
|
|
5
5
|
// Subida de imagen con arrastrar-y-soltar o clic (portado de kontuan).
|
|
6
|
+
// DIFERIDO: elegir fichero NO sube nada — el File queda en el v-model (con
|
|
7
|
+
// object URL para la vista previa) y quien lo usa lo envía al GUARDAR. La
|
|
8
|
+
// imagen ya guardada se muestra con `currentUrl` (miniatura + nombre).
|
|
6
9
|
// Agnóstico de i18n: los textos van por props.
|
|
7
10
|
const props = withDefaults(
|
|
8
11
|
defineProps<{
|
|
@@ -41,6 +44,40 @@ const displayUrl = computed(() => (removed.value ? null : previewUrl.value || pr
|
|
|
41
44
|
// El error externo (validación del servidor) manda sobre el interno.
|
|
42
45
|
const shownError = computed(() => props.error || localError.value)
|
|
43
46
|
|
|
47
|
+
// Nombre del fichero bajo la miniatura: el del File pendiente o, si se
|
|
48
|
+
// muestra la imagen guardada, el nombre extraído de su URL.
|
|
49
|
+
const displayName = computed(() => {
|
|
50
|
+
if (removed.value) return null
|
|
51
|
+
if (props.modelValue) return props.modelValue.name
|
|
52
|
+
if (!previewUrl.value && props.currentUrl) {
|
|
53
|
+
try {
|
|
54
|
+
const path = decodeURIComponent(props.currentUrl.split('?')[0].split('#')[0])
|
|
55
|
+
return path.split('/').pop() || null
|
|
56
|
+
} catch {
|
|
57
|
+
return null
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return null
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
// La vista previa se deriva del v-model (controlado): si el padre repone un
|
|
64
|
+
// File (p. ej. TranslatableImage al volver a un idioma con imagen pendiente)
|
|
65
|
+
// la miniatura reaparece sin re-elegir el fichero.
|
|
66
|
+
watch(
|
|
67
|
+
() => props.modelValue,
|
|
68
|
+
(file) => {
|
|
69
|
+
if (previewUrl.value) {
|
|
70
|
+
URL.revokeObjectURL(previewUrl.value)
|
|
71
|
+
previewUrl.value = null
|
|
72
|
+
}
|
|
73
|
+
if (file) {
|
|
74
|
+
previewUrl.value = URL.createObjectURL(file)
|
|
75
|
+
removed.value = false
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
{ immediate: true },
|
|
79
|
+
)
|
|
80
|
+
|
|
44
81
|
function handleFile(file: File) {
|
|
45
82
|
// Validación en cliente: tamaño y tipo, con feedback (no se ignora en silencio).
|
|
46
83
|
if (file.size > props.maxSize * 1024 * 1024) {
|
|
@@ -52,9 +89,6 @@ function handleFile(file: File) {
|
|
|
52
89
|
return
|
|
53
90
|
}
|
|
54
91
|
localError.value = null
|
|
55
|
-
removed.value = false
|
|
56
|
-
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
|
|
57
|
-
previewUrl.value = URL.createObjectURL(file)
|
|
58
92
|
emit('update:modelValue', file)
|
|
59
93
|
}
|
|
60
94
|
|
|
@@ -137,6 +171,7 @@ onBeforeUnmount(() => {
|
|
|
137
171
|
</div>
|
|
138
172
|
</template>
|
|
139
173
|
</div>
|
|
174
|
+
<p v-if="displayName" class="image-upload__name">{{ displayName }}</p>
|
|
140
175
|
<p v-if="shownError" class="image-upload__error">{{ shownError }}</p>
|
|
141
176
|
</div>
|
|
142
177
|
</template>
|
|
@@ -3,7 +3,7 @@ import { Search } from '@lucide/vue'
|
|
|
3
3
|
import SortToggles, { type SortValue } from './SortToggles.vue'
|
|
4
4
|
|
|
5
5
|
// Barra unificada de los index (admin y web pública): búsqueda (lupa a la
|
|
6
|
-
//
|
|
6
|
+
// izquierda, como el FilterBar del admin-kit) y toggles de ordenación. Los
|
|
7
7
|
// filtros del listado viven en la barra derecha (RightSidebar del admin /
|
|
8
8
|
// AppRightSidebar de la web), no aquí. En ancho es una fila
|
|
9
9
|
// [búsqueda][toggles]; en estrecho (container query propia) la búsqueda
|
|
@@ -43,6 +43,7 @@ const emit = defineEmits<{
|
|
|
43
43
|
<div class="index-toolbar">
|
|
44
44
|
<div class="index-toolbar__inner">
|
|
45
45
|
<div class="index-toolbar__search">
|
|
46
|
+
<Search :size="16" class="index-toolbar__search-icon" />
|
|
46
47
|
<input
|
|
47
48
|
type="search"
|
|
48
49
|
:value="modelValue"
|
|
@@ -50,7 +51,6 @@ const emit = defineEmits<{
|
|
|
50
51
|
class="index-toolbar__search-input"
|
|
51
52
|
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
|
|
52
53
|
/>
|
|
53
|
-
<Search :size="16" class="index-toolbar__search-icon" />
|
|
54
54
|
</div>
|
|
55
55
|
|
|
56
56
|
<div v-if="showSort" class="index-toolbar__actions">
|
|
@@ -16,19 +16,19 @@ const props = withDefaults(
|
|
|
16
16
|
|
|
17
17
|
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
|
|
18
18
|
|
|
19
|
-
// Paleta base (
|
|
19
|
+
// Paleta base: espectro cálido → frío, con el gris al final (el tono "Slate"
|
|
20
|
+
// heredado de kontuan). Los valores viajan como HEX, no como clave.
|
|
20
21
|
const PALETTE = [
|
|
21
|
-
{ name: '
|
|
22
|
-
{ name: '
|
|
23
|
-
{ name: '
|
|
24
|
-
{ name: '
|
|
25
|
-
{ name: '
|
|
26
|
-
{ name: '
|
|
27
|
-
{ name: '
|
|
28
|
-
{ name: '
|
|
29
|
-
{ name: '
|
|
30
|
-
{ name: '
|
|
31
|
-
{ name: 'Pink', hex: '#EC4899' },
|
|
22
|
+
{ name: 'Rojo', hex: '#f15959' },
|
|
23
|
+
{ name: 'Naranja', hex: '#f1753a' },
|
|
24
|
+
{ name: 'Lima', hex: '#88b033' },
|
|
25
|
+
{ name: 'Verde', hex: '#29ab5f' },
|
|
26
|
+
{ name: 'Esmeralda', hex: '#31a28e' },
|
|
27
|
+
{ name: 'Cian', hex: '#3999cd' },
|
|
28
|
+
{ name: 'Azul', hex: '#408cfd' },
|
|
29
|
+
{ name: 'Violeta', hex: '#7a64c8' },
|
|
30
|
+
{ name: 'Magenta', hex: '#a75da5' },
|
|
31
|
+
{ name: 'Gris', hex: '#64748B' },
|
|
32
32
|
]
|
|
33
33
|
|
|
34
34
|
const norm = (v: string | null) => (v ?? '').toLowerCase()
|
|
@@ -2,12 +2,15 @@
|
|
|
2
2
|
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
|
3
3
|
import { ChevronDown } from '@lucide/vue'
|
|
4
4
|
import ImageUpload from './ImageUpload.vue'
|
|
5
|
+
import { useFormLocaleField } from '../composables/useFormLocale'
|
|
5
6
|
|
|
6
|
-
// Imagen traducible (una
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
7
|
+
// Imagen traducible (una por locale): mismo selector desplegable de locale
|
|
8
|
+
// que TranslatableInput, con un ImageUpload para el idioma activo. DIFERIDO:
|
|
9
|
+
// este componente NO sube nada — el mapa del v-model lleva la URL guardada
|
|
10
|
+
// (string) o el File pendiente por locale, y quien lo usa sube los File al
|
|
11
|
+
// GUARDAR. Quitar la imagen de un locale borra su clave del mapa (también
|
|
12
|
+
// diferido). En el render, el motor localiza el valor con fallback al locale
|
|
13
|
+
// por defecto (localizeSettings).
|
|
11
14
|
interface Locale {
|
|
12
15
|
code: string
|
|
13
16
|
name: string
|
|
@@ -15,29 +18,35 @@ interface Locale {
|
|
|
15
18
|
|
|
16
19
|
const props = withDefaults(
|
|
17
20
|
defineProps<{
|
|
18
|
-
modelValue?: Record<string, string>
|
|
21
|
+
modelValue?: Record<string, string | File>
|
|
19
22
|
locales: Locale[]
|
|
20
23
|
label?: string
|
|
21
24
|
required?: boolean
|
|
22
|
-
/** Sube el fichero (con la URL a la que sustituye, para que el backend
|
|
23
|
-
* borre la anterior) y devuelve la URL pública que se guarda. */
|
|
24
|
-
upload: (file: File, replaces?: string | null) => Promise<string>
|
|
25
|
-
/** Borra el fichero al pulsar "quitar" (opcional). */
|
|
26
|
-
removeFile?: (url: string) => void | Promise<void>
|
|
27
25
|
error?: string
|
|
28
26
|
}>(),
|
|
29
27
|
{ modelValue: () => ({}), required: false },
|
|
30
28
|
)
|
|
31
29
|
|
|
32
|
-
const emit = defineEmits<{ 'update:modelValue': [Record<string, string>] }>()
|
|
30
|
+
const emit = defineEmits<{ 'update:modelValue': [Record<string, string | File>] }>()
|
|
33
31
|
|
|
34
32
|
const codes = computed(() => props.locales.map((l) => l.code))
|
|
35
33
|
const active = ref(codes.value[0] ?? 'es')
|
|
36
34
|
const open = ref(false)
|
|
37
35
|
const dropdownRef = ref<HTMLElement>()
|
|
38
|
-
const uploading = ref(false)
|
|
39
36
|
|
|
40
|
-
|
|
37
|
+
// Locale global del formulario (si el contenedor lo provee, p. ej. EditModal):
|
|
38
|
+
// una difusión cambia el tab activo; el selector propio sigue siendo local.
|
|
39
|
+
useFormLocaleField(
|
|
40
|
+
computed(() => props.locales),
|
|
41
|
+
(code) => {
|
|
42
|
+
active.value = code
|
|
43
|
+
},
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
const current = computed(() => props.modelValue?.[active.value] ?? null)
|
|
47
|
+
// La URL guardada y el File pendiente del locale activo, para el ImageUpload.
|
|
48
|
+
const currentFile = computed(() => (current.value instanceof File ? current.value : null))
|
|
49
|
+
const currentUrl = computed(() => (typeof current.value === 'string' ? current.value : null))
|
|
41
50
|
const filledCount = computed(() => codes.value.filter((c) => !!props.modelValue?.[c]).length)
|
|
42
51
|
const hasContent = (code: string) => !!props.modelValue?.[code]
|
|
43
52
|
|
|
@@ -46,30 +55,20 @@ function selectLocale(code: string) {
|
|
|
46
55
|
open.value = false
|
|
47
56
|
}
|
|
48
57
|
|
|
49
|
-
|
|
58
|
+
/** Deja el mapa en su estado final deseado: File pendiente, o sin clave. */
|
|
59
|
+
function setValue(value: string | File | null) {
|
|
50
60
|
const next = { ...props.modelValue }
|
|
51
|
-
if (
|
|
61
|
+
if (value) next[active.value] = value
|
|
52
62
|
else delete next[active.value]
|
|
53
63
|
emit('update:modelValue', next)
|
|
54
64
|
}
|
|
55
65
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
onRemove()
|
|
59
|
-
return
|
|
60
|
-
}
|
|
61
|
-
uploading.value = true
|
|
62
|
-
try {
|
|
63
|
-
setUrl(await props.upload(file, currentUrl.value))
|
|
64
|
-
} finally {
|
|
65
|
-
uploading.value = false
|
|
66
|
-
}
|
|
66
|
+
function onFile(file: File | null) {
|
|
67
|
+
setValue(file)
|
|
67
68
|
}
|
|
68
69
|
|
|
69
70
|
function onRemove() {
|
|
70
|
-
|
|
71
|
-
if (url && props.removeFile) Promise.resolve(props.removeFile(url)).catch(() => {})
|
|
72
|
-
setUrl(null)
|
|
71
|
+
setValue(null)
|
|
73
72
|
}
|
|
74
73
|
|
|
75
74
|
function onClickOutside(e: MouseEvent) {
|
|
@@ -117,9 +116,9 @@ onBeforeUnmount(() => document.removeEventListener('click', onClickOutside))
|
|
|
117
116
|
|
|
118
117
|
<ImageUpload
|
|
119
118
|
:key="active"
|
|
120
|
-
:model-value="
|
|
119
|
+
:model-value="currentFile"
|
|
121
120
|
:current-url="currentUrl"
|
|
122
|
-
:error="
|
|
121
|
+
:error="error"
|
|
123
122
|
@update:model-value="onFile"
|
|
124
123
|
@remove="onRemove"
|
|
125
124
|
/>
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { ref, computed, onMounted, onBeforeUnmount, defineAsyncComponent } from 'vue'
|
|
3
3
|
import { ChevronDown } from '@lucide/vue'
|
|
4
4
|
import type { RichTextLabels } from './RichTextInput.vue'
|
|
5
|
+
import { useFormLocaleField } from '../composables/useFormLocale'
|
|
5
6
|
|
|
6
7
|
// Editor WYSIWYG cargado en diferido: TipTap solo se descarga si algún campo
|
|
7
8
|
// traducible usa type="wysiwyg".
|
|
@@ -45,6 +46,15 @@ const emit = defineEmits<{ 'update:modelValue': [Record<string, string>] }>()
|
|
|
45
46
|
const codes = computed(() => props.locales.map((l) => l.code))
|
|
46
47
|
const active = ref(codes.value[0] ?? 'es')
|
|
47
48
|
const open = ref(false)
|
|
49
|
+
|
|
50
|
+
// Locale global del formulario (si el contenedor lo provee, p. ej. EditModal):
|
|
51
|
+
// una difusión cambia el tab activo; el selector propio sigue siendo local.
|
|
52
|
+
useFormLocaleField(
|
|
53
|
+
computed(() => props.locales),
|
|
54
|
+
(code) => {
|
|
55
|
+
active.value = code
|
|
56
|
+
},
|
|
57
|
+
)
|
|
48
58
|
const dropdownRef = ref<HTMLElement>()
|
|
49
59
|
const inputId = props.id || `translatable-${Math.random().toString(36).slice(2, 9)}`
|
|
50
60
|
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import {
|
|
2
|
+
computed,
|
|
3
|
+
inject,
|
|
4
|
+
onScopeDispose,
|
|
5
|
+
provide,
|
|
6
|
+
reactive,
|
|
7
|
+
ref,
|
|
8
|
+
watch,
|
|
9
|
+
type ComputedRef,
|
|
10
|
+
type InjectionKey,
|
|
11
|
+
type Ref,
|
|
12
|
+
} from 'vue'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Locale global de un FORMULARIO con campos traducibles.
|
|
16
|
+
*
|
|
17
|
+
* Cada campo traducible (TranslatableInput/TranslatableImage) conserva sus
|
|
18
|
+
* tabs de locale propias, pero además se SUSCRIBE (inject) al contexto que
|
|
19
|
+
* provee su formulario contenedor (EditModal lo hace solo): un selector
|
|
20
|
+
* compacto en la cabecera cambia el tab activo de TODOS los campos a la vez,
|
|
21
|
+
* y tocar el tab de un campo individual sigue afectando solo a ese campo.
|
|
22
|
+
*
|
|
23
|
+
* Los juegos no tocan nada: los campos del motor se registran/suscriben
|
|
24
|
+
* solos, y el selector solo se pinta si el formulario contiene alguno.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
export interface FormLocale {
|
|
28
|
+
code: string
|
|
29
|
+
name: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface FormLocaleBroadcast {
|
|
33
|
+
code: string
|
|
34
|
+
/** Contador: re-emite aunque se repita el mismo código. */
|
|
35
|
+
tick: number
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface FormLocaleContext {
|
|
39
|
+
/** Unión (sin duplicados) de los locales de los campos registrados. */
|
|
40
|
+
locales: ComputedRef<FormLocale[]>
|
|
41
|
+
/** ¿Hay campos traducibles suscritos? (el selector solo se pinta si sí). */
|
|
42
|
+
hasFields: ComputedRef<boolean>
|
|
43
|
+
/** Última difusión global (la observan los campos). */
|
|
44
|
+
broadcast: Ref<FormLocaleBroadcast | null>
|
|
45
|
+
/** Cambia el locale activo de TODOS los campos suscritos. */
|
|
46
|
+
setAll: (code: string) => void
|
|
47
|
+
/** Alta de un campo; devuelve la baja (la llama useFormLocaleField). */
|
|
48
|
+
register: (locales: ComputedRef<FormLocale[]> | Ref<FormLocale[]>) => () => void
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const FormLocaleKey: InjectionKey<FormLocaleContext> = Symbol('edc-form-locale')
|
|
52
|
+
|
|
53
|
+
/** Lo llama el contenedor del formulario (EditModal ya lo hace). */
|
|
54
|
+
export function provideFormLocale(): FormLocaleContext {
|
|
55
|
+
// Mapa id => locales del campo (reactive para que los computed reaccionen).
|
|
56
|
+
const fields = reactive(new Map<number, ComputedRef<FormLocale[]> | Ref<FormLocale[]>>())
|
|
57
|
+
let nextId = 0
|
|
58
|
+
|
|
59
|
+
const locales = computed<FormLocale[]>(() => {
|
|
60
|
+
const seen = new Map<string, FormLocale>()
|
|
61
|
+
for (const fieldLocales of fields.values()) {
|
|
62
|
+
for (const locale of fieldLocales.value) {
|
|
63
|
+
if (!seen.has(locale.code)) seen.set(locale.code, locale)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return [...seen.values()]
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
const hasFields = computed(() => fields.size > 0)
|
|
70
|
+
const broadcast = ref<FormLocaleBroadcast | null>(null)
|
|
71
|
+
|
|
72
|
+
function setAll(code: string) {
|
|
73
|
+
broadcast.value = { code, tick: (broadcast.value?.tick ?? 0) + 1 }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function register(fieldLocales: ComputedRef<FormLocale[]> | Ref<FormLocale[]>) {
|
|
77
|
+
const id = nextId++
|
|
78
|
+
fields.set(id, fieldLocales)
|
|
79
|
+
return () => {
|
|
80
|
+
fields.delete(id)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const context: FormLocaleContext = { locales, hasFields, broadcast, setAll, register }
|
|
85
|
+
provide(FormLocaleKey, context)
|
|
86
|
+
return context
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Lo llaman los campos traducibles en su setup: si hay proveedor por encima,
|
|
91
|
+
* se registran (alimentan el selector) y aplican cada difusión global que
|
|
92
|
+
* incluya alguno de sus locales. Sin proveedor, no hace nada.
|
|
93
|
+
*/
|
|
94
|
+
export function useFormLocaleField(
|
|
95
|
+
locales: ComputedRef<FormLocale[]> | Ref<FormLocale[]>,
|
|
96
|
+
apply: (code: string) => void,
|
|
97
|
+
): void {
|
|
98
|
+
const context = inject(FormLocaleKey, null)
|
|
99
|
+
if (!context) return
|
|
100
|
+
|
|
101
|
+
onScopeDispose(context.register(locales))
|
|
102
|
+
|
|
103
|
+
watch(context.broadcast, (b) => {
|
|
104
|
+
if (b && locales.value.some((locale) => locale.code === b.code)) apply(b.code)
|
|
105
|
+
})
|
|
106
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -15,6 +15,17 @@ export { default as NumericInput } from './components/NumericInput.vue'
|
|
|
15
15
|
export { default as PaletteColorPicker } from './components/PaletteColorPicker.vue'
|
|
16
16
|
export { default as TranslatableInput } from './components/TranslatableInput.vue'
|
|
17
17
|
export { default as TranslatableImage } from './components/TranslatableImage.vue'
|
|
18
|
+
// Locale global de un formulario: EditModal lo provee solo (selector compacto
|
|
19
|
+
// en su cabecera) y los campos traducibles se suscriben solos; cualquier otro
|
|
20
|
+
// contenedor puede montar lo mismo con provideFormLocale() + FormLocaleSwitch.
|
|
21
|
+
export { default as FormLocaleSwitch } from './components/FormLocaleSwitch.vue'
|
|
22
|
+
export {
|
|
23
|
+
provideFormLocale,
|
|
24
|
+
useFormLocaleField,
|
|
25
|
+
FormLocaleKey,
|
|
26
|
+
type FormLocale,
|
|
27
|
+
type FormLocaleContext,
|
|
28
|
+
} from './composables/useFormLocale'
|
|
18
29
|
// El WYSIWYG carga DIFERIDO (TipTap pesa ~450 KB): así la web pública no lo
|
|
19
30
|
// arrastra a su bundle y el admin lo trocea en su propio chunk.
|
|
20
31
|
export const RichTextInput = defineAsyncComponent(() => import('./components/RichTextInput.vue'))
|