@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,32 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
const showAnimation = ref(false)
|
|
3
|
+
onMounted(() => {
|
|
4
|
+
const seen = sessionStorage.getItem('auth-entered')
|
|
5
|
+
if (!seen) {
|
|
6
|
+
showAnimation.value = true
|
|
7
|
+
sessionStorage.setItem('auth-entered', 'true')
|
|
8
|
+
}
|
|
9
|
+
})
|
|
10
|
+
</script>
|
|
11
|
+
|
|
12
|
+
<template>
|
|
13
|
+
<div :class="{ 'animate-entrance': showAnimation }">
|
|
14
|
+
<AdminBase>
|
|
15
|
+
<template #logo><slot name="logo" /></template>
|
|
16
|
+
<template #menu><slot name="menu" /></template>
|
|
17
|
+
<template #user-footer><slot name="user-footer" /></template>
|
|
18
|
+
|
|
19
|
+
<div class="lg:ps-65">
|
|
20
|
+
<slot />
|
|
21
|
+
</div>
|
|
22
|
+
</AdminBase>
|
|
23
|
+
</div>
|
|
24
|
+
</template>
|
|
25
|
+
|
|
26
|
+
<style scoped>
|
|
27
|
+
.animate-entrance { animation: fadeInScale 0.5s cubic-bezier(0.4, 0, 0.2, 1); }
|
|
28
|
+
@keyframes fadeInScale {
|
|
29
|
+
from { opacity: 0; transform: scale(0.96); filter: blur(4px); }
|
|
30
|
+
to { opacity: 1; transform: scale(1); filter: blur(0); }
|
|
31
|
+
}
|
|
32
|
+
</style>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="bg-muted">
|
|
3
|
+
<div class="absolute top-4 right-4 z-20">
|
|
4
|
+
<slot name="theme-switch" />
|
|
5
|
+
</div>
|
|
6
|
+
<main class="flex min-h-full">
|
|
7
|
+
<!-- Columna izquierda: imagen/bienvenida -->
|
|
8
|
+
<div class="relative hidden min-h-screen lg:w-200 xl:w-[65%] bg-surface lg:flex flex-col justify-between p-6 overflow-hidden">
|
|
9
|
+
<slot name="background" />
|
|
10
|
+
<div class="z-0 p-20 pt-[max(180px,10vh)]">
|
|
11
|
+
<slot name="welcome" />
|
|
12
|
+
</div>
|
|
13
|
+
</div>
|
|
14
|
+
|
|
15
|
+
<!-- Columna derecha: formulario -->
|
|
16
|
+
<div class="grow px-12 z-10 -ml-6 pr-6 bg-card rounded-tl-[1.5rem] rounded-bl-[1.5rem]">
|
|
17
|
+
<div class="h-full min-h-screen sm:w-112 flex flex-col justify-center mx-auto space-y-5 p-4">
|
|
18
|
+
<div class="flex justify-center mb-6">
|
|
19
|
+
<slot name="logo" />
|
|
20
|
+
</div>
|
|
21
|
+
<slot />
|
|
22
|
+
<div class="absolute bottom-4 right-4 text-xs text-muted-foreground">
|
|
23
|
+
<slot name="version" />
|
|
24
|
+
</div>
|
|
25
|
+
</div>
|
|
26
|
+
</div>
|
|
27
|
+
</main>
|
|
28
|
+
</div>
|
|
29
|
+
</template>
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import * as TablerIcons from '@tabler/icons-vue'
|
|
3
|
+
|
|
4
|
+
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
interface NavItem {
|
|
7
|
+
label: string
|
|
8
|
+
icon: string
|
|
9
|
+
route: string
|
|
10
|
+
pattern?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface AppItem {
|
|
14
|
+
label: string
|
|
15
|
+
icon: string
|
|
16
|
+
route: string
|
|
17
|
+
bg?: string
|
|
18
|
+
text?: string
|
|
19
|
+
nav?: NavItem[]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface MenuItem {
|
|
23
|
+
label: string
|
|
24
|
+
icon: string
|
|
25
|
+
route?: string
|
|
26
|
+
pattern?: string
|
|
27
|
+
children?: NavItem[]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ─── Props / Emits ────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
const props = withDefaults(defineProps<{
|
|
33
|
+
appItems?: AppItem[]
|
|
34
|
+
menuItems?: MenuItem[]
|
|
35
|
+
homeRoute?: string
|
|
36
|
+
user?: { name?: string; email?: string } | null
|
|
37
|
+
floating?: boolean
|
|
38
|
+
}>(), {
|
|
39
|
+
appItems: () => [],
|
|
40
|
+
menuItems: () => [],
|
|
41
|
+
homeRoute: '/',
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
const emit = defineEmits<{ logout: [] }>()
|
|
45
|
+
|
|
46
|
+
// ─── Mobile sidebar ───────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
const isOpen = ref(false)
|
|
49
|
+
const open = () => { isOpen.value = true }
|
|
50
|
+
const close = () => { isOpen.value = false }
|
|
51
|
+
provide('spark:sidebar', { isOpen, open, close })
|
|
52
|
+
|
|
53
|
+
// ─── Route matching ───────────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
const route = useRoute()
|
|
56
|
+
|
|
57
|
+
const matchPattern = (pattern?: string) => {
|
|
58
|
+
if (!pattern) return false
|
|
59
|
+
return new RegExp('^' + pattern.replace(/\*/g, '.*') + '$').test(route.path)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const isChildActive = (children?: NavItem[]) =>
|
|
63
|
+
children?.some(c => matchPattern(c.pattern ?? c.route + '/*')) ?? false
|
|
64
|
+
|
|
65
|
+
// ─── Active app detection ─────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
const currentApp = computed(() =>
|
|
68
|
+
props.appItems.find(a => route.path.startsWith(a.route)) ?? null
|
|
69
|
+
)
|
|
70
|
+
const isInApp = computed(() => !!currentApp.value)
|
|
71
|
+
|
|
72
|
+
// ─── Hover preview ────────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
const hoveredApp = ref<AppItem | 'home' | null>(null)
|
|
75
|
+
|
|
76
|
+
const previewApp = computed(() =>
|
|
77
|
+
hoveredApp.value && hoveredApp.value !== 'home'
|
|
78
|
+
? hoveredApp.value as AppItem
|
|
79
|
+
: currentApp.value
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
const showMainMenu = computed(() =>
|
|
83
|
+
hoveredApp.value === 'home' || (!hoveredApp.value && !isInApp.value)
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
watch(isInApp, (val) => { if (!val) hoveredApp.value = null })
|
|
87
|
+
|
|
88
|
+
// ─── Main menu accordion ──────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
const openAccordions = ref<Record<number, boolean>>({})
|
|
91
|
+
const toggleAccordion = (i: number) => { openAccordions.value[i] = !openAccordions.value[i] }
|
|
92
|
+
|
|
93
|
+
watch(() => route.path, () => {
|
|
94
|
+
let active = -1
|
|
95
|
+
props.menuItems.forEach((item, i) => {
|
|
96
|
+
if (item.children && (matchPattern(item.pattern) || isChildActive(item.children))) active = i
|
|
97
|
+
})
|
|
98
|
+
openAccordions.value = active !== -1 ? { [active]: true } : {}
|
|
99
|
+
}, { immediate: true })
|
|
100
|
+
|
|
101
|
+
// ─── User / env ───────────────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
const userInitial = computed(() =>
|
|
104
|
+
props.user?.name?.charAt(0).toUpperCase() ??
|
|
105
|
+
props.user?.email?.charAt(0).toUpperCase() ?? 'U'
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
const config = useRuntimeConfig()
|
|
109
|
+
const appEnv = config.public.appEnv as string | undefined
|
|
110
|
+
const envLabel = computed(() => (!appEnv || appEnv === 'production') ? null : appEnv)
|
|
111
|
+
|
|
112
|
+
// ─── Icon resolver ────────────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
const icon = (name: string) => (TablerIcons as Record<string, unknown>)[name]
|
|
115
|
+
</script>
|
|
116
|
+
|
|
117
|
+
<template>
|
|
118
|
+
<div class="bg-background-1 min-h-screen">
|
|
119
|
+
|
|
120
|
+
<!-- Mobile backdrop -->
|
|
121
|
+
<Transition
|
|
122
|
+
enter-from-class="opacity-0" enter-active-class="transition-opacity duration-300"
|
|
123
|
+
leave-to-class="opacity-0" leave-active-class="transition-opacity duration-300"
|
|
124
|
+
>
|
|
125
|
+
<div
|
|
126
|
+
v-if="isOpen"
|
|
127
|
+
class="lg:hidden fixed inset-0 z-50 bg-black/40 backdrop-blur-sm"
|
|
128
|
+
@click="close"
|
|
129
|
+
/>
|
|
130
|
+
</Transition>
|
|
131
|
+
|
|
132
|
+
<!-- ═══ SIDEBAR ════════════════════════════════════════════════════════════ -->
|
|
133
|
+
<aside
|
|
134
|
+
:class="[
|
|
135
|
+
'fixed inset-y-0 start-0 z-60 w-65',
|
|
136
|
+
'transition-transform duration-300 lg:translate-x-0',
|
|
137
|
+
isOpen ? 'translate-x-0' : 'max-lg:-translate-x-full',
|
|
138
|
+
floating ? 'p-3' : '',
|
|
139
|
+
]"
|
|
140
|
+
>
|
|
141
|
+
<div
|
|
142
|
+
:class="[
|
|
143
|
+
'flex flex-col h-full bg-sidebar',
|
|
144
|
+
floating
|
|
145
|
+
? 'rounded-2xl border border-sidebar-line shadow-sm overflow-hidden'
|
|
146
|
+
: 'border-e border-sidebar-line',
|
|
147
|
+
]"
|
|
148
|
+
>
|
|
149
|
+
|
|
150
|
+
<!-- Logo ─────────────────────────────────────────────────────────────── -->
|
|
151
|
+
<header class="px-4 pt-4 pb-3 border-b border-sidebar-line shrink-0 flex items-center gap-x-2">
|
|
152
|
+
<div class="flex-1 min-w-0">
|
|
153
|
+
<slot name="logo" />
|
|
154
|
+
</div>
|
|
155
|
+
<!-- Mobile close -->
|
|
156
|
+
<button
|
|
157
|
+
type="button"
|
|
158
|
+
class="lg:hidden size-7 inline-flex justify-center items-center rounded-lg text-muted-foreground hover:bg-muted-hover transition-colors"
|
|
159
|
+
@click="close"
|
|
160
|
+
>
|
|
161
|
+
<svg class="size-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
162
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
163
|
+
</svg>
|
|
164
|
+
</button>
|
|
165
|
+
</header>
|
|
166
|
+
|
|
167
|
+
<!-- Search (optional) ───────────────────────────────────────────────── -->
|
|
168
|
+
<div v-if="$slots.search" class="px-3 pb-2 border-b border-sidebar-line shrink-0">
|
|
169
|
+
<slot name="search" />
|
|
170
|
+
</div>
|
|
171
|
+
|
|
172
|
+
<!-- Body: icon strip + right panel ──────────────────────────────────── -->
|
|
173
|
+
<div class="flex flex-1 min-h-0" @mouseleave="hoveredApp = null">
|
|
174
|
+
|
|
175
|
+
<!-- ── Icon strip (50 px) ──────────────────────────────────────────── -->
|
|
176
|
+
<div class="w-[50px] shrink-0 flex flex-col relative">
|
|
177
|
+
|
|
178
|
+
<!-- Separator line (behind strip items via z-0) -->
|
|
179
|
+
<span class="pointer-events-none absolute right-0 inset-y-0 w-px bg-sidebar-line z-0" />
|
|
180
|
+
|
|
181
|
+
<div class="flex-1 flex flex-col py-2 gap-1.5 overflow-y-auto relative z-10">
|
|
182
|
+
|
|
183
|
+
<!-- Home button -->
|
|
184
|
+
<NuxtLink
|
|
185
|
+
:to="homeRoute"
|
|
186
|
+
class="group relative flex items-center justify-center h-12 border border-r-0"
|
|
187
|
+
:class="!isInApp
|
|
188
|
+
? 'ml-[2px] bg-card border-sidebar-line rounded-l-lg'
|
|
189
|
+
: 'ml-[1px] w-12 border-transparent rounded-lg'"
|
|
190
|
+
@mouseenter="hoveredApp = 'home'"
|
|
191
|
+
>
|
|
192
|
+
<span
|
|
193
|
+
class="size-10 flex items-center justify-center rounded-lg shrink-0 transition-opacity duration-150 bg-surface text-muted-foreground"
|
|
194
|
+
:class="!isInApp ? 'opacity-100' : 'opacity-50 group-hover:opacity-100'"
|
|
195
|
+
>
|
|
196
|
+
<component :is="icon('IconHome')" class="size-[22px]" />
|
|
197
|
+
</span>
|
|
198
|
+
</NuxtLink>
|
|
199
|
+
|
|
200
|
+
<!-- App items -->
|
|
201
|
+
<NuxtLink
|
|
202
|
+
v-for="app in appItems"
|
|
203
|
+
:key="app.route"
|
|
204
|
+
:to="app.route"
|
|
205
|
+
class="group relative flex items-center justify-center h-12 border border-r-0"
|
|
206
|
+
:class="route.path.startsWith(app.route)
|
|
207
|
+
? 'ml-[2px] bg-card border-sidebar-line rounded-l-lg'
|
|
208
|
+
: 'ml-[1px] w-12 border-transparent rounded-lg'"
|
|
209
|
+
@mouseenter="hoveredApp = app"
|
|
210
|
+
>
|
|
211
|
+
<span
|
|
212
|
+
class="size-10 flex items-center justify-center rounded-lg shrink-0 transition-opacity duration-150"
|
|
213
|
+
:class="[
|
|
214
|
+
app.bg ?? 'bg-surface',
|
|
215
|
+
app.text ?? 'text-muted-foreground',
|
|
216
|
+
route.path.startsWith(app.route) ? 'opacity-100' : 'opacity-50 group-hover:opacity-100',
|
|
217
|
+
]"
|
|
218
|
+
>
|
|
219
|
+
<component :is="icon(app.icon)" class="size-[22px]" />
|
|
220
|
+
</span>
|
|
221
|
+
</NuxtLink>
|
|
222
|
+
|
|
223
|
+
</div>
|
|
224
|
+
</div>
|
|
225
|
+
<!-- /icon strip -->
|
|
226
|
+
|
|
227
|
+
<!-- ── Right panel ─────────────────────────────────────────────────── -->
|
|
228
|
+
<div class="flex-1 relative overflow-hidden bg-card">
|
|
229
|
+
|
|
230
|
+
<!-- MAIN MENU -->
|
|
231
|
+
<Transition name="panel-swap">
|
|
232
|
+
<div v-if="showMainMenu" key="main" class="absolute inset-0 overflow-y-auto">
|
|
233
|
+
<ul class="flex flex-col gap-y-1 px-3 pt-2 pb-3">
|
|
234
|
+
<li v-for="(item, index) in menuItems" :key="'menu-' + index">
|
|
235
|
+
|
|
236
|
+
<!-- Simple link -->
|
|
237
|
+
<NuxtLink
|
|
238
|
+
v-if="!item.children && item.route"
|
|
239
|
+
:to="item.route"
|
|
240
|
+
class="flex items-center gap-x-3 py-2 px-3 text-sm text-muted-foreground rounded-lg hover:bg-muted-hover transition-all border border-transparent"
|
|
241
|
+
:class="{ 'bg-surface text-foreground font-semibold border-card-line': matchPattern(item.pattern ?? item.route) }"
|
|
242
|
+
>
|
|
243
|
+
<component :is="icon(item.icon)" class="shrink-0 size-4" />
|
|
244
|
+
{{ item.label }}
|
|
245
|
+
</NuxtLink>
|
|
246
|
+
|
|
247
|
+
<!-- Accordion group -->
|
|
248
|
+
<div v-else-if="item.children" class="flex flex-col">
|
|
249
|
+
<button
|
|
250
|
+
type="button"
|
|
251
|
+
class="w-full text-start flex items-center gap-x-3 py-2 px-3 text-sm text-muted-foreground rounded-lg hover:bg-muted-hover transition-all"
|
|
252
|
+
:class="{ 'bg-muted text-foreground font-semibold': openAccordions[index] }"
|
|
253
|
+
@click="toggleAccordion(index)"
|
|
254
|
+
>
|
|
255
|
+
<component :is="icon(item.icon)" class="shrink-0 size-4" />
|
|
256
|
+
<span class="flex-1">{{ item.label }}</span>
|
|
257
|
+
<component
|
|
258
|
+
:is="icon('IconChevronDown')"
|
|
259
|
+
class="shrink-0 size-4 transition-transform duration-300"
|
|
260
|
+
:class="{ '-rotate-180': openAccordions[index] }"
|
|
261
|
+
/>
|
|
262
|
+
</button>
|
|
263
|
+
<div
|
|
264
|
+
class="overflow-hidden transition-all duration-300 ease-in-out"
|
|
265
|
+
:style="{
|
|
266
|
+
maxHeight: openAccordions[index] ? '300px' : '0px',
|
|
267
|
+
opacity: openAccordions[index] ? '1' : '0',
|
|
268
|
+
marginTop: openAccordions[index] ? '4px' : '0px',
|
|
269
|
+
}"
|
|
270
|
+
>
|
|
271
|
+
<ul class="ps-8 flex flex-col gap-y-1 relative before:absolute before:start-4.5 before:w-px before:h-full before:bg-sidebar-line">
|
|
272
|
+
<li v-for="child in item.children" :key="child.route">
|
|
273
|
+
<NuxtLink
|
|
274
|
+
:to="child.route"
|
|
275
|
+
class="flex items-center gap-x-4 py-2 px-3 text-sm text-muted-foreground rounded-lg hover:bg-muted-hover transition-colors border border-transparent"
|
|
276
|
+
:class="{ 'bg-surface text-foreground font-semibold': matchPattern(child.pattern ?? child.route) }"
|
|
277
|
+
>
|
|
278
|
+
<component :is="icon(child.icon)" class="shrink-0 size-4" />
|
|
279
|
+
{{ child.label }}
|
|
280
|
+
</NuxtLink>
|
|
281
|
+
</li>
|
|
282
|
+
</ul>
|
|
283
|
+
</div>
|
|
284
|
+
</div>
|
|
285
|
+
|
|
286
|
+
</li>
|
|
287
|
+
</ul>
|
|
288
|
+
</div>
|
|
289
|
+
</Transition>
|
|
290
|
+
|
|
291
|
+
<!-- APP SUBNAV -->
|
|
292
|
+
<Transition name="panel-swap">
|
|
293
|
+
<div v-if="!showMainMenu" :key="previewApp?.route ?? 'app'" class="absolute inset-0 flex flex-col">
|
|
294
|
+
<div class="px-3 pt-3 pb-3 border-b border-sidebar-line shrink-0">
|
|
295
|
+
<span class="text-[13px] font-bold text-muted-foreground uppercase tracking-wider truncate">
|
|
296
|
+
{{ previewApp?.label }}
|
|
297
|
+
</span>
|
|
298
|
+
</div>
|
|
299
|
+
<nav class="flex-1 overflow-y-auto py-2 px-2">
|
|
300
|
+
<ul class="flex flex-col gap-0.5">
|
|
301
|
+
<li v-for="item in (previewApp?.nav ?? [])" :key="item.route">
|
|
302
|
+
<NuxtLink
|
|
303
|
+
:to="item.route"
|
|
304
|
+
class="flex items-center gap-3 px-3 py-2 rounded-lg text-sm text-muted-foreground hover:bg-muted-hover transition-colors border border-transparent"
|
|
305
|
+
:class="{ 'bg-surface text-foreground font-semibold border-card-line': matchPattern(item.pattern ?? item.route + '*') }"
|
|
306
|
+
>
|
|
307
|
+
<component :is="icon(item.icon)" class="size-4 shrink-0" />
|
|
308
|
+
{{ item.label }}
|
|
309
|
+
</NuxtLink>
|
|
310
|
+
</li>
|
|
311
|
+
</ul>
|
|
312
|
+
</nav>
|
|
313
|
+
</div>
|
|
314
|
+
</Transition>
|
|
315
|
+
|
|
316
|
+
</div>
|
|
317
|
+
<!-- /right panel -->
|
|
318
|
+
|
|
319
|
+
</div>
|
|
320
|
+
<!-- /body -->
|
|
321
|
+
|
|
322
|
+
<!-- Footer ───────────────────────────────────────────────────────────── -->
|
|
323
|
+
<div class="shrink-0 border-t border-sidebar-line">
|
|
324
|
+
|
|
325
|
+
<!-- Controls slot (dark mode, notifications, etc.) -->
|
|
326
|
+
<div v-if="$slots['user-controls']" class="flex items-center gap-x-1.5 px-3 pt-3">
|
|
327
|
+
<slot name="user-controls" />
|
|
328
|
+
</div>
|
|
329
|
+
|
|
330
|
+
<!-- User info + logout -->
|
|
331
|
+
<div v-if="user" class="flex items-center gap-x-3 px-3 py-3">
|
|
332
|
+
<div class="size-9 rounded-lg bg-primary flex items-center justify-center text-primary-foreground text-sm font-bold shrink-0 select-none">
|
|
333
|
+
{{ userInitial }}
|
|
334
|
+
</div>
|
|
335
|
+
<div class="flex-1 min-w-0">
|
|
336
|
+
<p class="text-sm font-semibold text-foreground truncate">{{ user.name ?? user.email }}</p>
|
|
337
|
+
<p v-if="user.name && user.email" class="text-xs text-muted-foreground truncate">{{ user.email }}</p>
|
|
338
|
+
</div>
|
|
339
|
+
<button
|
|
340
|
+
type="button"
|
|
341
|
+
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"
|
|
342
|
+
title="Cerrar sesión"
|
|
343
|
+
@click="emit('logout')"
|
|
344
|
+
>
|
|
345
|
+
<svg class="size-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
|
346
|
+
<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" />
|
|
347
|
+
</svg>
|
|
348
|
+
</button>
|
|
349
|
+
</div>
|
|
350
|
+
|
|
351
|
+
<!-- Extra footer slot -->
|
|
352
|
+
<slot name="user-footer" />
|
|
353
|
+
|
|
354
|
+
<!-- Env banner -->
|
|
355
|
+
<div
|
|
356
|
+
v-if="envLabel"
|
|
357
|
+
class="flex items-center justify-center gap-x-2 py-2 bg-amber-400/15 border-t border-amber-400/30"
|
|
358
|
+
>
|
|
359
|
+
<span class="size-1.5 rounded-full bg-amber-400 shrink-0" />
|
|
360
|
+
<span class="text-[11px] font-semibold text-amber-600 dark:text-amber-400 uppercase tracking-wide">
|
|
361
|
+
{{ envLabel }}
|
|
362
|
+
</span>
|
|
363
|
+
</div>
|
|
364
|
+
|
|
365
|
+
</div>
|
|
366
|
+
<!-- /footer -->
|
|
367
|
+
|
|
368
|
+
</div>
|
|
369
|
+
</aside>
|
|
370
|
+
|
|
371
|
+
<!-- ═══ MAIN CONTENT ══════════════════════════════════════════════════════ -->
|
|
372
|
+
<div class="lg:ps-65 flex flex-col min-h-screen">
|
|
373
|
+
<slot name="topbar" />
|
|
374
|
+
<div class="flex-1 min-h-0">
|
|
375
|
+
<slot />
|
|
376
|
+
</div>
|
|
377
|
+
</div>
|
|
378
|
+
|
|
379
|
+
</div>
|
|
380
|
+
</template>
|
|
381
|
+
|
|
382
|
+
<style scoped>
|
|
383
|
+
/* Panel swap animation */
|
|
384
|
+
.panel-swap-enter-active,
|
|
385
|
+
.panel-swap-leave-active { transition: opacity 0.18s ease, transform 0.18s ease; }
|
|
386
|
+
.panel-swap-enter-from { opacity: 0; transform: translateX(10px); }
|
|
387
|
+
.panel-swap-leave-to { opacity: 0; transform: translateX(-10px); }
|
|
388
|
+
</style>
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// ─── Props / Emits ────────────────────────────────────────────────────────────
|
|
3
|
+
|
|
4
|
+
const props = withDefaults(defineProps<{
|
|
5
|
+
user?: { name?: string; email?: string; role?: string } | null
|
|
6
|
+
notificationsCount?: number
|
|
7
|
+
searchPlaceholder?: string
|
|
8
|
+
showSearch?: boolean
|
|
9
|
+
showNotifications?: boolean
|
|
10
|
+
showUser?: boolean
|
|
11
|
+
}>(), {
|
|
12
|
+
user: null,
|
|
13
|
+
notificationsCount: 0,
|
|
14
|
+
searchPlaceholder: 'Buscar…',
|
|
15
|
+
showSearch: true,
|
|
16
|
+
showNotifications: true,
|
|
17
|
+
showUser: true,
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
const emit = defineEmits<{
|
|
21
|
+
search: [query: string]
|
|
22
|
+
'notification-click': []
|
|
23
|
+
logout: []
|
|
24
|
+
}>()
|
|
25
|
+
|
|
26
|
+
// ─── Search ───────────────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
const searchQuery = ref('')
|
|
29
|
+
|
|
30
|
+
const handleSearch = () => emit('search', searchQuery.value)
|
|
31
|
+
|
|
32
|
+
// ⌘K / Ctrl+K → focus search
|
|
33
|
+
const searchRef = ref<HTMLInputElement | null>(null)
|
|
34
|
+
onMounted(() => {
|
|
35
|
+
window.addEventListener('keydown', (e) => {
|
|
36
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
|
37
|
+
e.preventDefault()
|
|
38
|
+
searchRef.value?.focus()
|
|
39
|
+
}
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
// ─── User initials ────────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
const userInitials = computed(() => {
|
|
46
|
+
const name = props.user?.name ?? props.user?.email ?? ''
|
|
47
|
+
return name.split(' ').slice(0, 2).map(p => p[0]).join('').toUpperCase() || 'U'
|
|
48
|
+
})
|
|
49
|
+
</script>
|
|
50
|
+
|
|
51
|
+
<template>
|
|
52
|
+
<header class="sticky top-0 z-40 h-14 bg-card/95 backdrop-blur-sm border-b border-card-line flex items-center gap-4 px-6">
|
|
53
|
+
|
|
54
|
+
<!-- Left slot (breadcrumb, page title, etc.) -->
|
|
55
|
+
<div v-if="$slots.left" class="flex items-center gap-3 min-w-0 mr-auto">
|
|
56
|
+
<slot name="left" />
|
|
57
|
+
</div>
|
|
58
|
+
<div v-else class="mr-auto" />
|
|
59
|
+
|
|
60
|
+
<!-- Global search -->
|
|
61
|
+
<div v-if="showSearch" class="relative hidden sm:block w-72">
|
|
62
|
+
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
|
63
|
+
<svg class="size-4 text-muted-foreground" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
64
|
+
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
|
65
|
+
</svg>
|
|
66
|
+
</div>
|
|
67
|
+
<input
|
|
68
|
+
ref="searchRef"
|
|
69
|
+
v-model="searchQuery"
|
|
70
|
+
type="search"
|
|
71
|
+
:placeholder="searchPlaceholder"
|
|
72
|
+
class="w-full h-9 pl-9 pr-12 text-sm bg-surface border border-card-line rounded-lg text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition"
|
|
73
|
+
@keydown.enter="handleSearch"
|
|
74
|
+
/>
|
|
75
|
+
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
|
|
76
|
+
<kbd class="hidden sm:inline-flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] font-mono font-semibold text-muted-foreground bg-muted border border-card-line rounded">
|
|
77
|
+
<span class="text-[9px]">⌘</span>K
|
|
78
|
+
</kbd>
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
<!-- Right slot -->
|
|
83
|
+
<slot name="right" />
|
|
84
|
+
|
|
85
|
+
<!-- Notifications -->
|
|
86
|
+
<button
|
|
87
|
+
v-if="showNotifications"
|
|
88
|
+
type="button"
|
|
89
|
+
class="relative size-9 flex items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted-hover transition-colors"
|
|
90
|
+
@click="emit('notification-click')"
|
|
91
|
+
>
|
|
92
|
+
<svg class="size-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
93
|
+
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/>
|
|
94
|
+
</svg>
|
|
95
|
+
<span
|
|
96
|
+
v-if="notificationsCount > 0"
|
|
97
|
+
class="absolute top-1.5 right-1.5 min-w-[16px] h-4 px-1 flex items-center justify-center rounded-full bg-red-500 text-[10px] font-bold text-white leading-none"
|
|
98
|
+
>{{ notificationsCount > 99 ? '99+' : notificationsCount }}</span>
|
|
99
|
+
</button>
|
|
100
|
+
|
|
101
|
+
<!-- User -->
|
|
102
|
+
<div v-if="showUser && user" class="flex items-center gap-2.5 pl-2 border-l border-card-line">
|
|
103
|
+
<div class="size-8 rounded-full bg-primary flex items-center justify-center text-primary-foreground text-xs font-bold shrink-0 select-none">
|
|
104
|
+
{{ userInitials }}
|
|
105
|
+
</div>
|
|
106
|
+
<div class="hidden md:block min-w-0">
|
|
107
|
+
<p class="text-sm font-semibold text-foreground truncate leading-tight">{{ user.name ?? user.email }}</p>
|
|
108
|
+
<p v-if="user.role" class="text-xs text-muted-foreground truncate leading-tight">{{ user.role }}</p>
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
|
|
112
|
+
</header>
|
|
113
|
+
</template>
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { IconDeviceDesktop, IconArrowRight, IconCopy, IconCheck } from '@tabler/icons-vue'
|
|
3
|
+
import type { AppDefinition } from '~/configs/apps'
|
|
4
|
+
|
|
5
|
+
const props = defineProps<{
|
|
6
|
+
/** App al que se intentó acceder (para el copy contextual). */
|
|
7
|
+
blockedApp?: AppDefinition | null
|
|
8
|
+
/** App mobile-friendly al que el usuario puede continuar (opcional). */
|
|
9
|
+
fallbackApp?: AppDefinition | null
|
|
10
|
+
}>()
|
|
11
|
+
|
|
12
|
+
const route = useRoute()
|
|
13
|
+
const currentUrl = computed(() => {
|
|
14
|
+
if (typeof window === 'undefined') return ''
|
|
15
|
+
return window.location.origin + route.fullPath
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
const copied = ref(false)
|
|
19
|
+
async function copyLink() {
|
|
20
|
+
try {
|
|
21
|
+
await navigator.clipboard.writeText(currentUrl.value)
|
|
22
|
+
copied.value = true
|
|
23
|
+
setTimeout(() => { copied.value = false }, 2000)
|
|
24
|
+
} catch {
|
|
25
|
+
/* clipboard puede fallar en algunos contextos — silent */
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const blockedAppLabel = computed(() => props.blockedApp?.label ?? 'esta sección')
|
|
30
|
+
</script>
|
|
31
|
+
|
|
32
|
+
<template>
|
|
33
|
+
<div class="min-h-screen flex items-center justify-center bg-white dark:bg-slate-950 px-6 py-10">
|
|
34
|
+
<div class="w-full max-w-sm text-center space-y-6">
|
|
35
|
+
<!-- Icono -->
|
|
36
|
+
<div class="mx-auto size-16 rounded-2xl bg-violet-100 dark:bg-violet-900/30 flex items-center justify-center">
|
|
37
|
+
<IconDeviceDesktop class="size-8 text-violet-600 dark:text-violet-400" />
|
|
38
|
+
</div>
|
|
39
|
+
|
|
40
|
+
<!-- Mensaje -->
|
|
41
|
+
<div class="space-y-2">
|
|
42
|
+
<h1 class="text-2xl font-bold text-slate-900 dark:text-white">
|
|
43
|
+
Mejor en pantalla grande
|
|
44
|
+
</h1>
|
|
45
|
+
<p class="text-sm text-slate-600 dark:text-slate-400 leading-relaxed">
|
|
46
|
+
{{ blockedAppLabel }} está optimizada para uso en escritorio o tablet. Para una mejor experiencia, abre este enlace en un computador.
|
|
47
|
+
</p>
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
<!-- Fallback: continuar en app mobile -->
|
|
51
|
+
<div v-if="fallbackApp" class="pt-2">
|
|
52
|
+
<NuxtLink
|
|
53
|
+
:to="fallbackApp.home"
|
|
54
|
+
class="flex items-center justify-between gap-3 w-full rounded-xl border border-violet-200 dark:border-violet-800 bg-violet-50 dark:bg-violet-900/20 px-4 py-3 text-left hover:bg-violet-100 dark:hover:bg-violet-900/40 transition-colors"
|
|
55
|
+
>
|
|
56
|
+
<div>
|
|
57
|
+
<div class="text-xs font-medium uppercase tracking-wide text-violet-700 dark:text-violet-300">
|
|
58
|
+
Continuar como
|
|
59
|
+
</div>
|
|
60
|
+
<div class="text-sm font-semibold text-slate-900 dark:text-white">
|
|
61
|
+
{{ fallbackApp.label }}
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
<IconArrowRight class="size-5 text-violet-600 dark:text-violet-400 shrink-0" />
|
|
65
|
+
</NuxtLink>
|
|
66
|
+
</div>
|
|
67
|
+
|
|
68
|
+
<!-- Copiar enlace para mandarse al pc -->
|
|
69
|
+
<button
|
|
70
|
+
type="button"
|
|
71
|
+
@click="copyLink"
|
|
72
|
+
class="inline-flex items-center gap-2 text-sm font-medium text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white transition-colors"
|
|
73
|
+
>
|
|
74
|
+
<IconCheck v-if="copied" class="size-4 text-emerald-600" />
|
|
75
|
+
<IconCopy v-else class="size-4" />
|
|
76
|
+
<span>{{ copied ? 'Enlace copiado' : 'Copiar enlace para abrir en pc' }}</span>
|
|
77
|
+
</button>
|
|
78
|
+
|
|
79
|
+
<!-- Footer -->
|
|
80
|
+
<p class="text-xs text-slate-400 dark:text-slate-600 pt-4">
|
|
81
|
+
© {{ new Date().getFullYear() }} {{ $config.public.appName ?? 'Asetio' }}
|
|
82
|
+
</p>
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
</template>
|