mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-14 23:20:19 +08:00
feat(frontend): add collapsible navigation sidebar
Persist the desktop sidebar state, provide accessible compact navigation tooltips, and cover the collapsed navigation markup with a focused component test.
This commit is contained in:
@@ -1,14 +1,23 @@
|
|||||||
<template>
|
<template>
|
||||||
<nav class="sidebar-nav w-full px-3">
|
<TooltipProvider :delay-duration="150">
|
||||||
|
<nav
|
||||||
|
class="sidebar-nav w-full"
|
||||||
|
:class="collapsed ? 'px-2' : 'px-3'"
|
||||||
|
:data-collapsed="collapsed"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
v-for="(group, index) in items"
|
v-for="(group, index) in items"
|
||||||
:key="index"
|
:key="index"
|
||||||
class="space-y-1 mb-5"
|
:class="[
|
||||||
|
collapsed ? 'mb-1 space-y-0' : 'mb-5 space-y-1',
|
||||||
|
collapsed && index > 0 ? 'border-t border-[#3d3929]/10 pt-1 dark:border-white/10' : ''
|
||||||
|
]"
|
||||||
|
:data-sidebar-group-divider="collapsed && index > 0 ? '' : undefined"
|
||||||
>
|
>
|
||||||
<!-- Section Header -->
|
<!-- Section Header -->
|
||||||
<div
|
<div
|
||||||
v-if="group.title"
|
v-if="group.title && !collapsed"
|
||||||
class="px-2.5 pb-1 flex items-center gap-2"
|
class="flex items-center gap-2 px-2.5 pb-1"
|
||||||
:class="index > 0 ? 'pt-1' : ''"
|
:class="index > 0 ? 'pt-1' : ''"
|
||||||
>
|
>
|
||||||
<span class="text-[10px] font-medium text-muted-foreground/50 font-mono tabular-nums">{{ String(index + 1).padStart(2, '0') }}</span>
|
<span class="text-[10px] font-medium text-muted-foreground/50 font-mono tabular-nums">{{ String(index + 1).padStart(2, '0') }}</span>
|
||||||
@@ -21,44 +30,69 @@
|
|||||||
v-for="item in group.items"
|
v-for="item in group.items"
|
||||||
:key="item.href"
|
:key="item.href"
|
||||||
>
|
>
|
||||||
|
<Tooltip
|
||||||
|
:open="collapsed && openTooltipHref === item.href"
|
||||||
|
@update:open="handleTooltipOpenChange(item.href, $event)"
|
||||||
|
>
|
||||||
|
<TooltipTrigger as-child>
|
||||||
<RouterLink
|
<RouterLink
|
||||||
:to="item.href"
|
:to="item.href"
|
||||||
class="group relative flex items-center justify-between px-2.5 py-2 rounded-lg transition-all duration-200"
|
class="group relative flex items-center rounded-lg"
|
||||||
:class="[
|
:class="[
|
||||||
|
collapsed
|
||||||
|
? 'h-9 justify-center px-0 transition-colors duration-150'
|
||||||
|
: 'justify-between px-2.5 py-2 transition-all duration-200',
|
||||||
isItemActive(item.href)
|
isItemActive(item.href)
|
||||||
? 'bg-primary/10 text-primary font-medium'
|
? 'bg-primary/10 text-primary font-medium'
|
||||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
|
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
|
||||||
]"
|
]"
|
||||||
|
:aria-label="collapsed ? item.name : undefined"
|
||||||
@pointerenter="schedulePrefetch(item.href)"
|
@pointerenter="schedulePrefetch(item.href)"
|
||||||
@pointerleave="cancelScheduledPrefetch(item.href)"
|
@pointerleave="cancelScheduledPrefetch(item.href)"
|
||||||
@pointerdown="prefetchNow(item.href)"
|
@pointerdown="prefetchNow(item.href)"
|
||||||
@focus="prefetchNow(item.href)"
|
@focus="prefetchNow(item.href)"
|
||||||
@click="handleNavigate(item.href)"
|
@click="handleNavigate(item.href)"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-2.5">
|
<div class="flex min-w-0 items-center gap-2.5">
|
||||||
<component
|
<component
|
||||||
:is="item.icon"
|
:is="item.icon"
|
||||||
class="h-4 w-4 transition-colors duration-200"
|
class="h-4 w-4 shrink-0 transition-colors duration-200"
|
||||||
:class="isItemActive(item.href) ? 'text-primary' : 'text-muted-foreground/70 group-hover:text-foreground'"
|
:class="isItemActive(item.href) ? 'text-primary' : 'text-muted-foreground/70 group-hover:text-foreground'"
|
||||||
:stroke-width="isItemActive(item.href) ? 2 : 1.75"
|
:stroke-width="isItemActive(item.href) ? 2 : 1.75"
|
||||||
/>
|
/>
|
||||||
<span class="text-[13px] tracking-tight">{{ item.name }}</span>
|
<span
|
||||||
|
v-if="!collapsed"
|
||||||
|
class="truncate text-[13px] tracking-tight"
|
||||||
|
>{{ item.name }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Active Indicator -->
|
<!-- Active Indicator -->
|
||||||
<div
|
<div
|
||||||
v-if="isItemActive(item.href)"
|
v-if="isItemActive(item.href)"
|
||||||
class="w-1 h-1 rounded-full bg-primary"
|
class="h-1 w-1 shrink-0 rounded-full bg-primary"
|
||||||
|
:class="collapsed ? 'absolute right-1.5' : ''"
|
||||||
/>
|
/>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
v-if="collapsed"
|
||||||
|
side="right"
|
||||||
|
:side-offset="10"
|
||||||
|
class="text-xs"
|
||||||
|
>
|
||||||
|
{{ item.name }}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
</TooltipProvider>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onBeforeUnmount, type Component } from 'vue'
|
import { onBeforeUnmount, ref, type Component } from 'vue'
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
|
|
||||||
export interface NavigationItem {
|
export interface NavigationItem {
|
||||||
name: string
|
name: string
|
||||||
@@ -76,6 +110,7 @@ const props = defineProps<{
|
|||||||
items: NavigationGroup[]
|
items: NavigationGroup[]
|
||||||
activePath?: string
|
activePath?: string
|
||||||
isActive?: (href: string) => boolean
|
isActive?: (href: string) => boolean
|
||||||
|
collapsed?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -86,6 +121,19 @@ const emit = defineEmits<{
|
|||||||
const HOVER_PREFETCH_DELAY_MS = 100
|
const HOVER_PREFETCH_DELAY_MS = 100
|
||||||
let scheduledPrefetchHref: string | null = null
|
let scheduledPrefetchHref: string | null = null
|
||||||
let scheduledPrefetchTimer: ReturnType<typeof setTimeout> | null = null
|
let scheduledPrefetchTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
const openTooltipHref = ref<string | null>(null)
|
||||||
|
|
||||||
|
function handleTooltipOpenChange(href: string, open: boolean) {
|
||||||
|
if (!props.collapsed) {
|
||||||
|
openTooltipHref.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (open) {
|
||||||
|
openTooltipHref.value = href
|
||||||
|
} else if (openTooltipHref.value === href) {
|
||||||
|
openTooltipHref.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function cancelScheduledPrefetch(href?: string) {
|
function cancelScheduledPrefetch(href?: string) {
|
||||||
if (href && scheduledPrefetchHref !== href) return
|
if (href && scheduledPrefetchHref !== href) return
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
|
import { createApp, defineComponent, h, nextTick, ref, type App } from 'vue'
|
||||||
|
|
||||||
|
import SidebarNav from '@/components/layout/SidebarNav.vue'
|
||||||
|
|
||||||
|
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
|
||||||
|
|
||||||
|
const TestIcon = defineComponent({
|
||||||
|
name: 'TestIcon',
|
||||||
|
setup: () => () => h('svg', { 'data-testid': 'nav-icon' }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const RouterLinkStub = defineComponent({
|
||||||
|
name: 'RouterLink',
|
||||||
|
inheritAttrs: false,
|
||||||
|
props: {
|
||||||
|
to: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
setup(props, { attrs, slots }) {
|
||||||
|
return () => h('a', { ...attrs, href: props.to }, slots.default?.())
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const { app, root } of mountedApps.splice(0)) {
|
||||||
|
app.unmount()
|
||||||
|
root.remove()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('SidebarNav', () => {
|
||||||
|
it('switches from labelled navigation to accessible icon-only links', async () => {
|
||||||
|
const collapsed = ref(false)
|
||||||
|
const root = document.createElement('div')
|
||||||
|
document.body.appendChild(root)
|
||||||
|
|
||||||
|
const app = createApp({
|
||||||
|
setup: () => () => h(SidebarNav, {
|
||||||
|
items: [{
|
||||||
|
title: 'Overview',
|
||||||
|
items: [
|
||||||
|
{ name: 'Dashboard', href: '/dashboard', icon: TestIcon },
|
||||||
|
{ name: 'Usage', href: '/usage', icon: TestIcon },
|
||||||
|
],
|
||||||
|
}, {
|
||||||
|
title: 'Management',
|
||||||
|
items: [
|
||||||
|
{ name: 'Settings', href: '/settings', icon: TestIcon },
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
activePath: '/dashboard',
|
||||||
|
collapsed: collapsed.value,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
app.component('RouterLink', RouterLinkStub)
|
||||||
|
app.mount(root)
|
||||||
|
mountedApps.push({ app, root })
|
||||||
|
|
||||||
|
expect(root.querySelector('nav')?.dataset.collapsed).toBe('false')
|
||||||
|
expect(root.textContent).toContain('Overview')
|
||||||
|
expect(root.textContent).toContain('Management')
|
||||||
|
expect(root.textContent).toContain('Dashboard')
|
||||||
|
expect(root.querySelectorAll('[data-sidebar-group-divider]')).toHaveLength(0)
|
||||||
|
const expandedDashboardLink = root.querySelector('a[href="/dashboard"]')
|
||||||
|
expect(expandedDashboardLink?.hasAttribute('aria-label')).toBe(false)
|
||||||
|
expect(expandedDashboardLink?.classList.contains('py-2')).toBe(true)
|
||||||
|
expect(expandedDashboardLink?.classList.contains('h-9')).toBe(false)
|
||||||
|
|
||||||
|
collapsed.value = true
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(root.querySelector('nav')?.dataset.collapsed).toBe('true')
|
||||||
|
expect(root.textContent).not.toContain('Overview')
|
||||||
|
expect(root.textContent).not.toContain('Management')
|
||||||
|
expect(root.textContent).not.toContain('Dashboard')
|
||||||
|
expect(root.querySelectorAll('[data-testid="nav-icon"]')).toHaveLength(3)
|
||||||
|
expect(root.querySelectorAll('[data-sidebar-group-divider]')).toHaveLength(1)
|
||||||
|
const collapsedDashboardLink = root.querySelector('a[href="/dashboard"]')
|
||||||
|
expect(collapsedDashboardLink?.getAttribute('aria-label')).toBe('Dashboard')
|
||||||
|
expect(collapsedDashboardLink?.classList.contains('h-9')).toBe(true)
|
||||||
|
expect(collapsedDashboardLink?.classList.contains('py-2')).toBe(false)
|
||||||
|
expect(root.querySelector('a[href="/usage"]')?.getAttribute('aria-label')).toBe('Usage')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -269,6 +269,8 @@ export const messages = {
|
|||||||
'nav.group.account': '账户',
|
'nav.group.account': '账户',
|
||||||
'nav.group.management': '管理',
|
'nav.group.management': '管理',
|
||||||
'nav.group.system': '系统',
|
'nav.group.system': '系统',
|
||||||
|
'nav.collapseSidebar': '收起侧边栏',
|
||||||
|
'nav.expandSidebar': '展开侧边栏',
|
||||||
'nav.dashboard': '仪表盘',
|
'nav.dashboard': '仪表盘',
|
||||||
'nav.healthMonitor': '健康监控',
|
'nav.healthMonitor': '健康监控',
|
||||||
'nav.modelCatalog': '模型目录',
|
'nav.modelCatalog': '模型目录',
|
||||||
@@ -580,6 +582,8 @@ export const messages = {
|
|||||||
'nav.group.account': 'Account',
|
'nav.group.account': 'Account',
|
||||||
'nav.group.management': 'Management',
|
'nav.group.management': 'Management',
|
||||||
'nav.group.system': 'System',
|
'nav.group.system': 'System',
|
||||||
|
'nav.collapseSidebar': 'Collapse sidebar',
|
||||||
|
'nav.expandSidebar': 'Expand sidebar',
|
||||||
'nav.dashboard': 'Dashboard',
|
'nav.dashboard': 'Dashboard',
|
||||||
'nav.healthMonitor': 'Health monitor',
|
'nav.healthMonitor': 'Health monitor',
|
||||||
'nav.modelCatalog': 'Model catalog',
|
'nav.modelCatalog': 'Model catalog',
|
||||||
|
|||||||
@@ -23,65 +23,156 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #sidebar>
|
<template #sidebar>
|
||||||
|
<div class="flex h-full w-full min-w-0 flex-col overflow-hidden">
|
||||||
<!-- HEADER (Brand) -->
|
<!-- HEADER (Brand) -->
|
||||||
<div class="shrink-0 flex items-center px-6 h-20">
|
<div
|
||||||
|
class="group/sidebar-brand relative flex shrink-0 items-center transition-[height,padding] [transition-duration:240ms] [transition-timing-function:cubic-bezier(0.4,0,0.2,1)] motion-reduce:transition-none"
|
||||||
|
:class="sidebarCollapsed ? 'h-16 px-4' : 'h-20 px-6'"
|
||||||
|
>
|
||||||
|
<Transition
|
||||||
|
name="sidebar-mode"
|
||||||
|
mode="out-in"
|
||||||
|
>
|
||||||
<RouterLink
|
<RouterLink
|
||||||
|
v-if="!sidebarCollapsed"
|
||||||
|
key="expanded-brand"
|
||||||
to="/"
|
to="/"
|
||||||
class="flex items-center gap-3 group transition-opacity hover:opacity-80"
|
class="group flex w-full min-w-0 items-center gap-3 pr-10 transition-opacity hover:opacity-80"
|
||||||
>
|
>
|
||||||
<HeaderLogo
|
<HeaderLogo
|
||||||
size="h-9 w-9"
|
size="h-9 w-9"
|
||||||
class-name="text-[#191919] dark:text-white"
|
class-name="shrink-0 text-[#191919] dark:text-white"
|
||||||
/>
|
/>
|
||||||
<div class="flex flex-col justify-center">
|
<div class="flex min-w-0 flex-col justify-center">
|
||||||
<h1 class="text-lg font-bold text-[#191919] dark:text-white leading-none">
|
<h1 class="truncate text-lg font-bold leading-none text-[#191919] dark:text-white">
|
||||||
{{ siteName }}
|
{{ siteName }}
|
||||||
</h1>
|
</h1>
|
||||||
<span class="text-[10px] text-[#91918d] dark:text-muted-foreground leading-none mt-1.5 font-medium tracking-wide">{{ siteSubtitle }}</span>
|
<span class="mt-1.5 truncate text-[10px] font-medium leading-none tracking-wide text-[#91918d] dark:text-muted-foreground">{{ siteSubtitle }}</span>
|
||||||
</div>
|
</div>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
key="collapsed-brand"
|
||||||
|
aria-hidden="true"
|
||||||
|
class="flex h-8 w-8 transform-gpu items-center justify-center transition-[opacity,transform] duration-200 ease-out will-change-[opacity,transform] group-hover/sidebar-brand:scale-90 group-hover/sidebar-brand:opacity-0 motion-reduce:transition-none"
|
||||||
|
>
|
||||||
|
<HeaderLogo
|
||||||
|
size="h-8 w-8"
|
||||||
|
class-name="shrink-0 text-[#191919] dark:text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="absolute top-1/2 z-10 flex h-8 w-8 shrink-0 -translate-y-1/2 transform-gpu items-center justify-center rounded-md text-muted-foreground transition-[right,color,background-color,opacity,transform] [transition-duration:240ms] [transition-timing-function:cubic-bezier(0.4,0,0.2,1)] hover:bg-muted/50 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary motion-reduce:transition-none"
|
||||||
|
:class="sidebarCollapsed ? 'right-[15px] scale-90 opacity-0 will-change-[right,opacity,transform] group-hover/sidebar-brand:scale-100 group-hover/sidebar-brand:opacity-100 focus-visible:scale-100 focus-visible:bg-[#faf9f5] focus-visible:opacity-100 dark:focus-visible:bg-[#1e1c19]' : 'right-6 opacity-100'"
|
||||||
|
:aria-label="sidebarCollapsed ? t('nav.expandSidebar') : t('nav.collapseSidebar')"
|
||||||
|
:aria-expanded="!sidebarCollapsed"
|
||||||
|
:title="sidebarCollapsed ? t('nav.expandSidebar') : t('nav.collapseSidebar')"
|
||||||
|
@click="sidebarCollapsed = !sidebarCollapsed"
|
||||||
|
>
|
||||||
|
<Transition
|
||||||
|
name="sidebar-icon"
|
||||||
|
mode="out-in"
|
||||||
|
>
|
||||||
|
<PanelLeftOpen
|
||||||
|
v-if="sidebarCollapsed"
|
||||||
|
key="open"
|
||||||
|
class="h-4 w-4"
|
||||||
|
/>
|
||||||
|
<PanelLeftClose
|
||||||
|
v-else
|
||||||
|
key="close"
|
||||||
|
class="h-4 w-4"
|
||||||
|
/>
|
||||||
|
</Transition>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- NAVIGATION -->
|
<!-- NAVIGATION -->
|
||||||
<div class="flex-1 overflow-y-auto py-2 scrollbar-none">
|
<div
|
||||||
|
class="flex-1 overflow-y-auto transition-[padding] [transition-duration:240ms] [transition-timing-function:cubic-bezier(0.4,0,0.2,1)] scrollbar-none motion-reduce:transition-none"
|
||||||
|
:class="sidebarCollapsed ? 'pb-2 pt-0' : 'py-2'"
|
||||||
|
>
|
||||||
|
<Transition
|
||||||
|
name="sidebar-mode"
|
||||||
|
mode="out-in"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
:key="sidebarCollapsed ? 'collapsed-nav' : 'expanded-nav'"
|
||||||
|
class="w-full"
|
||||||
|
:class="sidebarCollapsed ? 'max-w-16' : ''"
|
||||||
|
>
|
||||||
<SidebarNav
|
<SidebarNav
|
||||||
:items="navigation"
|
:items="navigation"
|
||||||
:is-active="isNavActive"
|
:is-active="isNavActive"
|
||||||
|
:collapsed="sidebarCollapsed"
|
||||||
@prefetch="prefetchNavigationItem"
|
@prefetch="prefetchNavigationItem"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- FOOTER (Profile) -->
|
<!-- FOOTER (Profile) -->
|
||||||
<div class="p-4 border-t border-[#3d3929]/5 dark:border-white/5">
|
<Transition
|
||||||
<div class="flex items-center justify-between p-2 rounded-xl">
|
name="sidebar-mode"
|
||||||
<div class="flex items-center gap-3 min-w-0">
|
mode="out-in"
|
||||||
<div class="w-8 h-8 rounded-full bg-[#f0f0eb] dark:bg-white/10 border border-black/5 flex items-center justify-center text-xs font-bold text-[#3d3929] dark:text-[#d4a27f] shrink-0">
|
>
|
||||||
|
<div
|
||||||
|
:key="sidebarCollapsed ? 'collapsed-footer' : 'expanded-footer'"
|
||||||
|
class="border-t border-[#3d3929]/5 dark:border-white/5"
|
||||||
|
:class="sidebarCollapsed ? 'max-w-16 p-2' : 'p-4'"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex items-center"
|
||||||
|
:class="sidebarCollapsed ? 'flex-col gap-2 rounded-lg p-1' : 'justify-between rounded-xl p-2'"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex min-w-0 items-center"
|
||||||
|
:class="sidebarCollapsed ? 'justify-center' : 'gap-3'"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-black/5 bg-[#f0f0eb] text-xs font-bold text-[#3d3929] dark:bg-white/10 dark:text-[#d4a27f]"
|
||||||
|
:title="sidebarCollapsed ? authStore.user?.username : undefined"
|
||||||
|
>
|
||||||
{{ authStore.user?.username?.substring(0, 2).toUpperCase() }}
|
{{ authStore.user?.username?.substring(0, 2).toUpperCase() }}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col min-w-0">
|
<div
|
||||||
<span class="text-xs font-semibold leading-none truncate opacity-90 text-foreground">{{ authStore.user?.username }}</span>
|
v-if="!sidebarCollapsed"
|
||||||
<span class="text-[10px] opacity-50 leading-none mt-1.5 text-muted-foreground">{{ currentRoleLabel }}</span>
|
class="flex min-w-0 flex-col"
|
||||||
|
>
|
||||||
|
<span class="truncate text-xs font-semibold leading-none text-foreground opacity-90">{{ authStore.user?.username }}</span>
|
||||||
|
<span class="mt-1.5 text-[10px] leading-none text-muted-foreground opacity-50">{{ currentRoleLabel }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-1">
|
<div
|
||||||
|
class="flex items-center gap-1"
|
||||||
|
:class="sidebarCollapsed ? 'flex-col' : ''"
|
||||||
|
>
|
||||||
<RouterLink
|
<RouterLink
|
||||||
to="/dashboard/settings"
|
to="/dashboard/settings"
|
||||||
class="p-1.5 hover:bg-muted/50 rounded-md text-muted-foreground hover:text-foreground transition-colors"
|
class="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
|
||||||
|
:aria-label="sidebarCollapsed ? t('common.settings') : undefined"
|
||||||
:title="t('common.settings')"
|
:title="t('common.settings')"
|
||||||
>
|
>
|
||||||
<Settings class="w-4 h-4" />
|
<Settings class="h-4 w-4" />
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<button
|
<button
|
||||||
class="p-1.5 rounded-md text-muted-foreground hover:text-red-500 transition-colors"
|
class="rounded-md p-1.5 text-muted-foreground transition-colors hover:text-red-500"
|
||||||
|
:aria-label="sidebarCollapsed ? t('common.logout') : undefined"
|
||||||
:title="t('common.logout')"
|
:title="t('common.logout')"
|
||||||
@click="handleLogout"
|
@click="handleLogout"
|
||||||
>
|
>
|
||||||
<LogOut class="w-4 h-4" />
|
<LogOut class="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #header>
|
<template #header>
|
||||||
@@ -392,6 +483,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch, onMounted, onUnmounted } from 'vue'
|
import { computed, ref, watch, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { useLocalStorage } from '@vueuse/core'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { marked } from 'marked'
|
import { marked } from 'marked'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
@@ -419,6 +511,8 @@ import {
|
|||||||
ChevronRight,
|
ChevronRight,
|
||||||
Menu,
|
Menu,
|
||||||
X,
|
X,
|
||||||
|
PanelLeftClose,
|
||||||
|
PanelLeftOpen,
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
|
|
||||||
import GithubIcon from '@/components/icons/GithubIcon.vue'
|
import GithubIcon from '@/components/icons/GithubIcon.vue'
|
||||||
@@ -441,6 +535,7 @@ const isAdmin = computed(() => authStore.user?.role === 'admin')
|
|||||||
|
|
||||||
const showAuthError = ref(false)
|
const showAuthError = ref(false)
|
||||||
const mobileMenuOpen = ref(false)
|
const mobileMenuOpen = ref(false)
|
||||||
|
const sidebarCollapsed = useLocalStorage('aether-sidebar-collapsed', false)
|
||||||
const requiredAnnouncements = ref<Announcement[]>([])
|
const requiredAnnouncements = ref<Announcement[]>([])
|
||||||
const acknowledgingRequiredAnnouncement = ref(false)
|
const acknowledgingRequiredAnnouncement = ref(false)
|
||||||
const requiredAnnouncementOpen = computed({
|
const requiredAnnouncementOpen = computed({
|
||||||
@@ -1143,8 +1238,8 @@ const breadcrumbs = computed(() => buildBreadcrumbs({
|
|||||||
|
|
||||||
// Styling Classes (Editorial)
|
// Styling Classes (Editorial)
|
||||||
const sidebarClasses = computed(() => {
|
const sidebarClasses = computed(() => {
|
||||||
// Fixed width, border right, background match
|
const widthClass = sidebarCollapsed.value ? 'w-16' : 'w-[260px]'
|
||||||
return `w-[260px] flex flex-col hidden lg:flex border-r border-[#3d3929]/5 dark:border-white/5 bg-[#faf9f5] dark:bg-[#1e1c19] h-screen sticky top-0`
|
return `${widthClass} flex-col hidden lg:flex border-r border-[#3d3929]/5 dark:border-white/5 bg-[#faf9f5] dark:bg-[#1e1c19] h-screen sticky top-0 transition-[width] [transition-duration:240ms] [transition-timing-function:cubic-bezier(0.4,0,0.2,1)] motion-reduce:transition-none`
|
||||||
})
|
})
|
||||||
|
|
||||||
const contentClasses = computed(() => {
|
const contentClasses = computed(() => {
|
||||||
@@ -1160,6 +1255,38 @@ const mainClasses = computed(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.sidebar-mode-enter-active {
|
||||||
|
transition: opacity 120ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-mode-leave-active {
|
||||||
|
transition: opacity 60ms ease-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-mode-enter-from,
|
||||||
|
.sidebar-mode-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-icon-enter-active,
|
||||||
|
.sidebar-icon-leave-active {
|
||||||
|
transition: opacity 60ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-icon-enter-from,
|
||||||
|
.sidebar-icon-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.sidebar-mode-enter-active,
|
||||||
|
.sidebar-mode-leave-active,
|
||||||
|
.sidebar-icon-enter-active,
|
||||||
|
.sidebar-icon-leave-active {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.scrollbar-none::-webkit-scrollbar { display: none; }
|
.scrollbar-none::-webkit-scrollbar { display: none; }
|
||||||
.scrollbar-none { -ms-overflow-style: none; scrollbar-width: none; }
|
.scrollbar-none { -ms-overflow-style: none; scrollbar-width: none; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user