@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.
- package/.github/workflows/auto-publish.yml +64 -0
- package/.github/workflows/release.yml +59 -0
- package/README.md +60 -0
- package/app.config.ts +70 -0
- package/components/Admin/Base.vue +144 -0
- package/components/Admin/Header.vue +32 -0
- package/components/Admin/Page.vue +65 -0
- package/components/Admin/PageHeader.vue +31 -0
- package/components/App/Button.vue +59 -0
- package/components/App/DevEnvironmentBar.vue +43 -0
- package/components/App/Dropdown.vue +286 -0
- package/components/App/EmptyState.vue +433 -0
- package/components/App/LoadingState.vue +40 -0
- package/components/App/PageLoadingSpinner.vue +118 -0
- package/components/App/PreviewDock.vue +64 -0
- package/components/App/SwitchColorTheme.vue +51 -0
- package/components/App/Tag.vue +193 -0
- package/components/DataTable.vue +713 -0
- package/components/Forms/DatePicker.vue +255 -0
- package/components/Forms/Input.vue +75 -0
- package/components/Forms/Select.vue +100 -0
- package/components/Forms/SelectServer.vue +726 -0
- package/components/Layout/Admin.vue +32 -0
- package/components/Layout/Auth.vue +29 -0
- package/components/Layout/SidebarWithAppColumn.vue +388 -0
- package/components/Layout/TopBar.vue +113 -0
- package/components/MobileBlocker.vue +85 -0
- package/components/MobileLoginPicker.vue +83 -0
- package/components/Modal/Base.vue +29 -0
- package/components/Modal/DeleteConfirm.vue +48 -0
- package/components/Modal.vue +103 -0
- package/components/Nav/Tabs.vue +55 -0
- package/components/PermissionsTree.vue +272 -0
- package/components/Table/Database.vue +183 -0
- package/components/Table/DownloadDropdown.vue +111 -0
- package/components/Table/Enterprise.vue +540 -0
- package/components/Table/FilterDropdown.vue +226 -0
- package/components/Table/Grid.vue +62 -0
- package/components/Table/Kanban.vue +188 -0
- package/components/Table/List.vue +128 -0
- package/components/Table/PreviewTimeline.vue +118 -0
- package/components/Table/Standard.vue +1217 -0
- package/components/Table/index.vue +974 -0
- package/components/TableExportable.vue +172 -0
- package/components/TableFilter.vue +93 -0
- package/components/Toast/Alert.vue +113 -0
- package/components/Toast/Container.vue +34 -0
- package/components/Toast/Notification.vue +45 -0
- package/components/Toast/Process.vue +88 -0
- package/composables/useApi.js +95 -0
- package/composables/useApp.ts +46 -0
- package/composables/useAuth.js +82 -0
- package/composables/useContext.js +44 -0
- package/composables/useDate.js +241 -0
- package/composables/useDevice.js +21 -0
- package/composables/useDockedPreviews.js +56 -0
- package/composables/useDownload.js +87 -0
- package/composables/useEntity.js +82 -0
- package/composables/useForm.js +119 -0
- package/composables/useInnertiaMode.ts +25 -0
- package/composables/useMobileGuard.ts +81 -0
- package/composables/useNotifications.js +22 -0
- package/composables/usePermissions.js +23 -0
- package/composables/useRealtime.js +123 -0
- package/composables/useRequestInterceptors.js +27 -0
- package/composables/useRoles.js +53 -0
- package/composables/useRutFormatter.js +39 -0
- package/composables/useTable.ts +94 -0
- package/composables/useTablePreferences.ts +33 -0
- package/composables/useTenant.js +27 -0
- package/composables/useTimeAgo.js +37 -0
- package/composables/useToast.js +69 -0
- package/composables/useUserRealtime.js +17 -0
- package/composables/useUsers.js +111 -0
- package/css/themes/autumn.css +401 -0
- package/css/themes/bubblegum.css +408 -0
- package/css/themes/cashmere.css +412 -0
- package/css/themes/harvest.css +416 -0
- package/css/themes/moon.css +140 -0
- package/css/themes/ocean.css +273 -0
- package/css/themes/olive.css +413 -0
- package/css/themes/retro.css +431 -0
- package/css/themes/theme.css +725 -0
- package/error.vue +78 -0
- package/middleware/01.detect-subdomain.global.ts +43 -0
- package/middleware/02.validate-tenant.global.ts +67 -0
- package/middleware/03.apps.global.ts +88 -0
- package/middleware/auth.ts +9 -0
- package/middleware/guest.ts +9 -0
- package/nuxt.config.ts +42 -0
- package/package.json +60 -0
- package/pages/tenant-error.vue +50 -0
- package/plugins/api-auth.ts +12 -0
- package/plugins/api-tenant.client.ts +21 -0
- package/plugins/appearance.ts +8 -0
- package/plugins/auth-init.ts +34 -0
- package/plugins/dark-state.client.ts +29 -0
- package/plugins/dockedPreviewsSync.client.js +17 -0
- package/plugins/preline.client.ts +68 -0
- package/plugins/theme.client.ts +7 -0
- package/plugins/vue-query.ts +29 -0
- package/public/init-theme.js +15 -0
- package/spark.css +721 -0
- package/stores/auth.js +130 -0
- package/stores/dockedPreviews.js +34 -0
- package/stores/notifications.js +24 -0
- package/stores/tenant.js +54 -0
- 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>
|