@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.
Files changed (73) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +45 -0
  3. package/package.json +47 -0
  4. package/scss/_base.scss +33 -0
  5. package/scss/_forms.scss +33 -0
  6. package/scss/_shared-components.scss +1 -0
  7. package/scss/_theme.scss +74 -0
  8. package/scss/_tokens.scss +114 -0
  9. package/scss/components/_base-button.scss +113 -0
  10. package/scss/components/_blocks.scss +211 -0
  11. package/scss/components/_breadcrumbs.scss +31 -0
  12. package/scss/components/_checkbox.scss +42 -0
  13. package/scss/components/_chip.scss +23 -0
  14. package/scss/components/_confirm.scss +3 -0
  15. package/scss/components/_edit-modal.scss +11 -0
  16. package/scss/components/_font-upload.scss +91 -0
  17. package/scss/components/_form-field.scss +85 -0
  18. package/scss/components/_icon-button.scss +23 -0
  19. package/scss/components/_image-upload.scss +85 -0
  20. package/scss/components/_index.scss +24 -0
  21. package/scss/components/_locale-selector.scss +89 -0
  22. package/scss/components/_modal.scss +68 -0
  23. package/scss/components/_motor-badge.scss +21 -0
  24. package/scss/components/_numeric-input.scss +53 -0
  25. package/scss/components/_page-background.scss +22 -0
  26. package/scss/components/_palette-color-picker.scss +69 -0
  27. package/scss/components/_rich-text.scss +144 -0
  28. package/scss/components/_search-select.scss +138 -0
  29. package/scss/components/_tabs.scss +120 -0
  30. package/scss/components/_theme-selector.scss +34 -0
  31. package/scss/components/_toast-container.scss +16 -0
  32. package/scss/components/_toast.scss +25 -0
  33. package/scss/components/_translatable-input.scss +73 -0
  34. package/src/blocks/BlockCta.vue +32 -0
  35. package/src/blocks/BlockFaq.vue +25 -0
  36. package/src/blocks/BlockHeader.vue +13 -0
  37. package/src/blocks/BlockIndex.vue +20 -0
  38. package/src/blocks/BlockQuote.vue +16 -0
  39. package/src/blocks/BlockShell.vue +30 -0
  40. package/src/blocks/BlockText.vue +20 -0
  41. package/src/blocks/BlockTextCard.vue +23 -0
  42. package/src/blocks/PageBackground.vue +15 -0
  43. package/src/blocks/index.ts +25 -0
  44. package/src/components/AppBreadcrumbs.vue +44 -0
  45. package/src/components/BaseButton.vue +24 -0
  46. package/src/components/BaseCheckbox.vue +37 -0
  47. package/src/components/BaseInput.vue +53 -0
  48. package/src/components/BaseModal.vue +98 -0
  49. package/src/components/BaseSelect.vue +53 -0
  50. package/src/components/BaseTabs.vue +30 -0
  51. package/src/components/BaseTextarea.vue +45 -0
  52. package/src/components/BaseToast.vue +31 -0
  53. package/src/components/ConfirmDialog.vue +35 -0
  54. package/src/components/EditModal.vue +57 -0
  55. package/src/components/FontUpload.vue +123 -0
  56. package/src/components/IconButton.vue +24 -0
  57. package/src/components/ImageUpload.vue +142 -0
  58. package/src/components/LocaleSelector.vue +59 -0
  59. package/src/components/MotorBadge.vue +17 -0
  60. package/src/components/NumericInput.vue +115 -0
  61. package/src/components/PaletteColorPicker.vue +94 -0
  62. package/src/components/RichTextInput.vue +273 -0
  63. package/src/components/SearchSelect.vue +172 -0
  64. package/src/components/ThemeSelector.vue +28 -0
  65. package/src/components/ToastContainer.vue +23 -0
  66. package/src/components/TranslatableImage.vue +120 -0
  67. package/src/components/TranslatableInput.vue +135 -0
  68. package/src/composables/useConfirm.ts +49 -0
  69. package/src/composables/useHead.ts +54 -0
  70. package/src/composables/useTheme.ts +50 -0
  71. package/src/composables/useToast.ts +26 -0
  72. package/src/index.ts +39 -0
  73. package/src/lib/createApi.ts +42 -0
