Yimaru-Admin/src/components/sidebar/Sidebar.tsx
Yared Yemane e75420e756 feat(admin): analytics user breakdowns, email templates, and team invites
Surface education, occupation, learning goals, and language challenges on the analytics page with normalized dashboard API parsing. Add email template management, accept-invite onboarding, and role-based team invitations.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 10:21:55 -07:00

243 lines
7.9 KiB
TypeScript

import {
BarChart3,
Bell,
BookOpen,
ChevronLeft,
ChevronRight,
CircleAlert,
ClipboardList,
LayoutDashboard,
LogOut,
Shield,
UserCircle2,
Users,
Users2,
Settings,
X,
} from "lucide-react";
import { type ComponentType, useEffect, useState } from "react";
import { NavLink } from "react-router-dom";
import { cn } from "../../lib/utils";
import { BrandLogo } from "../brand/BrandLogo";
import { getUnreadCount } from "../../api/notifications.api";
import { SidebarNavGroup } from "./SidebarNavGroup";
type NavLinkItem = {
kind: "link";
label: string;
to: string;
icon: ComponentType<{ className?: string }>;
};
type NavGroupItem = {
kind: "group";
label: string;
basePath: string;
icon: ComponentType<{ className?: string }>;
children: { label: string; to: string; end?: boolean }[];
};
type NavEntry = NavLinkItem | NavGroupItem;
const navEntries: NavEntry[] = [
{ kind: "link", label: "Dashboard", to: "/dashboard", icon: LayoutDashboard },
{ kind: "link", label: "User Management", to: "/users", icon: Users },
{ kind: "link", label: "Role Management", to: "/roles", icon: Shield },
{ kind: "link", label: "Content Management", to: "/content", icon: BookOpen },
{ kind: "link", label: "New Content", to: "/new-content", icon: BookOpen },
{
kind: "group",
label: "Notifications",
basePath: "/notifications",
icon: Bell,
children: [
{ label: "My Notifications", to: "/notifications", end: true },
{ label: "Email Templates", to: "/notifications/email-templates" },
],
},
{ kind: "link", label: "User Log", to: "/user-log", icon: ClipboardList },
{ kind: "link", label: "Issue Reports", to: "/issues", icon: CircleAlert },
{ kind: "link", label: "Analytics", to: "/analytics", icon: BarChart3 },
{ kind: "link", label: "Team Management", to: "/team", icon: Users2 },
{ kind: "link", label: "Profile", to: "/profile", icon: UserCircle2 },
{ kind: "link", label: "Settings", to: "/settings", icon: Settings },
];
type SidebarProps = {
isOpen: boolean;
isCollapsed: boolean;
onToggleCollapse: () => void;
onClose: () => void;
};
export function Sidebar({
isOpen,
isCollapsed,
onToggleCollapse,
onClose,
}: SidebarProps) {
const [unreadCount, setUnreadCount] = useState(0);
useEffect(() => {
const fetchUnread = async () => {
try {
const res = await getUnreadCount();
setUnreadCount(res.data.unread);
} catch {
// silently fail
}
};
fetchUnread();
window.addEventListener("notifications-updated", fetchUnread);
return () =>
window.removeEventListener("notifications-updated", fetchUnread);
}, []);
const unreadBadge = unreadCount > 0 && (
<span className="ml-auto flex h-5 min-w-[20px] items-center justify-center rounded-full bg-destructive px-1.5 text-[10px] font-bold text-white">
{unreadCount > 99 ? "99+" : unreadCount}
</span>
);
const collapsedUnreadDot = unreadCount > 0 && (
<span className="absolute -right-1 -top-1 h-2.5 w-2.5 rounded-full bg-destructive" />
);
return (
<>
<div
className={cn(
"fixed inset-0 z-40 bg-black/50 transition-opacity lg:hidden",
isOpen ? "opacity-100" : "pointer-events-none opacity-0",
)}
onClick={onClose}
aria-hidden="true"
/>
<aside
className={cn(
"group fixed left-0 top-0 z-50 flex h-screen flex-col border-r bg-grayScale-50 py-5 transition-all duration-300",
"w-[264px] px-4 lg:translate-x-0",
isCollapsed && "lg:w-[88px] lg:px-2",
isOpen ? "translate-x-0" : "-translate-x-full",
)}
>
<div
className={cn(
"flex items-center justify-between px-2",
isCollapsed && "justify-center",
)}
>
{isCollapsed ? (
<span className="h-10 w-10 overflow-hidden">
<BrandLogo className="h-10 w-auto max-w-none" />
</span>
) : (
<BrandLogo />
)}
<button
type="button"
className={cn(
"hidden h-8 w-8 place-items-center rounded-lg text-grayScale-500 transition-opacity hover:bg-grayScale-100 hover:text-brand-600 lg:grid lg:opacity-0 lg:pointer-events-none lg:group-hover:opacity-100 lg:group-hover:pointer-events-auto focus-visible:opacity-100 focus-visible:pointer-events-auto",
isCollapsed && "translate-x-2",
)}
onClick={onToggleCollapse}
aria-label={isCollapsed ? "Expand sidebar" : "Collapse sidebar"}
>
{isCollapsed ? (
<ChevronRight className="h-5 w-5" />
) : (
<ChevronLeft className="h-5 w-5" />
)}
</button>
<button
type="button"
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-500 hover:bg-grayScale-100 hover:text-brand-600 lg:hidden"
onClick={onClose}
aria-label="Close sidebar"
>
<X className="h-5 w-5" />
</button>
</div>
<nav className="mt-6 flex-1 space-y-1 overflow-y-auto">
{navEntries.map((entry) => {
if (entry.kind === "group") {
return (
<SidebarNavGroup
key={entry.basePath}
label={entry.label}
icon={entry.icon}
basePath={entry.basePath}
children={entry.children}
isCollapsed={isCollapsed}
onNavigate={onClose}
trailing={!isCollapsed ? unreadBadge : collapsedUnreadDot}
/>
);
}
const Icon = entry.icon;
return (
<NavLink
key={entry.to}
to={entry.to}
onClick={onClose}
className={({ isActive }) =>
cn(
"group flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-grayScale-600 transition",
isCollapsed && "justify-center px-2",
"hover:bg-grayScale-100 hover:text-brand-600",
isActive &&
"bg-brand-100/40 text-brand-600 shadow-[0_1px_0_rgba(0,0,0,0.02)] ring-1 ring-brand-100",
)
}
title={isCollapsed ? entry.label : undefined}
>
{({ isActive }) => (
<>
<span
className={cn(
"grid h-8 w-8 place-items-center rounded-lg bg-grayScale-100 text-grayScale-500 transition group-hover:bg-brand-100 group-hover:text-brand-600",
isActive && "bg-brand-500/90 text-white",
)}
>
<Icon className="h-4 w-4" />
</span>
{!isCollapsed && (
<span className="truncate">{entry.label}</span>
)}
{!isCollapsed && isActive ? (
<span className="ml-auto h-6 w-1 rounded-full bg-brand-500/80" />
) : null}
</>
)}
</NavLink>
);
})}
</nav>
<div className="px-2 pt-6">
<button
type="button"
onClick={() => {
localStorage.clear();
window.location.href = "/login";
}}
className={cn(
"flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium text-grayScale-500 hover:bg-grayScale-100 hover:text-brand-600",
isCollapsed && "justify-center px-2",
)}
title={isCollapsed ? "Logout" : undefined}
>
<LogOut className="h-4 w-4" />
{!isCollapsed && "Logout"}
</button>
</div>
</aside>
</>
);
}