Files
igny8/frontend/src/layout/AppSidebar.tsx
IGNY8 VPS (Salman) 7b022f3a06 colros update
2026-01-24 14:15:31 +00:00

488 lines
16 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,
TaskIcon,
BoltIcon,
DocsIcon,
ShootingStarIcon,
CalendarIcon,
TagIcon,
} 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 [openSubmenus, setOpenSubmenus] = useState<Set<string>>(new Set());
const [subMenuHeight, setSubMenuHeight] = useState<Record<string, number>>(
{}
);
const subMenuRefs = useRef<Record<string, HTMLDivElement | null>>({});
const submenusInitialized = useRef(false);
// Check if a path is active - exact match only for menu items
// Prefix matching is only used for parent menus to determine if submenu should be open
const isActive = useCallback(
(path: string, exactOnly: boolean = false) => {
// Exact match always works
if (location.pathname === path) return true;
// For prefix matching (used by parent menus to check if any child is active)
// Skip if exactOnly is requested (for submenu items)
if (!exactOnly && path !== '/' && location.pathname.startsWith(path + '/')) {
return true;
}
return false;
},
[location.pathname]
);
// Check if a submenu item path is active - uses exact match only
const isSubItemActive = useCallback(
(path: string) => {
return location.pathname === path;
},
[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 → Thinker
const setupItems: NavItem[] = [];
// Setup Wizard at top - guides users through site setup
setupItems.push({
icon: <BoltIcon />,
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",
});
// Keywords Library - Browse and add curated keywords
setupItems.push({
icon: <TagIcon />,
name: "Keywords Library",
path: "/keywords-library",
});
// Content Settings moved to Site Settings tabs - removed from sidebar
// 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" },
],
});
}
// Add Publisher (after Writer) - always visible
workflowItems.push({
icon: <CalendarIcon />,
name: "Publisher",
subItems: [
{ name: "Content Review", path: "/writer/review" },
{ name: "Publish / Schedule", path: "/writer/approved" },
{ name: "Content Calendar", path: "/publisher/content-calendar" },
],
});
// Add Automation if enabled (with dropdown)
if (isModuleEnabled('automation')) {
workflowItems.push({
icon: <ShootingStarIcon />,
name: "Automation",
subItems: [
{ name: "Overview", path: "/automation/overview" },
{ name: "Run Now (Manual)", path: "/automation/run" },
],
});
}
// 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,
},
];
}, [isModuleEnabled, moduleSettings]); // Re-run when settings change
// Combine all sections
const allSections = useMemo(() => {
return menuSections;
}, [menuSections]);
useEffect(() => {
if (submenusInitialized.current) return;
const initialKeys = new Set<string>();
allSections.forEach((section, sectionIndex) => {
section.items.forEach((nav, itemIndex) => {
if (nav.subItems) {
initialKeys.add(`${sectionIndex}-${itemIndex}`);
}
});
});
setOpenSubmenus(initialKeys);
submenusInitialized.current = true;
}, [allSections]);
useEffect(() => {
const currentPath = location.pathname;
allSections.forEach((section, sectionIndex) => {
section.items.forEach((nav, itemIndex) => {
if (nav.subItems) {
const shouldOpen = nav.subItems.some((subItem) => currentPath === subItem.path);
if (shouldOpen) {
const key = `${sectionIndex}-${itemIndex}`;
setOpenSubmenus((prev) => new Set([...prev, key]));
}
}
});
});
}, [location.pathname, allSections]);
useEffect(() => {
const frameId = requestAnimationFrame(() => {
setTimeout(() => {
openSubmenus.forEach((key) => {
const element = subMenuRefs.current[key];
if (element) {
const scrollHeight = element.scrollHeight;
if (scrollHeight > 0) {
setSubMenuHeight((prevHeights) => {
if (prevHeights[key] === scrollHeight) {
return prevHeights;
}
return {
...prevHeights,
[key]: scrollHeight,
};
});
}
}
});
}, 50);
});
return () => cancelAnimationFrame(frameId);
}, [openSubmenus]);
const handleSubmenuToggle = (sectionIndex: number, itemIndex: number) => {
const key = `${sectionIndex}-${itemIndex}`;
setOpenSubmenus((prev) => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
});
};
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
// Allow access for: admin role, staff users, or aws-admin account members
if (nav.adminOnly) {
const isAdmin = user?.role === 'admin' || user?.is_staff === true;
const isAwsAdminAccount = user?.account?.name === 'aws-admin' || user?.account?.slug === 'aws-admin';
if (!isAdmin && !isAwsAdminAccount) {
return false;
}
}
return true;
})
.map((nav, itemIndex) => {
// Check if any subitem is active to determine parent active state (uses exact match for subitems)
const hasActiveSubItem = nav.subItems?.some(subItem => isSubItemActive(subItem.path)) ?? false;
const isSubmenuOpen = openSubmenus.has(`${sectionIndex}-${itemIndex}`);
return (
<li key={`${sectionIndex}-${nav.name}-${location.pathname}`}>
{nav.subItems ? (
<button
onClick={() => handleSubmenuToggle(sectionIndex, itemIndex)}
className={`menu-item group ${
hasActiveSubItem
? "menu-item-active"
: "menu-item-inactive"
} cursor-pointer ${
!isExpanded && !isHovered
? "lg:justify-center"
: "lg:justify-start"
}`}
>
<span
className={`menu-item-icon-size ${
hasActiveSubItem
? "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 ${
isSubmenuOpen
? "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: isSubmenuOpen
? `${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 ${
isSubItemActive(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 ${
isSubItemActive(subItem.path)
? "menu-dropdown-badge-active"
: "menu-dropdown-badge-inactive"
} menu-dropdown-badge`}
>
new
</span>
)}
{subItem.pro && (
<span
className={`ml-auto ${
isSubItemActive(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 flex-col justify-center items-center border-b border-gray-200 dark:border-gray-800">
<Link to="/" className="flex flex-col 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/IGNY8_DARK_LOGO.png"
alt="Logo"
width={113}
height={30}
/>
{/* AI SEO Engine Badge */}
<span className="mt-2 px-2 py-0.5 text-xs font-medium rounded-[5px] bg-purple-100 text-purple-700 dark:bg-purple-500/20 dark:text-purple-300">
AI SEO Engine
</span>
</>
) : (
<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 mt-[22px]">
<nav>
<div className="flex flex-col gap-1">
{allSections.map((section, sectionIndex) => (
<div key={section.label || `section-${sectionIndex}`} className={section.label ? "mt-2" : ""}>
{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;