2025-12-10 20:52:44 +08:00
|
|
|
<template>
|
2026-07-25 21:28:51 +08:00
|
|
|
<TooltipProvider :delay-duration="150">
|
|
|
|
|
<nav
|
|
|
|
|
class="sidebar-nav w-full"
|
|
|
|
|
:class="collapsed ? 'px-2' : 'px-3'"
|
|
|
|
|
:data-collapsed="collapsed"
|
2025-12-12 16:15:16 +08:00
|
|
|
>
|
|
|
|
|
<div
|
2026-07-25 21:28:51 +08:00
|
|
|
v-for="(group, index) in items"
|
|
|
|
|
:key="index"
|
|
|
|
|
: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"
|
2025-12-12 16:15:16 +08:00
|
|
|
>
|
2026-07-25 21:28:51 +08:00
|
|
|
<!-- Section Header -->
|
|
|
|
|
<div
|
|
|
|
|
v-if="group.title && !collapsed"
|
|
|
|
|
class="flex items-center gap-2 px-2.5 pb-1"
|
|
|
|
|
:class="index > 0 ? 'pt-1' : ''"
|
2025-12-12 16:15:16 +08:00
|
|
|
>
|
2026-07-25 21:28:51 +08:00
|
|
|
<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-semibold text-muted-foreground/70 uppercase tracking-[0.1em]">{{ group.title }}</span>
|
|
|
|
|
</div>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
2026-07-25 21:28:51 +08:00
|
|
|
<!-- Links -->
|
|
|
|
|
<div class="space-y-0.5">
|
|
|
|
|
<template
|
|
|
|
|
v-for="item in group.items"
|
|
|
|
|
:key="item.href"
|
|
|
|
|
>
|
|
|
|
|
<Tooltip
|
|
|
|
|
:open="collapsed && openTooltipHref === item.href"
|
|
|
|
|
@update:open="handleTooltipOpenChange(item.href, $event)"
|
|
|
|
|
>
|
|
|
|
|
<TooltipTrigger as-child>
|
|
|
|
|
<RouterLink
|
|
|
|
|
:to="item.href"
|
|
|
|
|
class="group relative flex items-center rounded-lg"
|
|
|
|
|
: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)
|
|
|
|
|
? 'bg-primary/10 text-primary font-medium'
|
|
|
|
|
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
|
|
|
|
|
]"
|
|
|
|
|
:aria-label="collapsed ? item.name : undefined"
|
|
|
|
|
@pointerenter="schedulePrefetch(item.href)"
|
|
|
|
|
@pointerleave="cancelScheduledPrefetch(item.href)"
|
|
|
|
|
@pointerdown="prefetchNow(item.href)"
|
|
|
|
|
@focus="prefetchNow(item.href)"
|
|
|
|
|
@click="handleNavigate(item.href)"
|
|
|
|
|
>
|
|
|
|
|
<div class="flex min-w-0 items-center gap-2.5">
|
|
|
|
|
<component
|
|
|
|
|
:is="item.icon"
|
|
|
|
|
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'"
|
|
|
|
|
:stroke-width="isItemActive(item.href) ? 2 : 1.75"
|
|
|
|
|
/>
|
|
|
|
|
<span
|
|
|
|
|
v-if="!collapsed"
|
|
|
|
|
class="truncate text-[13px] tracking-tight"
|
|
|
|
|
>{{ item.name }}</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Active Indicator -->
|
|
|
|
|
<div
|
|
|
|
|
v-if="isItemActive(item.href)"
|
|
|
|
|
class="h-1 w-1 shrink-0 rounded-full bg-primary"
|
|
|
|
|
:class="collapsed ? 'absolute right-1.5' : ''"
|
|
|
|
|
/>
|
|
|
|
|
</RouterLink>
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
<TooltipContent
|
|
|
|
|
v-if="collapsed"
|
|
|
|
|
side="right"
|
|
|
|
|
:side-offset="10"
|
|
|
|
|
class="text-xs"
|
|
|
|
|
>
|
|
|
|
|
{{ item.name }}
|
|
|
|
|
</TooltipContent>
|
|
|
|
|
</Tooltip>
|
|
|
|
|
</template>
|
|
|
|
|
</div>
|
2025-12-10 20:52:44 +08:00
|
|
|
</div>
|
2026-07-25 21:28:51 +08:00
|
|
|
</nav>
|
|
|
|
|
</TooltipProvider>
|
2025-12-10 20:52:44 +08:00
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
2026-07-25 21:28:51 +08:00
|
|
|
import { onBeforeUnmount, ref, type Component } from 'vue'
|
|
|
|
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
2025-12-10 20:52:44 +08:00
|
|
|
|
|
|
|
|
export interface NavigationItem {
|
|
|
|
|
name: string
|
|
|
|
|
href: string
|
|
|
|
|
icon: Component
|
|
|
|
|
description?: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface NavigationGroup {
|
|
|
|
|
title?: string
|
|
|
|
|
items: NavigationItem[]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const props = defineProps<{
|
|
|
|
|
items: NavigationGroup[]
|
|
|
|
|
activePath?: string
|
|
|
|
|
isActive?: (href: string) => boolean
|
2026-07-25 21:28:51 +08:00
|
|
|
collapsed?: boolean
|
2025-12-10 20:52:44 +08:00
|
|
|
}>()
|
|
|
|
|
|
|
|
|
|
const emit = defineEmits<{
|
|
|
|
|
(e: 'navigate', href: string): void
|
2026-04-19 15:17:25 +08:00
|
|
|
(e: 'prefetch', href: string): void
|
2025-12-10 20:52:44 +08:00
|
|
|
}>()
|
|
|
|
|
|
2026-07-15 23:47:19 +08:00
|
|
|
const HOVER_PREFETCH_DELAY_MS = 100
|
|
|
|
|
let scheduledPrefetchHref: string | null = null
|
|
|
|
|
let scheduledPrefetchTimer: ReturnType<typeof setTimeout> | null = null
|
2026-07-25 21:28:51 +08:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-15 23:47:19 +08:00
|
|
|
|
|
|
|
|
function cancelScheduledPrefetch(href?: string) {
|
|
|
|
|
if (href && scheduledPrefetchHref !== href) return
|
|
|
|
|
if (scheduledPrefetchTimer) {
|
|
|
|
|
clearTimeout(scheduledPrefetchTimer)
|
|
|
|
|
scheduledPrefetchTimer = null
|
|
|
|
|
}
|
|
|
|
|
scheduledPrefetchHref = null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function schedulePrefetch(href: string) {
|
|
|
|
|
cancelScheduledPrefetch()
|
|
|
|
|
scheduledPrefetchHref = href
|
|
|
|
|
scheduledPrefetchTimer = setTimeout(() => {
|
|
|
|
|
scheduledPrefetchTimer = null
|
|
|
|
|
scheduledPrefetchHref = null
|
|
|
|
|
emit('prefetch', href)
|
|
|
|
|
}, HOVER_PREFETCH_DELAY_MS)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function prefetchNow(href: string) {
|
|
|
|
|
cancelScheduledPrefetch()
|
|
|
|
|
emit('prefetch', href)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
onBeforeUnmount(() => cancelScheduledPrefetch())
|
|
|
|
|
|
2025-12-10 20:52:44 +08:00
|
|
|
function isItemActive(href: string) {
|
|
|
|
|
if (props.isActive) {
|
|
|
|
|
return props.isActive(href)
|
|
|
|
|
}
|
|
|
|
|
if (props.activePath) {
|
|
|
|
|
return props.activePath === href || props.activePath.startsWith(`${href}/`)
|
|
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleNavigate(href: string) {
|
|
|
|
|
emit('navigate', href)
|
|
|
|
|
}
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
/* Navigation styles handled by Tailwind */
|
|
|
|
|
</style>
|