mega-plant/components/Sidebar.tsx

167 lines
5.8 KiB
TypeScript
Raw Normal View History

"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { ChevronDown, Menu, X } from "lucide-react";
import { cn } from "@/lib/utils";
export type NavLeaf = { href: string; label: string };
export type NavItem =
| { kind: "link"; href: string; label: string; icon: React.ReactNode }
| { kind: "group"; label: string; icon: React.ReactNode; children: NavLeaf[] };
export function Sidebar({ items }: { items: NavItem[] }) {
const pathname = usePathname();
const [mobileOpen, setMobileOpen] = useState(false);
// Close drawer on navigation
useEffect(() => {
setMobileOpen(false);
}, [pathname]);
// Lock body scroll when drawer is open
useEffect(() => {
document.body.style.overflow = mobileOpen ? "hidden" : "";
return () => {
document.body.style.overflow = "";
};
}, [mobileOpen]);
const renderNav = (onNavigate?: () => void) => (
<nav className="p-3 space-y-0.5 text-sm">
{items.map((item, i) =>
item.kind === "link" ? (
<LeafLink
key={i}
href={item.href}
label={item.label}
icon={item.icon}
active={pathname === item.href || (item.href !== "/" && pathname.startsWith(item.href))}
onClick={onNavigate}
/>
) : (
<Group
key={i}
item={item}
active={pathname.startsWith(item.children[0]?.href.split("/").slice(0, 3).join("/") ?? "")}
/>
)
)}
</nav>
);
return (
<>
{/* Desktop: persistent sidebar (hidden on mobile) */}
<aside className="hidden lg:block w-64 shrink-0 bg-white dark:bg-surface-muted border-r border-gray-200 dark:border-border min-h-[calc(100vh-4rem)]">
{renderNav()}
</aside>
{/* Mobile: hamburger in the top bar triggers a drawer */}
<button
type="button"
onClick={() => setMobileOpen(true)}
aria-label="Open navigation"
className="lg:hidden w-9 h-9 grid place-items-center rounded-lg hover:bg-gray-100 dark:hover:bg-white/5 text-gray-500 dark:text-gray-400"
>
<Menu className="w-5 h-5" />
</button>
{mobileOpen && (
<div
className="lg:hidden fixed inset-0 z-40 bg-black/40"
onClick={() => setMobileOpen(false)}
/>
)}
<aside
className={cn(
"lg:hidden fixed top-0 left-0 bottom-0 z-50 w-72 bg-white dark:bg-surface-muted shadow-2xl transition-transform",
mobileOpen ? "translate-x-0" : "-translate-x-full"
)}
>
<div className="flex items-center justify-between px-4 h-16 border-b border-gray-200 dark:border-border">
<div className="flex items-center gap-2">
<img src="/brand/logo.png" alt="Mega Plant" className="w-8 h-8 rounded-xl object-contain bg-white dark:bg-bg shadow-sm" />
<span className="text-sm font-semibold text-[#003D5B] dark:text-white">Mega Plant</span>
</div>
<button
type="button"
onClick={() => setMobileOpen(false)}
aria-label="Close navigation"
className="w-9 h-9 grid place-items-center rounded-lg hover:bg-gray-100 dark:hover:bg-white/5 text-gray-500 dark:text-gray-400"
>
<X className="w-5 h-5" />
</button>
</div>
{renderNav(() => setMobileOpen(false))}
</aside>
</>
);
}
function LeafLink({
href, label, icon, active, onClick,
}: {
href: string; label: string; icon: React.ReactNode; active: boolean; onClick?: () => void;
}) {
return (
<Link
href={href}
onClick={onClick}
className={cn(
"flex items-center gap-3 px-3 py-2 rounded-lg font-medium transition",
active
? "bg-gradient-to-r from-[#30638E]/10 to-[#00798C]/5 text-[#003D5B] dark:from-[#30638E]/20 dark:to-[#00798C]/10 dark:text-white"
: "text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-white/5 hover:text-gray-900 dark:hover:text-white"
)}
>
<span className={cn(active ? "text-accent-ink" : "text-gray-400")}>{icon}</span>
<span>{label}</span>
</Link>
);
}
function Group({ item, active }: { item: Extract<NavItem, { kind: "group" }>; active: boolean }) {
const [open, setOpen] = useState(active);
const pathname = usePathname();
return (
<div>
<button
onClick={() => setOpen((o) => !o)}
className={cn(
"w-full flex items-center gap-3 px-3 py-2 rounded-lg font-medium transition",
active
? "text-[#003D5B] dark:text-white"
: "text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-white/5 hover:text-gray-900 dark:hover:text-white"
)}
>
<span className={cn(active ? "text-accent-ink" : "text-gray-400")}>{item.icon}</span>
<span className="flex-1 text-left">{item.label}</span>
<ChevronDown className={cn("w-4 h-4 text-gray-400 transition-transform", open && "rotate-180")} />
</button>
{open && (
<div className="ml-7 mt-0.5 space-y-0.5 border-l-2 border-gray-100 dark:border-border pl-2">
{item.children.map((child) => {
const childActive = pathname === child.href;
return (
<Link
key={child.href}
href={child.href}
className={cn(
"block px-3 py-1.5 rounded-lg text-xs font-medium transition",
childActive
? "bg-[#30638E]/10 text-[#30638E] dark:bg-[#00798C]/15 dark:text-[#00798C]"
: "text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-white/5 hover:text-gray-900 dark:hover:text-white"
)}
>
{child.label}
</Link>
);
})}
</div>
)}
</div>
);
}