@edc-motor/ui 0.2.0
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/LICENSE +674 -0
- package/README.md +45 -0
- package/package.json +47 -0
- package/scss/_base.scss +33 -0
- package/scss/_forms.scss +33 -0
- package/scss/_shared-components.scss +1 -0
- package/scss/_theme.scss +74 -0
- package/scss/_tokens.scss +114 -0
- package/scss/components/_base-button.scss +113 -0
- package/scss/components/_blocks.scss +211 -0
- package/scss/components/_breadcrumbs.scss +31 -0
- package/scss/components/_checkbox.scss +42 -0
- package/scss/components/_chip.scss +23 -0
- package/scss/components/_confirm.scss +3 -0
- package/scss/components/_edit-modal.scss +11 -0
- package/scss/components/_font-upload.scss +91 -0
- package/scss/components/_form-field.scss +85 -0
- package/scss/components/_icon-button.scss +23 -0
- package/scss/components/_image-upload.scss +85 -0
- package/scss/components/_index.scss +24 -0
- package/scss/components/_locale-selector.scss +89 -0
- package/scss/components/_modal.scss +68 -0
- package/scss/components/_motor-badge.scss +21 -0
- package/scss/components/_numeric-input.scss +53 -0
- package/scss/components/_page-background.scss +22 -0
- package/scss/components/_palette-color-picker.scss +69 -0
- package/scss/components/_rich-text.scss +144 -0
- package/scss/components/_search-select.scss +138 -0
- package/scss/components/_tabs.scss +120 -0
- package/scss/components/_theme-selector.scss +34 -0
- package/scss/components/_toast-container.scss +16 -0
- package/scss/components/_toast.scss +25 -0
- package/scss/components/_translatable-input.scss +73 -0
- package/src/blocks/BlockCta.vue +32 -0
- package/src/blocks/BlockFaq.vue +25 -0
- package/src/blocks/BlockHeader.vue +13 -0
- package/src/blocks/BlockIndex.vue +20 -0
- package/src/blocks/BlockQuote.vue +16 -0
- package/src/blocks/BlockShell.vue +30 -0
- package/src/blocks/BlockText.vue +20 -0
- package/src/blocks/BlockTextCard.vue +23 -0
- package/src/blocks/PageBackground.vue +15 -0
- package/src/blocks/index.ts +25 -0
- package/src/components/AppBreadcrumbs.vue +44 -0
- package/src/components/BaseButton.vue +24 -0
- package/src/components/BaseCheckbox.vue +37 -0
- package/src/components/BaseInput.vue +53 -0
- package/src/components/BaseModal.vue +98 -0
- package/src/components/BaseSelect.vue +53 -0
- package/src/components/BaseTabs.vue +30 -0
- package/src/components/BaseTextarea.vue +45 -0
- package/src/components/BaseToast.vue +31 -0
- package/src/components/ConfirmDialog.vue +35 -0
- package/src/components/EditModal.vue +57 -0
- package/src/components/FontUpload.vue +123 -0
- package/src/components/IconButton.vue +24 -0
- package/src/components/ImageUpload.vue +142 -0
- package/src/components/LocaleSelector.vue +59 -0
- package/src/components/MotorBadge.vue +17 -0
- package/src/components/NumericInput.vue +115 -0
- package/src/components/PaletteColorPicker.vue +94 -0
- package/src/components/RichTextInput.vue +273 -0
- package/src/components/SearchSelect.vue +172 -0
- package/src/components/ThemeSelector.vue +28 -0
- package/src/components/ToastContainer.vue +23 -0
- package/src/components/TranslatableImage.vue +120 -0
- package/src/components/TranslatableInput.vue +135 -0
- package/src/composables/useConfirm.ts +49 -0
- package/src/composables/useHead.ts +54 -0
- package/src/composables/useTheme.ts +50 -0
- package/src/composables/useToast.ts +26 -0
- package/src/index.ts +39 -0
- package/src/lib/createApi.ts +42 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, ref, watch, onBeforeUnmount, onMounted } from 'vue'
|
|
3
|
+
import { useEditor, EditorContent } from '@tiptap/vue-3'
|
|
4
|
+
import StarterKit from '@tiptap/starter-kit'
|
|
5
|
+
import Image from '@tiptap/extension-image'
|
|
6
|
+
import {
|
|
7
|
+
Bold,
|
|
8
|
+
Italic,
|
|
9
|
+
Strikethrough,
|
|
10
|
+
Heading2,
|
|
11
|
+
List,
|
|
12
|
+
ListOrdered,
|
|
13
|
+
Undo2,
|
|
14
|
+
Redo2,
|
|
15
|
+
Smile,
|
|
16
|
+
Code,
|
|
17
|
+
} from '@lucide/vue'
|
|
18
|
+
|
|
19
|
+
// Editor de texto enriquecido (WYSIWYG) basado en TipTap (DC-09).
|
|
20
|
+
// v-model = HTML. Agnóstico de i18n. Si se pasan `icons`, muestra un selector
|
|
21
|
+
// para insertar iconos del juego (como en CDL): se insertan como <img.rt-icon>.
|
|
22
|
+
// Incluye un toggle para editar en modo visual o directamente el HTML.
|
|
23
|
+
export interface RichIcon {
|
|
24
|
+
name: string
|
|
25
|
+
url: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Textos de la barra de herramientas, sobreescribibles por la app (DC-29).
|
|
29
|
+
export interface RichTextLabels {
|
|
30
|
+
bold: string
|
|
31
|
+
italic: string
|
|
32
|
+
strike: string
|
|
33
|
+
heading: string
|
|
34
|
+
bulletList: string
|
|
35
|
+
orderedList: string
|
|
36
|
+
undo: string
|
|
37
|
+
redo: string
|
|
38
|
+
insertIcon: string
|
|
39
|
+
editHtml: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const defaultLabels: RichTextLabels = {
|
|
43
|
+
bold: 'Negrita',
|
|
44
|
+
italic: 'Cursiva',
|
|
45
|
+
strike: 'Tachado',
|
|
46
|
+
heading: 'Título',
|
|
47
|
+
bulletList: 'Lista',
|
|
48
|
+
orderedList: 'Lista numerada',
|
|
49
|
+
undo: 'Deshacer',
|
|
50
|
+
redo: 'Rehacer',
|
|
51
|
+
insertIcon: 'Insertar icono',
|
|
52
|
+
editHtml: 'Editar HTML',
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const props = withDefaults(
|
|
56
|
+
defineProps<{
|
|
57
|
+
modelValue?: string
|
|
58
|
+
placeholder?: string
|
|
59
|
+
disabled?: boolean
|
|
60
|
+
icons?: RichIcon[]
|
|
61
|
+
labels?: Partial<RichTextLabels>
|
|
62
|
+
}>(),
|
|
63
|
+
{ modelValue: '', disabled: false, icons: () => [], labels: () => ({}) },
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
|
|
67
|
+
|
|
68
|
+
const t = computed<RichTextLabels>(() => ({ ...defaultLabels, ...props.labels }))
|
|
69
|
+
|
|
70
|
+
// Declarado antes del watch de modelValue, que lo lee.
|
|
71
|
+
const source = ref(false)
|
|
72
|
+
|
|
73
|
+
const editor = useEditor({
|
|
74
|
+
content: props.modelValue,
|
|
75
|
+
editable: !props.disabled,
|
|
76
|
+
extensions: [
|
|
77
|
+
StarterKit,
|
|
78
|
+
// Los iconos son la única imagen que se inserta: en línea y con clase fija.
|
|
79
|
+
Image.configure({ inline: true, HTMLAttributes: { class: 'rt-icon' } }),
|
|
80
|
+
],
|
|
81
|
+
onUpdate: ({ editor }) => {
|
|
82
|
+
const html = editor.isEmpty ? '' : editor.getHTML()
|
|
83
|
+
emit('update:modelValue', html)
|
|
84
|
+
},
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
watch(
|
|
88
|
+
() => props.modelValue,
|
|
89
|
+
(value) => {
|
|
90
|
+
if (!editor.value || source.value) return
|
|
91
|
+
if (value !== (editor.value.isEmpty ? '' : editor.value.getHTML())) {
|
|
92
|
+
editor.value.commands.setContent(value || '', false)
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
)
|
|
96
|
+
watch(
|
|
97
|
+
() => props.disabled,
|
|
98
|
+
(d) => editor.value?.setEditable(!d),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
const tools = computed(() => [
|
|
102
|
+
{
|
|
103
|
+
key: 'bold',
|
|
104
|
+
icon: Bold,
|
|
105
|
+
title: t.value.bold,
|
|
106
|
+
run: () => editor.value?.chain().focus().toggleBold().run(),
|
|
107
|
+
active: () => editor.value?.isActive('bold'),
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
key: 'italic',
|
|
111
|
+
icon: Italic,
|
|
112
|
+
title: t.value.italic,
|
|
113
|
+
run: () => editor.value?.chain().focus().toggleItalic().run(),
|
|
114
|
+
active: () => editor.value?.isActive('italic'),
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
key: 'strike',
|
|
118
|
+
icon: Strikethrough,
|
|
119
|
+
title: t.value.strike,
|
|
120
|
+
run: () => editor.value?.chain().focus().toggleStrike().run(),
|
|
121
|
+
active: () => editor.value?.isActive('strike'),
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
key: 'h2',
|
|
125
|
+
icon: Heading2,
|
|
126
|
+
title: t.value.heading,
|
|
127
|
+
run: () => editor.value?.chain().focus().toggleHeading({ level: 2 }).run(),
|
|
128
|
+
active: () => editor.value?.isActive('heading', { level: 2 }),
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
key: 'ul',
|
|
132
|
+
icon: List,
|
|
133
|
+
title: t.value.bulletList,
|
|
134
|
+
run: () => editor.value?.chain().focus().toggleBulletList().run(),
|
|
135
|
+
active: () => editor.value?.isActive('bulletList'),
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
key: 'ol',
|
|
139
|
+
icon: ListOrdered,
|
|
140
|
+
title: t.value.orderedList,
|
|
141
|
+
run: () => editor.value?.chain().focus().toggleOrderedList().run(),
|
|
142
|
+
active: () => editor.value?.isActive('orderedList'),
|
|
143
|
+
},
|
|
144
|
+
])
|
|
145
|
+
|
|
146
|
+
// --- Selector de iconos ---
|
|
147
|
+
const pickerOpen = ref(false)
|
|
148
|
+
const pickerRef = ref<HTMLElement>()
|
|
149
|
+
|
|
150
|
+
function insertIcon(icon: RichIcon) {
|
|
151
|
+
editor.value?.chain().focus().setImage({ src: icon.url, alt: icon.name, title: icon.name }).run()
|
|
152
|
+
pickerOpen.value = false
|
|
153
|
+
}
|
|
154
|
+
function onClickOutside(e: MouseEvent) {
|
|
155
|
+
if (pickerRef.value && !pickerRef.value.contains(e.target as Node)) pickerOpen.value = false
|
|
156
|
+
}
|
|
157
|
+
onMounted(() => document.addEventListener('click', onClickOutside))
|
|
158
|
+
onBeforeUnmount(() => {
|
|
159
|
+
document.removeEventListener('click', onClickOutside)
|
|
160
|
+
editor.value?.destroy()
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
// --- Toggle modo visual / HTML (source declarado arriba, antes del watch) ---
|
|
164
|
+
const sourceHtml = ref('')
|
|
165
|
+
|
|
166
|
+
function toggleSource() {
|
|
167
|
+
if (!source.value) {
|
|
168
|
+
// Entrar a modo HTML: cargar el HTML actual.
|
|
169
|
+
sourceHtml.value = editor.value?.isEmpty ? '' : (editor.value?.getHTML() ?? '')
|
|
170
|
+
} else {
|
|
171
|
+
// Volver a visual: aplicar el HTML editado al editor.
|
|
172
|
+
editor.value?.commands.setContent(sourceHtml.value || '', false)
|
|
173
|
+
}
|
|
174
|
+
source.value = !source.value
|
|
175
|
+
pickerOpen.value = false
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function onSourceInput(value: string) {
|
|
179
|
+
sourceHtml.value = value
|
|
180
|
+
emit('update:modelValue', value)
|
|
181
|
+
}
|
|
182
|
+
</script>
|
|
183
|
+
|
|
184
|
+
<template>
|
|
185
|
+
<div class="rich-text" :class="{ 'rich-text--disabled': disabled }">
|
|
186
|
+
<div v-if="editor" class="rich-text__toolbar">
|
|
187
|
+
<template v-if="!source">
|
|
188
|
+
<button
|
|
189
|
+
v-for="tool in tools"
|
|
190
|
+
:key="tool.key"
|
|
191
|
+
type="button"
|
|
192
|
+
class="rich-text__tool"
|
|
193
|
+
:class="{ 'rich-text__tool--active': tool.active() }"
|
|
194
|
+
:title="tool.title"
|
|
195
|
+
:disabled="disabled"
|
|
196
|
+
@click="tool.run()"
|
|
197
|
+
>
|
|
198
|
+
<component :is="tool.icon" :size="16" />
|
|
199
|
+
</button>
|
|
200
|
+
<span class="rich-text__sep" />
|
|
201
|
+
<button
|
|
202
|
+
type="button"
|
|
203
|
+
class="rich-text__tool"
|
|
204
|
+
:title="t.undo"
|
|
205
|
+
:disabled="disabled"
|
|
206
|
+
@click="editor?.chain().focus().undo().run()"
|
|
207
|
+
>
|
|
208
|
+
<Undo2 :size="16" />
|
|
209
|
+
</button>
|
|
210
|
+
<button
|
|
211
|
+
type="button"
|
|
212
|
+
class="rich-text__tool"
|
|
213
|
+
:title="t.redo"
|
|
214
|
+
:disabled="disabled"
|
|
215
|
+
@click="editor?.chain().focus().redo().run()"
|
|
216
|
+
>
|
|
217
|
+
<Redo2 :size="16" />
|
|
218
|
+
</button>
|
|
219
|
+
|
|
220
|
+
<!-- Selector de iconos del juego -->
|
|
221
|
+
<template v-if="icons.length">
|
|
222
|
+
<span class="rich-text__sep" />
|
|
223
|
+
<div ref="pickerRef" class="rich-text__picker">
|
|
224
|
+
<button
|
|
225
|
+
type="button"
|
|
226
|
+
class="rich-text__tool"
|
|
227
|
+
:class="{ 'rich-text__tool--active': pickerOpen }"
|
|
228
|
+
:title="t.insertIcon"
|
|
229
|
+
:disabled="disabled"
|
|
230
|
+
@click="pickerOpen = !pickerOpen"
|
|
231
|
+
>
|
|
232
|
+
<Smile :size="16" />
|
|
233
|
+
</button>
|
|
234
|
+
<div v-if="pickerOpen" class="rich-text__icons">
|
|
235
|
+
<button
|
|
236
|
+
v-for="icon in icons"
|
|
237
|
+
:key="icon.url"
|
|
238
|
+
type="button"
|
|
239
|
+
class="rich-text__icon"
|
|
240
|
+
:title="icon.name"
|
|
241
|
+
@click="insertIcon(icon)"
|
|
242
|
+
>
|
|
243
|
+
<img :src="icon.url" :alt="icon.name" />
|
|
244
|
+
</button>
|
|
245
|
+
</div>
|
|
246
|
+
</div>
|
|
247
|
+
</template>
|
|
248
|
+
</template>
|
|
249
|
+
|
|
250
|
+
<!-- Toggle visual / HTML (siempre a la derecha) -->
|
|
251
|
+
<button
|
|
252
|
+
type="button"
|
|
253
|
+
class="rich-text__tool rich-text__tool--source"
|
|
254
|
+
:class="{ 'rich-text__tool--active': source }"
|
|
255
|
+
:title="t.editHtml"
|
|
256
|
+
:disabled="disabled"
|
|
257
|
+
@click="toggleSource"
|
|
258
|
+
>
|
|
259
|
+
<Code :size="16" />
|
|
260
|
+
</button>
|
|
261
|
+
</div>
|
|
262
|
+
|
|
263
|
+
<textarea
|
|
264
|
+
v-if="source"
|
|
265
|
+
class="rich-text__source"
|
|
266
|
+
:value="sourceHtml"
|
|
267
|
+
:disabled="disabled"
|
|
268
|
+
spellcheck="false"
|
|
269
|
+
@input="onSourceInput(($event.target as HTMLTextAreaElement).value)"
|
|
270
|
+
/>
|
|
271
|
+
<EditorContent v-show="!source" class="rich-text__content" :editor="editor" />
|
|
272
|
+
</div>
|
|
273
|
+
</template>
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
|
3
|
+
import { Check, ChevronDown } from '@lucide/vue'
|
|
4
|
+
|
|
5
|
+
// Select con buscador (combobox de selección ÚNICA): cerrado ocupa una
|
|
6
|
+
// línea; al desplegar muestra un input de búsqueda y las opciones filtradas
|
|
7
|
+
// (lista larga => scroll interno del desplegable). Emparentado con el
|
|
8
|
+
// TagCombobox de kontuan (multi-etiquetas), del que hereda el teclado
|
|
9
|
+
// (flechas + Enter + Escape) y el cierre por mousedown exterior. El filtrado
|
|
10
|
+
// lo hace el PADRE (evento `search`, con debounce aquí): en cliente o contra
|
|
11
|
+
// el servidor, y con `canLoadMore` pagina.
|
|
12
|
+
// Agnóstico de i18n (DC-29): textos por prop, defaults en castellano.
|
|
13
|
+
|
|
14
|
+
export interface SearchSelectOption {
|
|
15
|
+
id: number | string
|
|
16
|
+
label: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const props = withDefaults(
|
|
20
|
+
defineProps<{
|
|
21
|
+
modelValue?: number | string | null
|
|
22
|
+
options: SearchSelectOption[]
|
|
23
|
+
placeholder?: string
|
|
24
|
+
searchPlaceholder?: string
|
|
25
|
+
noResults?: string
|
|
26
|
+
loadMoreLabel?: string
|
|
27
|
+
canLoadMore?: boolean
|
|
28
|
+
}>(),
|
|
29
|
+
{
|
|
30
|
+
modelValue: null,
|
|
31
|
+
placeholder: 'Elige…',
|
|
32
|
+
searchPlaceholder: 'Buscar…',
|
|
33
|
+
noResults: 'Sin resultados.',
|
|
34
|
+
loadMoreLabel: 'Cargar más',
|
|
35
|
+
canLoadMore: false,
|
|
36
|
+
},
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
const emit = defineEmits<{
|
|
40
|
+
'update:modelValue': [id: number | string]
|
|
41
|
+
search: [query: string]
|
|
42
|
+
loadMore: []
|
|
43
|
+
}>()
|
|
44
|
+
|
|
45
|
+
const open = ref(false)
|
|
46
|
+
const query = ref('')
|
|
47
|
+
const highlighted = ref(0)
|
|
48
|
+
const root = ref<HTMLElement | null>(null)
|
|
49
|
+
const input = ref<HTMLInputElement | null>(null)
|
|
50
|
+
|
|
51
|
+
const selectedLabel = computed(
|
|
52
|
+
() => props.options.find((o) => o.id === props.modelValue)?.label ?? '',
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
async function toggle() {
|
|
56
|
+
open.value = !open.value
|
|
57
|
+
if (open.value) {
|
|
58
|
+
// Al abrir se resetea la búsqueda (lista completa) y el foco va al input.
|
|
59
|
+
if (query.value !== '') {
|
|
60
|
+
query.value = ''
|
|
61
|
+
emit('search', '')
|
|
62
|
+
}
|
|
63
|
+
highlighted.value = 0
|
|
64
|
+
document.addEventListener('mousedown', onOutside)
|
|
65
|
+
await nextTick()
|
|
66
|
+
input.value?.focus()
|
|
67
|
+
} else {
|
|
68
|
+
close()
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function close() {
|
|
73
|
+
open.value = false
|
|
74
|
+
document.removeEventListener('mousedown', onOutside)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function select(option: SearchSelectOption) {
|
|
78
|
+
emit('update:modelValue', option.id)
|
|
79
|
+
close()
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Búsqueda con debounce: el padre filtra (cliente o servidor).
|
|
83
|
+
let timer: ReturnType<typeof setTimeout> | null = null
|
|
84
|
+
function onInput() {
|
|
85
|
+
if (timer) clearTimeout(timer)
|
|
86
|
+
timer = setTimeout(() => emit('search', query.value), 300)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
watch(
|
|
90
|
+
() => props.options,
|
|
91
|
+
() => {
|
|
92
|
+
highlighted.value = 0
|
|
93
|
+
},
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
function onKeydown(e: KeyboardEvent) {
|
|
97
|
+
if (e.key === 'Escape') {
|
|
98
|
+
close()
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
if (e.key === 'ArrowDown') {
|
|
102
|
+
e.preventDefault()
|
|
103
|
+
if (props.options.length) highlighted.value = (highlighted.value + 1) % props.options.length
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
if (e.key === 'ArrowUp') {
|
|
107
|
+
e.preventDefault()
|
|
108
|
+
if (props.options.length) {
|
|
109
|
+
highlighted.value = (highlighted.value - 1 + props.options.length) % props.options.length
|
|
110
|
+
}
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
if (e.key === 'Enter') {
|
|
114
|
+
e.preventDefault()
|
|
115
|
+
const option = props.options[highlighted.value]
|
|
116
|
+
if (option) select(option)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function onOutside(e: MouseEvent) {
|
|
121
|
+
if (root.value && !root.value.contains(e.target as Node)) close()
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
onBeforeUnmount(() => {
|
|
125
|
+
document.removeEventListener('mousedown', onOutside)
|
|
126
|
+
if (timer) clearTimeout(timer)
|
|
127
|
+
})
|
|
128
|
+
</script>
|
|
129
|
+
|
|
130
|
+
<template>
|
|
131
|
+
<div ref="root" class="search-select" :class="{ 'is-open': open }">
|
|
132
|
+
<button type="button" class="search-select__trigger" @click="toggle">
|
|
133
|
+
<span class="search-select__value" :class="{ 'is-placeholder': !selectedLabel }">
|
|
134
|
+
{{ selectedLabel || placeholder }}
|
|
135
|
+
</span>
|
|
136
|
+
<ChevronDown :size="16" class="search-select__chevron" />
|
|
137
|
+
</button>
|
|
138
|
+
|
|
139
|
+
<div v-if="open" class="search-select__dropdown">
|
|
140
|
+
<input
|
|
141
|
+
ref="input"
|
|
142
|
+
v-model="query"
|
|
143
|
+
type="text"
|
|
144
|
+
class="search-select__input"
|
|
145
|
+
:placeholder="searchPlaceholder"
|
|
146
|
+
@input="onInput"
|
|
147
|
+
@keydown="onKeydown"
|
|
148
|
+
/>
|
|
149
|
+
<ul class="search-select__options">
|
|
150
|
+
<li v-for="(option, index) in options" :key="option.id">
|
|
151
|
+
<button
|
|
152
|
+
type="button"
|
|
153
|
+
class="search-select__option"
|
|
154
|
+
:class="{
|
|
155
|
+
'is-active': option.id === modelValue,
|
|
156
|
+
'is-highlighted': index === highlighted,
|
|
157
|
+
}"
|
|
158
|
+
@mousedown.prevent="select(option)"
|
|
159
|
+
@mouseenter="highlighted = index"
|
|
160
|
+
>
|
|
161
|
+
<span class="search-select__label">{{ option.label }}</span>
|
|
162
|
+
<Check v-if="option.id === modelValue" :size="14" />
|
|
163
|
+
</button>
|
|
164
|
+
</li>
|
|
165
|
+
<li v-if="!options.length" class="search-select__empty">{{ noResults }}</li>
|
|
166
|
+
<li v-if="canLoadMore" class="search-select__more">
|
|
167
|
+
<button type="button" @mousedown.prevent="emit('loadMore')">{{ loadMoreLabel }}</button>
|
|
168
|
+
</li>
|
|
169
|
+
</ul>
|
|
170
|
+
</div>
|
|
171
|
+
</div>
|
|
172
|
+
</template>
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { type Component } from 'vue'
|
|
3
|
+
import { Sun, Moon, Monitor } from '@lucide/vue'
|
|
4
|
+
import { useTheme, type ThemeMode } from '../composables/useTheme'
|
|
5
|
+
|
|
6
|
+
const { themeMode, setTheme } = useTheme()
|
|
7
|
+
|
|
8
|
+
const options: { value: ThemeMode; icon: Component }[] = [
|
|
9
|
+
{ value: 'light', icon: Sun },
|
|
10
|
+
{ value: 'dark', icon: Moon },
|
|
11
|
+
{ value: 'system', icon: Monitor },
|
|
12
|
+
]
|
|
13
|
+
</script>
|
|
14
|
+
|
|
15
|
+
<template>
|
|
16
|
+
<div class="theme-selector">
|
|
17
|
+
<button
|
|
18
|
+
v-for="opt in options"
|
|
19
|
+
:key="opt.value"
|
|
20
|
+
type="button"
|
|
21
|
+
:class="['theme-btn', { active: themeMode === opt.value }]"
|
|
22
|
+
:title="opt.value"
|
|
23
|
+
@click="setTheme(opt.value)"
|
|
24
|
+
>
|
|
25
|
+
<component :is="opt.icon" :size="14" />
|
|
26
|
+
</button>
|
|
27
|
+
</div>
|
|
28
|
+
</template>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { useToast } from '../composables/useToast'
|
|
3
|
+
import BaseToast from './BaseToast.vue'
|
|
4
|
+
|
|
5
|
+
const { toasts, remove } = useToast()
|
|
6
|
+
</script>
|
|
7
|
+
|
|
8
|
+
<template>
|
|
9
|
+
<Teleport to="body">
|
|
10
|
+
<div class="toast-container">
|
|
11
|
+
<TransitionGroup name="toast">
|
|
12
|
+
<BaseToast
|
|
13
|
+
v-for="toast in toasts"
|
|
14
|
+
:key="toast.id"
|
|
15
|
+
:variant="toast.variant"
|
|
16
|
+
:message="toast.message"
|
|
17
|
+
:duration="toast.duration"
|
|
18
|
+
@close="remove(toast.id)"
|
|
19
|
+
/>
|
|
20
|
+
</TransitionGroup>
|
|
21
|
+
</div>
|
|
22
|
+
</Teleport>
|
|
23
|
+
</template>
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
|
3
|
+
import { ChevronDown } from '@lucide/vue'
|
|
4
|
+
import ImageUpload from './ImageUpload.vue'
|
|
5
|
+
|
|
6
|
+
// Imagen traducible (una URL por locale): mismo selector desplegable de
|
|
7
|
+
// locale que TranslatableInput, con un ImageUpload para el idioma activo.
|
|
8
|
+
// La subida la pone quien lo usa (prop `upload`): este componente solo
|
|
9
|
+
// gestiona el mapa locale => URL. En el render, el motor localiza el valor
|
|
10
|
+
// con fallback al locale por defecto (localizeSettings).
|
|
11
|
+
interface Locale {
|
|
12
|
+
code: string
|
|
13
|
+
name: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const props = withDefaults(
|
|
17
|
+
defineProps<{
|
|
18
|
+
modelValue?: Record<string, string>
|
|
19
|
+
locales: Locale[]
|
|
20
|
+
label?: string
|
|
21
|
+
required?: boolean
|
|
22
|
+
/** Sube el fichero y devuelve la URL pública que se guarda. */
|
|
23
|
+
upload: (file: File) => Promise<string>
|
|
24
|
+
error?: string
|
|
25
|
+
}>(),
|
|
26
|
+
{ modelValue: () => ({}), required: false },
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
const emit = defineEmits<{ 'update:modelValue': [Record<string, string>] }>()
|
|
30
|
+
|
|
31
|
+
const codes = computed(() => props.locales.map((l) => l.code))
|
|
32
|
+
const active = ref(codes.value[0] ?? 'es')
|
|
33
|
+
const open = ref(false)
|
|
34
|
+
const dropdownRef = ref<HTMLElement>()
|
|
35
|
+
const uploading = ref(false)
|
|
36
|
+
|
|
37
|
+
const currentUrl = computed(() => props.modelValue?.[active.value] || null)
|
|
38
|
+
const filledCount = computed(() => codes.value.filter((c) => !!props.modelValue?.[c]).length)
|
|
39
|
+
const hasContent = (code: string) => !!props.modelValue?.[code]
|
|
40
|
+
|
|
41
|
+
function selectLocale(code: string) {
|
|
42
|
+
active.value = code
|
|
43
|
+
open.value = false
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function setUrl(url: string | null) {
|
|
47
|
+
const next = { ...props.modelValue }
|
|
48
|
+
if (url) next[active.value] = url
|
|
49
|
+
else delete next[active.value]
|
|
50
|
+
emit('update:modelValue', next)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function onFile(file: File | null) {
|
|
54
|
+
if (!file) {
|
|
55
|
+
setUrl(null)
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
uploading.value = true
|
|
59
|
+
try {
|
|
60
|
+
setUrl(await props.upload(file))
|
|
61
|
+
} finally {
|
|
62
|
+
uploading.value = false
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function onClickOutside(e: MouseEvent) {
|
|
67
|
+
if (dropdownRef.value && !dropdownRef.value.contains(e.target as Node)) open.value = false
|
|
68
|
+
}
|
|
69
|
+
onMounted(() => document.addEventListener('click', onClickOutside))
|
|
70
|
+
onBeforeUnmount(() => document.removeEventListener('click', onClickOutside))
|
|
71
|
+
</script>
|
|
72
|
+
|
|
73
|
+
<template>
|
|
74
|
+
<div class="form-field" :class="{ 'form-field--error': error }">
|
|
75
|
+
<div class="translatable-header">
|
|
76
|
+
<span v-if="label" class="form-field__label">
|
|
77
|
+
{{ label }}<span v-if="required" class="form-field__required">*</span>
|
|
78
|
+
</span>
|
|
79
|
+
<div ref="dropdownRef" class="translatable-selector">
|
|
80
|
+
<button type="button" class="translatable-trigger" @click="open = !open">
|
|
81
|
+
<span class="translatable-trigger__code">{{ active.toUpperCase() }}</span>
|
|
82
|
+
<span class="translatable-trigger__count">{{ filledCount }}/{{ codes.length }}</span>
|
|
83
|
+
<ChevronDown class="translatable-trigger__chevron" :size="12" />
|
|
84
|
+
</button>
|
|
85
|
+
<Transition name="dropdown">
|
|
86
|
+
<div v-if="open" class="translatable-dropdown">
|
|
87
|
+
<button
|
|
88
|
+
v-for="loc in locales"
|
|
89
|
+
:key="loc.code"
|
|
90
|
+
type="button"
|
|
91
|
+
:class="[
|
|
92
|
+
'translatable-option',
|
|
93
|
+
{
|
|
94
|
+
'translatable-option--active': loc.code === active,
|
|
95
|
+
'translatable-option--empty': !hasContent(loc.code),
|
|
96
|
+
},
|
|
97
|
+
]"
|
|
98
|
+
@click="selectLocale(loc.code)"
|
|
99
|
+
>
|
|
100
|
+
<span class="translatable-option__code">{{ loc.code.toUpperCase() }}</span>
|
|
101
|
+
<span class="translatable-option__label">{{ loc.name }}</span>
|
|
102
|
+
<span v-if="hasContent(loc.code)" class="translatable-option__filled" />
|
|
103
|
+
</button>
|
|
104
|
+
</div>
|
|
105
|
+
</Transition>
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
|
|
109
|
+
<ImageUpload
|
|
110
|
+
:key="active"
|
|
111
|
+
:model-value="null"
|
|
112
|
+
:current-url="currentUrl"
|
|
113
|
+
:error="uploading ? undefined : error"
|
|
114
|
+
@update:model-value="onFile"
|
|
115
|
+
@remove="setUrl(null)"
|
|
116
|
+
/>
|
|
117
|
+
|
|
118
|
+
<p v-if="error" class="form-field__error">{{ error }}</p>
|
|
119
|
+
</div>
|
|
120
|
+
</template>
|