241 lines
10 KiB
TypeScript
241 lines
10 KiB
TypeScript
"use client"
|
|
|
|
import Link from "next/link"
|
|
import { usePathname } from "next/navigation"
|
|
import { ChevronRight, Circle } from "lucide-react"
|
|
|
|
import type { NavGroup, NavItem } from "@/base/types/navigation"
|
|
import { cn } from "@/shared/lib/utils"
|
|
import {
|
|
Collapsible,
|
|
CollapsibleContent,
|
|
CollapsibleTrigger,
|
|
} from "@/shared/components/ui/collapsible"
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuLabel,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from "@/shared/components/ui/dropdown-menu"
|
|
import {
|
|
Sidebar,
|
|
SidebarContent,
|
|
SidebarGroup,
|
|
SidebarGroupLabel,
|
|
SidebarHeader,
|
|
SidebarMenu,
|
|
SidebarMenuButton,
|
|
SidebarMenuItem,
|
|
SidebarMenuSub,
|
|
SidebarMenuSubButton,
|
|
SidebarMenuSubItem,
|
|
SidebarRail,
|
|
useSidebar,
|
|
} from "@/shared/components/ui/sidebar"
|
|
|
|
type AppSidebarProps = React.ComponentProps<typeof Sidebar> & {
|
|
navGroups: NavGroup[]
|
|
logo?: React.ReactNode
|
|
}
|
|
|
|
export function AppSidebar({ navGroups, logo, ...props }: AppSidebarProps) {
|
|
const { state, isMobile } = useSidebar()
|
|
const isCollapsed = state === "collapsed" && !isMobile
|
|
|
|
return (
|
|
<Sidebar collapsible="icon" {...props} className="bg-card">
|
|
{logo && (
|
|
<SidebarHeader className="flex p-4">
|
|
{logo}
|
|
</SidebarHeader>
|
|
)}
|
|
<SidebarContent className={cn("transition-[padding] duration-200", !isCollapsed && "ps-2")}>
|
|
{navGroups.map((group, groupIndex) => (
|
|
<SidebarGroup key={group.label ?? groupIndex}>
|
|
{group.label && (
|
|
<SidebarGroupLabel className="uppercase text-xs tracking-wider text-muted-foreground">
|
|
{group.label}
|
|
</SidebarGroupLabel>
|
|
)}
|
|
<SidebarMenu>
|
|
{group.items.map((item) =>
|
|
item.items && item.items.length > 0 ? (
|
|
<CollapsibleNavItem key={item.href} item={item} isCollapsed={isCollapsed} />
|
|
) : (
|
|
<SimpleNavItem key={item.href} item={item} isCollapsed={isCollapsed} />
|
|
)
|
|
)}
|
|
</SidebarMenu>
|
|
</SidebarGroup>
|
|
))}
|
|
</SidebarContent>
|
|
<SidebarRail />
|
|
</Sidebar>
|
|
)
|
|
}
|
|
|
|
function SimpleNavItem({ item, isCollapsed }: { item: NavItem; isCollapsed: boolean }) {
|
|
const pathname = usePathname()
|
|
const isActive = item.isActive ?? pathname === item.href
|
|
|
|
return (
|
|
<SidebarMenuItem>
|
|
<SidebarMenuButton
|
|
|
|
asChild
|
|
isActive={isActive}
|
|
tooltip={item.title}
|
|
className="dashboard-nav-item"
|
|
data-collapsed={isCollapsed}
|
|
>
|
|
<Link href={item.href}>
|
|
{item.icon}
|
|
{
|
|
!isCollapsed &&
|
|
<span>{item.title}</span>
|
|
}
|
|
</Link>
|
|
</SidebarMenuButton>
|
|
</SidebarMenuItem>
|
|
)
|
|
}
|
|
|
|
function CollapsibleNavItem({ item, isCollapsed }: { item: NavItem; isCollapsed: boolean }) {
|
|
const pathname = usePathname()
|
|
const isChildActive = item.items?.some((sub) => pathname === sub.href)
|
|
const isActive = item.isActive ?? (pathname === item.href || isChildActive === true)
|
|
|
|
// Collapsed sidebar → flyout dropdown with sub-items
|
|
if (isCollapsed) {
|
|
return (
|
|
<SidebarMenuItem>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<SidebarMenuButton
|
|
|
|
isActive={isActive}
|
|
tooltip={item.title}
|
|
className="dashboard-nav-item"
|
|
data-collapsed={isCollapsed}
|
|
>
|
|
<span
|
|
className={cn(
|
|
"transition-transform duration-300",
|
|
isActive && "text-primary"
|
|
)}
|
|
>
|
|
{item.icon}
|
|
</span>
|
|
{
|
|
!isCollapsed &&
|
|
<span>{item.title}</span>
|
|
}
|
|
</SidebarMenuButton>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent
|
|
side="right"
|
|
align="start"
|
|
sideOffset={4}
|
|
className="min-w-45"
|
|
>
|
|
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
|
{item.title}
|
|
</DropdownMenuLabel>
|
|
<DropdownMenuSeparator />
|
|
{item.items?.map((sub) => {
|
|
const isSubActive = sub.isActive ?? pathname === sub.href
|
|
return (
|
|
<DropdownMenuItem key={sub.href} asChild>
|
|
<Link
|
|
href={sub.href}
|
|
className={cn(
|
|
"flex items-center gap-2",
|
|
isSubActive && "bg-primary/10 text-primary font-medium"
|
|
)}
|
|
>
|
|
{sub.icon ? (
|
|
<span className={cn("shrink-0 [&>svg]:size-4", isSubActive ? "text-primary" : "text-muted-foreground/70")}>
|
|
{sub.icon}
|
|
</span>
|
|
) : (
|
|
<Circle
|
|
className={cn(
|
|
"size-1.5",
|
|
isSubActive ? "fill-primary text-primary" : "fill-muted-foreground/50 text-muted-foreground/50"
|
|
)}
|
|
/>
|
|
)}
|
|
{sub.title}
|
|
</Link>
|
|
</DropdownMenuItem>
|
|
)
|
|
})}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</SidebarMenuItem>
|
|
)
|
|
}
|
|
|
|
// Expanded sidebar → collapsible/accordion sub-menu
|
|
return (
|
|
<Collapsible asChild defaultOpen={isActive} className="group/collapsible">
|
|
<SidebarMenuItem>
|
|
<CollapsibleTrigger asChild>
|
|
<SidebarMenuButton tooltip={item.title} isActive={isActive} className="dashboard-nav-item" data-collapsed={isCollapsed}>
|
|
<span
|
|
className={cn(
|
|
"transition-transform duration-300",
|
|
isActive && "text-primary"
|
|
)}
|
|
>
|
|
{item.icon}
|
|
</span>
|
|
|
|
|
|
<span>{item.title}</span>
|
|
|
|
<ChevronRight
|
|
className={cn(
|
|
"ms-auto size-4 shrink-0 transition-transform duration-300 ease-[cubic-bezier(0.87,0,0.13,1)]",
|
|
"group-data-[state=open]/collapsible:rotate-90"
|
|
)}
|
|
/>
|
|
</SidebarMenuButton>
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent className="overflow-hidden data-[state=open]:animate-collapsible-down data-[state=closed]:animate-collapsible-up">
|
|
<SidebarMenuSub>
|
|
{item.items?.map((sub) => {
|
|
const isSubActive = sub.isActive ?? pathname === sub.href
|
|
return (
|
|
<SidebarMenuSubItem key={sub.href}>
|
|
<SidebarMenuSubButton asChild isActive={isSubActive} className="dashboard-nav-sub-item">
|
|
<Link href={sub.href}>
|
|
{sub.icon ? (
|
|
<span className={cn("shrink-0 transition-colors duration-200 [&>svg]:size-4", isSubActive ? "text-primary" : "text-muted-foreground/70 group-hover/menu-sub-item:text-foreground")}>
|
|
{sub.icon}
|
|
</span>
|
|
) : (
|
|
<Circle
|
|
className={cn(
|
|
"size-1.5 transition-colors duration-200",
|
|
isSubActive
|
|
? "fill-primary text-primary"
|
|
: "fill-muted-foreground/40 text-muted-foreground/40 group-hover/menu-sub-item:fill-foreground group-hover/menu-sub-item:text-foreground"
|
|
)}
|
|
/>
|
|
)}
|
|
<span>{sub.title}</span>
|
|
</Link>
|
|
</SidebarMenuSubButton>
|
|
</SidebarMenuSubItem>
|
|
)
|
|
})}
|
|
</SidebarMenuSub>
|
|
</CollapsibleContent>
|
|
</SidebarMenuItem>
|
|
</Collapsible>
|
|
)
|
|
}
|