Files
rustfs-console/components/app-sidebar.tsx
T
2026-06-26 11:36:33 +08:00

286 lines
11 KiB
TypeScript

"use client"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { useTranslation } from "react-i18next"
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
import { ScrollArea } from "@/components/ui/scroll-area"
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarRail,
SidebarSeparator,
useSidebar,
} from "@/components/ui/sidebar"
import { RiArrowRightSLine, RiExternalLinkLine } from "@remixicon/react"
import { getIconComponent } from "@/lib/icon-map"
import navs from "@/config/navs"
import type { NavItem } from "@/types/app-config"
import { usePermissions } from "@/hooks/use-permissions"
import { useFirstAccessibleDashboardRoute } from "@/hooks/use-first-accessible-dashboard-route"
import { canAccessDashboardRoute } from "@/lib/dashboard-route-meta"
import { SidebarVersion } from "@/components/sidebars/version"
import { useDirection } from "@/components/ui/direction"
import { getThemeManifest } from "@/lib/theme/manifest"
import { ThemeLogo } from "@/components/theme/logo"
const theme = getThemeManifest()
const APP_NAME = theme.brand.name
function NavIcon({ name }: { name?: string }) {
const Icon = getIconComponent(name)
if (!Icon) return null
// Icon from static map lookup; rule flags dynamic lookup as "component during render"
return (
// eslint-disable-next-line react-hooks/static-components -- icon from static map
<Icon className="size-4 shrink-0" aria-hidden />
)
}
function hasChildren(item: NavItem) {
return Array.isArray(item.children) && item.children.length > 0
}
function isExternal(item: NavItem) {
return Boolean(item.target) || /^https?:/i.test(item.to ?? "")
}
function normalizedTo(item: NavItem) {
return item.to ?? "/"
}
export function AppSidebar() {
const { t } = useTranslation()
const pathname = usePathname()
const { state } = useSidebar()
const isCollapsed = state === "collapsed"
const dir = useDirection()
const brandInitial = APP_NAME.charAt(0).toUpperCase() ?? "R"
const { isAdmin, canAccessPath, hasResolvedAdmin, hasFetchedPolicy, isLoading } = usePermissions()
const { route: homeRoute } = useFirstAccessibleDashboardRoute()
const isPermissionsReady = hasResolvedAdmin && (isAdmin || (!isLoading && hasFetchedPolicy))
if (!isPermissionsReady) {
return null
}
const visibleNavs = navs.flatMap((nav) => {
if (nav.type === "divider") {
return [nav]
}
let visibleChildren: NavItem[] = []
if (nav.children?.length) {
visibleChildren = nav.children.filter((child) => {
if (!child.to || isExternal(child)) return true
if (!canAccessDashboardRoute(child.to, { isAdmin, canAccessPath })) return false
return true
})
if (visibleChildren.length === 0 && !nav.to) return []
} else {
if (nav.to && !isExternal(nav) && !canAccessDashboardRoute(nav.to, { isAdmin, canAccessPath })) return []
}
const navItem = { ...nav }
if (visibleChildren.length > 0) {
navItem.children = visibleChildren
} else {
delete navItem.children
}
return [navItem]
})
const navGroups: NavItem[][] = []
let current: NavItem[] = []
for (let index = 0; index < visibleNavs.length; index++) {
const nav = visibleNavs[index]
if (!nav) continue
if (nav.type === "divider") {
const hasItemsBefore = current.length > 0
const hasItemsAfter = visibleNavs.slice(index + 1).some((item) => item.type !== "divider")
if (hasItemsBefore && hasItemsAfter) {
navGroups.push(current)
current = []
}
continue
}
current.push(nav)
}
if (current.length > 0) {
navGroups.push(current)
}
const isRouteActive = (item: NavItem): boolean => {
if (hasChildren(item)) {
return item.children!.some((child) => isRouteActive(child))
}
if (!item.to || isExternal(item)) return false
// Root path "/" must match exactly; otherwise "/browser" etc. would match via startsWith("/")
if (item.to === "/") {
return pathname === "/"
}
return pathname.startsWith(item.to)
}
const getLabel = (item: NavItem) => t(item.label)
return (
<Sidebar
collapsible="icon"
side={dir === "rtl" ? "right" : "left"}
className="**:data-[sidebar=menu-button]:text-start! **:data-[sidebar=menu-sub-button]:text-start!"
>
<SidebarHeader>
<Link href={homeRoute ?? "/"} className="flex items-center gap-3">
{isCollapsed ? (
<div className="flex size-8 items-center justify-center rounded-none bg-primary text-sm font-semibold text-primary-foreground">
<span>{brandInitial}</span>
</div>
) : (
<div className="flex min-w-0 items-baseline gap-2 px-3 py-4">
<ThemeLogo width={96} height={16} className="shrink-0" alt={APP_NAME} priority />
</div>
)}
</Link>
</SidebarHeader>
<SidebarContent>
<ScrollArea className="flex-1 pe-1">
<div className="flex flex-col gap-4">
{navGroups.map((group, groupIndex) => (
<SidebarGroup key={groupIndex} className="gap-4 py-0">
<SidebarGroupContent>
<SidebarMenu>
{group.map((item) => (
<Collapsible
key={item.label}
defaultOpen={hasChildren(item) && isRouteActive(item)}
className="group/collapsible"
render={
hasChildren(item) ? (
<SidebarMenuItem>
<CollapsibleTrigger
render={
<SidebarMenuButton
isActive={isRouteActive(item)}
tooltip={getLabel(item)}
className="gap-3"
>
<NavIcon name={item.icon} />
<span className="flex-1 truncate">{getLabel(item)}</span>
<RiArrowRightSLine
className="size-4 shrink-0 transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
aria-hidden
/>
</SidebarMenuButton>
}
/>
<CollapsibleContent>
<SidebarMenuSub className="border-l-0 border-s rtl:-translate-x-px">
{item.children!.map((child) => (
<SidebarMenuSubItem key={child.label}>
{isExternal(child) ? (
<SidebarMenuSubButton
size="sm"
render={
<a
href={normalizedTo(child)}
target="_blank"
rel="noopener noreferrer"
className="flex w-full items-center gap-2"
>
<NavIcon name={child.icon} />
<span className="truncate">{getLabel(child)}</span>
<RiExternalLinkLine
className="ms-auto size-3 text-muted-foreground"
aria-hidden
/>
</a>
}
/>
) : (
<SidebarMenuSubButton
size="sm"
isActive={isRouteActive(child)}
render={
<Link href={normalizedTo(child)} className="flex w-full items-center gap-2">
<NavIcon name={child.icon} />
<span className="truncate">{getLabel(child)}</span>
</Link>
}
/>
)}
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
) : (
<SidebarMenuItem>
{isExternal(item) ? (
<SidebarMenuButton
tooltip={getLabel(item)}
render={
<a
href={normalizedTo(item)}
target="_blank"
rel="noopener noreferrer"
className="flex w-full items-center gap-3"
>
<NavIcon name={item.icon} />
<span className="flex-1 truncate">{getLabel(item)}</span>
<RiExternalLinkLine className="size-3.5 text-muted-foreground" aria-hidden />
</a>
}
/>
) : (
<SidebarMenuButton
isActive={isRouteActive(item)}
tooltip={getLabel(item)}
render={
<Link href={normalizedTo(item)} className="flex w-full items-center gap-3">
<NavIcon name={item.icon} />
<span className="flex-1 truncate">{getLabel(item)}</span>
</Link>
}
/>
)}
</SidebarMenuItem>
)
}
/>
))}
</SidebarMenu>
</SidebarGroupContent>
{groupIndex !== navGroups.length - 1 ? <SidebarSeparator className="mx-2" /> : null}
</SidebarGroup>
))}
</div>
</ScrollArea>
</SidebarContent>
<SidebarFooter className="mt-auto px-2 pb-2">
<SidebarVersion appName={APP_NAME} />
</SidebarFooter>
<SidebarRail />
</Sidebar>
)
}