527 lines
17 KiB
TypeScript
527 lines
17 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { Link, useLocation } from "react-router-dom";
|
|
import { Bell } from "lucide-react";
|
|
|
|
// Assume these icons are imported from an icon library
|
|
import {
|
|
ChevronDownIcon,
|
|
GridIcon,
|
|
HorizontaLDots,
|
|
ListIcon,
|
|
PieChartIcon,
|
|
PlugInIcon,
|
|
TaskIcon,
|
|
BoltIcon,
|
|
DocsIcon,
|
|
PageIcon,
|
|
DollarLineIcon,
|
|
FileIcon,
|
|
UserIcon,
|
|
UserCircleIcon,
|
|
ShootingStarIcon,
|
|
} from "../icons";
|
|
import { useSidebar } from "../context/SidebarContext";
|
|
import { useAuthStore } from "../store/authStore";
|
|
import { useSettingsStore } from "../store/settingsStore";
|
|
import { useModuleStore } from "../store/moduleStore";
|
|
|
|
type NavItem = {
|
|
name: string;
|
|
icon: React.ReactNode;
|
|
path?: string;
|
|
subItems?: { name: string; path: string; pro?: boolean; new?: boolean }[];
|
|
adminOnly?: boolean;
|
|
};
|
|
|
|
type MenuSection = {
|
|
label: string;
|
|
items: NavItem[];
|
|
};
|
|
|
|
const AppSidebar: React.FC = () => {
|
|
const { isExpanded, isMobileOpen, isHovered, setIsHovered, toggleSidebar } = useSidebar();
|
|
const location = useLocation();
|
|
const { user, isAuthenticated } = useAuthStore();
|
|
const { isModuleEnabled, settings: moduleSettings } = useModuleStore();
|
|
|
|
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) => {
|
|
// Exact match
|
|
if (location.pathname === path) return true;
|
|
// For sub-pages, match if pathname starts with the path (except for root)
|
|
if (path !== '/' && location.pathname.startsWith(path + '/')) return true;
|
|
return false;
|
|
},
|
|
[location.pathname]
|
|
);
|
|
|
|
// Define menu sections with useMemo to prevent recreation on every render
|
|
// New structure: Dashboard (standalone) → SETUP → WORKFLOW → SETTINGS
|
|
// Module visibility is controlled by GlobalModuleSettings (Django Admin only)
|
|
const menuSections: MenuSection[] = useMemo(() => {
|
|
// SETUP section items - Ordered: Setup Wizard → Sites → Add Keywords → Content Settings → Thinker
|
|
const setupItems: NavItem[] = [];
|
|
|
|
// Setup Wizard at top - guides users through site setup
|
|
setupItems.push({
|
|
icon: <ShootingStarIcon />,
|
|
name: "Setup Wizard",
|
|
path: "/setup/wizard",
|
|
});
|
|
|
|
// Sites is always visible - it's core functionality for managing sites
|
|
setupItems.push({
|
|
icon: <GridIcon />,
|
|
name: "Sites",
|
|
path: "/sites",
|
|
});
|
|
|
|
// Add Keywords second
|
|
setupItems.push({
|
|
icon: <DocsIcon />,
|
|
name: "Add Keywords",
|
|
path: "/setup/add-keywords",
|
|
});
|
|
|
|
// Content Settings third - with dropdown
|
|
setupItems.push({
|
|
icon: <PageIcon />,
|
|
name: "Content Settings",
|
|
subItems: [
|
|
{ name: "Content Generation", path: "/account/content-settings" },
|
|
{ name: "Publishing", path: "/account/content-settings/publishing" },
|
|
{ name: "Image Settings", path: "/account/content-settings/images" },
|
|
],
|
|
});
|
|
|
|
// Add Thinker last (admin only - prompts and AI settings)
|
|
if (isModuleEnabled('thinker')) {
|
|
setupItems.push({
|
|
icon: <BoltIcon />,
|
|
name: "Thinker",
|
|
subItems: [
|
|
{ name: "Prompts", path: "/thinker/prompts" },
|
|
{ name: "Author Profiles", path: "/thinker/author-profiles" },
|
|
],
|
|
adminOnly: true, // Only visible to admin/staff users
|
|
});
|
|
}
|
|
|
|
// WORKFLOW section items (conditionally shown based on global settings)
|
|
const workflowItems: NavItem[] = [];
|
|
|
|
// Add Planner with dropdown if enabled
|
|
if (isModuleEnabled('planner')) {
|
|
workflowItems.push({
|
|
icon: <ListIcon />,
|
|
name: "Planner",
|
|
subItems: [
|
|
{ name: "Keywords", path: "/planner/keywords" },
|
|
{ name: "Clusters", path: "/planner/clusters" },
|
|
{ name: "Ideas", path: "/planner/ideas" },
|
|
],
|
|
});
|
|
}
|
|
|
|
// Add Writer with dropdown if enabled
|
|
if (isModuleEnabled('writer')) {
|
|
workflowItems.push({
|
|
icon: <TaskIcon />,
|
|
name: "Writer",
|
|
subItems: [
|
|
{ name: "Content Queue", path: "/writer/tasks" },
|
|
{ name: "Content Drafts", path: "/writer/content" },
|
|
{ name: "Content Images", path: "/writer/images" },
|
|
{ name: "Content Review", path: "/writer/review" },
|
|
{ name: "Content Approved", path: "/writer/approved" },
|
|
],
|
|
});
|
|
}
|
|
|
|
// Add Automation if enabled (no dropdown - single page)
|
|
if (isModuleEnabled('automation')) {
|
|
workflowItems.push({
|
|
icon: <BoltIcon />,
|
|
name: "Automation",
|
|
path: "/automation",
|
|
});
|
|
}
|
|
|
|
// Linker and Optimizer removed - not active modules
|
|
|
|
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: <Bell className="w-5 h-5" />,
|
|
name: "Notifications",
|
|
path: "/account/notifications",
|
|
},
|
|
{
|
|
icon: <UserCircleIcon />,
|
|
name: "Account Settings",
|
|
subItems: [
|
|
{ name: "Account", path: "/account/settings" },
|
|
{ name: "Profile", path: "/account/settings/profile" },
|
|
{ name: "Team", path: "/account/settings/team" },
|
|
],
|
|
},
|
|
{
|
|
icon: <DollarLineIcon />,
|
|
name: "Plans & Billing",
|
|
subItems: [
|
|
{ name: "Current Plan", path: "/account/plans" },
|
|
{ name: "Upgrade Plan", path: "/account/plans/upgrade" },
|
|
{ name: "History", path: "/account/plans/history" },
|
|
],
|
|
},
|
|
{
|
|
icon: <PieChartIcon />,
|
|
name: "Usage",
|
|
subItems: [
|
|
{ name: "Limits & Usage", path: "/account/usage" },
|
|
{ name: "Credit History", path: "/account/usage/credits" },
|
|
{ name: "Activity", path: "/account/usage/activity" },
|
|
],
|
|
},
|
|
{
|
|
icon: <PlugInIcon />,
|
|
name: "AI Models",
|
|
path: "/settings/integration",
|
|
adminOnly: true, // Only visible to admin/staff users
|
|
},
|
|
],
|
|
},
|
|
{
|
|
label: "HELP",
|
|
items: [
|
|
{
|
|
icon: <DocsIcon />,
|
|
name: "Help & Docs",
|
|
path: "/help",
|
|
},
|
|
],
|
|
},
|
|
];
|
|
}, [isModuleEnabled, moduleSettings]); // Re-run when settings change
|
|
|
|
// Combine all sections
|
|
const allSections = useMemo(() => {
|
|
return menuSections;
|
|
}, [menuSections]);
|
|
|
|
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-0.5">
|
|
{items
|
|
.filter((nav) => {
|
|
// Filter out admin-only items for non-admin users
|
|
if (nav.adminOnly && user?.role !== 'admin' && !user?.is_staff) {
|
|
return false;
|
|
}
|
|
return true;
|
|
})
|
|
.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-4 flex justify-center items-center">
|
|
<Link to="/" className="flex justify-center items-center">
|
|
{isExpanded || isHovered || isMobileOpen ? (
|
|
<>
|
|
<img
|
|
className="dark:hidden"
|
|
src="/images/logo/IGNY8_LIGHT_LOGO.png"
|
|
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.png"
|
|
alt="Logo"
|
|
width={24}
|
|
height={24}
|
|
/>
|
|
)}
|
|
</Link>
|
|
</div>
|
|
<div className="flex flex-col overflow-y-auto duration-300 ease-linear no-scrollbar">
|
|
<nav>
|
|
<div className="flex flex-col gap-1">
|
|
{allSections.map((section, sectionIndex) => (
|
|
<div key={section.label || `section-${sectionIndex}`} className={section.label ? "mt-4" : ""}>
|
|
{section.label && (
|
|
<h2
|
|
className={`mb-2 text-xs font-medium uppercase flex leading-[20px] text-gray-500 dark: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>
|
|
</div>
|
|
</aside>
|
|
);
|
|
};
|
|
|
|
export default AppSidebar;
|