Files
trustlab/src/layout/AppSidebar.tsx

374 lines
12 KiB
TypeScript

"use client";
import React, { useEffect, useRef, useState,useCallback } from "react";
import Link from "next/link";
import Image from "next/image";
import { useTranslations } from "next-intl";
import { usePathname } from "next/navigation";
import { useSidebar } from "../context/SidebarContext";
import useSWR from "swr";
import axios from "@/lib/axios";
import {
LayoutGrid,
Calendar,
Users,
ShieldCheck,
LifeBuoy,
FileText,
Send,
Mail,
Server,
Layers,
UserCircle,
Settings,
KeyRound,
ChevronDown,
Ellipsis,
} from "lucide-react";
type NavItem = {
name: string;
icon: string;
route?: string;
subItems?: { name: string; route: string; pro?: boolean; new?: boolean }[];
};
type MenuGroup = {
title: string;
items: NavItem[];
};
const iconMap: Record<string, React.ReactNode> = {
dashboard: <LayoutGrid size={20} />,
calendar: <Calendar size={20} />,
users: <Users size={20} />,
certificate: <ShieldCheck size={20} />,
"support-ticket": <LifeBuoy size={20} />,
pages: <FileText size={20} />,
email: <Send size={20} />,
inbox: <Mail size={20} />,
smtp: <Send size={20} />,
"server-settings": <Server size={20} />,
layers: <Layers size={20} />,
"user-profile": <UserCircle size={20} />,
settings: <Settings size={20} />,
"api-key": <KeyRound size={20} />,
};
const getIcon = (iconName: string) => iconMap[iconName] || <LayoutGrid size={20} />;
// Static items removed in favor of dynamic API data
const AppSidebar: React.FC = () => {
const { isExpanded, isMobileOpen, isHovered, setIsHovered } = useSidebar();
const pathname = usePathname();
const t = useTranslations("Navigation");
const renderMenuItems = (
navItems: NavItem[],
menuType: string
) => (
<ul className="flex flex-col gap-4">
{navItems.map((nav, index) => (
<li key={nav.name}>
{nav.subItems ? (
<button
onClick={() => handleSubmenuToggle(index, menuType)}
className={`menu-item group ${
openSubmenu?.type === menuType && openSubmenu?.index === index
? "menu-item-active"
: "menu-item-inactive"
} cursor-pointer ${
!isExpanded && !isHovered
? "lg:justify-center"
: "lg:justify-start"
}`}
>
<span
className={` ${
openSubmenu?.type === menuType && openSubmenu?.index === index
? "menu-item-icon-active"
: "menu-item-icon-inactive"
}`}
>
{getIcon(nav.icon)}
</span>
{(isExpanded || isHovered || isMobileOpen) && (
<span className={`menu-item-text`}>
{(() => {
const key = nav.name.toLowerCase().replace(/\s+/g, '_');
const translated = t(key);
return translated === `Navigation.${key}` ? nav.name : translated;
})()}
</span>
)}
{(isExpanded || isHovered || isMobileOpen) && (
<ChevronDown
size={20}
className={`ml-auto w-5 h-5 transition-transform duration-200 ${
openSubmenu?.type === menuType &&
openSubmenu?.index === index
? "rotate-180 text-brand-500"
: ""
}`}
/>
)}
</button>
) : (
nav.route && (
<Link
href={nav.route}
className={`menu-item group ${
isActive(nav.route) ? "menu-item-active" : "menu-item-inactive"
}`}
>
<span
className={`${
isActive(nav.route)
? "menu-item-icon-active"
: "menu-item-icon-inactive"
}`}
>
{getIcon(nav.icon)}
</span>
{(isExpanded || isHovered || isMobileOpen) && (
<span className={`menu-item-text`}>
{(() => {
const key = nav.name.toLowerCase().replace(/\s+/g, '_');
const translated = t(key);
return translated === `Navigation.${key}` ? nav.name : translated;
})()}
</span>
)}
</Link>
)
)}
{nav.subItems && (isExpanded || isHovered || isMobileOpen) && (
<div
ref={(el) => {
subMenuRefs.current[`${menuType}-${index}`] = el;
}}
className="overflow-hidden transition-all duration-300"
style={{
height:
openSubmenu?.type === menuType && openSubmenu?.index === index
? `${subMenuHeight[`${menuType}-${index}`]}px`
: "0px",
}}
>
<ul className="mt-2 space-y-1 ml-9">
{nav.subItems.map((subItem) => (
<li key={subItem.name}>
<Link
href={subItem.route}
className={`menu-dropdown-item ${
isActive(subItem.route)
? "menu-dropdown-item-active"
: "menu-dropdown-item-inactive"
}`}
>
{(() => {
const key = subItem.name.toLowerCase().replace(/\s+/g, '_');
const translated = t(key);
return translated === `Navigation.${key}` ? subItem.name : translated;
})()}
<span className="flex items-center gap-1 ml-auto">
{subItem.new && (
<span
className={`ml-auto ${
isActive(subItem.route)
? "menu-dropdown-badge-active"
: "menu-dropdown-badge-inactive"
} menu-dropdown-badge `}
>
new
</span>
)}
{subItem.pro && (
<span
className={`ml-auto ${
isActive(subItem.route)
? "menu-dropdown-badge-active"
: "menu-dropdown-badge-inactive"
} menu-dropdown-badge `}
>
pro
</span>
)}
</span>
</Link>
</li>
))}
</ul>
</div>
)}
</li>
))}
</ul>
);
const { data: menuGroups, error } = useSWR<MenuGroup[]>('/api/navigation', () =>
axios.get('/api/navigation').then(res => res.data)
);
const [openSubmenu, setOpenSubmenu] = useState<{
type: string;
index: number;
} | null>(null);
const [subMenuHeight, setSubMenuHeight] = useState<Record<string, number>>(
{}
);
const subMenuRefs = useRef<Record<string, HTMLDivElement | null>>({});
// const isActive = (path: string) => path === pathname;
const isActive = useCallback((path: string) => path === pathname, [pathname]);
useEffect(() => {
if (!menuGroups) return;
// Check if the current path matches any submenu item
let targetSubmenu: { type: string; index: number } | null = null;
// Use some/find to break early if possible, or just foreach
for (const group of menuGroups) {
for (let index = 0; index < group.items.length; index++) {
const nav = group.items[index];
if (nav.subItems) {
const hasActiveSubItem = nav.subItems.some(subItem => isActive(subItem.route));
if (hasActiveSubItem) {
targetSubmenu = { type: group.title, index };
break;
}
}
}
if (targetSubmenu) break;
}
// Only update state if it has changed to avoid loops and unnecessary re-renders
const isMismatch =
(openSubmenu === null && targetSubmenu !== null) ||
(openSubmenu !== null && targetSubmenu === null) ||
(openSubmenu && targetSubmenu && (openSubmenu.type !== targetSubmenu.type || openSubmenu.index !== targetSubmenu.index));
if (isMismatch) {
setOpenSubmenu(targetSubmenu);
}
}, [pathname, isActive, menuGroups, openSubmenu]);
useEffect(() => {
// Set the height of the submenu items when the submenu is opened
if (openSubmenu !== null) {
const key = `${openSubmenu.type}-${openSubmenu.index}`;
if (subMenuRefs.current[key]) {
setSubMenuHeight((prevHeights) => ({
...prevHeights,
[key]: subMenuRefs.current[key]?.scrollHeight || 0,
}));
}
}
}, [openSubmenu]);
const handleSubmenuToggle = (index: number, menuType: string) => {
setOpenSubmenu((prevOpenSubmenu) => {
if (
prevOpenSubmenu &&
prevOpenSubmenu.type === menuType &&
prevOpenSubmenu.index === index
) {
return null;
}
return { type: menuType, index };
});
};
return (
<aside
className={`fixed mt-16 flex flex-col lg:mt-0 top-0 px-5 left-0 bg-white dark:bg-gray-900 dark:border-gray-800 text-gray-900 h-[calc(100vh-4rem)] lg:h-screen transition-all duration-300 ease-in-out z-50 border-r border-gray-200
${
isExpanded || isMobileOpen
? "w-[290px]"
: isHovered
? "w-[290px]"
: "w-[90px]"
}
${isMobileOpen ? "translate-x-0" : "-translate-x-full"}
lg:translate-x-0`}
onMouseEnter={() => !isExpanded && setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div
className={`py-8 flex ${
!isExpanded && !isHovered ? "lg:justify-center" : "justify-start"
}`}
>
<Link href="/">
{isExpanded || isHovered || isMobileOpen ? (
<>
<Image
className="dark:hidden"
src="/images/logo/logo.svg"
alt="Logo"
width={150}
height={40}
style={{ width: "auto", height: "auto" }}
priority
/>
<Image
className="hidden dark:block"
src="/images/logo/logo-dark.svg"
alt="Logo"
width={150}
height={40}
style={{ width: "auto", height: "auto" }}
priority
/>
</>
) : (
<Image
src="/images/logo/logo-icon.svg"
alt="Logo"
width={32}
height={32}
style={{ width: "auto", height: "auto" }}
/>
)}
</Link>
</div>
<div className="flex flex-col flex-1 overflow-y-auto duration-300 ease-linear no-scrollbar pb-24">
<nav className="mb-6">
<div className="flex flex-col gap-4">
{menuGroups?.map((group, groupIndex) => (
<div key={group.title}>
<h2
className={`mb-4 text-xs uppercase flex leading-[20px] text-gray-400 ${
!isExpanded && !isHovered
? "lg:justify-center"
: "justify-start"
}`}
>
{isExpanded || isHovered || isMobileOpen ? (
(() => {
const key = group.title.toLowerCase().replace(/\s+/g, '_');
try { return t(key); } catch { return group.title; }
})()
) : (
<Ellipsis size={16} />
)}
</h2>
{renderMenuItems(group.items, group.title)}
</div>
))}
{error && (
<div className="px-4 py-2 text-xs text-red-500 bg-red-50 rounded-lg">
Failed to load navigation
</div>
)}
</div>
</nav>
</div>
</aside>
);
};
export default AppSidebar;