mirror of
https://github.com/dyzulk/trustlab.git
synced 2026-01-26 21:41:52 +07:00
First commit
This commit is contained in:
366
src/layout/AppSidebar.tsx
Normal file
366
src/layout/AppSidebar.tsx
Normal file
@@ -0,0 +1,366 @@
|
||||
"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 {
|
||||
BoxCubeIcon,
|
||||
CalenderIcon,
|
||||
ChevronDownIcon,
|
||||
GridIcon,
|
||||
HorizontaLDots,
|
||||
ListIcon,
|
||||
PageIcon,
|
||||
PieChartIcon,
|
||||
PlugInIcon,
|
||||
TableIcon,
|
||||
UserCircleIcon,
|
||||
LockIcon,
|
||||
MailIcon,
|
||||
PaperPlaneIcon,
|
||||
} from "../icons/index";
|
||||
|
||||
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: <GridIcon />,
|
||||
calendar: <CalenderIcon />,
|
||||
users: <UserCircleIcon />,
|
||||
certificate: <BoxCubeIcon />,
|
||||
"support-ticket": <PlugInIcon />,
|
||||
pages: <PageIcon />,
|
||||
email: <PaperPlaneIcon />,
|
||||
inbox: <MailIcon />,
|
||||
smtp: <PaperPlaneIcon />,
|
||||
"server-settings": <BoxCubeIcon />,
|
||||
layers: <BoxCubeIcon />,
|
||||
"user-profile": <UserCircleIcon />,
|
||||
settings: <BoxCubeIcon />,
|
||||
"api-key": <LockIcon />,
|
||||
};
|
||||
|
||||
const getIcon = (iconName: string) => iconMap[iconName] || <BoxCubeIcon />;
|
||||
|
||||
// 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) && (
|
||||
<ChevronDownIcon
|
||||
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 submenuMatched = false;
|
||||
menuGroups.forEach((group) => {
|
||||
group.items.forEach((nav, index) => {
|
||||
if (nav.subItems) {
|
||||
nav.subItems.forEach((subItem) => {
|
||||
if (isActive(subItem.route)) {
|
||||
setOpenSubmenu({
|
||||
type: group.title,
|
||||
index,
|
||||
});
|
||||
submenuMatched = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// If no submenu item matches, close the open submenu
|
||||
if (!submenuMatched) {
|
||||
setOpenSubmenu(null);
|
||||
}
|
||||
}, [pathname, isActive, menuGroups]);
|
||||
|
||||
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; }
|
||||
})()
|
||||
) : (
|
||||
<HorizontaLDots />
|
||||
)}
|
||||
</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;
|
||||
Reference in New Issue
Block a user