Files
igny8/frontend/src/layout/AppSidebar.tsx
IGNY8 VPS (Salman) 7877a245b4 menu fix
2025-12-06 17:33:23 +00:00

606 lines
20 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Link, useLocation } from "react-router-dom";
// Assume these icons are imported from an icon library
import {
ChevronDownIcon,
GridIcon,
HorizontaLDots,
ListIcon,
PieChartIcon,
PlugInIcon,
TaskIcon,
BoltIcon,
DocsIcon,
PageIcon,
DollarLineIcon,
FileIcon,
UserIcon,
UserCircleIcon,
} from "../icons";
import { useSidebar } from "../context/SidebarContext";
import SidebarWidget from "./SidebarWidget";
import { APP_VERSION } from "../config/version";
import { useAuthStore } from "../store/authStore";
import { useSettingsStore } from "../store/settingsStore";
import ApiStatusIndicator from "../components/sidebar/ApiStatusIndicator";
type NavItem = {
name: string;
icon: React.ReactNode;
path?: string;
subItems?: { name: string; path: string; pro?: boolean; new?: boolean }[];
};
type MenuSection = {
label: string;
items: NavItem[];
};
const AppSidebar: React.FC = () => {
const { isExpanded, isMobileOpen, isHovered, setIsHovered } = useSidebar();
const location = useLocation();
const { user, isAuthenticated } = useAuthStore();
const { moduleEnableSettings, isModuleEnabled: checkModuleEnabled, loadModuleEnableSettings, loading: settingsLoading } = useSettingsStore();
// Show admin menu only for users in aws-admin account
const isAwsAdminAccount = Boolean(
user?.account?.slug === 'aws-admin' ||
user?.role === 'developer' // Also show for developers as fallback
);
// Helper to check if module is enabled - memoized to prevent infinite loops
const moduleEnabled = useCallback((moduleName: string): boolean => {
if (!moduleEnableSettings) return true; // Default to enabled if not loaded
return checkModuleEnabled(moduleName);
}, [moduleEnableSettings, checkModuleEnabled]);
const [openSubmenu, setOpenSubmenu] = useState<{
sectionIndex: number;
itemIndex: number;
} | null>(null);
const [subMenuHeight, setSubMenuHeight] = useState<Record<string, number>>(
{}
);
const subMenuRefs = useRef<Record<string, HTMLDivElement | null>>({});
const isActive = useCallback(
(path: string) => location.pathname === path,
[location.pathname]
);
// Load module enable settings on mount (only once) - but only if user is authenticated
useEffect(() => {
// Only load if user is authenticated and settings aren't already loaded
if (user && isAuthenticated && !moduleEnableSettings && !settingsLoading) {
loadModuleEnableSettings().catch((error) => {
console.warn('Failed to load module enable settings:', error);
});
}
}, [user, isAuthenticated]); // Only run when user/auth state changes
// Define menu sections with useMemo to prevent recreation on every render
// Filter out disabled modules based on module enable settings
// New structure: Dashboard (standalone) → SETUP → WORKFLOW → SETTINGS
const menuSections: MenuSection[] = useMemo(() => {
// SETUP section items (single items, no dropdowns - submenus shown as in-page navigation)
const setupItems: NavItem[] = [
{
icon: <DocsIcon />,
name: "Add Keywords",
path: "/setup/add-keywords",
},
{
icon: <GridIcon />,
name: "Sites",
path: "/sites", // Submenus shown as in-page navigation
},
];
// Add Thinker if enabled (single item, no dropdown)
if (moduleEnabled('thinker')) {
setupItems.push({
icon: <BoltIcon />,
name: "Thinker",
path: "/thinker/prompts", // Default to prompts, submenus shown as in-page navigation
});
}
// WORKFLOW section items (single items, no dropdowns - submenus shown as in-page navigation)
const workflowItems: NavItem[] = [];
// Add Planner if enabled (single item, no dropdown)
if (moduleEnabled('planner')) {
workflowItems.push({
icon: <ListIcon />,
name: "Planner",
path: "/planner/keywords", // Default to keywords, submenus shown as in-page navigation
});
}
// Add Writer if enabled (single item, no dropdown)
if (moduleEnabled('writer')) {
workflowItems.push({
icon: <TaskIcon />,
name: "Writer",
path: "/writer/tasks", // Default to tasks, submenus shown as in-page navigation
});
}
// Add Automation (always available if Writer is enabled)
if (moduleEnabled('writer')) {
workflowItems.push({
icon: <BoltIcon />,
name: "Automation",
path: "/automation",
});
}
// Add Linker if enabled (single item, no dropdown)
if (moduleEnabled('linker')) {
workflowItems.push({
icon: <PlugInIcon />,
name: "Linker",
path: "/linker/content",
});
}
// Add Optimizer if enabled (single item, no dropdown)
if (moduleEnabled('optimizer')) {
workflowItems.push({
icon: <BoltIcon />,
name: "Optimizer",
path: "/optimizer/content",
});
}
return [
// Dashboard is standalone (no section header)
{
label: "", // Empty label for standalone Dashboard
items: [
{
icon: <GridIcon />,
name: "Dashboard",
path: "/",
},
],
},
{
label: "SETUP",
items: setupItems,
},
{
label: "WORKFLOW",
items: workflowItems,
},
{
label: "ACCOUNT",
items: [
{
icon: <UserCircleIcon />,
name: "Account Settings",
path: "/account/settings",
},
{
icon: <DollarLineIcon />,
name: "Plans & Billing",
path: "/account/billing",
},
{
icon: <UserIcon />,
name: "Team Management",
path: "/account/team",
},
{
icon: <PieChartIcon />,
name: "Usage & Analytics",
path: "/account/usage",
},
],
},
{
label: "SETTINGS",
items: [
{
icon: <UserCircleIcon />,
name: "Profile Settings",
path: "/settings/profile",
},
{
icon: <PlugInIcon />,
name: "Integration",
path: "/settings/integration",
},
{
icon: <PageIcon />,
name: "Publishing",
path: "/settings/publishing",
},
{
icon: <FileIcon />,
name: "Import / Export",
path: "/settings/import-export",
},
],
},
{
label: "HELP & DOCS",
items: [
{
icon: <DocsIcon />,
name: "Help & Documentation",
path: "/help",
},
],
},
];
}, [moduleEnabled]);
// Admin section - only shown for users in aws-admin account
const adminSection: MenuSection = useMemo(() => ({
label: "ADMIN",
items: [
{
icon: <GridIcon />,
name: "System Dashboard",
path: "/admin/dashboard",
},
{
icon: <UserIcon />,
name: "Account Management",
subItems: [
{ name: "All Accounts", path: "/admin/accounts" },
{ name: "Subscriptions", path: "/admin/subscriptions" },
{ name: "Account Limits", path: "/admin/account-limits" },
],
},
{
icon: <DollarLineIcon />,
name: "Billing Administration",
subItems: [
{ name: "Billing Overview", path: "/admin/billing" },
{ name: "Invoices", path: "/admin/invoices" },
{ name: "Payments", path: "/admin/payments" },
{ name: "Credit Costs Config", path: "/admin/credit-costs" },
{ name: "Credit Packages", path: "/admin/credit-packages" },
],
},
{
icon: <UserCircleIcon />,
name: "User Administration",
subItems: [
{ name: "All Users", path: "/admin/users" },
{ name: "Roles & Permissions", path: "/admin/roles" },
{ name: "Activity Logs", path: "/admin/activity-logs" },
],
},
{
icon: <PlugInIcon />,
name: "System Configuration",
subItems: [
{ name: "System Settings", path: "/admin/system-settings" },
{ name: "AI Settings", path: "/admin/ai-settings" },
{ name: "Module Settings", path: "/admin/module-settings" },
{ name: "Integration Settings", path: "/admin/integration-settings" },
],
},
{
icon: <PieChartIcon />,
name: "Monitoring",
subItems: [
{ name: "System Health", path: "/settings/status" },
{ name: "API Monitor", path: "/settings/api-monitor" },
{ name: "Debug Status", path: "/settings/debug-status" },
],
},
{
icon: <BoltIcon />,
name: "Developer Tools",
subItems: [
{ name: "Function Testing", path: "/admin/function-testing" },
{ name: "System Testing", path: "/admin/system-testing" },
{ name: "UI Elements", path: "/admin/ui-elements" },
],
},
],
}), []);
// Combine all sections, including admin if user is in aws-admin account
const allSections = useMemo(() => {
return isAwsAdminAccount
? [...menuSections, adminSection]
: menuSections;
}, [isAwsAdminAccount, menuSections, adminSection]);
useEffect(() => {
const currentPath = location.pathname;
let foundMatch = false;
// Find the matching submenu for the current path
allSections.forEach((section, sectionIndex) => {
section.items.forEach((nav, itemIndex) => {
if (nav.subItems && !foundMatch) {
const shouldOpen = nav.subItems.some((subItem) => {
if (currentPath === subItem.path) return true;
if (subItem.path !== '/' && currentPath.startsWith(subItem.path + '/')) return true;
return false;
});
if (shouldOpen) {
setOpenSubmenu((prev) => {
// Only update if different to prevent infinite loops
if (prev?.sectionIndex === sectionIndex && prev?.itemIndex === itemIndex) {
return prev;
}
return {
sectionIndex,
itemIndex,
};
});
foundMatch = true;
}
}
});
});
// If no match found and we're not on a submenu path, don't change the state
// This allows manual toggles to persist
}, [location.pathname, allSections]);
useEffect(() => {
if (openSubmenu !== null) {
const key = `${openSubmenu.sectionIndex}-${openSubmenu.itemIndex}`;
// Use requestAnimationFrame and setTimeout to ensure DOM is ready
const frameId = requestAnimationFrame(() => {
setTimeout(() => {
const element = subMenuRefs.current[key];
if (element) {
// scrollHeight should work even when height is 0px due to overflow-hidden
const scrollHeight = element.scrollHeight;
if (scrollHeight > 0) {
setSubMenuHeight((prevHeights) => {
// Only update if height changed to prevent infinite loops
if (prevHeights[key] === scrollHeight) {
return prevHeights;
}
return {
...prevHeights,
[key]: scrollHeight,
};
});
}
}
}, 50);
});
return () => cancelAnimationFrame(frameId);
}
}, [openSubmenu]);
const handleSubmenuToggle = (sectionIndex: number, itemIndex: number) => {
setOpenSubmenu((prevOpenSubmenu) => {
if (
prevOpenSubmenu &&
prevOpenSubmenu.sectionIndex === sectionIndex &&
prevOpenSubmenu.itemIndex === itemIndex
) {
return null;
}
return { sectionIndex, itemIndex };
});
};
const renderMenuItems = (items: NavItem[], sectionIndex: number) => (
<ul className="flex flex-col gap-2">
{items.map((nav, itemIndex) => (
<li key={nav.name}>
{nav.subItems ? (
<button
onClick={() => handleSubmenuToggle(sectionIndex, itemIndex)}
className={`menu-item group ${
openSubmenu?.sectionIndex === sectionIndex && openSubmenu?.itemIndex === itemIndex ||
(nav.subItems && nav.subItems.some(subItem => isActive(subItem.path)))
? "menu-item-active"
: "menu-item-inactive"
} cursor-pointer ${
!isExpanded && !isHovered
? "lg:justify-center"
: "lg:justify-start"
}`}
>
<span
className={`menu-item-icon-size ${
(openSubmenu?.sectionIndex === sectionIndex && openSubmenu?.itemIndex === itemIndex) ||
(nav.subItems && nav.subItems.some(subItem => isActive(subItem.path)))
? "menu-item-icon-active"
: "menu-item-icon-inactive"
}`}
>
{nav.icon}
</span>
{(isExpanded || isHovered || isMobileOpen) && (
<span className="menu-item-text">{nav.name}</span>
)}
{(isExpanded || isHovered || isMobileOpen) && (
<ChevronDownIcon
className={`ml-auto w-5 h-5 transition-transform duration-200 ${
openSubmenu?.sectionIndex === sectionIndex &&
openSubmenu?.itemIndex === itemIndex
? "rotate-180 text-brand-500"
: ""
}`}
/>
)}
</button>
) : (
nav.path && (
<Link
to={nav.path}
className={`menu-item group ${
isActive(nav.path) ? "menu-item-active" : "menu-item-inactive"
}`}
>
<span
className={`menu-item-icon-size ${
isActive(nav.path)
? "menu-item-icon-active"
: "menu-item-icon-inactive"
}`}
>
{nav.icon}
</span>
{(isExpanded || isHovered || isMobileOpen) && (
<span className="menu-item-text">{nav.name}</span>
)}
</Link>
)
)}
{nav.subItems && (isExpanded || isHovered || isMobileOpen) && (
<div
ref={(el) => {
subMenuRefs.current[`${sectionIndex}-${itemIndex}`] = el;
}}
className="overflow-hidden transition-all duration-300"
style={{
height:
openSubmenu?.sectionIndex === sectionIndex && openSubmenu?.itemIndex === itemIndex
? `${subMenuHeight[`${sectionIndex}-${itemIndex}`]}px`
: "0px",
}}
>
<ul className="mt-2 flex flex-col gap-1 ml-9">
{nav.subItems.map((subItem) => (
<li key={subItem.name}>
<Link
to={subItem.path}
className={`menu-dropdown-item ${
isActive(subItem.path)
? "menu-dropdown-item-active"
: "menu-dropdown-item-inactive"
}`}
>
{subItem.name}
<span className="flex items-center gap-1 ml-auto">
{subItem.new && (
<span
className={`ml-auto ${
isActive(subItem.path)
? "menu-dropdown-badge-active"
: "menu-dropdown-badge-inactive"
} menu-dropdown-badge`}
>
new
</span>
)}
{subItem.pro && (
<span
className={`ml-auto ${
isActive(subItem.path)
? "menu-dropdown-badge-active"
: "menu-dropdown-badge-inactive"
} menu-dropdown-badge`}
>
pro
</span>
)}
</span>
</Link>
</li>
))}
</ul>
</div>
)}
</li>
))}
</ul>
);
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-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 flex-col justify-center items-center gap-3`}
>
<Link to="/" className="flex justify-center items-center">
{isExpanded || isHovered || isMobileOpen ? (
<>
<img
className="dark:hidden"
src="/images/logo/logo.svg"
alt="Logo"
width={113}
height={30}
/>
<img
className="hidden dark:block"
src="/images/logo/logo-dark.svg"
alt="Logo"
width={113}
height={30}
/>
</>
) : (
<img
src="/images/logo/logo-icon.svg"
alt="Logo"
width={24}
height={24}
/>
)}
</Link>
{/* Version Badge - Only show when sidebar is expanded */}
{(isExpanded || isHovered || isMobileOpen) && (
<div className="flex justify-center items-center">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-900 dark:bg-gray-700 text-gray-100 dark:text-gray-300">
v{APP_VERSION}
</span>
</div>
)}
</div>
<div className="flex flex-col overflow-y-auto duration-300 ease-linear no-scrollbar">
{/* API Status Indicator - above OVERVIEW section */}
<ApiStatusIndicator />
<nav className="mb-6">
<div className="flex flex-col gap-2">
{allSections.map((section, sectionIndex) => (
<div key={section.label || `section-${sectionIndex}`}>
{section.label && (
<h2
className={`mb-4 text-xs uppercase flex leading-[20px] text-gray-400 ${
!isExpanded && !isHovered
? "lg:justify-center"
: "justify-start"
}`}
>
{isExpanded || isHovered || isMobileOpen ? (
section.label
) : (
<HorizontaLDots className="size-6" />
)}
</h2>
)}
{renderMenuItems(section.items, sectionIndex)}
</div>
))}
</div>
</nav>
{isExpanded || isHovered || isMobileOpen ? <SidebarWidget /> : null}
</div>
</aside>
);
};
export default AppSidebar;