@@ -0,0 +1,135 @@
1
+ <script setup lang="ts">
2
+ import { ref, computed, onMounted, onBeforeUnmount, defineAsyncComponent } from 'vue'
3
+ import { ChevronDown } from '@lucide/vue'
4
+ import type { RichTextLabels } from './RichTextInput.vue'
5
+
6
+ // Editor WYSIWYG cargado en diferido: TipTap solo se descarga si algún campo
7
+ // traducible usa type="wysiwyg".
8
+ const RichTextInput = defineAsyncComponent(() => import('./RichTextInput.vue'))
9
+
10
+ // Campo traducible (portado de kontuan): usa el estilo `.form-field` y un
11
+ // selector desplegable de locale con contador de rellenados.
12
+ interface Locale {
13
+ code: string
14
+ name: string
15
+ }
16
+
17
+ const props = withDefaults(
18
+ defineProps<{
19
+ modelValue?: Record<string, string>
20
+ locales: Locale[]
21
+ label?: string
22
+ type?: 'text' | 'textarea' | 'wysiwyg'
23
+ placeholder?: string
24
+ rows?: number
25
+ required?: boolean
26
+ error?: string
27
+ id?: string
28
+ /** Iconos del juego para el selector del editor (solo type="wysiwyg"). */
29
+ icons?: { name: string; url: string }[]
30
+ /** Textos de la barra del editor (solo type="wysiwyg"), DC-29. */
31
+ richLabels?: Partial<RichTextLabels>
32
+ }>(),
33
+ {
34
+ modelValue: () => ({}),
35
+ type: 'text',
36
+ rows: 4,
37
+ required: false,
38
+ icons: () => [],
39
+ richLabels: () => ({}),
40
+ },
41
+ )
42
+
43
+ const emit = defineEmits<{ 'update:modelValue': [Record<string, string>] }>()
44
+
45
+ const codes = computed(() => props.locales.map((l) => l.code))
46
+ const active = ref(codes.value[0] ?? 'es')
47
+ const open = ref(false)
48
+ const dropdownRef = ref<HTMLElement>()
49
+ const inputId = props.id || `translatable-${Math.random().toString(36).slice(2, 9)}`
50
+
51
+ const currentValue = computed(() => props.modelValue?.[active.value] || '')
52
+ const filledCount = computed(() => codes.value.filter((c) => !!props.modelValue?.[c]).length)
53
+ const hasContent = (code: string) => !!props.modelValue?.[code]
54
+
55
+ function selectLocale(code: string) {
56
+ active.value = code
57
+ open.value = false
58
+ }
59
+ function update(value: string) {
60
+ emit('update:modelValue', { ...props.modelValue, [active.value]: value })
61
+ }
62
+ function onClickOutside(e: MouseEvent) {
63
+ if (dropdownRef.value && !dropdownRef.value.contains(e.target as Node)) open.value = false
64
+ }
65
+ onMounted(() => document.addEventListener('click', onClickOutside))
66
+ onBeforeUnmount(() => document.removeEventListener('click', onClickOutside))
67
+ </script>
68
+
69
+ <template>
70
+ <div class="form-field" :class="{ 'form-field--error': error }">
71
+ <div class="translatable-header">
72
+ <label v-if="label" :for="`${inputId}-${active}`" class="form-field__label">
73
+ {{ label }}<span v-if="required" class="form-field__required">*</span>
74
+ </label>
75
+ <div ref="dropdownRef" class="translatable-selector">
76
+ <button type="button" class="translatable-trigger" @click="open = !open">
77
+ <span class="translatable-trigger__code">{{ active.toUpperCase() }}</span>
78
+ <span class="translatable-trigger__count">{{ filledCount }}/{{ codes.length }}</span>
79
+ <ChevronDown class="translatable-trigger__chevron" :size="12" />
80
+ </button>
81
+ <Transition name="dropdown">
82
+ <div v-if="open" class="translatable-dropdown">
83
+ <button
84
+ v-for="loc in locales"
85
+ :key="loc.code"
86
+ type="button"
87
+ :class="[
88
+ 'translatable-option',
89
+ {
90
+ 'translatable-option--active': loc.code === active,
91
+ 'translatable-option--empty': !hasContent(loc.code),
92
+ },
93
+ ]"
94
+ @click="selectLocale(loc.code)"
95
+ >
96
+ <span class="translatable-option__code">{{ loc.code.toUpperCase() }}</span>
97
+ <span class="translatable-option__label">{{ loc.name }}</span>
98
+ <span v-if="hasContent(loc.code)" class="translatable-option__filled" />
99
+ </button>
100
+ </div>
101
+ </Transition>
102
+ </div>
103
+ </div>
104
+
105
+ <RichTextInput
106
+ v-if="type === 'wysiwyg'"
107
+ :key="active"
108
+ :model-value="currentValue"
109
+ :placeholder="placeholder"
110
+ :icons="icons"
111
+ :labels="richLabels"
112
+ @update:model-value="update"
113
+ />
114
+ <textarea
115
+ v-else-if="type === 'textarea'"
116
+ :id="`${inputId}-${active}`"
117
+ :value="currentValue"
118
+ :placeholder="placeholder"
119
+ :rows="rows"
120
+ class="form-field__textarea"
121
+ @input="update(($event.target as HTMLTextAreaElement).value)"
122
+ />
123
+ <input
124
+ v-else
125
+ :id="`${inputId}-${active}`"
126
+ type="text"
127
+ :value="currentValue"
128
+ :placeholder="placeholder"
129
+ class="form-field__input"
130
+ @input="update(($event.target as HTMLInputElement).value)"
131
+ />
132
+
133
+ <p v-if="error" class="form-field__error">{{ error }}</p>
134
+ </div>
135
+ </template>
@@ -0,0 +1,49 @@
1
+ import { reactive } from 'vue'
2
+
3
+ export interface ConfirmOptions {
4
+ title?: string
5
+ message: string
6
+ confirmLabel?: string
7
+ cancelLabel?: string
8
+ variant?: 'primary' | 'danger' | 'success'
9
+ }
10
+
11
+ interface ConfirmState extends Required<Omit<ConfirmOptions, 'message'>> {
12
+ open: boolean
13
+ message: string
14
+ resolver: ((value: boolean) => void) | null
15
+ }
16
+
17
+ const state = reactive<ConfirmState>({
18
+ open: false,
19
+ title: '',
20
+ message: '',
21
+ confirmLabel: 'Confirmar',
22
+ cancelLabel: 'Cancelar',
23
+ variant: 'primary',
24
+ resolver: null,
25
+ })
26
+
27
+ export function useConfirm() {
28
+ /** Muestra un diálogo de confirmación y resuelve a true/false. */
29
+ function confirm(options: ConfirmOptions): Promise<boolean> {
30
+ return new Promise((resolve) => {
31
+ // Si había otro diálogo pendiente, se resuelve como cancelado para no
32
+ // dejar su promesa colgada para siempre.
33
+ state.resolver?.(false)
34
+ state.open = true
35
+ state.title = options.title ?? ''
36
+ state.message = options.message
37
+ state.confirmLabel = options.confirmLabel ?? 'Confirmar'
38
+ state.cancelLabel = options.cancelLabel ?? 'Cancelar'
39
+ state.variant = options.variant ?? 'primary'
40
+ state.resolver = resolve
41
+ })
42
+ }
43
+ function resolve(value: boolean) {
44
+ state.open = false
45
+ state.resolver?.(value)
46
+ state.resolver = null
47
+ }
48
+ return { state, confirm, resolve }
49
+ }
@@ -0,0 +1,54 @@
1
+ // SEO de la SPA pública (doc 10, DC-18): fija <title>, meta description,
2
+ // canonical y alternates hreflang de la ruta actual manipulando el <head>.
3
+ // Sin dependencias: cada vista lo llama al cargar sus datos (y el prerender
4
+ // captura el head ya resuelto).
5
+
6
+ export interface HeadInput {
7
+ title?: string
8
+ description?: string
9
+ /** URL canónica absoluta de la ruta en el locale activo. */
10
+ canonical?: string
11
+ /** URL absoluta por locale (hreflang). */
12
+ alternates?: Record<string, string>
13
+ }
14
+
15
+ function upsertMeta(name: string, content: string | undefined) {
16
+ let tag = document.head.querySelector<HTMLMetaElement>(`meta[name="${name}"]`)
17
+ if (!content) {
18
+ tag?.remove()
19
+ return
20
+ }
21
+ if (!tag) {
22
+ tag = document.createElement('meta')
23
+ tag.setAttribute('name', name)
24
+ document.head.appendChild(tag)
25
+ }
26
+ tag.setAttribute('content', content)
27
+ }
28
+
29
+ function upsertLink(rel: string, href: string, hreflang?: string) {
30
+ const selector = hreflang ? `link[rel="${rel}"][hreflang="${hreflang}"]` : `link[rel="${rel}"]`
31
+ let tag = document.head.querySelector<HTMLLinkElement>(selector)
32
+ if (!tag) {
33
+ tag = document.createElement('link')
34
+ tag.setAttribute('rel', rel)
35
+ if (hreflang) tag.setAttribute('hreflang', hreflang)
36
+ document.head.appendChild(tag)
37
+ }
38
+ tag.setAttribute('href', href)
39
+ }
40
+
41
+ export function useHead(input: HeadInput): void {
42
+ if (input.title !== undefined) document.title = input.title
43
+ upsertMeta('description', input.description)
44
+
45
+ // Canonical y alternates se reponen enteros en cada ruta (los sobrantes
46
+ // de la ruta anterior se retiran).
47
+ document.head
48
+ .querySelectorAll('link[rel="canonical"], link[rel="alternate"][hreflang]')
49
+ .forEach((tag) => tag.remove())
50
+ if (input.canonical) upsertLink('canonical', input.canonical)
51
+ for (const [locale, href] of Object.entries(input.alternates ?? {})) {
52
+ upsertLink('alternate', href, locale)
53
+ }
54
+ }
@@ -0,0 +1,50 @@
1
+ import { ref, watch, onMounted, onUnmounted } from 'vue'
2
+
3
+ // Modo de tema. 'system' sigue la preferencia del SO. Copiado de kontuan.
4
+ export type ThemeMode = 'light' | 'dark' | 'system'
5
+
6
+ function storedTheme(): ThemeMode {
7
+ // Guarda ante entornos sin window (prerender en build, DC-18).
8
+ if (typeof window === 'undefined') return 'dark'
9
+ return (localStorage.getItem('theme') as ThemeMode) || 'dark'
10
+ }
11
+
12
+ const themeMode = ref<ThemeMode>(storedTheme())
13
+
14
+ function getSystemTheme(): 'light' | 'dark' {
15
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
16
+ }
17
+
18
+ function applyTheme(mode: ThemeMode) {
19
+ const resolved = mode === 'system' ? getSystemTheme() : mode
20
+ document.documentElement.setAttribute('data-theme', resolved)
21
+ }
22
+
23
+ export function useTheme() {
24
+ function setTheme(mode: ThemeMode) {
25
+ themeMode.value = mode
26
+ localStorage.setItem('theme', mode)
27
+ applyTheme(mode)
28
+ }
29
+
30
+ watch(themeMode, (mode) => applyTheme(mode))
31
+
32
+ const media =
33
+ typeof window !== 'undefined' ? window.matchMedia('(prefers-color-scheme: dark)') : null
34
+ const onSystemChange = () => {
35
+ if (themeMode.value === 'system') {
36
+ applyTheme('system')
37
+ }
38
+ }
39
+
40
+ onMounted(() => {
41
+ applyTheme(themeMode.value)
42
+ media?.addEventListener('change', onSystemChange)
43
+ })
44
+
45
+ onUnmounted(() => {
46
+ media?.removeEventListener('change', onSystemChange)
47
+ })
48
+
49
+ return { themeMode, setTheme }
50
+ }
@@ -0,0 +1,26 @@
1
+ import { reactive } from 'vue'
2
+
3
+ export interface Toast {
4
+ id: number
5
+ variant: 'success' | 'warning' | 'danger' | 'info'
6
+ message: string
7
+ duration: number
8
+ }
9
+
10
+ const toasts = reactive<Toast[]>([])
11
+ let nextId = 0
12
+
13
+ export function useToast() {
14
+ function add(message: string, variant: Toast['variant'] = 'info', duration = 4000) {
15
+ toasts.push({ id: nextId++, variant, message, duration })
16
+ }
17
+ function remove(id: number) {
18
+ const i = toasts.findIndex((t) => t.id === id)
19
+ if (i > -1) toasts.splice(i, 1)
20
+ }
21
+ const success = (m: string) => add(m, 'success')
22
+ const warning = (m: string) => add(m, 'warning')
23
+ const danger = (m: string) => add(m, 'danger')
24
+ const info = (m: string) => add(m, 'info')
25
+ return { toasts, add, remove, success, warning, danger, info }
26
+ }
package/src/index.ts ADDED
@@ -0,0 +1,39 @@
1
+ // @edc-motor/ui — librería de componentes base + tokens + utilidades del motor.
2
+
3
+ import { defineAsyncComponent } from 'vue'
4
+
5
+ export { default as BaseButton } from './components/BaseButton.vue'
6
+ export { default as IconButton } from './components/IconButton.vue'
7
+ export { default as MotorBadge } from './components/MotorBadge.vue'
8
+ export { default as BaseInput } from './components/BaseInput.vue'
9
+ export { default as BaseTextarea } from './components/BaseTextarea.vue'
10
+ export { default as BaseSelect, type SelectOption } from './components/BaseSelect.vue'
11
+ export { default as SearchSelect, type SearchSelectOption } from './components/SearchSelect.vue'
12
+ export { default as BaseCheckbox } from './components/BaseCheckbox.vue'
13
+ export { default as NumericInput } from './components/NumericInput.vue'
14
+ export { default as PaletteColorPicker } from './components/PaletteColorPicker.vue'
15
+ export { default as TranslatableInput } from './components/TranslatableInput.vue'
16
+ export { default as TranslatableImage } from './components/TranslatableImage.vue'
17
+ // El WYSIWYG carga DIFERIDO (TipTap pesa ~450 KB): así la web pública no lo
18
+ // arrastra a su bundle y el admin lo trocea en su propio chunk.
19
+ export const RichTextInput = defineAsyncComponent(() => import('./components/RichTextInput.vue'))
20
+ export type { RichIcon, RichTextLabels } from './components/RichTextInput.vue'
21
+ export { default as ImageUpload } from './components/ImageUpload.vue'
22
+ export { default as FontUpload } from './components/FontUpload.vue'
23
+ export { default as BaseModal } from './components/BaseModal.vue'
24
+ export { default as EditModal } from './components/EditModal.vue'
25
+ export { default as BaseTabs } from './components/BaseTabs.vue'
26
+ export { default as BaseToast } from './components/BaseToast.vue'
27
+ export { default as ToastContainer } from './components/ToastContainer.vue'
28
+ export { default as ConfirmDialog } from './components/ConfirmDialog.vue'
29
+ export { default as ThemeSelector } from './components/ThemeSelector.vue'
30
+ export { default as LocaleSelector } from './components/LocaleSelector.vue'
31
+ export { default as AppBreadcrumbs, type Crumb } from './components/AppBreadcrumbs.vue'
32
+ export { createApi, type CreateApiOptions } from './lib/createApi'
33
+ export { useToast, type Toast } from './composables/useToast'
34
+ export { useConfirm, type ConfirmOptions } from './composables/useConfirm'
35
+ export { useTheme, type ThemeMode } from './composables/useTheme'
36
+ export { useHead, type HeadInput } from './composables/useHead'
37
+
38
+ // Bloques de presentación del CRM (doc 03).
39
+ export * from './blocks'
@@ -0,0 +1,42 @@
1
+ import axios, { type AxiosInstance } from 'axios'
2
+
3
+ export interface CreateApiOptions {
4
+ /** URL base de la API (p. ej. http://localhost:8010/api). */
5
+ baseURL: string
6
+ /** Clave de localStorage donde se guarda el token. */
7
+ tokenKey: string
8
+ /** Se invoca cuando la API responde 401 (token inválido/expirado). */
9
+ onUnauthorized?: () => void
10
+ }
11
+
12
+ /**
13
+ * Crea una instancia de axios con el token Bearer inyectado desde localStorage
14
+ * y manejo de 401. Patrón compartido entre admin y app (estilo kontuan).
15
+ */
16
+ export function createApi(options: CreateApiOptions): AxiosInstance {
17
+ const api = axios.create({
18
+ baseURL: options.baseURL,
19
+ headers: { Accept: 'application/json' },
20
+ })
21
+
22
+ api.interceptors.request.use((config) => {
23
+ const token = localStorage.getItem(options.tokenKey)
24
+ if (token) {
25
+ config.headers.Authorization = `Bearer ${token}`
26
+ }
27
+ return config
28
+ })
29
+
30
+ api.interceptors.response.use(
31
+ (response) => response,
32
+ (error) => {
33
+ if (error.response?.status === 401) {
34
+ localStorage.removeItem(options.tokenKey)
35
+ options.onUnauthorized?.()
36
+ }
37
+ return Promise.reject(error)
38
+ },
39
+ )
40
+
41
+ return api
42
+ }