doxa-maps-cdn/app/layouts/admin.vue

216 lines
8.1 KiB
Vue

<script setup lang="ts">
import '~/assets/css/admin.css'
// Dev-only: Vite's dev server pins main.scss (and its @use partials) into
// the module graph as soon as you visit the public site, then injects link
// tags for them on every subsequent page in the session — even admin pages
// that don't import them. Strip them on mount so the admin shell isn't
// styled by the public-site SCSS. Production code-splits per-route, so this
// branch is dead-code-eliminated and has no effect on the prod bundle.
if (import.meta.dev) {
onMounted(() => {
document.querySelectorAll('link[rel="stylesheet"]').forEach((link) => {
if ((link as HTMLLinkElement).href.includes('/assets/styles/')) {
link.remove()
}
})
})
}
const { user } = useAuth()
const { hasPermission } = usePermissions()
const route = useRoute()
const mobileOpen = ref(false)
interface NavItem {
to: string
label: string
icon: string
children?: Array<{ to: string; label: string }>
}
const navItems = computed<NavItem[]>(() => [
{ to: '/admin', label: 'Dashboard', icon: 'i-lucide-layout-dashboard' },
...(hasPermission('pages.view')
? [{
to: '/admin/pages',
label: 'Pages',
icon: 'i-lucide-file-text',
children: [
{ to: '/admin/pages/categories', label: 'Categories' }
]
}]
: []),
...(hasPermission('users.view')
? [{
to: '/admin/users',
label: 'Users',
icon: 'i-lucide-users',
children: [
...(hasPermission('users.manage')
? [{ to: '/admin/oauth', label: 'OAuth' }]
: [])
]
}]
: []),
...(hasPermission('roles.view')
? [{ to: '/admin/roles', label: 'Roles', icon: 'i-lucide-shield' }]
: []),
...(hasPermission('settings.view')
? [{ to: '/admin/settings', label: 'Settings', icon: 'i-lucide-settings' }]
: [])
])
const isActive = (to: string) => {
if (to === '/admin') return route.path === '/admin'
return route.path === to || route.path.startsWith(to + '/')
}
// A parent row is "expanded" when the user is somewhere inside its
// section — keeps the submenu visible on the category pages. Also
// expands when any child route is active, since some children (e.g.
// /admin/oauth under Users) live outside the parent's URL tree.
const isExpanded = (item: NavItem) => {
if (!item.children?.length) return false
if (isActive(item.to)) return true
return item.children.some(c => isExactActive(c.to))
}
const isExactActive = (to: string) => route.path === to || route.path.startsWith(to + '/')
watch(() => route.path, () => {
mobileOpen.value = false
})
</script>
<template>
<UApp>
<div class="min-h-screen bg-(--ui-bg) text-(--ui-text)">
<!-- Mobile top bar -->
<header class="lg:hidden bg-(--ui-bg-elevated) border-b border-(--ui-border) py-3 px-4 sticky top-0 z-40 flex items-center justify-between">
<div class="flex items-center gap-3">
<UButton
icon="i-lucide-menu"
variant="ghost"
color="neutral"
aria-label="Open menu"
@click="mobileOpen = true"
/>
<span class="text-lg font-semibold">Admin</span>
</div>
<span class="text-sm text-(--ui-text-muted) truncate max-w-[50%]">
{{ user?.display_name || user?.email }}
</span>
</header>
<div class="lg:flex">
<!-- Desktop sidebar -->
<aside class="hidden lg:flex lg:flex-col w-64 min-h-screen border-r border-(--ui-border) bg-(--ui-bg-elevated) sticky top-0 h-screen">
<div class="px-6 py-5 border-b border-(--ui-border)">
<h1 class="text-xl font-semibold">Admin</h1>
</div>
<nav class="flex-1 px-3 py-4 space-y-1 overflow-y-auto">
<template v-for="item in navItems" :key="item.to">
<NuxtLink
:to="item.to"
class="flex items-center gap-3 px-3 py-2 rounded-md text-sm transition-colors"
:class="isActive(item.to)
? 'bg-(--ui-bg-accented) text-(--ui-text) font-medium'
: 'text-(--ui-text-muted) hover:bg-(--ui-bg-accented) hover:text-(--ui-text)'"
>
<UIcon :name="item.icon" class="size-5 shrink-0" />
<span>{{ item.label }}</span>
</NuxtLink>
<div v-if="isExpanded(item)" class="ml-8 mt-1 space-y-1">
<NuxtLink
v-for="child in item.children"
:key="child.to"
:to="child.to"
class="flex items-center gap-2 px-3 py-1.5 rounded-md text-sm transition-colors"
:class="isExactActive(child.to)
? 'bg-(--ui-bg-accented) text-(--ui-text) font-medium'
: 'text-(--ui-text-muted) hover:bg-(--ui-bg-accented) hover:text-(--ui-text)'"
>
<span>{{ child.label }}</span>
</NuxtLink>
</div>
</template>
</nav>
<div class="border-t border-(--ui-border) px-4 py-4 space-y-2">
<div class="text-sm text-(--ui-text-muted) truncate">
{{ user?.display_name || user?.email }}
</div>
<NuxtLink
to="/admin/profile"
class="flex items-center gap-2 text-sm text-(--ui-text-muted) hover:text-(--ui-text) transition-colors"
>
<UIcon name="i-lucide-user" class="size-4" />
<span>Profile</span>
</NuxtLink>
</div>
</aside>
<!-- Mobile drawer -->
<USlideover v-model:open="mobileOpen" side="left" :ui="{ content: 'max-w-xs' }">
<template #content>
<div class="flex flex-col h-full bg-(--ui-bg-elevated)">
<div class="px-6 py-5 border-b border-(--ui-border) flex items-center justify-between">
<h1 class="text-xl font-semibold">Admin</h1>
<UButton
icon="i-lucide-x"
variant="ghost"
color="neutral"
aria-label="Close menu"
@click="mobileOpen = false"
/>
</div>
<nav class="flex-1 px-3 py-4 space-y-1 overflow-y-auto">
<template v-for="item in navItems" :key="item.to">
<NuxtLink
:to="item.to"
class="flex items-center gap-3 px-3 py-2 rounded-md text-sm transition-colors"
:class="isActive(item.to)
? 'bg-(--ui-bg-accented) text-(--ui-text) font-medium'
: 'text-(--ui-text-muted) hover:bg-(--ui-bg-accented) hover:text-(--ui-text)'"
>
<UIcon :name="item.icon" class="size-5 shrink-0" />
<span>{{ item.label }}</span>
</NuxtLink>
<div v-if="isExpanded(item)" class="ml-8 mt-1 space-y-1">
<NuxtLink
v-for="child in item.children"
:key="child.to"
:to="child.to"
class="flex items-center gap-2 px-3 py-1.5 rounded-md text-sm transition-colors"
:class="isExactActive(child.to)
? 'bg-(--ui-bg-accented) text-(--ui-text) font-medium'
: 'text-(--ui-text-muted) hover:bg-(--ui-bg-accented) hover:text-(--ui-text)'"
>
<span>{{ child.label }}</span>
</NuxtLink>
</div>
</template>
</nav>
<div class="border-t border-(--ui-border) px-4 py-4 space-y-2">
<div class="text-sm text-(--ui-text-muted) truncate">
{{ user?.display_name || user?.email }}
</div>
<NuxtLink
to="/"
class="flex items-center gap-2 text-sm text-(--ui-text-muted) hover:text-(--ui-text) transition-colors"
>
<UIcon name="i-lucide-arrow-left" class="size-4" />
<span>Back to app</span>
</NuxtLink>
</div>
</div>
</template>
</USlideover>
<main class="flex-1 px-4 sm:px-6 lg:px-8 py-6 lg:py-8">
<slot />
</main>
</div>
</div>
</UApp>
</template>