@innertia-solutions/innertia-nuxt 0.1.1

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 (108) hide show
  1. package/.github/workflows/auto-publish.yml +64 -0
  2. package/.github/workflows/release.yml +59 -0
  3. package/README.md +60 -0
  4. package/app.config.ts +70 -0
  5. package/components/Admin/Base.vue +144 -0
  6. package/components/Admin/Header.vue +32 -0
  7. package/components/Admin/Page.vue +65 -0
  8. package/components/Admin/PageHeader.vue +31 -0
  9. package/components/App/Button.vue +59 -0
  10. package/components/App/DevEnvironmentBar.vue +43 -0
  11. package/components/App/Dropdown.vue +286 -0
  12. package/components/App/EmptyState.vue +433 -0
  13. package/components/App/LoadingState.vue +40 -0
  14. package/components/App/PageLoadingSpinner.vue +118 -0
  15. package/components/App/PreviewDock.vue +64 -0
  16. package/components/App/SwitchColorTheme.vue +51 -0
  17. package/components/App/Tag.vue +193 -0
  18. package/components/DataTable.vue +713 -0
  19. package/components/Forms/DatePicker.vue +255 -0
  20. package/components/Forms/Input.vue +75 -0
  21. package/components/Forms/Select.vue +100 -0
  22. package/components/Forms/SelectServer.vue +726 -0
  23. package/components/Layout/Admin.vue +32 -0
  24. package/components/Layout/Auth.vue +29 -0
  25. package/components/Layout/SidebarWithAppColumn.vue +388 -0
  26. package/components/Layout/TopBar.vue +113 -0
  27. package/components/MobileBlocker.vue +85 -0
  28. package/components/MobileLoginPicker.vue +83 -0
  29. package/components/Modal/Base.vue +29 -0
  30. package/components/Modal/DeleteConfirm.vue +48 -0
  31. package/components/Modal.vue +103 -0
  32. package/components/Nav/Tabs.vue +55 -0
  33. package/components/PermissionsTree.vue +272 -0
  34. package/components/Table/Database.vue +183 -0
  35. package/components/Table/DownloadDropdown.vue +111 -0
  36. package/components/Table/Enterprise.vue +540 -0
  37. package/components/Table/FilterDropdown.vue +226 -0
  38. package/components/Table/Grid.vue +62 -0
  39. package/components/Table/Kanban.vue +188 -0
  40. package/components/Table/List.vue +128 -0
  41. package/components/Table/PreviewTimeline.vue +118 -0
  42. package/components/Table/Standard.vue +1217 -0
  43. package/components/Table/index.vue +974 -0
  44. package/components/TableExportable.vue +172 -0
  45. package/components/TableFilter.vue +93 -0
  46. package/components/Toast/Alert.vue +113 -0
  47. package/components/Toast/Container.vue +34 -0
  48. package/components/Toast/Notification.vue +45 -0
  49. package/components/Toast/Process.vue +88 -0
  50. package/composables/useApi.js +95 -0
  51. package/composables/useApp.ts +46 -0
  52. package/composables/useAuth.js +82 -0
  53. package/composables/useContext.js +44 -0
  54. package/composables/useDate.js +241 -0
  55. package/composables/useDevice.js +21 -0
  56. package/composables/useDockedPreviews.js +56 -0
  57. package/composables/useDownload.js +87 -0
  58. package/composables/useEntity.js +82 -0
  59. package/composables/useForm.js +119 -0
  60. package/composables/useInnertiaMode.ts +25 -0
  61. package/composables/useMobileGuard.ts +81 -0
  62. package/composables/useNotifications.js +22 -0
  63. package/composables/usePermissions.js +23 -0
  64. package/composables/useRealtime.js +123 -0
  65. package/composables/useRequestInterceptors.js +27 -0
  66. package/composables/useRoles.js +53 -0
  67. package/composables/useRutFormatter.js +39 -0
  68. package/composables/useTable.ts +94 -0
  69. package/composables/useTablePreferences.ts +33 -0
  70. package/composables/useTenant.js +27 -0
  71. package/composables/useTimeAgo.js +37 -0
  72. package/composables/useToast.js +69 -0
  73. package/composables/useUserRealtime.js +17 -0
  74. package/composables/useUsers.js +111 -0
  75. package/css/themes/autumn.css +401 -0
  76. package/css/themes/bubblegum.css +408 -0
  77. package/css/themes/cashmere.css +412 -0
  78. package/css/themes/harvest.css +416 -0
  79. package/css/themes/moon.css +140 -0
  80. package/css/themes/ocean.css +273 -0
  81. package/css/themes/olive.css +413 -0
  82. package/css/themes/retro.css +431 -0
  83. package/css/themes/theme.css +725 -0
  84. package/error.vue +78 -0
  85. package/middleware/01.detect-subdomain.global.ts +43 -0
  86. package/middleware/02.validate-tenant.global.ts +67 -0
  87. package/middleware/03.apps.global.ts +88 -0
  88. package/middleware/auth.ts +9 -0
  89. package/middleware/guest.ts +9 -0
  90. package/nuxt.config.ts +42 -0
  91. package/package.json +60 -0
  92. package/pages/tenant-error.vue +50 -0
  93. package/plugins/api-auth.ts +12 -0
  94. package/plugins/api-tenant.client.ts +21 -0
  95. package/plugins/appearance.ts +8 -0
  96. package/plugins/auth-init.ts +34 -0
  97. package/plugins/dark-state.client.ts +29 -0
  98. package/plugins/dockedPreviewsSync.client.js +17 -0
  99. package/plugins/preline.client.ts +68 -0
  100. package/plugins/theme.client.ts +7 -0
  101. package/plugins/vue-query.ts +29 -0
  102. package/public/init-theme.js +15 -0
  103. package/spark.css +721 -0
  104. package/stores/auth.js +130 -0
  105. package/stores/dockedPreviews.js +34 -0
  106. package/stores/notifications.js +24 -0
  107. package/stores/tenant.js +54 -0
  108. package/stores/toast.js +129 -0
