geo/frontend/components/layout/side-nav.tsx

167 lines
4.9 KiB
TypeScript

"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
// ─── Types ────────────────────────────────────────────────────────────────────
export interface NavItem {
id: string;
label: string;
href?: string;
icon?: React.ReactNode;
badge?: string | number;
disabled?: boolean;
}
export interface NavGroup {
id: string;
title?: string;
items: NavItem[];
}
export interface SideNavProps extends React.HTMLAttributes<HTMLElement> {
/** 品牌名 */
brandName?: string;
/** 品牌图标 */
brandIcon?: React.ReactNode;
/** 导航分组 */
groups: NavGroup[];
/** 当前激活的导航项 id */
activeId?: string;
/** 导航项点击回调 */
onNavClick?: (item: NavItem) => void;
}
// ─── NavItemRow ───────────────────────────────────────────────────────────────
const NavItemRow = React.memo(
({
item,
active,
onClick,
}: {
item: NavItem;
active: boolean;
onClick?: () => void;
}) => {
return (
<button
type="button"
disabled={item.disabled}
onClick={onClick}
className={cn(
"group relative flex w-full items-center gap-3 rounded-lg py-2.5 px-4 text-sm font-medium transition-all duration-150",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-1",
"disabled:pointer-events-none disabled:opacity-40",
active
? "bg-emerald-50 text-emerald-600 font-semibold border-l-[3px] border-emerald-500"
: "text-gray-600 hover:bg-gray-50 border-l-[3px] border-transparent"
)}
>
{/* icon */}
{item.icon && (
<span
className={cn(
"flex h-5 w-5 shrink-0 items-center justify-center transition-colors duration-150",
active ? "text-emerald-500" : "text-gray-400 group-hover:text-gray-600"
)}
>
{item.icon}
</span>
)}
{/* label */}
<span className="flex-1 truncate text-left">{item.label}</span>
{/* badge */}
{item.badge !== undefined && (
<span
className={cn(
"ml-auto flex h-5 min-w-5 items-center justify-center rounded-full px-1.5 text-[10px] font-bold",
active
? "bg-emerald-500 text-white"
: "bg-gray-200 text-gray-600"
)}
>
{item.badge}
</span>
)}
</button>
);
}
);
NavItemRow.displayName = "NavItemRow";
// ─── Main SideNav ─────────────────────────────────────────────────────────────
const SideNav = React.forwardRef<HTMLElement, SideNavProps>(
(
{
className,
brandName = "GEO Platform",
brandIcon,
groups,
activeId,
onNavClick,
...props
},
ref
) => {
return (
<nav
ref={ref}
className={cn(
"flex flex-col h-full bg-white border-r border-gray-200 overflow-y-auto",
"w-60",
className
)}
{...props}
>
{/* ── Brand header ── */}
<div className="flex items-center gap-3 px-5 py-5 border-b border-gray-200 pb-6 mb-4 shrink-0">
{brandIcon && (
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-emerald-500 text-white">
{brandIcon}
</div>
)}
{!brandIcon && (
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-emerald-500 text-sm font-bold text-white">
G
</div>
)}
<span className="text-base font-bold text-gray-900 tracking-tight truncate">
{brandName}
</span>
</div>
{/* ── Nav groups ── */}
<div className="flex-1 overflow-y-auto px-3 pb-3 space-y-6">
{groups.map((group) => (
<div key={group.id}>
{group.title && (
<p className="mb-2 px-4 text-xs font-semibold text-gray-400 uppercase tracking-wider">
{group.title}
</p>
)}
<div className="space-y-0.5">
{group.items.map((item) => (
<NavItemRow
key={item.id}
item={item}
active={item.id === activeId}
onClick={() => onNavClick?.(item)}
/>
))}
</div>
</div>
))}
</div>
</nav>
);
}
);
SideNav.displayName = "SideNav";
export { SideNav };