@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
@@ -0,0 +1,64 @@
1
+ name: Auto Publish
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ paths-ignore:
7
+ - '**.md'
8
+ - '.github/**'
9
+
10
+ jobs:
11
+ publish:
12
+ if: github.actor != 'github-actions[bot]'
13
+ runs-on: ubuntu-latest
14
+ environment: NPM
15
+ permissions:
16
+ contents: write
17
+ id-token: write
18
+
19
+ steps:
20
+ - uses: actions/checkout@v4
21
+ with:
22
+ fetch-depth: 0
23
+ token: ${{ secrets.GITHUB_TOKEN }}
24
+
25
+ - uses: pnpm/action-setup@v4
26
+ with:
27
+ version: 9
28
+
29
+ - uses: actions/setup-node@v4
30
+ with:
31
+ node-version: 20
32
+ registry-url: 'https://registry.npmjs.org'
33
+
34
+ - run: pnpm install --no-frozen-lockfile
35
+
36
+ - name: Configure git
37
+ run: |
38
+ git config user.name "github-actions[bot]"
39
+ git config user.email "github-actions[bot]@users.noreply.github.com"
40
+
41
+ - name: Bump and publish
42
+ id: publish
43
+ env:
44
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
45
+ run: |
46
+ npm version patch --no-git-tag-version
47
+ PKG_NAME=$(node -p "require('./package.json').name")
48
+ VERSION=$(node -p "require('./package.json').version")
49
+
50
+ if npm view "$PKG_NAME@$VERSION" version 2>/dev/null | grep -q "$VERSION"; then
51
+ echo "⚠ $PKG_NAME@$VERSION already published — keeping version bump, skipping publish"
52
+ else
53
+ npm publish --access public --provenance
54
+ echo "✓ $PKG_NAME@$VERSION"
55
+ fi
56
+
57
+ echo "pkg_name=$PKG_NAME" >> $GITHUB_OUTPUT
58
+ echo "version=$VERSION" >> $GITHUB_OUTPUT
59
+
60
+ - name: Commit version bump
61
+ run: |
62
+ git add package.json pnpm-lock.yaml || git add package.json
63
+ git commit -m "chore: release ${{ steps.publish.outputs.pkg_name }}@${{ steps.publish.outputs.version }}"
64
+ git push
@@ -0,0 +1,59 @@
1
+ name: Release (manual)
2
+
3
+ on:
4
+ workflow_dispatch:
5
+ inputs:
6
+ bump:
7
+ description: 'Version bump'
8
+ required: true
9
+ type: choice
10
+ options:
11
+ - patch
12
+ - minor
13
+ - major
14
+
15
+ jobs:
16
+ release:
17
+ runs-on: ubuntu-latest
18
+ environment: NPM
19
+ permissions:
20
+ contents: write
21
+ id-token: write
22
+
23
+ steps:
24
+ - uses: actions/checkout@v4
25
+ with:
26
+ token: ${{ secrets.GITHUB_TOKEN }}
27
+
28
+ - uses: pnpm/action-setup@v4
29
+ with:
30
+ version: 9
31
+
32
+ - uses: actions/setup-node@v4
33
+ with:
34
+ node-version: 20
35
+ registry-url: 'https://registry.npmjs.org'
36
+
37
+ - run: pnpm install --no-frozen-lockfile
38
+
39
+ - name: Configure git
40
+ run: |
41
+ git config user.name "github-actions[bot]"
42
+ git config user.email "github-actions[bot]@users.noreply.github.com"
43
+
44
+ - name: Bump, publish, tag
45
+ env:
46
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
47
+ run: |
48
+ npm version ${{ inputs.bump }} --no-git-tag-version
49
+ PKG_NAME=$(node -p "require('./package.json').name")
50
+ VERSION=$(node -p "require('./package.json').version")
51
+ TAG="v${VERSION}"
52
+
53
+ npm publish --access public --provenance
54
+
55
+ git add package.json pnpm-lock.yaml || git add package.json
56
+ git commit -m "chore: release ${PKG_NAME}@${VERSION}"
57
+ git tag "$TAG"
58
+ git push
59
+ git push --tags
package/README.md ADDED
@@ -0,0 +1,60 @@
1
+ # @innertia-solutions/innertia-nuxt
2
+
3
+ Capa Nuxt unificada de Innertia Solutions. Provee en un solo paquete:
4
+
5
+ - **Core** — composables base (useApi, useDate, useDevice, useDownload, useRealtime, etc.) + plugin pusher + SEO
6
+ - **App** — auth (JWT), contextos, permisos, vue-query, stores de notifications/auth, middlewares `auth`/`guest`
7
+ - **Saas** — multitenancy por subdomain (`X-Tenant` header, validación de tenant, store de tenant)
8
+ - **Spark** — design system: components, layouts, theme Tailwind, Preline, Tabler icons
9
+ - **Contexts** — apps multi-contexto (backoffice/teacher/technician...), mobile guard configurable
10
+
11
+ ## Uso
12
+
13
+ ```bash
14
+ pnpm add @innertia-solutions/innertia-nuxt
15
+ ```
16
+
17
+ ```ts
18
+ // nuxt.config.ts
19
+ export default defineNuxtConfig({
20
+ extends: ['@innertia-solutions/innertia-nuxt'],
21
+ css: ['@innertia-solutions/innertia-nuxt/spark.css'],
22
+
23
+ appConfig: {
24
+ innertia: {
25
+ mode: 'saas', // 'saas' (default) | 'app'
26
+
27
+ apps: {
28
+ backoffice: {
29
+ path: '/backoffice',
30
+ context: 'backoffice',
31
+ label: 'Backoffice',
32
+ icon: 'IconBuildingSkyscraper',
33
+ loginPath: '/backoffice/login',
34
+ home: '/backoffice',
35
+ mobile: { mode: 'block' },
36
+ },
37
+ // ...más contextos
38
+ },
39
+
40
+ mobile: { breakpoint: 1024, rememberChoice: true },
41
+ },
42
+
43
+ spark: { theme: 'default' },
44
+ },
45
+ })
46
+ ```
47
+
48
+ ## Modos
49
+
50
+ | Modo | Subdomain detection | Tenant validation | X-Tenant header | Auth | Apps |
51
+ |---|---|---|---|---|---|
52
+ | `saas` (default) | ✅ | ✅ | ✅ | ✅ | ✅ |
53
+ | `app` | ❌ | ❌ | ❌ | ✅ | ✅ |
54
+
55
+ ## CI
56
+
57
+ - **Push a `main`** → `auto-publish.yml` bumpea patch y publica automáticamente.
58
+ - **Workflow `release.yml` manual** → permite elegir bump (patch/minor/major).
59
+
60
+ Requiere `NPM_TOKEN` configurado en environment `NPM` del repo.
package/app.config.ts ADDED
@@ -0,0 +1,70 @@
1
+ // Defaults para `appConfig.innertia`. Los productos sobreescriben esto en su nuxt.config.ts:
2
+ //
3
+ // appConfig: {
4
+ // innertia: {
5
+ // apps: { backoffice: {...}, technician: {...} },
6
+ // mobile: { breakpoint: 1024 },
7
+ // },
8
+ // }
9
+ //
10
+ // Si el producto no declara `apps`, el feature de contextos queda inactivo
11
+ // (middleware no redirige, picker no se muestra).
12
+ export default defineAppConfig({
13
+ innertia: {
14
+ /**
15
+ * Modo de operación:
16
+ * - 'saas' → multi-tenant. Activa detect-subdomain, validate-tenant y X-Tenant header.
17
+ * Default por compatibilidad con productos existentes.
18
+ * - 'app' → single-tenant / app interna. No detecta subdomain ni inyecta tenant header.
19
+ * Auth y contextos siguen funcionando igual que en saas.
20
+ */
21
+ mode: 'saas' as InnertiaMode,
22
+
23
+ /**
24
+ * Declaración de "apps" (contextos) del producto.
25
+ * Cada app define un prefijo de URL que mapea a un contexto del backend
26
+ * (matching con `availableContexts` que devuelve `auth/me`).
27
+ */
28
+ apps: {} as Record<string, AppDefinition>,
29
+
30
+ /**
31
+ * Política mobile global. Se aplica si una `app` no tiene `mobile.mode` propio.
32
+ */
33
+ mobile: {
34
+ /** Threshold de viewport en px para considerar mobile (default 1024 = Tailwind `lg`). */
35
+ breakpoint: 1024,
36
+ /** Persistir elección del picker en cookie. */
37
+ rememberChoice: true,
38
+ },
39
+ },
40
+ })
41
+
42
+ // ─── Tipos públicos ──────────────────────────────────────────────────────────
43
+ // Re-exportados desde acá para que cualquier composable / componente los importe.
44
+
45
+ export type InnertiaMode = 'saas' | 'app'
46
+
47
+ export type AppMobileMode = 'allow' | 'block' | 'redirect'
48
+
49
+ export interface AppDefinition {
50
+ /** Prefijo de URL — la ruta debe empezar con esto para considerarse "dentro" del app. */
51
+ path: string
52
+ /** Clave del contexto que matchea con `availableContexts` del backend. */
53
+ context: string
54
+ /** Label visible en pickers, dropdowns, etc. */
55
+ label: string
56
+ /** Descripción opcional para el picker mobile. */
57
+ description?: string
58
+ /** Nombre del icono de @tabler/icons-vue (sin "Icon"). */
59
+ icon?: string
60
+ /** Ruta de login del app. */
61
+ loginPath: string
62
+ /** Ruta home (post-login). */
63
+ home: string
64
+ /** Política de uso en mobile. */
65
+ mobile: {
66
+ mode: AppMobileMode
67
+ /** Solo si mode === 'redirect' — adónde mandar desde mobile. */
68
+ redirectTo?: string
69
+ }
70
+ }
@@ -0,0 +1,144 @@
1
+ <script setup lang="ts">
2
+ const props = defineProps<{
3
+ floating?: boolean
4
+ user?: { name?: string; email?: string } | null
5
+ menuSize?: string
6
+ }>()
7
+
8
+ const emit = defineEmits<{ logout: [] }>()
9
+
10
+ const isOpen = ref(false)
11
+ const open = () => { isOpen.value = true }
12
+ const close = () => { isOpen.value = false }
13
+
14
+ provide('spark:sidebar', { isOpen, open, close })
15
+
16
+ const userInitial = computed(() =>
17
+ props.user?.name?.charAt(0).toUpperCase() ?? props.user?.email?.charAt(0).toUpperCase() ?? 'U'
18
+ )
19
+
20
+ const config = useRuntimeConfig()
21
+ const appEnv = config.public.appEnv as string | undefined
22
+
23
+ const envLabel = computed(() => {
24
+ switch (appEnv) {
25
+ case 'local': return 'Entorno local'
26
+ case 'dev': return 'Entorno dev'
27
+ case 'staging': return 'Staging'
28
+ case 'production': return null
29
+ default: return appEnv ?? null
30
+ }
31
+ })
32
+ </script>
33
+
34
+ <template>
35
+ <div class="bg-background-1 min-h-screen">
36
+
37
+ <!-- Mobile backdrop -->
38
+ <Transition
39
+ enter-from-class="opacity-0" enter-active-class="transition-opacity duration-300"
40
+ leave-to-class="opacity-0" leave-active-class="transition-opacity duration-300"
41
+ >
42
+ <div v-if="isOpen" class="lg:hidden fixed inset-0 z-50 bg-black/40 backdrop-blur-sm" @click="close" />
43
+ </Transition>
44
+
45
+ <!-- Sidebar -->
46
+ <aside
47
+ tabindex="-1"
48
+ aria-label="Sidebar"
49
+ :class="[
50
+ 'fixed inset-y-0 start-0 z-60 w-65',
51
+ 'transition-transform duration-300 lg:translate-x-0',
52
+ isOpen ? 'translate-x-0' : 'max-lg:-translate-x-full',
53
+ floating ? 'p-3' : '',
54
+ ]"
55
+ >
56
+ <div
57
+ :class="[
58
+ 'flex flex-col h-full',
59
+ floating
60
+ ? 'bg-sidebar rounded-2xl border border-sidebar-line shadow-sm overflow-hidden'
61
+ : 'bg-sidebar border-e border-sidebar-line',
62
+ ]"
63
+ >
64
+ <!-- Logo + mobile close -->
65
+ <header class="flex items-center gap-x-1 px-3 pt-4 pb-5 shrink-0">
66
+ <div class="flex-1 min-w-0">
67
+ <slot name="logo" />
68
+ </div>
69
+ <button
70
+ type="button"
71
+ class="lg:hidden size-7 inline-flex justify-center items-center rounded-lg text-muted-foreground hover:bg-muted-hover transition-colors"
72
+ @click="close"
73
+ >
74
+ <svg class="size-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
75
+ <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
76
+ </svg>
77
+ </button>
78
+ </header>
79
+
80
+ <!-- Search -->
81
+ <div v-if="$slots.search" class="px-3 pb-2 shrink-0">
82
+ <slot name="search" />
83
+ </div>
84
+
85
+ <!-- Nav menu (scrollable) -->
86
+ <div
87
+ :class="[
88
+ 'flex-1 min-h-0 overflow-y-auto',
89
+ '[&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:bg-scrollbar-thumb',
90
+ menuSize ?? 'text-sm',
91
+ ]"
92
+ >
93
+ <slot name="menu" />
94
+ </div>
95
+
96
+ <!-- User footer -->
97
+ <div class="shrink-0 border-t border-sidebar-line px-3 pt-5 pb-3 space-y-2">
98
+
99
+ <!-- Controls slot (dark mode, notifications, etc.) -->
100
+ <div v-if="$slots['user-controls']" class="flex items-center gap-x-1.5">
101
+ <slot name="user-controls" />
102
+ </div>
103
+
104
+ <!-- User info + logout -->
105
+ <div v-if="user" class="flex items-center gap-x-3">
106
+ <div class="size-9 rounded-lg bg-primary flex items-center justify-center text-primary-foreground text-sm font-bold shrink-0 select-none">
107
+ {{ userInitial }}
108
+ </div>
109
+ <div class="flex-1 min-w-0">
110
+ <p class="text-sm font-semibold text-foreground truncate">{{ user.name ?? user.email }}</p>
111
+ <p v-if="user.name && user.email" class="text-xs text-muted-foreground truncate">{{ user.email }}</p>
112
+ </div>
113
+ <button
114
+ type="button"
115
+ class="size-7 inline-flex items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted-hover transition-colors shrink-0"
116
+ title="Cerrar sesión"
117
+ @click="emit('logout')"
118
+ >
119
+ <svg class="size-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
120
+ <path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M18 9l3 3m0 0l-3 3m3-3H9" />
121
+ </svg>
122
+ </button>
123
+ </div>
124
+
125
+ <!-- Env / extra slot -->
126
+ <slot name="user-footer" />
127
+ </div>
128
+
129
+ <!-- Environment banner — full width, outside the padded footer -->
130
+ <div v-if="envLabel" class="shrink-0 flex items-center justify-center gap-x-2 py-2 bg-amber-400/15 border-t border-amber-400/30">
131
+ <span class="size-1.5 rounded-full bg-amber-400 shrink-0"></span>
132
+ <span class="text-[11px] font-semibold text-amber-600 dark:text-amber-400 uppercase tracking-wide">{{ envLabel }}</span>
133
+ </div>
134
+
135
+ </div>
136
+ </aside>
137
+
138
+ <!-- Main content -->
139
+ <div class="lg:ps-65 p-3">
140
+ <slot />
141
+ </div>
142
+
143
+ </div>
144
+ </template>
@@ -0,0 +1,32 @@
1
+ <script setup lang="ts">
2
+ defineProps<{
3
+ title?: string
4
+ }>()
5
+
6
+ const sidebar = inject('spark:sidebar', null) as any
7
+ </script>
8
+
9
+ <template>
10
+ <header class="sticky top-0 z-40 bg-card border-b border-card-line px-4 lg:px-6 h-14 flex items-center gap-x-3">
11
+ <!-- Hamburger mobile -->
12
+ <button type="button"
13
+ class="lg:hidden size-8 flex items-center justify-center text-muted-foreground hover:text-foreground"
14
+ @click="sidebar?.open()">
15
+ <svg class="size-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
16
+ <line x1="3" y1="6" x2="21" y2="6" /><line x1="3" y1="12" x2="21" y2="12" /><line x1="3" y1="18" x2="21" y2="18" />
17
+ </svg>
18
+ </button>
19
+
20
+ <!-- Left slot -->
21
+ <div class="flex-1 flex items-center gap-x-3">
22
+ <slot name="left">
23
+ <span v-if="title" class="text-sm font-medium text-foreground">{{ title }}</span>
24
+ </slot>
25
+ </div>
26
+
27
+ <!-- Right slot -->
28
+ <div class="flex items-center gap-x-2">
29
+ <slot name="right" />
30
+ </div>
31
+ </header>
32
+ </template>
@@ -0,0 +1,65 @@
1
+ <script setup>
2
+ import * as TablerIcons from '@tabler/icons-vue'
3
+
4
+ const props = defineProps({
5
+ title: { type: String, default: '' },
6
+ description: { type: String, default: '' },
7
+ icon: { type: String, default: '' },
8
+ color: { type: String, default: 'slate' },
9
+ })
10
+
11
+ const slots = useSlots()
12
+
13
+ const iconComponent = computed(() => props.icon ? TablerIcons[props.icon] ?? null : null)
14
+
15
+ const iconColorClass = computed(() => ({
16
+ slate: 'bg-surface text-muted-foreground-1',
17
+ blue: 'bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400',
18
+ green: 'bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400',
19
+ amber: 'bg-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400',
20
+ red: 'bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400',
21
+ purple: 'bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400',
22
+ rose: 'bg-rose-100 dark:bg-rose-900/30 text-rose-600 dark:text-rose-400',
23
+ gray: 'bg-surface text-muted-foreground-1',
24
+ }[props.color] ?? 'bg-surface text-muted-foreground-1'))
25
+ </script>
26
+
27
+ <template>
28
+ <div class="relative space-y-2">
29
+
30
+ <!-- Page header card -->
31
+ <div v-if="title" class="sticky top-0 z-20 -mx-3 -mt-3 px-3 pt-3 bg-background-1">
32
+ <div v-if="$slots.breadcrumb" class="flex items-center gap-x-1 px-1 pt-2 pb-0.5">
33
+ <slot name="breadcrumb" />
34
+ </div>
35
+ <div class="flex items-center justify-between bg-card border border-card-line rounded-2xl shadow-sm px-4 py-3">
36
+ <div class="flex items-center gap-x-4 min-w-0">
37
+ <div v-if="iconComponent" class="shrink-0 size-10 rounded-xl flex items-center justify-center border border-current/15" :class="iconColorClass">
38
+ <component :is="iconComponent" class="size-5" stroke="1.5" />
39
+ </div>
40
+ <div class="min-w-0">
41
+ <div class="flex items-baseline gap-x-2 flex-wrap">
42
+ <h1 class="text-lg font-semibold text-foreground">{{ title }}</h1>
43
+ <template v-if="description">
44
+ <span class="size-1 rounded-full bg-surface-1 shrink-0 self-center hidden sm:block" />
45
+ <p class="text-sm text-muted-foreground">{{ description }}</p>
46
+ </template>
47
+ </div>
48
+ </div>
49
+ </div>
50
+ <div v-if="$slots.actions" class="flex items-center gap-x-2 shrink-0 ms-4">
51
+ <slot name="actions" />
52
+ </div>
53
+ </div>
54
+ </div>
55
+
56
+ <!-- Tabs -->
57
+ <div v-if="$slots.tabs">
58
+ <slot name="tabs" :color="color" />
59
+ </div>
60
+
61
+ <!-- Page content -->
62
+ <div class="relative"><slot /></div>
63
+
64
+ </div>
65
+ </template>
@@ -0,0 +1,31 @@
1
+ <script setup lang="ts">
2
+ defineProps<{
3
+ title: string
4
+ description?: string
5
+ }>()
6
+ </script>
7
+
8
+ <template>
9
+ <div class="sticky top-0 z-20 -mx-3 -mt-3 px-3 pt-3 bg-background-1">
10
+ <div class="flex items-center justify-between bg-card border border-card-line rounded-2xl shadow-sm px-4 py-3">
11
+ <div class="flex items-center gap-x-4">
12
+ <slot name="icon" />
13
+ <div>
14
+ <div class="flex items-baseline gap-x-2">
15
+ <h1 class="text-lg font-semibold text-foreground">{{ title }}</h1>
16
+ <template v-if="description">
17
+ <span class="size-1 rounded-full bg-surface-1 shrink-0 self-center"></span>
18
+ <p class="text-sm text-muted-foreground">{{ description }}</p>
19
+ </template>
20
+ </div>
21
+ <div v-if="$slots.breadcrumb" class="flex items-center gap-x-1 mt-0.5">
22
+ <slot name="breadcrumb" />
23
+ </div>
24
+ </div>
25
+ </div>
26
+ <div class="flex items-center gap-x-2">
27
+ <slot name="actions" />
28
+ </div>
29
+ </div>
30
+ </div>
31
+ </template>
@@ -0,0 +1,59 @@
1
+ <script setup>
2
+ const props = defineProps({
3
+ text: { type: String, required: true },
4
+ size: { type: String, default: "sm", validator: (v) => ["xs","sm","md","lg"].includes(v) },
5
+ class: { type: String, default: "" },
6
+ iconClass: { type: String, default: "" },
7
+ textClass: { type: String, default: "" },
8
+ outline: { type: Boolean, default: false },
9
+ severity: { type: String, default: "secondary", validator: (v) => ["primary","secondary","success","danger","warning","info"].includes(v) },
10
+ icon: { type: [Object, Function], default: null },
11
+ iconPosition: { type: String, default: "left", validator: (v) => ["left","right"].includes(v) },
12
+ loading: { type: Boolean, default: false },
13
+ loadingText: { type: String, default: "Cargando..." },
14
+ disabled: { type: Boolean, default: false },
15
+ type: { type: String, default: "button", validator: (v) => ["button","link"].includes(v) },
16
+ link: { type: String, default: "" },
17
+ variant: { type: String, default: "default", validator: (v) => ["default","dropdown"].includes(v) },
18
+ })
19
+
20
+ const sizeClasses = computed(() => ({ xs:"py-0.5 px-2 text-xs font-light", sm:"py-1 px-2.5 text-sm font-light", md:"py-2.5 px-3 text-sm font-light", lg:"py-3 px-4 text-base font-light" }[props.size] || "py-1 px-2.5 text-sm font-light"))
21
+ const iconSizeClasses = computed(() => ({ xs:"size-2", sm:"size-3", md:"size-4", lg:"size-5" }[props.size] || "size-3"))
22
+
23
+ const severityClasses = computed(() => {
24
+ if (props.variant === "dropdown") {
25
+ const d = { primary: "text-primary hover:bg-primary/10", secondary:"text-foreground hover:bg-muted-hover", success:"text-emerald-600 hover:bg-emerald-50 dark:text-emerald-400 dark:hover:bg-emerald-900/20", danger:"text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20", warning:"text-yellow-600 hover:bg-yellow-50 dark:text-yellow-400 dark:hover:bg-yellow-900/20", info:"text-cyan-600 hover:bg-cyan-50 dark:text-cyan-400 dark:hover:bg-cyan-900/20" }
26
+ return d[props.severity] || d.secondary
27
+ }
28
+ const base = "rounded-lg border transition-colors"
29
+ const v = { primary: "border-primary bg-primary/10 text-primary hover:bg-primary/20 dark:bg-primary/15 dark:hover:bg-primary/25", secondary:"border-slate-300 bg-slate-50 text-slate-700 hover:bg-muted-hover dark:border-card-line dark:bg-card dark:text-muted-foreground-1 dark:hover:bg-muted-hover", success:"border-emerald-600 bg-emerald-50 text-emerald-700 hover:bg-emerald-100 dark:border-emerald-500 dark:bg-emerald-900/20 dark:text-emerald-300 dark:hover:bg-emerald-900/35", danger:"border-red-600 bg-red-50 text-red-700 hover:bg-red-100 dark:border-red-500 dark:bg-red-900/20 dark:text-red-300 dark:hover:bg-red-900/35", warning:"border-yellow-600 bg-yellow-50 text-yellow-700 hover:bg-yellow-100 dark:border-yellow-500 dark:bg-yellow-900/20 dark:text-yellow-300 dark:hover:bg-yellow-900/35", info:"border-cyan-600 bg-cyan-50 text-cyan-700 hover:bg-cyan-100 dark:border-cyan-500 dark:bg-cyan-900/20 dark:text-cyan-300 dark:hover:bg-cyan-900/35" }
30
+ return `${base} ${v[props.severity] || v.primary}`
31
+ })
32
+
33
+ const buttonClasses = computed(() => {
34
+ if (props.variant === "dropdown") {
35
+ const dis = props.type === "button" ? "disabled:opacity-50 disabled:pointer-events-none" : isDisabled.value ? "opacity-50 pointer-events-none" : ""
36
+ return `w-full flex items-center gap-x-3.5 py-2 px-3 rounded-lg text-sm transition-colors text-left ${severityClasses.value} ${dis} focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary/40 dark:focus:ring-offset-gray-800 ${props.class}`
37
+ }
38
+ const dis = props.type === "button" ? "disabled:opacity-50 disabled:pointer-events-none" : isDisabled.value ? "opacity-50 pointer-events-none" : ""
39
+ const cursor = props.type === "link" ? "cursor-pointer" : ""
40
+ return `${sizeClasses.value} ${severityClasses.value} ${dis} focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-primary/40 dark:focus:ring-offset-gray-800 ${cursor} inline-flex justify-center items-center gap-x-2 whitespace-nowrap ${props.class}`
41
+ })
42
+
43
+ const displayText = computed(() => props.loading ? props.loadingText : props.text)
44
+ const isDisabled = computed(() => props.disabled || props.loading)
45
+ </script>
46
+ <template>
47
+ <NuxtLink v-if="type === 'link'" :to="link" :class="buttonClasses">
48
+ <div v-if="loading" :class="`animate-spin border-[2.5px] border-t-transparent rounded-full ${iconSizeClasses}`" :style="`border-color: currentColor; border-top-color: transparent`" role="status" />
49
+ <component v-else-if="icon && iconPosition === 'left'" :is="icon" :class="variant === 'dropdown' ? 'size-4 shrink-0' : `${iconSizeClasses} ${iconClass}`" />
50
+ <slot><span :class="textClass">{{ displayText }}</span></slot>
51
+ <component v-if="!loading && icon && iconPosition === 'right'" :is="icon" :class="variant === 'dropdown' ? 'size-4 shrink-0' : `${iconSizeClasses} ${iconClass}`" />
52
+ </NuxtLink>
53
+ <button v-else :disabled="isDisabled" :class="buttonClasses" type="button">
54
+ <div v-if="loading" :class="`animate-spin border-[2.5px] border-t-transparent rounded-full ${iconSizeClasses}`" :style="`border-color: currentColor; border-top-color: transparent`" role="status" />
55
+ <component v-else-if="icon && iconPosition === 'left'" :is="icon" :class="variant === 'dropdown' ? 'size-4 shrink-0' : `${iconSizeClasses} ${iconClass}`" />
56
+ <slot><span :class="textClass">{{ displayText }}</span></slot>
57
+ <component v-if="!loading && icon && iconPosition === 'right'" :is="icon" :class="variant === 'dropdown' ? 'size-4 shrink-0' : `${iconSizeClasses} ${iconClass}`" />
58
+ </button>
59
+ </template>
@@ -0,0 +1,43 @@
1
+ <script setup>
2
+ const props = defineProps({ sidebar: { type: Boolean, default: false } })
3
+
4
+ const config = useRuntimeConfig()
5
+ const appEnv = config.public.appEnv
6
+
7
+ const isVisible = computed(() => appEnv && appEnv !== 'production')
8
+
9
+ useHead({
10
+ htmlAttrs: { class: (!props.sidebar && isVisible.value) ? 'has-env-bar' : '' },
11
+ style: [{ children: (!props.sidebar && isVisible.value) ? ':root { --env-bar-height: 1.5rem; }' : ':root { --env-bar-height: 0px; }' }],
12
+ })
13
+
14
+ const envColor = computed(() => {
15
+ const env = appEnv.toLowerCase()
16
+ if (env === 'staging') return props.sidebar ? 'bg-amber-50 text-amber-600 border border-amber-200 dark:bg-amber-500/10 dark:text-amber-400 dark:border-amber-500/30' : 'bg-amber-500 text-white'
17
+ if (env === 'local' || env === 'dev') return props.sidebar ? 'bg-blue-50 text-blue-600 border border-blue-200 dark:bg-blue-500/10 dark:text-blue-400 dark:border-blue-500/30' : 'bg-blue-600 text-white'
18
+ return props.sidebar ? 'bg-red-50 text-red-600 border border-red-200 dark:bg-red-500/10 dark:text-red-400 dark:border-red-500/30' : 'bg-red-600 text-white'
19
+ })
20
+ </script>
21
+
22
+ <template>
23
+ <!-- Sidebar inline variant -->
24
+ <div v-if="isVisible && sidebar"
25
+ :class="['w-full rounded-lg px-3 py-1.5 flex items-center justify-center gap-x-1.5 select-none', envColor]"
26
+ >
27
+ <span class="text-[9px] font-black tracking-widest uppercase">ENTORNO: {{ appEnv }}</span>
28
+ </div>
29
+
30
+ <!-- Full-width fixed bar -->
31
+ <div v-else-if="isVisible && !sidebar"
32
+ class="fixed bottom-0 left-0 right-0 z-[100] h-6 overflow-hidden select-none pointer-events-none"
33
+ >
34
+ <div
35
+ class="flex items-center justify-center w-full h-full text-[10px] font-bold tracking-widest uppercase opacity-90 shadow-lg border-t border-white/10"
36
+ :class="envColor"
37
+ >
38
+ <span class="mr-2">━━━━━</span>
39
+ ENTORNO: {{ appEnv }}
40
+ <span class="ml-2">━━━━━</span>
41
+ </div>
42
+ </div>
43
+ </template>