Introduce structural CSS classes for page shells, headers, and surface cards Update primary theme color to red and neutral to zinc across the application
467 lines
16 KiB
Vue
467 lines
16 KiB
Vue
<template>
|
|
<div class="relative min-h-dvh bg-muted text-default">
|
|
<template v-if="auth.user.value">
|
|
<Transition
|
|
enter-active-class="transition-opacity duration-200 ease-out"
|
|
enter-from-class="opacity-0"
|
|
enter-to-class="opacity-100"
|
|
leave-active-class="transition-opacity duration-150 ease-in"
|
|
leave-from-class="opacity-100"
|
|
leave-to-class="opacity-0"
|
|
>
|
|
<button
|
|
v-if="mobileMenuOpen"
|
|
type="button"
|
|
class="fixed inset-0 z-40 bg-black/45 backdrop-blur-[2px] lg:hidden"
|
|
aria-label="Close navigation"
|
|
@click="mobileMenuOpen = false"
|
|
/>
|
|
</Transition>
|
|
|
|
<Transition
|
|
enter-active-class="transform transition duration-300 ease-out"
|
|
enter-from-class="-translate-x-6 opacity-0"
|
|
enter-to-class="translate-x-0 opacity-100"
|
|
leave-active-class="transform transition duration-200 ease-in"
|
|
leave-from-class="translate-x-0 opacity-100"
|
|
leave-to-class="-translate-x-6 opacity-0"
|
|
>
|
|
<aside
|
|
v-if="mobileMenuOpen"
|
|
class="fixed inset-y-2 left-2 z-50 flex w-[min(20rem,calc(100vw-1rem))] flex-col rounded-lg border border-default/80 bg-default/96 p-3 shadow-2xl backdrop-blur-xl lg:hidden"
|
|
>
|
|
<div class="flex items-start justify-between gap-3 rounded-lg border border-default bg-elevated px-4 py-4">
|
|
<div class="flex min-w-0 items-center gap-3">
|
|
<div class="flex size-11 shrink-0 items-center justify-center rounded-lg bg-primary/12 text-primary">
|
|
<UIcon name="i-lucide-grid-2x2" class="size-5" />
|
|
</div>
|
|
|
|
<div class="min-w-0">
|
|
<div class="truncate text-xs font-semibold uppercase tracking-[0.22em] text-muted">
|
|
{{ t('layout.brand') }}
|
|
</div>
|
|
<div class="truncate text-sm font-semibold text-highlighted">
|
|
{{ auth.user.value.fullName }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<UButton
|
|
color="neutral"
|
|
variant="ghost"
|
|
icon="i-lucide-x"
|
|
class="rounded-full"
|
|
aria-label="Close navigation"
|
|
@click="mobileMenuOpen = false"
|
|
/>
|
|
</div>
|
|
|
|
<nav class="mt-4 grid gap-2">
|
|
<NuxtLink
|
|
v-for="item in systemMenuItems"
|
|
:key="item.to"
|
|
:to="item.to"
|
|
:aria-current="isMenuItemActive(item) ? 'page' : undefined"
|
|
class="group flex min-h-14 items-center gap-3 rounded-lg border px-4 py-3 text-sm font-medium transition-all duration-200"
|
|
:class="mobileMenuItemClasses(item)"
|
|
>
|
|
<div
|
|
class="flex size-10 shrink-0 items-center justify-center rounded-lg transition-colors duration-200"
|
|
:class="mobileMenuIconClasses(item)"
|
|
>
|
|
<UIcon :name="item.icon" class="size-5" />
|
|
</div>
|
|
<span class="min-w-0 truncate">{{ item.label }}</span>
|
|
<UIcon name="i-lucide-chevron-right" class="ml-auto size-4 opacity-50 transition-opacity duration-200 group-hover:opacity-100" />
|
|
</NuxtLink>
|
|
</nav>
|
|
|
|
<div class="mt-auto space-y-3 border-t border-default pt-3">
|
|
<div class="rounded-lg border border-default bg-elevated px-4 py-4 shadow-sm">
|
|
<div class="flex items-center gap-3">
|
|
<div class="flex size-11 shrink-0 items-center justify-center rounded-lg bg-muted text-highlighted">
|
|
<UIcon name="i-lucide-user-round" class="size-5" />
|
|
</div>
|
|
|
|
<div class="min-w-0">
|
|
<div class="truncate text-sm font-semibold text-highlighted">
|
|
{{ auth.user.value.fullName }}
|
|
</div>
|
|
<div class="truncate text-sm text-muted">
|
|
@{{ auth.user.value.username }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<UBadge
|
|
:label="userRoleLabel"
|
|
color="primary"
|
|
variant="soft"
|
|
class="mt-3 rounded-full"
|
|
/>
|
|
</div>
|
|
|
|
<UButton
|
|
to="/"
|
|
:label="t('common.public')"
|
|
color="neutral"
|
|
variant="ghost"
|
|
icon="i-lucide-house"
|
|
class="w-full justify-start rounded-lg"
|
|
@click="mobileMenuOpen = false"
|
|
/>
|
|
|
|
<UButton
|
|
:label="currentLocaleOption.label"
|
|
color="neutral"
|
|
variant="ghost"
|
|
icon="i-lucide-languages"
|
|
class="w-full justify-start rounded-lg"
|
|
:aria-label="t('common.switchLanguage')"
|
|
@click="toggleLocale"
|
|
/>
|
|
|
|
<UButton
|
|
:loading="logoutPending"
|
|
:label="t('common.logout')"
|
|
color="neutral"
|
|
variant="outline"
|
|
icon="i-lucide-log-out"
|
|
class="w-full justify-start rounded-lg"
|
|
@click="logout"
|
|
/>
|
|
</div>
|
|
</aside>
|
|
</Transition>
|
|
|
|
<div class="relative lg:grid lg:min-h-dvh lg:grid-cols-[17.5rem_minmax(0,1fr)]">
|
|
<aside class="sticky top-0 hidden h-dvh flex-col border-r border-default/80 bg-default/92 px-5 py-5 backdrop-blur-xl lg:flex">
|
|
<div class="rounded-lg border border-default bg-elevated px-4 py-4 shadow-sm">
|
|
<div class="flex items-center gap-3">
|
|
<div class="flex size-12 shrink-0 items-center justify-center rounded-lg bg-primary/12 text-primary">
|
|
<UIcon name="i-lucide-grid-2x2" class="size-6" />
|
|
</div>
|
|
|
|
<div class="min-w-0">
|
|
<div class="truncate text-xs font-semibold uppercase tracking-[0.22em] text-muted">
|
|
{{ t('layout.brand') }}
|
|
</div>
|
|
<div class="truncate text-sm font-semibold text-highlighted">
|
|
{{ auth.user.value.fullName }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<nav class="mt-6 grid gap-2">
|
|
<NuxtLink
|
|
v-for="item in systemMenuItems"
|
|
:key="item.to"
|
|
:to="item.to"
|
|
:aria-current="isMenuItemActive(item) ? 'page' : undefined"
|
|
class="group flex min-h-14 items-center gap-3 rounded-lg border px-4 py-3 text-sm font-medium transition-all duration-200"
|
|
:class="desktopMenuItemClasses(item)"
|
|
>
|
|
<div
|
|
class="flex size-10 shrink-0 items-center justify-center rounded-lg transition-colors duration-200"
|
|
:class="desktopMenuIconClasses(item)"
|
|
>
|
|
<UIcon :name="item.icon" class="size-5" />
|
|
</div>
|
|
<span class="min-w-0 flex-1 truncate">{{ item.label }}</span>
|
|
<div
|
|
class="h-2.5 w-2.5 rounded-full transition-all duration-200"
|
|
:class="isMenuItemActive(item) ? 'bg-primary shadow-[0_0_0_4px_rgba(var(--ui-primary-rgb),0.14)]' : 'bg-default group-hover:bg-primary/30'"
|
|
/>
|
|
</NuxtLink>
|
|
</nav>
|
|
|
|
<div class="mt-auto space-y-3">
|
|
<div class="rounded-lg border border-default bg-elevated px-4 py-4 shadow-sm">
|
|
<div class="flex items-center gap-3">
|
|
<div class="flex size-11 shrink-0 items-center justify-center rounded-lg bg-muted text-highlighted">
|
|
<UIcon name="i-lucide-user-round" class="size-5" />
|
|
</div>
|
|
|
|
<div class="min-w-0">
|
|
<div class="truncate text-sm font-semibold text-highlighted">
|
|
{{ auth.user.value.fullName }}
|
|
</div>
|
|
<div class="truncate text-sm text-muted">
|
|
@{{ auth.user.value.username }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<UBadge
|
|
:label="userRoleLabel"
|
|
color="primary"
|
|
variant="soft"
|
|
class="mt-3 rounded-full"
|
|
/>
|
|
</div>
|
|
|
|
<div class="grid gap-2">
|
|
<UButton
|
|
to="/"
|
|
:label="t('common.public')"
|
|
color="neutral"
|
|
variant="ghost"
|
|
icon="i-lucide-house"
|
|
class="justify-start rounded-lg"
|
|
/>
|
|
|
|
<UButton
|
|
:label="currentLocaleOption.label"
|
|
color="neutral"
|
|
variant="ghost"
|
|
icon="i-lucide-languages"
|
|
class="justify-start rounded-lg"
|
|
:aria-label="t('common.switchLanguage')"
|
|
@click="toggleLocale"
|
|
/>
|
|
|
|
<UButton
|
|
:loading="logoutPending"
|
|
:label="t('common.logout')"
|
|
color="neutral"
|
|
variant="outline"
|
|
icon="i-lucide-log-out"
|
|
class="justify-start rounded-lg"
|
|
@click="logout"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
|
|
<div class="min-w-0 bg-muted">
|
|
<header class="sticky top-0 z-30 border-b border-default/80 bg-default/92 backdrop-blur-xl lg:hidden">
|
|
<UContainer class="py-3">
|
|
<div class="flex items-center justify-between gap-3">
|
|
<div class="flex min-w-0 items-center gap-3 rounded-lg border border-default bg-elevated px-3 py-2 shadow-sm">
|
|
<div class="flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary/12 text-primary">
|
|
<UIcon name="i-lucide-grid-2x2" class="size-5" />
|
|
</div>
|
|
|
|
<div class="min-w-0">
|
|
<div class="truncate text-xs font-semibold uppercase tracking-[0.22em] text-muted">
|
|
{{ t('layout.brand') }}
|
|
</div>
|
|
<div class="truncate text-sm font-semibold text-highlighted">
|
|
{{ auth.user.value.fullName }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<UButton
|
|
color="neutral"
|
|
variant="outline"
|
|
class="shrink-0 rounded-full"
|
|
:label="mobileMenuOpen ? t('common.close') : t('common.menu')"
|
|
:icon="mobileMenuOpen ? 'i-lucide-x' : 'i-lucide-menu'"
|
|
:aria-expanded="mobileMenuOpen"
|
|
aria-label="Toggle navigation"
|
|
@click="mobileMenuOpen = !mobileMenuOpen"
|
|
/>
|
|
</div>
|
|
</UContainer>
|
|
</header>
|
|
|
|
<UMain class="relative z-10">
|
|
<slot />
|
|
</UMain>
|
|
|
|
<footer class="border-t border-default bg-default/96">
|
|
<UContainer class="py-5 text-center text-sm text-muted">
|
|
{{ t('layout.footer') }}
|
|
</UContainer>
|
|
</footer>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<template v-else>
|
|
<header class="relative border-b border-default/80 bg-default/92 backdrop-blur-xl">
|
|
<UContainer class="py-4 md:py-5">
|
|
<div class="flex items-center justify-between gap-3">
|
|
<div class="flex min-w-0 items-center gap-3 rounded-lg border border-default bg-elevated px-3 py-2 shadow-sm">
|
|
<div class="flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
|
<UIcon name="i-lucide-grid-2x2" class="size-5" />
|
|
</div>
|
|
|
|
<div class="min-w-0">
|
|
<div class="truncate text-xs font-semibold uppercase tracking-[0.24em] text-muted">
|
|
{{ t('layout.brand') }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-2">
|
|
<UButton
|
|
color="neutral"
|
|
variant="outline"
|
|
icon="i-lucide-languages"
|
|
:label="currentLocaleOption.shortLabel"
|
|
class="rounded-full"
|
|
:aria-label="t('common.switchLanguage')"
|
|
@click="toggleLocale"
|
|
/>
|
|
|
|
<UButton
|
|
id="loginBtn"
|
|
:to="route.path.startsWith('/login') ? '/' : '/login'"
|
|
:label="route.path.startsWith('/login') ? t('common.back') : t('common.login')"
|
|
color="neutral"
|
|
:variant="route.path.startsWith('/login') ? 'outline' : 'solid'"
|
|
:icon="route.path.startsWith('/login') ? 'i-lucide-arrow-left' : 'i-lucide-lock-keyhole'"
|
|
class="rounded-full"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</UContainer>
|
|
</header>
|
|
|
|
<UMain class="relative z-10">
|
|
<slot />
|
|
</UMain>
|
|
|
|
<footer class="border-t border-default bg-default/96">
|
|
<UContainer class="py-5 text-center text-sm text-muted">
|
|
{{ t('layout.footer') }}
|
|
</UContainer>
|
|
</footer>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
|
|
<script lang="ts" setup>
|
|
import { getErrorMessage } from '../utils/errors'
|
|
|
|
interface SystemMenuItem {
|
|
labelKey: 'nav.bookings' | 'nav.security' | 'nav.users'
|
|
to: string
|
|
icon: string
|
|
requiresSuperAdmin?: boolean
|
|
matches: (path: string) => boolean
|
|
}
|
|
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
const toast = useToast()
|
|
const auth = useAuth()
|
|
const apiClient = useApiClient()
|
|
const { currentLocaleOption, locale, t, toggleLocale } = useLocale()
|
|
const logoutPending = ref(false)
|
|
const mobileMenuOpen = ref(false)
|
|
|
|
await auth.fetchSession()
|
|
|
|
useHead(() => ({
|
|
htmlAttrs: {
|
|
lang: locale.value === 'zh' ? 'zh-Hans' : 'en'
|
|
}
|
|
}))
|
|
|
|
const allSystemMenuItems: SystemMenuItem[] = [
|
|
{
|
|
labelKey: 'nav.bookings',
|
|
to: '/bookings',
|
|
icon: 'i-lucide-clipboard-list',
|
|
matches: (path) => path.startsWith('/bookings')
|
|
},
|
|
{
|
|
labelKey: 'nav.security',
|
|
to: '/security',
|
|
icon: 'i-lucide-shield-check',
|
|
matches: (path) => path.startsWith('/security')
|
|
},
|
|
{
|
|
labelKey: 'nav.users',
|
|
to: '/management/users',
|
|
icon: 'i-lucide-users',
|
|
requiresSuperAdmin: true,
|
|
matches: (path) => path.startsWith('/management/users')
|
|
}
|
|
]
|
|
|
|
const systemMenuItems = computed(() => {
|
|
return allSystemMenuItems
|
|
.filter((item) => {
|
|
return !item.requiresSuperAdmin || auth.isSuperAdmin.value
|
|
})
|
|
.map((item) => ({
|
|
...item,
|
|
label: t(item.labelKey)
|
|
}))
|
|
})
|
|
|
|
const userRoleLabel = computed(() => {
|
|
return auth.isSuperAdmin.value ? t('role.superAdmin') : t('role.staff')
|
|
})
|
|
|
|
function isMenuItemActive(item: SystemMenuItem) {
|
|
return item.matches(route.path)
|
|
}
|
|
|
|
function desktopMenuItemClasses(item: SystemMenuItem) {
|
|
return isMenuItemActive(item)
|
|
? 'border-primary/35 bg-primary/10 text-primary shadow-sm'
|
|
: 'border-default bg-default/80 text-default hover:border-primary/20 hover:bg-muted/60 hover:text-highlighted'
|
|
}
|
|
|
|
function desktopMenuIconClasses(item: SystemMenuItem) {
|
|
return isMenuItemActive(item)
|
|
? 'bg-primary/15 text-primary'
|
|
: 'bg-muted text-muted group-hover:bg-primary/10 group-hover:text-primary'
|
|
}
|
|
|
|
function mobileMenuItemClasses(item: SystemMenuItem) {
|
|
return isMenuItemActive(item)
|
|
? 'border-primary/35 bg-primary/10 text-primary shadow-sm'
|
|
: 'border-default bg-default/88 text-default'
|
|
}
|
|
|
|
function mobileMenuIconClasses(item: SystemMenuItem) {
|
|
return isMenuItemActive(item)
|
|
? 'bg-primary/15 text-primary'
|
|
: 'bg-muted text-muted'
|
|
}
|
|
|
|
watch(() => route.path, () => {
|
|
mobileMenuOpen.value = false
|
|
})
|
|
|
|
async function logout() {
|
|
if (logoutPending.value) {
|
|
return
|
|
}
|
|
|
|
logoutPending.value = true
|
|
|
|
try {
|
|
await apiClient('/api/auth/logout', {
|
|
method: 'POST'
|
|
})
|
|
|
|
auth.clearUser()
|
|
await router.push('/login')
|
|
|
|
toast.add({
|
|
title: t('layout.signedOut'),
|
|
description: t('layout.sessionCleared'),
|
|
color: 'success',
|
|
icon: 'i-lucide-check-circle-2'
|
|
})
|
|
} catch (error: any) {
|
|
toast.add({
|
|
title: t('layout.logoutFailed'),
|
|
description: getErrorMessage(error, t('layout.logoutFailedDescription')),
|
|
color: 'error',
|
|
icon: 'i-lucide-circle-alert'
|
|
})
|
|
} finally {
|
|
logoutPending.value = false
|
|
}
|
|
}
|
|
</script>
|