mirror of
https://github.com/rustfs/console.git
synced 2026-08-29 03:52:28 +08:00
286 lines
11 KiB
TypeScript
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>
|
|
)
|
|
}
|