Files
NextEdu/src/modules/layout/components/site-header.tsx
SpecialX 3569d83b8e feat(modules): update layout, notifications, questions, rbac, files
- layout: update site-header.tsx

- notifications: update use-desktop-notifications.ts

- questions: update question-actions.tsx, question-columns.tsx

- rbac: update permission-catalog.ts

- files: update types.ts
2026-07-04 10:23:16 +08:00

176 lines
6.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client"
import * as React from "react"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { Menu } from "lucide-react"
import { signOut, useSession } from "next-auth/react"
import { useTranslations } from "next-intl"
import { Button } from "@/shared/components/ui/button"
import { Separator } from "@/shared/components/ui/separator"
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/shared/components/ui/breadcrumb"
import { Avatar, AvatarFallback } from "@/shared/components/ui/avatar"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
import { GlobalSearch } from "@/shared/components/global-search"
import { NotificationDropdown } from "@/modules/notifications/components/notification-dropdown"
import { useSidebar } from "./sidebar-provider"
import { NAV_CONFIG } from "../config/navigation"
// Build lookup map for breadcrumbs: href → i18n key
const BREADCRUMB_MAP = new Map<string, string>()
Object.values(NAV_CONFIG).forEach((items) => {
items?.forEach((item) => {
BREADCRUMB_MAP.set(item.href, item.title)
item.items?.forEach((subItem) => {
BREADCRUMB_MAP.set(subItem.href, subItem.title)
})
})
})
export function SiteHeader() {
const pathname = usePathname()
const { toggleSidebar, isMobile } = useSidebar()
const { data: session, status } = useSession()
const tNav = useTranslations("nav")
const tCommon = useTranslations("common")
// v3: root layout 已通过 auth() 获取 server-side session 传给 SessionProvider
// SSR 和 CSR 期间 useSession 返回相同数据,不再需要 mounted workaround。
const name = session?.user?.name ?? ""
const email = session?.user?.email ?? ""
const displayName = name || email || (status === "loading" ? tCommon("user.loading") : tCommon("user.notLoggedIn"))
const fallbackBase = name || email || "?"
const avatarFallback = fallbackBase
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((p) => p[0]?.toUpperCase())
.join("")
// Generate breadcrumbs
const segments = pathname.split("/").filter(Boolean)
const roleSegments = new Set(["admin", "teacher", "student", "parent"])
const breadcrumbs = segments
.map((segment, index) => {
const href = `/${segments.slice(0, index + 1).join("/")}`
const titleKey = BREADCRUMB_MAP.get(href)
const title = titleKey
? tNav(titleKey)
: segment.charAt(0).toUpperCase() + segment.slice(1)
return { href, title, isLast: index === segments.length - 1, isRole: roleSegments.has(segment.toLowerCase()) }
})
.filter((b, idx) => {
// Keep the first role segment (root context), filter out subsequent role segments
if (b.isRole) {
return idx === 0
}
return true
})
return (
<header className="bg-background/95 supports-[backdrop-filter]:bg-background/60 sticky top-0 z-50 flex h-16 items-center border-b px-4 backdrop-blur-sm">
<div className="flex flex-1 items-center gap-4">
{/* Mobile Toggle */}
{isMobile && (
<Button variant="ghost" size="icon" onClick={toggleSidebar} className="mr-2">
<Menu className="size-5" />
<span className="sr-only">{tCommon("sidebar.toggleSidebar")}</span>
</Button>
)}
<Separator orientation="vertical" className="mr-2 hidden h-6 md:block" />
{/* Breadcrumbs */}
<Breadcrumb className="hidden md:flex">
<BreadcrumbList>
{breadcrumbs.length > 0 ? (
breadcrumbs.map((crumb) => (
<React.Fragment key={crumb.href}>
<BreadcrumbItem>
{crumb.isLast ? (
<BreadcrumbPage>{crumb.title}</BreadcrumbPage>
) : (
<BreadcrumbLink asChild>
<Link href={crumb.href}>{crumb.title}</Link>
</BreadcrumbLink>
)}
</BreadcrumbItem>
{!crumb.isLast && <BreadcrumbSeparator />}
</React.Fragment>
))
) : (
<BreadcrumbItem>
<BreadcrumbPage>{tCommon("breadcrumb.home")}</BreadcrumbPage>
</BreadcrumbItem>
)}
</BreadcrumbList>
</Breadcrumb>
</div>
<div className="flex items-center gap-4">
{/* Global Search */}
<GlobalSearch className="hidden md:block" />
{/* Notifications */}
<NotificationDropdown />
{/* User Nav */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="relative size-8 rounded-full">
<Avatar className="size-8">
<AvatarFallback>{avatarFallback}</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="end" forceMount>
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col space-y-1">
<p className="text-sm font-medium leading-none">{displayName}</p>
{email ? (
<p className="text-muted-foreground text-xs leading-none">{email}</p>
) : null}
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href="/profile">{tCommon("user.profile")}</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/settings">{tCommon("user.settings")}</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:bg-destructive/10"
onSelect={(e) => {
e.preventDefault()
signOut({ callbackUrl: "/login" })
}}
>
{tCommon("user.logout")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
)
}