191 lines
4.9 KiB
TypeScript
191 lines
4.9 KiB
TypeScript
|
|
"use client";
|
||
|
|
|
||
|
|
import * as React from "react";
|
||
|
|
import Link from "next/link";
|
||
|
|
import { usePathname } from "next/navigation";
|
||
|
|
import {
|
||
|
|
LayoutDashboard,
|
||
|
|
CreditCard,
|
||
|
|
CalendarDays,
|
||
|
|
Users,
|
||
|
|
BarChart3,
|
||
|
|
Settings,
|
||
|
|
ChevronsLeft,
|
||
|
|
ChevronsRight,
|
||
|
|
} from "lucide-react";
|
||
|
|
import { cn } from "@/lib/utils";
|
||
|
|
import {
|
||
|
|
Tooltip,
|
||
|
|
TooltipTrigger,
|
||
|
|
TooltipContent,
|
||
|
|
} from "@/components/ui/tooltip";
|
||
|
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||
|
|
|
||
|
|
const STORAGE_KEY = "echo-sidebar-collapsed";
|
||
|
|
|
||
|
|
const navItems = [
|
||
|
|
{ href: "/", label: "Dashboard", icon: LayoutDashboard },
|
||
|
|
{ href: "/cards", label: "Response Cards", icon: CreditCard },
|
||
|
|
{ href: "/events", label: "Collection Days", icon: CalendarDays },
|
||
|
|
{ href: "/people", label: "People", icon: Users },
|
||
|
|
{ href: "/reports", label: "Reports", icon: BarChart3 },
|
||
|
|
];
|
||
|
|
|
||
|
|
const bottomItems = [
|
||
|
|
{ href: "/settings", label: "Settings", icon: Settings },
|
||
|
|
];
|
||
|
|
|
||
|
|
function isActive(pathname: string, href: string) {
|
||
|
|
if (href === "/") return pathname === "/";
|
||
|
|
return pathname === href || pathname.startsWith(href + "/");
|
||
|
|
}
|
||
|
|
|
||
|
|
type SidebarContextValue = {
|
||
|
|
collapsed: boolean;
|
||
|
|
setCollapsed: (v: boolean) => void;
|
||
|
|
toggle: () => void;
|
||
|
|
};
|
||
|
|
|
||
|
|
const SidebarContext = React.createContext<SidebarContextValue>({
|
||
|
|
collapsed: false,
|
||
|
|
setCollapsed: () => {},
|
||
|
|
toggle: () => {},
|
||
|
|
});
|
||
|
|
|
||
|
|
export function useSidebar() {
|
||
|
|
return React.useContext(SidebarContext);
|
||
|
|
}
|
||
|
|
|
||
|
|
export function SidebarProvider({ children }: { children: React.ReactNode }) {
|
||
|
|
const [collapsed, setCollapsedState] = React.useState(false);
|
||
|
|
const [mounted, setMounted] = React.useState(false);
|
||
|
|
|
||
|
|
React.useEffect(() => {
|
||
|
|
setMounted(true);
|
||
|
|
try {
|
||
|
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||
|
|
if (stored === "true") setCollapsedState(true);
|
||
|
|
} catch {}
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
const setCollapsed = React.useCallback((v: boolean) => {
|
||
|
|
setCollapsedState(v);
|
||
|
|
try {
|
||
|
|
localStorage.setItem(STORAGE_KEY, String(v));
|
||
|
|
} catch {}
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
const toggle = React.useCallback(() => {
|
||
|
|
setCollapsed(!collapsed);
|
||
|
|
}, [collapsed, setCollapsed]);
|
||
|
|
|
||
|
|
const value = React.useMemo(
|
||
|
|
() => ({ collapsed: mounted ? collapsed : false, setCollapsed, toggle }),
|
||
|
|
[mounted, collapsed, setCollapsed, toggle]
|
||
|
|
);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<SidebarContext.Provider value={value}>{children}</SidebarContext.Provider>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function NavItem({
|
||
|
|
href,
|
||
|
|
label,
|
||
|
|
icon: Icon,
|
||
|
|
active,
|
||
|
|
collapsed,
|
||
|
|
}: {
|
||
|
|
href: string;
|
||
|
|
label: string;
|
||
|
|
icon: React.ComponentType<{ className?: string }>;
|
||
|
|
active: boolean;
|
||
|
|
collapsed: boolean;
|
||
|
|
}) {
|
||
|
|
const content = (
|
||
|
|
<Link
|
||
|
|
href={href}
|
||
|
|
className={cn(
|
||
|
|
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
|
||
|
|
active
|
||
|
|
? "bg-sidebar-accent text-sidebar-accent-foreground"
|
||
|
|
: "text-sidebar-foreground/70 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground",
|
||
|
|
collapsed && "justify-center px-0"
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
<Icon className="size-4 shrink-0" />
|
||
|
|
{!collapsed && <span className="truncate">{label}</span>}
|
||
|
|
</Link>
|
||
|
|
);
|
||
|
|
|
||
|
|
if (collapsed) {
|
||
|
|
return (
|
||
|
|
<Tooltip>
|
||
|
|
<TooltipTrigger render={<div />}>{content}</TooltipTrigger>
|
||
|
|
<TooltipContent side="right" sideOffset={8}>
|
||
|
|
{label}
|
||
|
|
</TooltipContent>
|
||
|
|
</Tooltip>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
return content;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function Sidebar() {
|
||
|
|
const pathname = usePathname();
|
||
|
|
const { collapsed, toggle } = useSidebar();
|
||
|
|
|
||
|
|
return (
|
||
|
|
<aside
|
||
|
|
className={cn(
|
||
|
|
"fixed left-0 top-16 z-30 flex h-[calc(100vh-4rem)] flex-col border-r border-sidebar-border bg-sidebar transition-[width] duration-200 ease-in-out",
|
||
|
|
collapsed ? "w-16" : "w-60"
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
<ScrollArea className="flex-1">
|
||
|
|
<nav className={cn("flex flex-col gap-1 p-3", collapsed && "px-2")}>
|
||
|
|
{navItems.map((item) => (
|
||
|
|
<NavItem
|
||
|
|
key={item.href}
|
||
|
|
{...item}
|
||
|
|
active={isActive(pathname, item.href)}
|
||
|
|
collapsed={collapsed}
|
||
|
|
/>
|
||
|
|
))}
|
||
|
|
</nav>
|
||
|
|
</ScrollArea>
|
||
|
|
|
||
|
|
<div
|
||
|
|
className={cn(
|
||
|
|
"flex flex-col gap-1 border-t border-sidebar-border p-3",
|
||
|
|
collapsed && "px-2"
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
{bottomItems.map((item) => (
|
||
|
|
<NavItem
|
||
|
|
key={item.href}
|
||
|
|
{...item}
|
||
|
|
active={isActive(pathname, item.href)}
|
||
|
|
collapsed={collapsed}
|
||
|
|
/>
|
||
|
|
))}
|
||
|
|
<button
|
||
|
|
onClick={toggle}
|
||
|
|
className={cn(
|
||
|
|
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-sidebar-foreground/50 transition-colors hover:bg-sidebar-accent/50 hover:text-sidebar-foreground",
|
||
|
|
collapsed && "justify-center px-0"
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
{collapsed ? (
|
||
|
|
<ChevronsRight className="size-4 shrink-0" />
|
||
|
|
) : (
|
||
|
|
<ChevronsLeft className="size-4 shrink-0" />
|
||
|
|
)}
|
||
|
|
{!collapsed && <span>Collapse</span>}
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</aside>
|
||
|
|
);
|
||
|
|
}
|