@innertia-solutions/innertia-nuxt 0.1.1 → 0.1.2
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/app.config.ts +75 -0
- package/components/Auth/LoginForm.vue +225 -0
- package/components/Auth/MarketingPanel.vue +180 -0
- package/layouts/auth.vue +87 -0
- package/layouts/backoffice.vue +209 -0
- package/package.json +1 -1
- package/plugins/colors.ts +81 -0
package/app.config.ts
CHANGED
|
@@ -20,6 +20,64 @@ export default defineAppConfig({
|
|
|
20
20
|
*/
|
|
21
21
|
mode: 'saas' as InnertiaMode,
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Branding del producto (nombre + versión). Los logos viven en `/public/isologo-light.png`
|
|
25
|
+
* y `/public/isologo-dark.png` de cada producto — la librería los referencia por path fijo.
|
|
26
|
+
*/
|
|
27
|
+
branding: {
|
|
28
|
+
name: 'Innertia',
|
|
29
|
+
version: '1.0.0',
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Colores primario y secundario. Acepta:
|
|
34
|
+
* - Nombre de color de Tailwind (string): 'violet', 'indigo', 'emerald', etc.
|
|
35
|
+
* - Scale custom (object): { 50: '#f5f3ff', ..., 950: '#2e1065' }
|
|
36
|
+
*
|
|
37
|
+
* El plugin `colors` aplica estos valores a las CSS vars `--primary-{50..950}`
|
|
38
|
+
* y `--secondary-{50..950}` en SSR y cliente.
|
|
39
|
+
*/
|
|
40
|
+
colors: {
|
|
41
|
+
primary: 'blue' as ColorOption,
|
|
42
|
+
secondary: 'slate' as ColorOption,
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Contenido del panel marketing del layout `auth`. Si no se declara, el panel queda vacío.
|
|
47
|
+
*/
|
|
48
|
+
marketing: {
|
|
49
|
+
/** Palabras del typewriter. Vacío = sin typewriter (muestra heading estático). */
|
|
50
|
+
words: [] as string[],
|
|
51
|
+
/** Tagline mostrada después del typewriter. Soporta `\n` para saltos de línea. */
|
|
52
|
+
tagline: '',
|
|
53
|
+
/** Descripción debajo del tagline. */
|
|
54
|
+
description: '',
|
|
55
|
+
/** Footer con lista de items (ej. normas, certificaciones, integraciones). */
|
|
56
|
+
footer: {
|
|
57
|
+
title: '',
|
|
58
|
+
items: [] as string[],
|
|
59
|
+
description: '',
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Proveedores OAuth para modo `app`. En modo `saas` se ignora — la lista viene
|
|
65
|
+
* de `tenantStore.config.oauth` que se carga por SSR desde el backend.
|
|
66
|
+
*/
|
|
67
|
+
oauth: [] as OAuthProvider[],
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Items principales del menú del layout `backoffice`.
|
|
71
|
+
* Cada item: { label, icon ('IconName' de @tabler/icons-vue), route, pattern? }.
|
|
72
|
+
*/
|
|
73
|
+
menu: [] as MenuItem[],
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Items secundarios del menú backoffice (apps / módulos). Renderizan junto a `menu`
|
|
77
|
+
* pero permiten diferenciar visualmente en el futuro (ej. sub-secciones modulares).
|
|
78
|
+
*/
|
|
79
|
+
menuApps: [] as MenuItem[],
|
|
80
|
+
|
|
23
81
|
/**
|
|
24
82
|
* Declaración de "apps" (contextos) del producto.
|
|
25
83
|
* Cada app define un prefijo de URL que mapea a un contexto del backend
|
|
@@ -46,6 +104,23 @@ export type InnertiaMode = 'saas' | 'app'
|
|
|
46
104
|
|
|
47
105
|
export type AppMobileMode = 'allow' | 'block' | 'redirect'
|
|
48
106
|
|
|
107
|
+
/** Color: nombre de paleta Tailwind o scale custom 50→950. */
|
|
108
|
+
export type ColorScale = Partial<Record<50 | 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 | 950, string>>
|
|
109
|
+
export type ColorOption = string | ColorScale
|
|
110
|
+
|
|
111
|
+
/** Proveedor OAuth — slug del provider (matchea con backend SocialAuthController). */
|
|
112
|
+
export type OAuthProvider = 'google' | 'microsoft' | 'apple' | 'github'
|
|
113
|
+
|
|
114
|
+
/** Item de menú del layout backoffice. */
|
|
115
|
+
export interface MenuItem {
|
|
116
|
+
label: string
|
|
117
|
+
/** Nombre de icono de @tabler/icons-vue (con prefijo Icon). Ej: 'IconHome'. */
|
|
118
|
+
icon: string
|
|
119
|
+
route: string
|
|
120
|
+
/** Pattern (glob style con `*`) para resaltar item activo. Si no se pasa, usa route. */
|
|
121
|
+
pattern?: string
|
|
122
|
+
}
|
|
123
|
+
|
|
49
124
|
export interface AppDefinition {
|
|
50
125
|
/** Prefijo de URL — la ruta debe empezar con esto para considerarse "dentro" del app. */
|
|
51
126
|
path: string
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Formulario de login estándar reutilizable.
|
|
4
|
+
*
|
|
5
|
+
* - Email + password + botón ingresar
|
|
6
|
+
* - Botones OAuth dinámicos según tenant (saas) o appConfig (app)
|
|
7
|
+
* - Banner de modo demo si tenant.config.demo está presente
|
|
8
|
+
* - Cross-links a otros contextos mobile-friendly (configurable via prop)
|
|
9
|
+
*
|
|
10
|
+
* Uso:
|
|
11
|
+
* <AuthLoginForm context="backoffice" />
|
|
12
|
+
*/
|
|
13
|
+
import { IconBrandGoogle, IconBrandWindows, IconBrandApple, IconBrandGithub, IconArrowRight, IconEye, IconEyeOff } from '@tabler/icons-vue'
|
|
14
|
+
import type { OAuthProvider } from '../../app.config'
|
|
15
|
+
|
|
16
|
+
const props = defineProps<{
|
|
17
|
+
/** Contexto del backend al que el form hace login (ej. 'backoffice', 'technician'). */
|
|
18
|
+
context: string
|
|
19
|
+
/** Título del form. */
|
|
20
|
+
title?: string
|
|
21
|
+
/** Subtítulo / descripción. */
|
|
22
|
+
description?: string
|
|
23
|
+
/** Mostrar cross-links a otros apps mobile-friendly. */
|
|
24
|
+
showCrossLinks?: boolean
|
|
25
|
+
}>()
|
|
26
|
+
|
|
27
|
+
const heading = computed(() => props.title ?? 'Bienvenido de nuevo')
|
|
28
|
+
const subheading = computed(() => props.description ?? 'Ingresa tus credenciales para acceder a tu espacio de trabajo.')
|
|
29
|
+
|
|
30
|
+
const { performLogin, getOauthRedirectUrl } = useAuth()
|
|
31
|
+
const config = useRuntimeConfig()
|
|
32
|
+
|
|
33
|
+
// ── Demo credentials & OAuth providers (vienen del tenant config en saas, del appConfig en app) ──
|
|
34
|
+
const { hasTenant, isApp } = useInnertiaMode()
|
|
35
|
+
const appConfig = useAppConfig()
|
|
36
|
+
const tenantStore = hasTenant() ? useTenantStore() : null
|
|
37
|
+
|
|
38
|
+
const demo = computed<{ email?: string; password?: string } | null>(() => {
|
|
39
|
+
if (tenantStore) return (tenantStore as any).config?.demo ?? null
|
|
40
|
+
return null
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
const oauthProviders = computed<OAuthProvider[]>(() => {
|
|
44
|
+
if (tenantStore) {
|
|
45
|
+
const fromTenant = (tenantStore as any).config?.oauth as OAuthProvider[] | undefined
|
|
46
|
+
return Array.isArray(fromTenant) ? fromTenant : []
|
|
47
|
+
}
|
|
48
|
+
return (appConfig.innertia?.oauth ?? []) as OAuthProvider[]
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
const oauthIcon = (provider: OAuthProvider) => ({
|
|
52
|
+
google: IconBrandGoogle,
|
|
53
|
+
microsoft: IconBrandWindows,
|
|
54
|
+
apple: IconBrandApple,
|
|
55
|
+
github: IconBrandGithub,
|
|
56
|
+
}[provider])
|
|
57
|
+
|
|
58
|
+
const oauthLabel = (provider: OAuthProvider) => ({
|
|
59
|
+
google: 'Google',
|
|
60
|
+
microsoft: 'Microsoft',
|
|
61
|
+
apple: 'Apple',
|
|
62
|
+
github: 'GitHub',
|
|
63
|
+
}[provider])
|
|
64
|
+
|
|
65
|
+
// ── Form state ─────────────────────────────────────────────────────────────
|
|
66
|
+
const form = useForm({
|
|
67
|
+
email: { value: demo.value?.email ?? '', rules: ['required', 'email'] },
|
|
68
|
+
password: { value: demo.value?.password ?? '', rules: ['required', { name: 'min', arg: 8 }] },
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
const processing = ref(false)
|
|
72
|
+
const showPassword = ref(false)
|
|
73
|
+
|
|
74
|
+
async function handleSubmit() {
|
|
75
|
+
if (!form.validate()) return
|
|
76
|
+
processing.value = true
|
|
77
|
+
try {
|
|
78
|
+
const data = await performLogin(props.context, form.values.email, form.values.password)
|
|
79
|
+
if (data?.requires_password_change) {
|
|
80
|
+
await navigateTo(`/${props.context}/auth/change-password`)
|
|
81
|
+
} else {
|
|
82
|
+
await navigateTo(config.public.homePath || `/${props.context}`)
|
|
83
|
+
}
|
|
84
|
+
} catch (e: any) {
|
|
85
|
+
form.addError('password', e?.data?.message ?? 'Credenciales incorrectas.')
|
|
86
|
+
} finally {
|
|
87
|
+
processing.value = false
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function handleOauth(provider: OAuthProvider) {
|
|
92
|
+
try {
|
|
93
|
+
const url = await getOauthRedirectUrl(props.context, provider)
|
|
94
|
+
if (url) window.location.href = url
|
|
95
|
+
} catch (e: any) {
|
|
96
|
+
form.addError('email', e?.data?.message ?? `Error al iniciar sesión con ${oauthLabel(provider)}.`)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── Cross-links a otros contextos mobile-friendly (opcional) ───────────────
|
|
101
|
+
const { isMobile } = useMobileGuard()
|
|
102
|
+
const { all } = useApp()
|
|
103
|
+
const otherMobileApps = computed(() => {
|
|
104
|
+
if (!props.showCrossLinks) return []
|
|
105
|
+
return all.value.filter((a: any) => a.path !== `/${props.context}` && a.mobile?.mode === 'allow')
|
|
106
|
+
})
|
|
107
|
+
</script>
|
|
108
|
+
|
|
109
|
+
<template>
|
|
110
|
+
<div class="space-y-6">
|
|
111
|
+
<!-- Heading -->
|
|
112
|
+
<div>
|
|
113
|
+
<h1 class="text-2xl font-bold text-slate-900 dark:text-white">{{ heading }}</h1>
|
|
114
|
+
<p class="mt-1.5 text-sm text-slate-500 dark:text-slate-400">{{ subheading }}</p>
|
|
115
|
+
</div>
|
|
116
|
+
|
|
117
|
+
<!-- OAuth providers (dinámicos según tenant/app config) -->
|
|
118
|
+
<div v-if="oauthProviders.length > 0" class="space-y-3">
|
|
119
|
+
<div :class="oauthProviders.length === 1 ? '' : 'grid grid-cols-2 gap-3'">
|
|
120
|
+
<button
|
|
121
|
+
v-for="provider in oauthProviders"
|
|
122
|
+
:key="provider"
|
|
123
|
+
type="button"
|
|
124
|
+
@click="handleOauth(provider)"
|
|
125
|
+
class="inline-flex items-center justify-center gap-2 rounded-lg border border-slate-200 bg-white px-4 py-2.5 text-sm font-medium text-slate-700 shadow-xs hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-slate-200 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700 transition-colors"
|
|
126
|
+
>
|
|
127
|
+
<component :is="oauthIcon(provider)" class="size-4 shrink-0" />
|
|
128
|
+
{{ oauthLabel(provider) }}
|
|
129
|
+
</button>
|
|
130
|
+
</div>
|
|
131
|
+
<div class="relative">
|
|
132
|
+
<div class="absolute inset-0 flex items-center"><div class="w-full border-t border-slate-200 dark:border-slate-700" /></div>
|
|
133
|
+
<div class="relative flex justify-center"><span class="bg-white dark:bg-slate-900 px-3 text-xs text-slate-400">O continúa con tu correo</span></div>
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
136
|
+
|
|
137
|
+
<!-- Demo banner -->
|
|
138
|
+
<div v-if="demo" class="flex items-start gap-2.5 rounded-lg border border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-900/20 px-3.5 py-3">
|
|
139
|
+
<svg class="shrink-0 mt-0.5 size-4 text-amber-600 dark:text-amber-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
140
|
+
<circle cx="12" cy="12" r="10" /><line x1="12" x2="12" y1="8" y2="12" /><line x1="12" x2="12.01" y1="16" y2="16" />
|
|
141
|
+
</svg>
|
|
142
|
+
<div>
|
|
143
|
+
<p class="text-xs font-semibold text-amber-700 dark:text-amber-400">Modo demo</p>
|
|
144
|
+
<p class="text-xs text-amber-600 dark:text-amber-500 mt-0.5">Credenciales precargadas. Haz clic en <strong>Ingresar</strong> para explorar.</p>
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
|
|
148
|
+
<!-- Form -->
|
|
149
|
+
<form class="space-y-4" @submit.prevent="handleSubmit" novalidate>
|
|
150
|
+
<div>
|
|
151
|
+
<label class="block mb-2 text-sm font-medium text-slate-800 dark:text-white">Correo electrónico</label>
|
|
152
|
+
<input
|
|
153
|
+
v-model="form.values.email"
|
|
154
|
+
type="email"
|
|
155
|
+
:disabled="processing"
|
|
156
|
+
autocomplete="email"
|
|
157
|
+
placeholder="tu@correo.com"
|
|
158
|
+
@blur="form.validate('email')"
|
|
159
|
+
class="py-2.5 px-3 block w-full border rounded-lg sm:text-sm placeholder:text-slate-400 focus:ring-1 disabled:opacity-50 dark:bg-transparent dark:text-slate-300"
|
|
160
|
+
:class="form.errors.email?.length ? 'border-red-400 focus:border-red-400 focus:ring-red-400' : 'border-slate-200 focus:border-primary focus:ring-primary dark:border-slate-700'"
|
|
161
|
+
/>
|
|
162
|
+
<p v-if="form.errors.email?.length" class="mt-1.5 text-xs text-red-500">{{ form.errors.email[0] }}</p>
|
|
163
|
+
</div>
|
|
164
|
+
|
|
165
|
+
<div>
|
|
166
|
+
<div class="flex justify-between items-center mb-2">
|
|
167
|
+
<label class="text-sm font-medium text-slate-800 dark:text-white">Contraseña</label>
|
|
168
|
+
<NuxtLink :to="`/${context}/auth/forgot-password`" class="text-xs text-primary hover:underline">
|
|
169
|
+
¿Olvidaste tu contraseña?
|
|
170
|
+
</NuxtLink>
|
|
171
|
+
</div>
|
|
172
|
+
<div class="relative">
|
|
173
|
+
<input
|
|
174
|
+
v-model="form.values.password"
|
|
175
|
+
:type="showPassword ? 'text' : 'password'"
|
|
176
|
+
:disabled="processing"
|
|
177
|
+
autocomplete="current-password"
|
|
178
|
+
placeholder="••••••••"
|
|
179
|
+
@blur="form.validate('password')"
|
|
180
|
+
class="py-2.5 px-3 block w-full border rounded-lg sm:text-sm placeholder:text-slate-400 focus:ring-1 disabled:opacity-50 dark:bg-transparent dark:text-slate-300"
|
|
181
|
+
:class="form.errors.password?.length ? 'border-red-400 focus:border-red-400 focus:ring-red-400' : 'border-slate-200 focus:border-primary focus:ring-primary dark:border-slate-700'"
|
|
182
|
+
/>
|
|
183
|
+
<button
|
|
184
|
+
type="button"
|
|
185
|
+
tabindex="-1"
|
|
186
|
+
@click="showPassword = !showPassword"
|
|
187
|
+
class="absolute inset-y-0 end-0 flex items-center px-3 cursor-pointer text-slate-400 hover:text-slate-600 dark:hover:text-slate-200"
|
|
188
|
+
>
|
|
189
|
+
<IconEyeOff v-if="showPassword" class="size-4" />
|
|
190
|
+
<IconEye v-else class="size-4" />
|
|
191
|
+
</button>
|
|
192
|
+
</div>
|
|
193
|
+
<p v-if="form.errors.password?.length" class="mt-1.5 text-xs text-red-500">{{ form.errors.password[0] }}</p>
|
|
194
|
+
</div>
|
|
195
|
+
|
|
196
|
+
<button
|
|
197
|
+
type="submit"
|
|
198
|
+
:disabled="processing"
|
|
199
|
+
class="py-2.5 px-3 w-full inline-flex justify-center items-center gap-x-2 text-sm font-semibold rounded-lg border border-transparent bg-primary hover:bg-primary-hover text-white disabled:opacity-50"
|
|
200
|
+
>
|
|
201
|
+
<span v-if="processing" class="animate-spin inline-block size-4 border-[2px] border-t-transparent border-white rounded-full" />
|
|
202
|
+
<span v-else class="flex items-center gap-x-2">
|
|
203
|
+
Ingresar
|
|
204
|
+
<IconArrowRight class="size-4" />
|
|
205
|
+
</span>
|
|
206
|
+
</button>
|
|
207
|
+
</form>
|
|
208
|
+
|
|
209
|
+
<!-- Cross-links a otros contextos mobile-friendly -->
|
|
210
|
+
<div v-if="isMobile && otherMobileApps.length > 0" class="space-y-2 pt-2">
|
|
211
|
+
<div class="relative py-1">
|
|
212
|
+
<div class="absolute inset-0 flex items-center"><div class="w-full border-t border-slate-200 dark:border-slate-700" /></div>
|
|
213
|
+
<div class="relative flex justify-center"><span class="bg-white dark:bg-slate-900 px-3 text-[11px] uppercase tracking-wider text-slate-400">o</span></div>
|
|
214
|
+
</div>
|
|
215
|
+
<NuxtLink
|
|
216
|
+
v-for="other in otherMobileApps"
|
|
217
|
+
:key="other.path"
|
|
218
|
+
:to="other.loginPath"
|
|
219
|
+
class="w-full inline-flex items-center justify-center gap-2 py-2.5 px-3 rounded-lg border border-primary-200 dark:border-primary-800 bg-primary-50 dark:bg-primary-900/20 text-sm font-medium text-primary-700 dark:text-primary-300 hover:bg-primary-100 dark:hover:bg-primary-900/40 transition-colors"
|
|
220
|
+
>
|
|
221
|
+
Entrar como {{ other.label }}
|
|
222
|
+
</NuxtLink>
|
|
223
|
+
</div>
|
|
224
|
+
</div>
|
|
225
|
+
</template>
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Panel marketing del auth layout — typewriter + tagline + footer con items.
|
|
4
|
+
*
|
|
5
|
+
* Lee contenido desde `appConfig.innertia.marketing`. Si no hay contenido configurado,
|
|
6
|
+
* renderiza vacío para que el producto pueda llenarlo via slots del layout o no mostrarlo.
|
|
7
|
+
*
|
|
8
|
+
* Colores: usa --primary (configurable via appConfig.innertia.colors.primary).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const appConfig = useAppConfig()
|
|
12
|
+
const marketing = computed(() => appConfig.innertia?.marketing ?? {
|
|
13
|
+
words: [],
|
|
14
|
+
tagline: '',
|
|
15
|
+
description: '',
|
|
16
|
+
footer: { title: '', items: [], description: '' },
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
const hasContent = computed(() =>
|
|
20
|
+
(marketing.value.words ?? []).length > 0 ||
|
|
21
|
+
!!marketing.value.tagline ||
|
|
22
|
+
!!marketing.value.description ||
|
|
23
|
+
!!marketing.value.footer?.title
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
// ── Typewriter ─────────────────────────────────────────────────────────────
|
|
27
|
+
const typed = ref('')
|
|
28
|
+
const cursor = ref(true)
|
|
29
|
+
let wordIdx = 0
|
|
30
|
+
let charIdx = 0
|
|
31
|
+
let deleting = false
|
|
32
|
+
let typeTimeout: ReturnType<typeof setTimeout> | null = null
|
|
33
|
+
let cursorInterval: ReturnType<typeof setInterval> | null = null
|
|
34
|
+
|
|
35
|
+
function typeStep() {
|
|
36
|
+
const words = marketing.value.words ?? []
|
|
37
|
+
if (words.length === 0) return
|
|
38
|
+
|
|
39
|
+
const word = words[wordIdx]
|
|
40
|
+
if (!deleting) {
|
|
41
|
+
charIdx++
|
|
42
|
+
typed.value = word.slice(0, charIdx)
|
|
43
|
+
if (charIdx === word.length) {
|
|
44
|
+
deleting = true
|
|
45
|
+
typeTimeout = setTimeout(typeStep, 1600)
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
typeTimeout = setTimeout(typeStep, 90)
|
|
49
|
+
} else {
|
|
50
|
+
charIdx--
|
|
51
|
+
typed.value = word.slice(0, charIdx)
|
|
52
|
+
if (charIdx === 0) {
|
|
53
|
+
deleting = false
|
|
54
|
+
wordIdx = (wordIdx + 1) % words.length
|
|
55
|
+
typeTimeout = setTimeout(typeStep, 300)
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
typeTimeout = setTimeout(typeStep, 50)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
onMounted(() => {
|
|
63
|
+
if ((marketing.value.words ?? []).length > 0) {
|
|
64
|
+
typeStep()
|
|
65
|
+
cursorInterval = setInterval(() => { cursor.value = !cursor.value }, 530)
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
onUnmounted(() => {
|
|
70
|
+
if (typeTimeout) clearTimeout(typeTimeout)
|
|
71
|
+
if (cursorInterval) clearInterval(cursorInterval)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
// ── Mouse glow ─────────────────────────────────────────────────────────────
|
|
75
|
+
const mouseX = ref(50)
|
|
76
|
+
const mouseY = ref(50)
|
|
77
|
+
const glowVisible = ref(false)
|
|
78
|
+
|
|
79
|
+
function onMouseMove(e: MouseEvent) {
|
|
80
|
+
const target = e.currentTarget as HTMLElement
|
|
81
|
+
const rect = target.getBoundingClientRect()
|
|
82
|
+
mouseX.value = ((e.clientX - rect.left) / rect.width) * 100
|
|
83
|
+
mouseY.value = ((e.clientY - rect.top) / rect.height) * 100
|
|
84
|
+
glowVisible.value = true
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function onMouseLeave() {
|
|
88
|
+
glowVisible.value = false
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const dotMaskStyle = computed(() => ({
|
|
92
|
+
opacity: glowVisible.value ? 1 : 0,
|
|
93
|
+
backgroundImage: 'radial-gradient(circle, var(--primary-300, rgba(196,181,253,0.55)) 1.5px, transparent 1.5px)',
|
|
94
|
+
backgroundSize: '22px 22px',
|
|
95
|
+
WebkitMaskImage: `radial-gradient(circle 180px at ${mouseX.value}% ${mouseY.value}%, black 0%, transparent 70%)`,
|
|
96
|
+
maskImage: `radial-gradient(circle 180px at ${mouseX.value}% ${mouseY.value}%, black 0%, transparent 70%)`,
|
|
97
|
+
transition: 'opacity 0.5s ease',
|
|
98
|
+
}))
|
|
99
|
+
|
|
100
|
+
// Tagline puede tener \n para saltos de línea
|
|
101
|
+
const taglineLines = computed(() => (marketing.value.tagline ?? '').split('\n'))
|
|
102
|
+
</script>
|
|
103
|
+
|
|
104
|
+
<template>
|
|
105
|
+
<div
|
|
106
|
+
class="hidden lg:flex lg:w-[42%] xl:w-[38%] bg-primary-50 dark:bg-slate-950 flex-col justify-between p-10 relative overflow-hidden shrink-0"
|
|
107
|
+
@mousemove="onMouseMove"
|
|
108
|
+
@mouseleave="onMouseLeave"
|
|
109
|
+
>
|
|
110
|
+
<!-- Decoración de fondo -->
|
|
111
|
+
<div class="absolute inset-0 pointer-events-none">
|
|
112
|
+
<!-- Cuadrícula de puntos base — light mode -->
|
|
113
|
+
<div
|
|
114
|
+
class="absolute inset-0 dark:hidden"
|
|
115
|
+
:style="{
|
|
116
|
+
backgroundImage: 'radial-gradient(circle, var(--primary-900, rgba(76,29,149,0.5)) 1px, transparent 1px)',
|
|
117
|
+
backgroundSize: '22px 22px',
|
|
118
|
+
opacity: 0.18,
|
|
119
|
+
}"
|
|
120
|
+
/>
|
|
121
|
+
<!-- Cuadrícula de puntos base — dark mode -->
|
|
122
|
+
<div
|
|
123
|
+
class="absolute inset-0 hidden dark:block"
|
|
124
|
+
style="background-image: radial-gradient(circle, rgba(255,255,255,0.5) 1px, transparent 1px); background-size: 22px 22px; opacity: 0.18;"
|
|
125
|
+
/>
|
|
126
|
+
|
|
127
|
+
<!-- Cuadrícula brillante enmascarada al cursor -->
|
|
128
|
+
<div class="absolute inset-0" :style="dotMaskStyle" />
|
|
129
|
+
|
|
130
|
+
<!-- Blobs estáticos -->
|
|
131
|
+
<div class="absolute -top-32 -left-32 size-96 rounded-full bg-primary-400/25 dark:bg-primary-600/10 blur-3xl" />
|
|
132
|
+
<div class="absolute bottom-0 right-0 size-80 rounded-full bg-primary-500/20 dark:bg-primary-800/10 blur-3xl" />
|
|
133
|
+
</div>
|
|
134
|
+
|
|
135
|
+
<!-- Logo (slot opcional, default: logos del /public del producto) -->
|
|
136
|
+
<div class="relative z-10">
|
|
137
|
+
<slot name="logo">
|
|
138
|
+
<img src="/isologo-light.png" :alt="appConfig.innertia?.branding?.name ?? 'Logo'" class="h-8 dark:hidden" />
|
|
139
|
+
<img src="/isologo-dark.png" :alt="appConfig.innertia?.branding?.name ?? 'Logo'" class="h-8 hidden dark:block" />
|
|
140
|
+
</slot>
|
|
141
|
+
</div>
|
|
142
|
+
|
|
143
|
+
<!-- Tagline + typewriter -->
|
|
144
|
+
<div v-if="hasContent" class="relative z-10 space-y-5">
|
|
145
|
+
<h2 class="text-4xl xl:text-5xl font-bold text-slate-900 dark:text-white leading-snug">
|
|
146
|
+
<template v-if="(marketing.words ?? []).length > 0">
|
|
147
|
+
<span class="text-primary-700 dark:text-primary-300">
|
|
148
|
+
{{ typed }}<span
|
|
149
|
+
:class="cursor ? 'opacity-60' : 'opacity-0'"
|
|
150
|
+
class="inline-block w-px h-[0.85em] bg-primary-700/60 dark:bg-primary-300/60 align-middle ml-0.5 transition-opacity duration-100"
|
|
151
|
+
/>
|
|
152
|
+
</span><br />
|
|
153
|
+
</template>
|
|
154
|
+
<template v-for="(line, i) in taglineLines" :key="i">
|
|
155
|
+
{{ line }}<br v-if="i < taglineLines.length - 1" />
|
|
156
|
+
</template>
|
|
157
|
+
</h2>
|
|
158
|
+
<p v-if="marketing.description" class="text-slate-700 dark:text-slate-400 text-sm leading-relaxed max-w-xs">
|
|
159
|
+
{{ marketing.description }}
|
|
160
|
+
</p>
|
|
161
|
+
</div>
|
|
162
|
+
|
|
163
|
+
<!-- Footer items -->
|
|
164
|
+
<div v-if="marketing.footer?.title || (marketing.footer?.items ?? []).length > 0" class="relative z-10 space-y-3">
|
|
165
|
+
<p v-if="marketing.footer.title" class="text-xs font-medium text-slate-600 dark:text-slate-500 uppercase tracking-widest">
|
|
166
|
+
{{ marketing.footer.title }}
|
|
167
|
+
</p>
|
|
168
|
+
<div v-if="(marketing.footer.items ?? []).length > 0" class="flex flex-wrap items-center gap-2">
|
|
169
|
+
<span
|
|
170
|
+
v-for="item in marketing.footer.items"
|
|
171
|
+
:key="item"
|
|
172
|
+
class="inline-flex items-center rounded-md border border-slate-900/10 bg-white/40 px-2.5 py-1 text-xs font-medium text-slate-700 dark:border-white/10 dark:bg-white/5 dark:text-slate-400"
|
|
173
|
+
>{{ item }}</span>
|
|
174
|
+
</div>
|
|
175
|
+
<p v-if="marketing.footer.description" class="text-xs text-slate-600 dark:text-slate-600 leading-relaxed">
|
|
176
|
+
{{ marketing.footer.description }}
|
|
177
|
+
</p>
|
|
178
|
+
</div>
|
|
179
|
+
</div>
|
|
180
|
+
</template>
|
package/layouts/auth.vue
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Layout para flujos de autenticación (login, recovery, register).
|
|
4
|
+
*
|
|
5
|
+
* Estructura:
|
|
6
|
+
* [Panel marketing (lg+ only)] [Form area: header (theme switch) | slot | footer (© + version)]
|
|
7
|
+
*
|
|
8
|
+
* Configurable via:
|
|
9
|
+
* - `appConfig.innertia.branding.name` y `branding.version` (footer)
|
|
10
|
+
* - `appConfig.innertia.marketing.*` (panel izquierdo — typewriter, tagline, footer)
|
|
11
|
+
* - `appConfig.innertia.colors.primary` (color de acentos)
|
|
12
|
+
*
|
|
13
|
+
* Slots disponibles:
|
|
14
|
+
* - `marketing` — sobreescribe el panel izquierdo entero
|
|
15
|
+
* - `logo` — sobreescribe el logo del topbar mobile (default: /isologo-{light,dark}.png)
|
|
16
|
+
* - default — contenido del formulario (login/recovery/register)
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
onMounted(() => {
|
|
20
|
+
if (typeof sessionStorage !== 'undefined') sessionStorage.removeItem('auth-entered')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
const appConfig = useAppConfig()
|
|
24
|
+
const branding = computed(() => appConfig.innertia?.branding ?? { name: 'Innertia', version: '' })
|
|
25
|
+
</script>
|
|
26
|
+
|
|
27
|
+
<template>
|
|
28
|
+
<div class="min-h-screen flex bg-white dark:bg-slate-900">
|
|
29
|
+
|
|
30
|
+
<!-- ── Columna izquierda: marketing panel ─────────────────────────────── -->
|
|
31
|
+
<slot name="marketing">
|
|
32
|
+
<AuthMarketingPanel />
|
|
33
|
+
</slot>
|
|
34
|
+
|
|
35
|
+
<!-- ── Columna derecha: formulario ────────────────────────────────────── -->
|
|
36
|
+
<div class="flex flex-1 flex-col">
|
|
37
|
+
|
|
38
|
+
<!-- Barra superior -->
|
|
39
|
+
<div class="relative flex items-center justify-end px-6 pt-5">
|
|
40
|
+
<!-- Logo centrado en mobile (oculto en lg+) -->
|
|
41
|
+
<div class="lg:hidden absolute inset-x-0 px-6 flex justify-center pointer-events-none">
|
|
42
|
+
<div class="w-full max-w-sm flex">
|
|
43
|
+
<NuxtLink to="/" class="pointer-events-auto">
|
|
44
|
+
<slot name="logo">
|
|
45
|
+
<img src="/isologo-light.png" :alt="branding.name" class="h-7 dark:hidden" />
|
|
46
|
+
<img src="/isologo-dark.png" :alt="branding.name" class="h-7 hidden dark:block" />
|
|
47
|
+
</slot>
|
|
48
|
+
</NuxtLink>
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
<AppSwitchColorTheme />
|
|
52
|
+
</div>
|
|
53
|
+
|
|
54
|
+
<!-- Contenido del formulario -->
|
|
55
|
+
<div class="flex flex-1 items-center justify-center px-6 py-10">
|
|
56
|
+
<div class="w-full max-w-sm">
|
|
57
|
+
<Transition name="auth-content" mode="out-in">
|
|
58
|
+
<slot />
|
|
59
|
+
</Transition>
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
<!-- Pie de página: © {nombre} - v{version} -->
|
|
64
|
+
<div class="flex items-center justify-end gap-x-2 px-6 pb-5">
|
|
65
|
+
<span class="text-xs text-slate-400 dark:text-slate-600">
|
|
66
|
+
© {{ new Date().getFullYear() }} {{ branding.name }}
|
|
67
|
+
</span>
|
|
68
|
+
<template v-if="branding.version">
|
|
69
|
+
<span class="text-xs text-slate-300 dark:text-slate-700">-</span>
|
|
70
|
+
<span class="text-xs text-slate-400 dark:text-slate-600">v{{ branding.version }}</span>
|
|
71
|
+
</template>
|
|
72
|
+
</div>
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
</template>
|
|
76
|
+
|
|
77
|
+
<style>
|
|
78
|
+
.auth-content-enter-active,
|
|
79
|
+
.auth-content-leave-active {
|
|
80
|
+
transition: opacity 200ms ease, transform 200ms ease;
|
|
81
|
+
}
|
|
82
|
+
.auth-content-enter-from,
|
|
83
|
+
.auth-content-leave-to {
|
|
84
|
+
opacity: 0;
|
|
85
|
+
transform: translateY(6px);
|
|
86
|
+
}
|
|
87
|
+
</style>
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Layout backoffice estándar — topbar oscuro con logo + menú + user dropdown.
|
|
4
|
+
*
|
|
5
|
+
* Configurable via `appConfig.innertia.menu` (items principales) y
|
|
6
|
+
* `appConfig.innertia.menuApps` (apps secundarias, opcional para layouts dobles).
|
|
7
|
+
*
|
|
8
|
+
* Slots disponibles:
|
|
9
|
+
* - `search` — contenido del centro del topbar (ej. buscador AI). Default: vacío.
|
|
10
|
+
* - `logo` — sobreescribe el logo. Default: /isologo-{light,dark}.png + link a home del primer contexto.
|
|
11
|
+
* - default — contenido principal de la página.
|
|
12
|
+
*/
|
|
13
|
+
import * as icons from '@tabler/icons-vue'
|
|
14
|
+
|
|
15
|
+
interface MenuItem {
|
|
16
|
+
label: string
|
|
17
|
+
icon: string
|
|
18
|
+
route: string
|
|
19
|
+
pattern?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const { logout } = useAuth()
|
|
23
|
+
const authStore = useAuthStore()
|
|
24
|
+
const { isDark, toggle } = useTheme()
|
|
25
|
+
const { docked } = useDockedPreviews()
|
|
26
|
+
const route = useRoute()
|
|
27
|
+
const appConfig = useAppConfig()
|
|
28
|
+
|
|
29
|
+
const branding = computed(() => appConfig.innertia?.branding ?? { name: 'Innertia' })
|
|
30
|
+
const menuItems = computed<MenuItem[]>(() => (appConfig.innertia?.menu ?? []) as MenuItem[])
|
|
31
|
+
const menuApps = computed<MenuItem[]>(() => (appConfig.innertia?.menuApps ?? []) as MenuItem[])
|
|
32
|
+
|
|
33
|
+
const matchPattern = (pattern: string | undefined, fallback: string) => {
|
|
34
|
+
const p = pattern || fallback
|
|
35
|
+
if (!p) return false
|
|
36
|
+
const regex = new RegExp('^' + p.replace(/\*/g, '.*') + '$')
|
|
37
|
+
return regex.test(route.path)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const mobileOpen = ref(false)
|
|
41
|
+
watch(() => route.path, () => { mobileOpen.value = false })
|
|
42
|
+
|
|
43
|
+
const userInitial = computed(() =>
|
|
44
|
+
(authStore.user as any)?.name?.charAt(0)?.toUpperCase()
|
|
45
|
+
?? (authStore.user as any)?.email?.charAt(0)?.toUpperCase()
|
|
46
|
+
?? 'U'
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
// Default home — primer item del menu o '/'
|
|
50
|
+
const homeRoute = computed(() => menuItems.value[0]?.route ?? '/')
|
|
51
|
+
</script>
|
|
52
|
+
|
|
53
|
+
<template>
|
|
54
|
+
<div class="relative bg-background min-h-screen">
|
|
55
|
+
<!-- Dot pattern background -->
|
|
56
|
+
<div
|
|
57
|
+
class="fixed inset-0 pointer-events-none dark:hidden"
|
|
58
|
+
style="background-image: radial-gradient(circle, rgba(0,0,0,0.07) 1px, transparent 1px); background-size: 20px 20px; opacity: 0.5;"
|
|
59
|
+
/>
|
|
60
|
+
<div
|
|
61
|
+
class="fixed inset-0 pointer-events-none hidden dark:block"
|
|
62
|
+
style="background-image: radial-gradient(circle, rgba(255,255,255,0.04) 1px, transparent 1px); background-size: 20px 20px;"
|
|
63
|
+
/>
|
|
64
|
+
|
|
65
|
+
<!-- ========== HEADER ========== -->
|
|
66
|
+
<header class="flex flex-col z-50 sticky top-0">
|
|
67
|
+
|
|
68
|
+
<!-- Top bar -->
|
|
69
|
+
<div class="relative overflow-hidden bg-navbar-inverse border-b border-navbar-divider">
|
|
70
|
+
<div class="max-w-[85rem] flex justify-between lg:grid lg:grid-cols-3 basis-full items-center w-full mx-auto py-1.5 px-4 sm:px-6 lg:px-8">
|
|
71
|
+
|
|
72
|
+
<!-- Left: Logo + mobile hamburger -->
|
|
73
|
+
<div class="flex items-center gap-x-3">
|
|
74
|
+
<slot name="logo">
|
|
75
|
+
<NuxtLink :to="homeRoute" class="flex-none ml-1">
|
|
76
|
+
<img src="/isologo-light.png" class="h-6 w-auto dark:hidden" :alt="branding.name" />
|
|
77
|
+
<img src="/isologo-dark.png" class="h-6 w-auto hidden dark:block" :alt="branding.name" />
|
|
78
|
+
</NuxtLink>
|
|
79
|
+
</slot>
|
|
80
|
+
<button
|
|
81
|
+
type="button"
|
|
82
|
+
class="lg:hidden inline-flex justify-center items-center size-9 rounded-lg text-foreground-inverse hover:bg-plain/10 focus:outline-hidden"
|
|
83
|
+
@click="mobileOpen = !mobileOpen"
|
|
84
|
+
:aria-expanded="mobileOpen"
|
|
85
|
+
aria-label="Toggle navigation"
|
|
86
|
+
>
|
|
87
|
+
<icons.IconMenu2 v-if="!mobileOpen" class="size-4 shrink-0" />
|
|
88
|
+
<icons.IconX v-else class="size-4 shrink-0" />
|
|
89
|
+
</button>
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
<!-- Center: slot custom (ej. buscador AI) -->
|
|
93
|
+
<div class="hidden lg:flex justify-center">
|
|
94
|
+
<slot name="search" />
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
<!-- Right: theme toggle + alerts + user -->
|
|
98
|
+
<div class="flex items-center justify-end gap-x-1">
|
|
99
|
+
|
|
100
|
+
<button
|
|
101
|
+
type="button"
|
|
102
|
+
class="inline-flex justify-center items-center size-9 rounded-full text-foreground-inverse hover:bg-plain/10 focus:outline-hidden"
|
|
103
|
+
@click="toggle"
|
|
104
|
+
:title="isDark ? 'Modo claro' : 'Modo oscuro'"
|
|
105
|
+
>
|
|
106
|
+
<icons.IconSun v-if="isDark" class="size-4 shrink-0" />
|
|
107
|
+
<icons.IconMoon v-else class="size-4 shrink-0" />
|
|
108
|
+
</button>
|
|
109
|
+
|
|
110
|
+
<slot name="topbar-actions" />
|
|
111
|
+
|
|
112
|
+
<div class="hidden sm:block w-px h-6 bg-navbar-divider/20 mx-1" />
|
|
113
|
+
|
|
114
|
+
<!-- User dropdown -->
|
|
115
|
+
<div class="hs-dropdown [--placement:bottom-right] relative inline-flex">
|
|
116
|
+
<button
|
|
117
|
+
type="button"
|
|
118
|
+
class="inline-flex shrink-0 items-center rounded-full focus:outline-hidden"
|
|
119
|
+
aria-haspopup="menu"
|
|
120
|
+
aria-label="Cuenta"
|
|
121
|
+
>
|
|
122
|
+
<div class="size-9 rounded-full bg-primary flex items-center justify-center text-primary-foreground text-sm font-bold select-none">
|
|
123
|
+
{{ userInitial }}
|
|
124
|
+
</div>
|
|
125
|
+
</button>
|
|
126
|
+
|
|
127
|
+
<div
|
|
128
|
+
class="hs-dropdown-menu hs-dropdown-open:opacity-100 w-60 transition-[opacity,margin] opacity-0 hidden z-20 bg-dropdown border border-dropdown-line rounded-xl shadow-xl"
|
|
129
|
+
role="menu"
|
|
130
|
+
>
|
|
131
|
+
<div class="p-1 border-b border-dropdown-divider">
|
|
132
|
+
<div class="py-2 px-3 flex items-center gap-x-3">
|
|
133
|
+
<div class="size-8 rounded-full bg-primary flex items-center justify-center text-primary-foreground text-sm font-bold shrink-0">
|
|
134
|
+
{{ userInitial }}
|
|
135
|
+
</div>
|
|
136
|
+
<div class="grow min-w-0">
|
|
137
|
+
<p class="text-sm font-semibold text-foreground truncate">
|
|
138
|
+
{{ (authStore.user as any)?.name ?? (authStore.user as any)?.email }}
|
|
139
|
+
</p>
|
|
140
|
+
<p v-if="(authStore.user as any)?.name && (authStore.user as any)?.email" class="text-xs text-muted-foreground truncate">
|
|
141
|
+
{{ (authStore.user as any).email }}
|
|
142
|
+
</p>
|
|
143
|
+
</div>
|
|
144
|
+
</div>
|
|
145
|
+
</div>
|
|
146
|
+
<div class="p-1">
|
|
147
|
+
<button
|
|
148
|
+
type="button"
|
|
149
|
+
class="w-full flex items-center gap-x-3 py-2 px-3 rounded-lg text-sm text-dropdown-item-foreground hover:bg-dropdown-item-hover focus:outline-hidden"
|
|
150
|
+
@click="logout"
|
|
151
|
+
>
|
|
152
|
+
<icons.IconLogout class="shrink-0 size-4" />
|
|
153
|
+
Cerrar sesión
|
|
154
|
+
</button>
|
|
155
|
+
</div>
|
|
156
|
+
</div>
|
|
157
|
+
</div>
|
|
158
|
+
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
</div>
|
|
162
|
+
|
|
163
|
+
<!-- Secondary navbar (menu items + app items) -->
|
|
164
|
+
<nav v-if="menuItems.length > 0 || menuApps.length > 0" class="bg-navbar border-b border-navbar-line">
|
|
165
|
+
<div class="max-w-[85rem] w-full mx-auto px-4 sm:px-6 lg:px-8">
|
|
166
|
+
<div
|
|
167
|
+
class="overflow-hidden transition-all duration-300 lg:block"
|
|
168
|
+
:class="mobileOpen ? 'block' : 'hidden'"
|
|
169
|
+
>
|
|
170
|
+
<div class="flex flex-col lg:flex-row lg:items-center lg:gap-x-1 py-1.5 space-y-0.5 lg:space-y-0">
|
|
171
|
+
<NuxtLink
|
|
172
|
+
v-for="item in menuItems"
|
|
173
|
+
:key="item.route"
|
|
174
|
+
:to="item.route"
|
|
175
|
+
class="py-1.5 px-2.5 flex items-center gap-x-2 text-[13px] text-nowrap text-navbar-nav-foreground rounded-lg hover:bg-navbar-nav-hover focus:outline-hidden"
|
|
176
|
+
:class="{ 'bg-navbar-nav-active': matchPattern(item.pattern, item.route) }"
|
|
177
|
+
>
|
|
178
|
+
<component :is="(icons as any)[item.icon]" class="shrink-0 size-4" />
|
|
179
|
+
{{ item.label }}
|
|
180
|
+
</NuxtLink>
|
|
181
|
+
<NuxtLink
|
|
182
|
+
v-for="item in menuApps"
|
|
183
|
+
:key="item.route"
|
|
184
|
+
:to="item.route"
|
|
185
|
+
class="py-1.5 px-2.5 flex items-center gap-x-2 text-[13px] text-nowrap text-navbar-nav-foreground rounded-lg hover:bg-navbar-nav-hover focus:outline-hidden"
|
|
186
|
+
:class="{ 'bg-navbar-nav-active': matchPattern(item.pattern, item.route + '/*') }"
|
|
187
|
+
>
|
|
188
|
+
<component :is="(icons as any)[item.icon]" class="shrink-0 size-4" />
|
|
189
|
+
{{ item.label }}
|
|
190
|
+
</NuxtLink>
|
|
191
|
+
</div>
|
|
192
|
+
</div>
|
|
193
|
+
</div>
|
|
194
|
+
</nav>
|
|
195
|
+
|
|
196
|
+
</header>
|
|
197
|
+
|
|
198
|
+
<!-- Main content -->
|
|
199
|
+
<main
|
|
200
|
+
class="max-w-[85rem] w-full mx-auto px-4 sm:px-6 lg:px-8 py-4 transition-[padding]"
|
|
201
|
+
:class="docked.length ? 'pb-20' : ''"
|
|
202
|
+
>
|
|
203
|
+
<slot />
|
|
204
|
+
</main>
|
|
205
|
+
|
|
206
|
+
<!-- Preview dock (previews minimizados de tablas) -->
|
|
207
|
+
<AppPreviewDock />
|
|
208
|
+
</div>
|
|
209
|
+
</template>
|
package/package.json
CHANGED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { ColorOption, ColorScale } from '../app.config'
|
|
2
|
+
|
|
3
|
+
const SCALE_LEVELS = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950] as const
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Aplica las CSS vars `--primary-{50..950}` y `--secondary-{50..950}` desde
|
|
7
|
+
* `appConfig.innertia.colors`. Funciona en SSR (inyecta <style> en head) y cliente.
|
|
8
|
+
*
|
|
9
|
+
* Acepta:
|
|
10
|
+
* - Nombre Tailwind: 'violet' → mapea a var(--color-violet-50..950)
|
|
11
|
+
* - Scale custom: { 50: '#f5f3ff', ..., 950: '#2e1065' }
|
|
12
|
+
*
|
|
13
|
+
* También deriva las vars semánticas `--primary`, `--primary-hover`, `--primary-foreground`,
|
|
14
|
+
* etc., usando el nivel 600 (default) o el más cercano en una scale custom.
|
|
15
|
+
*/
|
|
16
|
+
export default defineNuxtPlugin(() => {
|
|
17
|
+
const appConfig = useAppConfig()
|
|
18
|
+
const colors = appConfig.innertia?.colors as { primary?: ColorOption; secondary?: ColorOption } | undefined
|
|
19
|
+
if (!colors) return
|
|
20
|
+
|
|
21
|
+
const css = buildColorCSS(colors)
|
|
22
|
+
if (!css) return
|
|
23
|
+
|
|
24
|
+
useHead({
|
|
25
|
+
style: [
|
|
26
|
+
{
|
|
27
|
+
// Marcamos como innertia para poder identificarla en debug
|
|
28
|
+
id: 'innertia-colors',
|
|
29
|
+
children: css,
|
|
30
|
+
},
|
|
31
|
+
],
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
function buildColorCSS(colors: { primary?: ColorOption; secondary?: ColorOption }): string {
|
|
36
|
+
const lines: string[] = [':root {']
|
|
37
|
+
|
|
38
|
+
if (colors.primary !== undefined) {
|
|
39
|
+
lines.push(...buildRoleVars('primary', colors.primary))
|
|
40
|
+
}
|
|
41
|
+
if (colors.secondary !== undefined) {
|
|
42
|
+
lines.push(...buildRoleVars('secondary', colors.secondary))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
lines.push('}')
|
|
46
|
+
return lines.join('\n')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function buildRoleVars(role: 'primary' | 'secondary', value: ColorOption): string[] {
|
|
50
|
+
const out: string[] = []
|
|
51
|
+
|
|
52
|
+
if (typeof value === 'string') {
|
|
53
|
+
// Tailwind color name → referenciar las vars que Tailwind ya define
|
|
54
|
+
const tone = value
|
|
55
|
+
for (const lvl of SCALE_LEVELS) {
|
|
56
|
+
out.push(` --${role}-${lvl}: var(--color-${tone}-${lvl});`)
|
|
57
|
+
}
|
|
58
|
+
// Semantic tokens — niveles default 600 (base), 700 (hover/focus/active)
|
|
59
|
+
out.push(` --${role}: var(--color-${tone}-600);`)
|
|
60
|
+
out.push(` --${role}-hover: var(--color-${tone}-700);`)
|
|
61
|
+
out.push(` --${role}-focus: var(--color-${tone}-700);`)
|
|
62
|
+
out.push(` --${role}-active: var(--color-${tone}-800);`)
|
|
63
|
+
out.push(` --${role}-checked: var(--color-${tone}-600);`)
|
|
64
|
+
out.push(` --${role}-foreground: #ffffff;`)
|
|
65
|
+
} else {
|
|
66
|
+
// Scale custom — usar los valores explícitos
|
|
67
|
+
const scale = value as ColorScale
|
|
68
|
+
for (const lvl of SCALE_LEVELS) {
|
|
69
|
+
const hex = scale[lvl]
|
|
70
|
+
if (hex) out.push(` --${role}-${lvl}: ${hex};`)
|
|
71
|
+
}
|
|
72
|
+
if (scale[600]) out.push(` --${role}: ${scale[600]};`)
|
|
73
|
+
if (scale[700]) out.push(` --${role}-hover: ${scale[700]};`)
|
|
74
|
+
if (scale[700]) out.push(` --${role}-focus: ${scale[700]};`)
|
|
75
|
+
if (scale[800]) out.push(` --${role}-active: ${scale[800]};`)
|
|
76
|
+
if (scale[600]) out.push(` --${role}-checked: ${scale[600]};`)
|
|
77
|
+
out.push(` --${role}-foreground: #ffffff;`)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return out
|
|
81
|
+
}
|