Files
RenewCMS/web/src/components/DashboardShell.vue
T

268 lines
7.1 KiB
Vue

<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useSessionStore } from '@/stores/session'
import { useUpdateStore } from '@/stores/update'
import { useApi } from '@/composables/useApi'
import { useSidebarCollapsed } from '@/composables/useSidebarCollapsed'
import AppBoot from '@/components/AppBoot.vue'
import AccountMenuSheet from '@/components/AccountMenuSheet.vue'
import AppLogo from '@/components/AppLogo.vue'
import ErrorView from '@/components/ErrorView.vue'
import OfflineBanner from '@/components/OfflineBanner.vue'
import UpdatePill from '@/components/UpdatePill.vue'
import UserMenu from '@/components/UserMenu.vue'
const props = withDefaults(
defineProps<{
mobileTitle?: string
fullBleed?: boolean
compactPadding?: boolean
renderWhileChecking?: boolean
offlineMessage?: string
offlineIcon?: string
}>(),
{
mobileTitle: undefined,
offlineMessage: undefined,
offlineIcon: undefined,
fullBleed: false,
compactPadding: false,
renderWhileChecking: false,
},
)
const COMPACT_BODY = 'gap-0 px-4 pt-4 pb-22 sm:gap-0 sm:px-4 sm:pt-4 sm:pb-22 lg:p-6'
const DEFAULT_BODY = 'gap-0 px-4 pt-5 pb-22 sm:gap-0 sm:px-4 sm:pt-5 sm:pb-22 lg:p-8'
const bodyUi = computed(() => {
if (props.fullBleed) {
return 'gap-0 p-0 sm:gap-0 sm:p-0 lg:p-0'
}
return props.compactPadding ? COMPACT_BODY : DEFAULT_BODY
})
const route = useRoute()
const router = useRouter()
const session = useSessionStore()
const update = useUpdateStore()
const api = useApi()
const navItems = computed(() =>
[
{ label: 'Home', to: '/home', icon: 'i-lucide-home' },
{ label: 'Articles', to: '/article', icon: 'i-lucide-file-text' },
{ label: 'Media', to: '/media', icon: 'i-lucide-images' },
{ label: 'Users', to: '/users', icon: 'i-lucide-users' },
{ label: 'Integration', to: '/integration', icon: 'i-lucide-plug' },
{ label: 'Settings', to: '/settings', icon: 'i-lucide-settings', slot: 'settings' },
].map((item) => ({
...item,
active: route.path === item.to || route.path.startsWith(`${item.to}/`),
})),
)
const routeTitle = computed(() => (route.meta.title as string) ?? '')
const pageTitle = computed(() => props.mobileTitle ?? routeTitle.value)
const sidebarCollapsed = useSidebarCollapsed()
const sidebarUi = computed(() => ({
root: sidebarCollapsed.value ? 'min-w-14 px-1 py-3' : 'min-w-14 p-3',
header: 'mb-3 h-10 gap-2.5 px-1.5',
body: 'gap-0.5 p-0',
footer: 'p-0',
}))
const navUi = computed(() => ({
list: 'flex flex-col gap-0.5',
link: sidebarCollapsed.value
? 'h-8 justify-center gap-0 px-0 text-sm'
: 'h-8 gap-2.5 px-2.5 py-0 text-sm',
linkLeadingIcon: 'size-4',
}))
const loggingOut = ref(false)
const sidebarOpen = ref(false)
const accountSheetOpen = ref(false)
const NAVBAR_TOGGLE_SELECTOR = '[data-slot="toggle"]'
let sheetOpener: HTMLElement | undefined
function openAccountSheet(opener: HTMLElement | undefined) {
sheetOpener = opener
sidebarOpen.value = false
accountSheetOpen.value = true
}
async function restoreFocusAfterSheet() {
await nextTick()
if (loggingOut.value) {
return
}
const target = sheetOpener?.isConnected
? sheetOpener
: document.querySelector<HTMLElement>(NAVBAR_TOGGLE_SELECTOR)
sheetOpener = undefined
target?.focus()
}
watch(accountSheetOpen, (isOpen) => {
if (!isOpen) {
restoreFocusAfterSheet()
}
})
const showBoot = computed(() => session.status === 'checking' && !props.renderWhileChecking)
function onRetry() {
session.retry()
}
async function onLogout() {
loggingOut.value = true
try {
await api.request('/v1/auth/logout', { method: 'POST' })
} finally {
loggingOut.value = false
}
session.clear()
await router.push('/login')
}
</script>
<template>
<UDashboardGroup unit="rem">
<a href="#dashboard-content" class="sr-only focus:not-sr-only">Skip to content</a>
<UDashboardSidebar
v-model:open="sidebarOpen"
v-model:collapsed="sidebarCollapsed"
collapsible
:resizable="false"
:default-size="14"
:collapsed-size="3.5"
:ui="sidebarUi"
>
<template #header>
<AppLogo :size="26" />
<span
v-if="!sidebarCollapsed"
aria-hidden="true"
class="text-base font-semibold text-highlighted"
>RenewCMS</span
>
</template>
<UNavigationMenu
orientation="vertical"
:items="navItems"
:collapsed="sidebarCollapsed"
aria-label="Main"
:ui="navUi"
>
<template #settings-trailing>
<UpdatePill v-if="update.showCue && !sidebarCollapsed" />
</template>
</UNavigationMenu>
<template #footer>
<UserMenu
:username="session.session?.username ?? ''"
:display-name="session.session?.displayName"
:email="session.session?.email"
:collapsed="sidebarCollapsed"
:sheet-open="accountSheetOpen"
@logout="onLogout"
@open-sheet="openAccountSheet"
/>
</template>
</UDashboardSidebar>
<UDashboardPanel :ui="{ body: bodyUi }">
<template #header>
<UDashboardNavbar
:title="pageTitle"
:ui="{
root: 'h-14 gap-1 bg-default px-1.5 sm:px-1.5 lg:h-12 lg:gap-2 lg:px-6',
left: 'min-w-0 flex-1 gap-1 lg:gap-2',
title: 'text-lg lg:sr-only',
toggle: 'size-11 justify-center',
}"
>
<template #toggle>
<span class="relative lg:hidden">
<UDashboardSidebarToggle
data-slot="toggle"
color="neutral"
variant="ghost"
class="size-11 justify-center"
:ui="{ leadingIcon: 'size-5' }"
/>
<span
v-if="update.showCue"
role="img"
aria-label="Update available"
class="absolute top-2.25 right-2.25 size-2.5 border-2 border-(--ui-bg) bg-info"
/>
</span>
</template>
<template #leading>
<UDashboardSidebarCollapse
color="neutral"
variant="ghost"
size="sm"
class="-mx-1.5 text-muted"
:aria-expanded="!sidebarCollapsed"
data-testid="sidebar-collapse"
/>
<span
aria-hidden="true"
class="mx-1.5 hidden h-4 w-px bg-(--ui-border) lg:block"
/>
<slot name="breadcrumb">
<UBreadcrumb
:items="[{ label: routeTitle }]"
color="neutral"
class="hidden lg:block"
:ui="{ link: 'text-sm font-medium' }"
/>
</slot>
</template>
<template #right>
<slot name="navbar-right" />
</template>
</UDashboardNavbar>
</template>
<template #body>
<div
id="dashboard-content"
:class="fullBleed ? 'flex h-full min-h-0 flex-1 flex-col' : ''"
>
<OfflineBanner
:message="offlineMessage"
:icon="offlineIcon"
:bleed="compactPadding"
/>
<AppBoot v-if="showBoot" />
<ErrorView
v-else-if="session.status === 'error'"
kind="error"
@retry="onRetry"
/>
<div v-else :class="fullBleed ? 'flex min-h-0 flex-1 flex-col' : ''">
<slot />
</div>
</div>
</template>
</UDashboardPanel>
<AccountMenuSheet
v-model:open="accountSheetOpen"
:username="session.session?.username ?? ''"
:display-name="session.session?.displayName"
:email="session.session?.email"
@logout="onLogout"
/>
</UDashboardGroup>
</template>