package/error.vue ADDED
@@ -0,0 +1,78 @@
1
+ <script setup lang="ts">
2
+ definePageMeta({ layout: false })
3
+
4
+ const props = defineProps<{
5
+ error: {
6
+ statusCode: number
7
+ statusMessage?: string
8
+ message?: string
9
+ }
10
+ }>()
11
+
12
+ const config = {
13
+ 404: {
14
+ title: 'Página no encontrada',
15
+ description: 'La página que buscas no existe o fue movida.',
16
+ icon: '404',
17
+ },
18
+ 403: {
19
+ title: 'Acceso denegado',
20
+ description: 'No tienes permisos para ver esta página.',
21
+ icon: '403',
22
+ },
23
+ 500: {
24
+ title: 'Error del servidor',
25
+ description: 'Algo salió mal. Intenta nuevamente en unos momentos.',
26
+ icon: '500',
27
+ },
28
+ } as const
29
+
30
+ type KnownCode = keyof typeof config
31
+
32
+ const current = computed(() => {
33
+ const code = props.error.statusCode as KnownCode
34
+ return config[code] ?? {
35
+ title: 'Error inesperado',
36
+ description: props.error.statusMessage || props.error.message || 'Ocurrió un error desconocido.',
37
+ icon: String(props.error.statusCode),
38
+ }
39
+ })
40
+
41
+ function goBack() {
42
+ if (window.history.length > 1) {
43
+ window.history.back()
44
+ } else {
45
+ navigateTo('/')
46
+ }
47
+ }
48
+ </script>
49
+
50
+ <template>
51
+ <div class="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-900 px-4">
52
+ <div class="max-w-md w-full text-center space-y-4">
53
+ <div class="text-7xl font-bold text-slate-200 dark:text-slate-700 select-none">
54
+ {{ current.icon }}
55
+ </div>
56
+ <h1 class="text-2xl font-semibold text-slate-800 dark:text-white">
57
+ {{ current.title }}
58
+ </h1>
59
+ <p class="text-slate-500 dark:text-slate-400">
60
+ {{ current.description }}
61
+ </p>
62
+ <div class="flex items-center justify-center gap-3 pt-2">
63
+ <button
64
+ class="px-4 py-2 rounded-lg border border-slate-200 dark:border-slate-700 text-sm text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800"
65
+ @click="goBack"
66
+ >
67
+ Volver
68
+ </button>
69
+ <NuxtLink
70
+ to="/"
71
+ class="px-4 py-2 rounded-lg bg-slate-800 dark:bg-slate-700 text-white text-sm hover:bg-slate-700 dark:hover:bg-slate-600"
72
+ >
73
+ Ir al inicio
74
+ </NuxtLink>
75
+ </div>
76
+ </div>
77
+ </div>
78
+ </template>
@@ -0,0 +1,43 @@
1
+ // useTenantStore auto-imported from saas stores.
2
+ // Server-only: lee el hostname de la request para extraer el subdomain del tenant.
3
+ // Solo activo en mode === 'saas'.
4
+ export default defineNuxtRouteMiddleware((to) => {
5
+ // Modo no-saas → no hay tenant que detectar
6
+ if (!useInnertiaMode().hasTenant()) return
7
+
8
+ // Evitar loop infinito: si ya estamos en la página de error de tenant, no redirigir de nuevo.
9
+ if (to.path === '/tenant-error') return
10
+
11
+ if (!import.meta.server) return
12
+
13
+ const config = useRuntimeConfig()
14
+ const requestUrl = useRequestURL()
15
+ const hostname = requestUrl.hostname // ej. "acme.app.com" o "localhost"
16
+
17
+ const parts = hostname.split('.')
18
+
19
+ // Hostname bare (localhost, IP) sin subdominio → usar slug "local" en dev, error en prod
20
+ const isBareLocalhost = hostname === 'localhost' || /^\d+(\.\d+){3}$/.test(hostname)
21
+
22
+ if (isBareLocalhost) {
23
+ return navigateTo('/tenant-error?reason=no-subdomain')
24
+ }
25
+
26
+ // Hostname bare sin subdominio (ej. "tudominio.com") → sin tenant
27
+ if (parts.length < 2 || parts[0] === 'www' || /^\d+$/.test(parts[0])) {
28
+ return navigateTo('/tenant-error?reason=no-subdomain')
29
+ }
30
+
31
+ const subdomain = parts[0]
32
+
33
+ // Reservado: subdomain admin bypasea el flujo de tenant
34
+ if (subdomain === 'admin') {
35
+ useState('isAdminContext', () => false).value = true
36
+ return
37
+ }
38
+
39
+ // Guardar slug en useState (SSR-safe) y en tenantStore (Pinia)
40
+ useState<string>('tenantSlug', () => '').value = subdomain
41
+ const tenantStore = useTenantStore()
42
+ tenantStore.setSlug(subdomain)
43
+ })
@@ -0,0 +1,67 @@
1
+ // useTenantStore auto-imported.
2
+ // Server-only: valida el tenant via GET /ping con X-Tenant header.
3
+ // Usa la URL interna del backend (apiInternalUrl) para evitar pasar por el proxy de Nitro.
4
+ // Solo activo en mode === 'saas'.
5
+ export default defineNuxtRouteMiddleware(async (to) => {
6
+ // Modo no-saas → no hay tenant que validar
7
+ if (!useInnertiaMode().hasTenant()) return
8
+
9
+ if (!import.meta.server) return
10
+
11
+ // Rutas públicas que no requieren tenant válido
12
+ const publicRoutes = ['/tenant-error', '/404']
13
+ const isPublic =
14
+ publicRoutes.some(r => to.path.startsWith(r)) ||
15
+ to.path.startsWith('/auth/')
16
+
17
+ if (isPublic) return
18
+
19
+ // Skip en contexto admin
20
+ if (useState('isAdminContext', () => false).value) return
21
+
22
+ const tenantSlug = useState<string>('tenantSlug', () => '').value
23
+
24
+ console.log(`[tenant:validate] path=${to.path} slug="${tenantSlug}"`)
25
+
26
+ if (!tenantSlug) {
27
+ console.warn('[tenant:validate] sin slug → /tenant-error?reason=no-subdomain')
28
+ return navigateTo('/tenant-error?reason=no-subdomain')
29
+ }
30
+
31
+ // URL interna del backend (no pasa por el proxy Nitro — no existe en SSR)
32
+ const config = useRuntimeConfig()
33
+ const internalUrl = (config as any).apiInternalUrl || 'http://api:80'
34
+ const pingUrl = `${internalUrl}/ping`
35
+
36
+ console.log(`[tenant:validate] ping → ${pingUrl} (X-Tenant: ${tenantSlug})`)
37
+
38
+ try {
39
+ const data = await $fetch<{ ok: boolean; tenant: { id: number; status: string; config: any } }>(
40
+ pingUrl,
41
+ {
42
+ headers: { 'X-Tenant': tenantSlug, 'Accept': 'application/json' },
43
+ timeout: 5000,
44
+ }
45
+ )
46
+
47
+ console.log(`[tenant:validate] respuesta:`, JSON.stringify(data))
48
+
49
+ if (!data?.ok) {
50
+ console.warn('[tenant:validate] tenant inactivo → /tenant-error?reason=inactive')
51
+ return navigateTo('/tenant-error?reason=inactive')
52
+ }
53
+
54
+ const tenantStore = useTenantStore()
55
+ tenantStore.setTenant(data.tenant.id, data.tenant.config ?? {})
56
+ console.log(`[tenant:validate] OK — tenant id=${data.tenant.id} status=${data.tenant.status}`)
57
+ } catch (e: any) {
58
+ const status = e?.response?.status
59
+ if (status === 404) {
60
+ console.warn(`[tenant:validate] 404 tenant no encontrado → inactive`)
61
+ return navigateTo('/tenant-error?reason=inactive')
62
+ }
63
+ const reason = e?.message?.includes('timeout') ? 'timeout' : 'unreachable'
64
+ console.error(`[tenant:validate] error → ${reason}`, e?.message)
65
+ return navigateTo(`/tenant-error?reason=${reason}`)
66
+ }
67
+ })
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Middleware global que se ejecuta en cada navegación.
3
+ *
4
+ * Lee la declaración de apps desde `appConfig.innertia.apps`. Si el producto
5
+ * no declara apps, este middleware no hace nada (feature inactivo).
6
+ *
7
+ * Cuando hay apps declaradas, resuelve qué "app" (contexto) está intentando
8
+ * acceder el usuario según el prefijo de URL y:
9
+ * 1. Si la URL no cae en ningún app declarado → pasa (deja seguir)
10
+ * 2. Si la URL es la ruta de login del app → pasa (sin chequeo de permisos)
11
+ * 3. Si el usuario no está autenticado → deja que el middleware `auth` redirija
12
+ * 4. Si `availableContexts` está vacío (SSR sin hidratar) → intenta fetchMe()
13
+ * y si falla, pasa sin redirigir (mejor renderizar que redirigir mal)
14
+ * 5. Si el usuario está autenticado pero NO tiene acceso al contexto del app →
15
+ * redirige al primer app accesible (o a su loginPath si no tiene ninguno)
16
+ * 6. Si todo OK pero `currentContext !== app.context` → sincroniza el contexto
17
+ * en el authStore y recarga permisos vía fetchMe()
18
+ *
19
+ * Esto es lo que permite el "cambio de contexto implícito por URL" sin botón.
20
+ *
21
+ * Numeración 03. para correr DESPUÉS de los middlewares saas
22
+ * (01.detect-subdomain, 02.validate-tenant).
23
+ */
24
+ import type { AppDefinition } from '../app.config'
25
+
26
+ export default defineNuxtRouteMiddleware(async (to) => {
27
+ const appConfig = useAppConfig()
28
+ const apps = (appConfig.innertia?.apps ?? {}) as Record<string, AppDefinition>
29
+ const list = Object.values(apps)
30
+
31
+ // Si el producto no declaró apps, el feature está inactivo
32
+ if (list.length === 0) return
33
+
34
+ // ¿La ruta cae bajo algún app?
35
+ const targetApp = list.find(app =>
36
+ to.path === app.path || to.path.startsWith(app.path + '/')
37
+ )
38
+
39
+ if (!targetApp) return // ruta pública / fuera de apps
40
+
41
+ // La ruta de login del propio app no requiere permisos del contexto
42
+ if (to.path === targetApp.loginPath) return
43
+
44
+ const authStore = useAuthStore()
45
+
46
+ // Si no está autenticado, deja que el middleware `auth` de la página haga el redirect
47
+ if (!authStore.isAuthenticated()) return
48
+
49
+ // ── Hidratación defensiva ─────────────────────────────────────────────────
50
+ // `availableContexts` se persiste en localStorage, que NO se lee en SSR.
51
+ // El plugin auth-init hace fetchMe en SSR pero puede fallar silenciosamente
52
+ // (ej. tenant no resuelto). Si llegamos con token válido pero array vacío,
53
+ // intentamos cargar los contextos antes de decidir el redirect.
54
+ if ((authStore.availableContexts ?? []).length === 0) {
55
+ try {
56
+ const { fetchMe } = useAuth()
57
+ await fetchMe()
58
+ } catch {
59
+ // no podemos resolver — pasamos: mejor renderizar la página que redirigir mal
60
+ return
61
+ }
62
+ }
63
+
64
+ const userContexts = (authStore.availableContexts ?? []) as string[]
65
+
66
+ // Si tras hidratar seguimos sin contextos, NO redirigimos.
67
+ if (userContexts.length === 0) return
68
+
69
+ // ¿Tiene acceso al contexto del app?
70
+ if (!userContexts.includes(targetApp.context)) {
71
+ const fallback = list.find(app => userContexts.includes(app.context))
72
+ if (fallback) {
73
+ return navigateTo(fallback.home, { replace: true })
74
+ }
75
+ return navigateTo(list[0]?.loginPath ?? '/', { replace: true })
76
+ }
77
+
78
+ // Sincronizar currentContext silenciosamente si cambia
79
+ if (authStore.currentContext !== targetApp.context) {
80
+ authStore.setCurrentContext(targetApp.context)
81
+ try {
82
+ const { fetchMe } = useAuth()
83
+ await fetchMe()
84
+ } catch {
85
+ // best-effort
86
+ }
87
+ }
88
+ })
@@ -0,0 +1,9 @@
1
+ // Redirect unauthenticated users to login.
2
+ // useAuthStore auto-imported from nuxt-app stores.
3
+ export default defineNuxtRouteMiddleware(() => {
4
+ const authStore = useAuthStore()
5
+ const config = useRuntimeConfig()
6
+ if (!authStore.isAuthenticated()) {
7
+ return navigateTo(config.public.loginPath || '/login')
8
+ }
9
+ })
@@ -0,0 +1,9 @@
1
+ // Redirect already-authenticated users away from guest-only pages (login, register).
2
+ // useAuthStore auto-imported from nuxt-app stores.
3
+ export default defineNuxtRouteMiddleware(() => {
4
+ const authStore = useAuthStore()
5
+ const config = useRuntimeConfig()
6
+ if (authStore.isAuthenticated()) {
7
+ return navigateTo(config.public.homePath || '/')
8
+ }
9
+ })
package/nuxt.config.ts ADDED
@@ -0,0 +1,42 @@
1
+ import tailwindcss from '@tailwindcss/vite'
2
+
3
+ // @innertia-solutions/innertia-nuxt — capa base unificada.
4
+ // Provee: core (utilities, pusher, seo) + app (auth, context, vue-query)
5
+ // + saas (multitenancy) + spark (tema visual, components, layouts)
6
+ export default defineNuxtConfig({
7
+ modules: [
8
+ '@nuxtjs/seo',
9
+ '@pinia/nuxt',
10
+ 'pinia-plugin-persistedstate/nuxt', // requerido para persistir auth, dockedPreviews, etc.
11
+ ],
12
+ css: ['@innertia-solutions/innertia-nuxt/spark.css'],
13
+ components: [
14
+ { path: './components', pathPrefix: true, prefix: '' },
15
+ ],
16
+ imports: {
17
+ dirs: ['stores', 'composables'],
18
+ presets: [
19
+ {
20
+ from: '@tanstack/vue-query',
21
+ imports: ['useQuery', 'useMutation', 'useQueryClient', 'useInfiniteQuery'],
22
+ },
23
+ ],
24
+ },
25
+ runtimeConfig: {
26
+ public: {
27
+ loginPath: '/backoffice/login',
28
+ homePath: '/backoffice',
29
+ },
30
+ },
31
+ appConfig: {
32
+ spark: {
33
+ theme: 'default', // default | harvest | retro | ocean | autumn | moon | bubblegum | olive | cashmere
34
+ },
35
+ },
36
+ vite: {
37
+ plugins: [tailwindcss()],
38
+ optimizeDeps: {
39
+ include: ['pusher-js'],
40
+ },
41
+ },
42
+ })
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "@innertia-solutions/innertia-nuxt",
3
+ "version": "0.1.1",
4
+ "description": "Innertia Solutions — Nuxt layer unificada: core, auth, multitenancy, theme y app contexts",
5
+ "keywords": [
6
+ "nuxt",
7
+ "vue",
8
+ "pinia",
9
+ "saas",
10
+ "multitenancy",
11
+ "innertia"
12
+ ],
13
+ "license": "MIT",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "https://github.com/innertia-solutions/innertia-nuxt"
17
+ },
18
+ "publishConfig": {
19
+ "access": "public"
20
+ },
21
+ "main": "./nuxt.config.ts",
22
+ "exports": {
23
+ ".": "./nuxt.config.ts",
24
+ "./spark.css": "./spark.css"
25
+ },
26
+ "peerDependencies": {
27
+ "nuxt": ">=4.0.0",
28
+ "vue": ">=3.5.0",
29
+ "pinia": ">=3.0.0",
30
+ "@pinia/nuxt": ">=0.11.0",
31
+ "pinia-plugin-persistedstate": ">=4.0.0"
32
+ },
33
+ "dependencies": {
34
+ "@nuxtjs/seo": "^3.4.0",
35
+ "@pinia/nuxt": "^0.11.3",
36
+ "@tabler/icons-vue": "^3.44.0",
37
+ "@tailwindcss/aspect-ratio": "^0.4.2",
38
+ "@tailwindcss/forms": "^0.5.10",
39
+ "@tailwindcss/vite": "^4.0.0",
40
+ "@tanstack/vue-query": "^5.100.10",
41
+ "@tanstack/vue-table": "^8.21.3",
42
+ "@vueuse/core": "^14.3.0",
43
+ "date-fns": "^4.1.0",
44
+ "date-fns-tz": "^3.2.0",
45
+ "dayjs": "^1.11.0",
46
+ "pinia-plugin-persistedstate": "^4.7.1",
47
+ "preline": "^3.2.3",
48
+ "pusher-js": "^8.5.0",
49
+ "tailwindcss": "^4.0.0",
50
+ "uuid": "^13.0.0",
51
+ "vanilla-calendar-pro": "^3.1.0"
52
+ },
53
+ "devDependencies": {
54
+ "nuxt": "^4.4.2",
55
+ "vue": "^3.5.0",
56
+ "@pinia/nuxt": "^0.11.3",
57
+ "pinia": "^3.0.4",
58
+ "pinia-plugin-persistedstate": "^4.7.1"
59
+ }
60
+ }
@@ -0,0 +1,50 @@
1
+ <script setup lang="ts">
2
+ definePageMeta({ layout: false })
3
+
4
+ const route = useRoute()
5
+ const reason = computed(() => route.query.reason as string | undefined)
6
+
7
+ const messages = {
8
+ 'no-subdomain': {
9
+ title: 'Acceso no válido',
10
+ description: 'No se encontró un espacio de trabajo en esta dirección. Verifica que estés usando la URL correcta.',
11
+ },
12
+ 'inactive': {
13
+ title: 'Espacio de trabajo inactivo',
14
+ description: 'Este espacio de trabajo ha sido desactivado. Contacta al administrador.',
15
+ },
16
+ 'timeout': {
17
+ title: 'Sin respuesta del servidor',
18
+ description: 'El servidor tardó demasiado en responder. Intenta nuevamente en unos momentos.',
19
+ },
20
+ 'unreachable': {
21
+ title: 'Servicio no disponible',
22
+ description: 'No se pudo conectar con el servidor. Verifica tu conexión e intenta nuevamente.',
23
+ },
24
+ } as const
25
+
26
+ const fallback = { title: 'Error inesperado', description: 'Ocurrió un problema al cargar tu espacio de trabajo.' }
27
+ const current = computed(() => messages[reason.value as keyof typeof messages] ?? fallback)
28
+ </script>
29
+
30
+ <template>
31
+ <div class="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-900 px-4">
32
+ <div class="max-w-md w-full text-center space-y-4">
33
+ <div class="text-6xl font-bold text-slate-300 dark:text-slate-700 select-none">
34
+ :(
35
+ </div>
36
+ <h1 class="text-2xl font-semibold text-slate-800 dark:text-white">
37
+ {{ current.title }}
38
+ </h1>
39
+ <p class="text-slate-500 dark:text-slate-400">
40
+ {{ current.description }}
41
+ </p>
42
+ <button
43
+ class="mt-4 px-4 py-2 rounded-lg bg-slate-800 text-white text-sm hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600"
44
+ @click="$router.go(0)"
45
+ >
46
+ Reintentar
47
+ </button>
48
+ </div>
49
+ </div>
50
+ </template>
@@ -0,0 +1,12 @@
1
+ // Registers the Authorization header interceptor into the shared registry.
2
+ // Universal: useCookie reads request cookies in SSR and document.cookie on client.
3
+ export default defineNuxtPlugin(() => {
4
+ const { add } = useRequestInterceptors()
5
+
6
+ add((headers: Record<string, string>, options: Record<string, unknown>) => {
7
+ if (options.useToken !== false) {
8
+ const token = useAuthStore().getToken()
9
+ if (token) headers['Authorization'] = `Bearer ${token}`
10
+ }
11
+ })
12
+ })
@@ -0,0 +1,21 @@
1
+ // Registra el interceptor del header X-Tenant.
2
+ // El backend (ResolveTenantFromHeader) identifica el tenant por su key/slug,
3
+ // no por UUID. Solo se envía cuando el tenant está validado (tenantId != null)
4
+ // para evitar enviar el slug 'local' de dev que no existe en la DB.
5
+ // useRequestInterceptors auto-imported desde nuxt-core.
6
+ // useTenantStore auto-imported desde saas stores.
7
+ // Solo activo en mode === 'saas'.
8
+ export default defineNuxtPlugin(() => {
9
+ // Modo no-saas → no inyectar header de tenant
10
+ if (!useInnertiaMode().hasTenant()) return
11
+
12
+ const { add } = useRequestInterceptors()
13
+ const tenantStore = useTenantStore()
14
+
15
+ add((headers: Record<string, string>) => {
16
+ // Solo enviar si el tenant fue validado (tiene id) y tiene slug
17
+ if (tenantStore.tenantId && tenantStore.tenantSlug) {
18
+ headers['X-Tenant'] = tenantStore.tenantSlug
19
+ }
20
+ })
21
+ })
@@ -0,0 +1,8 @@
1
+ // Reads the hs_theme cookie (set by SwitchColorTheme + applyAppearance) and applies
2
+ // the dark class to <html> during SSR so there's no flash on first paint.
3
+ export default defineNuxtPlugin(() => {
4
+ const cookie = useCookie('hs_theme')
5
+ if (cookie.value === 'dark') {
6
+ useHead({ htmlAttrs: { class: 'dark' } })
7
+ }
8
+ })
@@ -0,0 +1,34 @@
1
+ // Universal plugin: runs on both server (SSR) and client.
2
+ // On SSR: token is read from cookie → fetches user from API → pinia state is serialized
3
+ // into the Nuxt payload and transferred to the client → no hydration mismatch.
4
+ // Avoids useQueryClient() because vue-query.ts runs after this plugin alphabetically.
5
+ // Universal plugin: runs on both server (SSR) and client.
6
+ // On SSR: token is read from cookie → fetches user from API → pinia state is serialized
7
+ // into the Nuxt payload and transferred to the client → no hydration mismatch.
8
+ // Avoids useQueryClient() because vue-query.ts runs after this plugin alphabetically.
9
+ export default defineNuxtPlugin(async () => {
10
+ const authStore = useAuthStore()
11
+
12
+ // Use getToken() not authStore.token — pinia-plugin-persistedstate may not have
13
+ // hydrated the store yet in SSR, but getToken() reads useCookie() directly.
14
+ if (!authStore.getToken()) return
15
+
16
+ if (!authStore.isAuthenticated()) {
17
+ authStore.logout()
18
+ return
19
+ }
20
+
21
+ if (!authStore.user) {
22
+ try {
23
+ const api = useApi()
24
+ const data = await api.get('auth/me')
25
+ if (data) {
26
+ authStore.saveUser(data.user ?? data)
27
+ authStore.savePermissions(data.permissions ?? [])
28
+ authStore.availableContexts = data.availableContexts ?? []
29
+ }
30
+ } catch {
31
+ if (import.meta.client) authStore.logout()
32
+ }
33
+ }
34
+ })
@@ -0,0 +1,29 @@
1
+ export default defineNuxtPlugin(() => {
2
+ if (!process.client) return
3
+
4
+ const isDark = useState<boolean>('isDark', () => false)
5
+
6
+ const updateTheme = () => {
7
+ if (typeof document !== 'undefined') {
8
+ isDark.value = document.documentElement.classList.contains('dark')
9
+ }
10
+ }
11
+
12
+ let observer: MutationObserver | null = null
13
+
14
+ const initializeTheme = () => {
15
+ updateTheme()
16
+ if (observer) observer.disconnect()
17
+ observer = new MutationObserver(() => updateTheme())
18
+ observer.observe(document.documentElement, {
19
+ attributes: true,
20
+ attributeFilter: ['class'],
21
+ })
22
+ }
23
+
24
+ if (document.readyState === 'loading') {
25
+ document.addEventListener('DOMContentLoaded', initializeTheme)
26
+ } else {
27
+ nextTick(initializeTheme)
28
+ }
29
+ })
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Sincroniza el store de previews minimizados entre pestañas del navegador.
3
+ * Escucha los eventos `storage` que dispara localStorage cuando otra pestaña escribe.
4
+ */
5
+ export default defineNuxtPlugin(() => {
6
+ const store = useDockedPreviewsStore()
7
+
8
+ window.addEventListener('storage', (event) => {
9
+ if (event.key !== 'docked-previews' || !event.newValue) return
10
+ try {
11
+ const persisted = JSON.parse(event.newValue)
12
+ store.hydrate(persisted.items ?? [])
13
+ } catch {
14
+ // JSON inválido — ignorar
15
+ }
16
+ })
17
+ })
@@ -0,0 +1,68 @@
1
+ declare global {
2
+ interface Window {
3
+ HSStaticMethods?: { autoInit?: () => void }
4
+ HSSelect?: new (el: HTMLElement) => { destroy?: () => void }
5
+ HSThemeAppearance?: { init?: () => void }
6
+ }
7
+ }
8
+
9
+ export default defineNuxtPlugin(async () => {
10
+ if (!process.client) return
11
+
12
+ try {
13
+ await import('preline')
14
+
15
+ const initPreline = () => {
16
+ try { window.HSStaticMethods?.autoInit?.() } catch (_) {}
17
+ try { window.HSThemeAppearance?.init?.() } catch (_) {}
18
+ }
19
+
20
+ const performMultipleInits = () => {
21
+ initPreline()
22
+ setTimeout(initPreline, 50)
23
+ setTimeout(initPreline, 200)
24
+ setTimeout(initPreline, 500)
25
+ }
26
+
27
+ if (document.readyState === 'loading') {
28
+ document.addEventListener('DOMContentLoaded', performMultipleInits)
29
+ } else {
30
+ nextTick(performMultipleInits)
31
+ }
32
+
33
+ const nuxtApp = useNuxtApp()
34
+ nuxtApp.hooks.hookOnce('app:mounted', () => performMultipleInits())
35
+ nuxtApp.hooks.hook('page:finish', () => requestAnimationFrame(performMultipleInits))
36
+
37
+ const observer = new MutationObserver((mutations) => {
38
+ const hasPreline = mutations.some(({ addedNodes }) =>
39
+ Array.from(addedNodes).some((node) => {
40
+ if (node.nodeType !== Node.ELEMENT_NODE) return false
41
+ const el = node as Element
42
+ return (
43
+ el.querySelector?.('[data-hs-overlay],[data-hs-dropdown],[data-hs-select]') ||
44
+ el.hasAttribute?.('data-hs-overlay') ||
45
+ el.hasAttribute?.('data-hs-dropdown') ||
46
+ el.hasAttribute?.('data-hs-select') ||
47
+ (typeof el.className === 'string' && el.className.includes('hs-'))
48
+ )
49
+ })
50
+ )
51
+ if (hasPreline) {
52
+ setTimeout(initPreline, 10)
53
+ setTimeout(initPreline, 100)
54
+ }
55
+ })
56
+
57
+ if (document.readyState === 'loading') {
58
+ document.addEventListener('DOMContentLoaded', () =>
59
+ observer.observe(document.body, { childList: true, subtree: true })
60
+ )
61
+ } else {
62
+ observer.observe(document.body, { childList: true, subtree: true })
63
+ }
64
+
65
+ } catch (e) {
66
+ console.warn('[nuxt-core] Error al cargar Preline:', e)
67
+ }
68
+ })
@@ -0,0 +1,7 @@
1
+ export default defineNuxtPlugin(() => {
2
+ const appConfig = useAppConfig() as { spark?: { theme?: string } }
3
+ const theme = appConfig.spark?.theme
4
+ if (theme && theme !== 'default') {
5
+ document.documentElement.setAttribute('data-theme', `theme-${theme}`)
6
+ }
7
+ })