- Added Linker and Optimizer apps to `INSTALLED_APPS` in `settings.py`. - Configured API endpoints for Linker and Optimizer in `urls.py`. - Implemented `OptimizeContentFunction` for content optimization in the AI module. - Created prompts for content optimization and site structure generation. - Updated `OptimizerService` to utilize the new AI function for content optimization. - Developed frontend components including dashboards and content lists for Linker and Optimizer. - Integrated new routes and sidebar navigation for Linker and Optimizer in the frontend. - Enhanced content management with source and sync status filters in the Writer module. - Comprehensive test coverage added for new features and components.
599 lines
20 KiB
TypeScript
599 lines
20 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { Link, useLocation } from "react-router";
|
|
|
|
// Assume these icons are imported from an icon library
|
|
import {
|
|
ChevronDownIcon,
|
|
GridIcon,
|
|
HorizontaLDots,
|
|
ListIcon,
|
|
PieChartIcon,
|
|
PlugInIcon,
|
|
TaskIcon,
|
|
BoltIcon,
|
|
DocsIcon,
|
|
PageIcon,
|
|
DollarLineIcon,
|
|
} 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
|
|
const menuSections: MenuSection[] = useMemo(() => {
|
|
const workflowItems: NavItem[] = [
|
|
{
|
|
icon: <PlugInIcon />,
|
|
name: "Setup",
|
|
subItems: [
|
|
{ name: "Sites", path: "/settings/sites" },
|
|
{ name: "Keywords Opportunities", path: "/planner/keyword-opportunities" },
|
|
],
|
|
},
|
|
];
|
|
|
|
// Add Planner if enabled
|
|
if (moduleEnabled('planner')) {
|
|
workflowItems.push({
|
|
icon: <ListIcon />,
|
|
name: "Planner",
|
|
subItems: [
|
|
{ name: "Dashboard", path: "/planner" },
|
|
{ name: "Keywords", path: "/planner/keywords" },
|
|
{ name: "Clusters", path: "/planner/clusters" },
|
|
{ name: "Ideas", path: "/planner/ideas" },
|
|
],
|
|
});
|
|
}
|
|
|
|
// Add Writer if enabled
|
|
if (moduleEnabled('writer')) {
|
|
workflowItems.push({
|
|
icon: <TaskIcon />,
|
|
name: "Writer",
|
|
subItems: [
|
|
{ name: "Dashboard", path: "/writer" },
|
|
{ name: "Tasks", path: "/writer/tasks" },
|
|
{ name: "Content", path: "/writer/content" },
|
|
{ name: "Images", path: "/writer/images" },
|
|
{ name: "Published", path: "/writer/published" },
|
|
],
|
|
});
|
|
}
|
|
|
|
// Add Thinker if enabled
|
|
if (moduleEnabled('thinker')) {
|
|
workflowItems.push({
|
|
icon: <BoltIcon />,
|
|
name: "Thinker",
|
|
subItems: [
|
|
{ name: "Dashboard", path: "/thinker" },
|
|
{ name: "Prompts", path: "/thinker/prompts" },
|
|
{ name: "Author Profiles", path: "/thinker/author-profiles" },
|
|
{ name: "Strategies", path: "/thinker/strategies" },
|
|
{ name: "Image Testing", path: "/thinker/image-testing" },
|
|
],
|
|
});
|
|
}
|
|
|
|
// Add Linker if enabled
|
|
if (moduleEnabled('linker')) {
|
|
workflowItems.push({
|
|
icon: <PlugInIcon />,
|
|
name: "Linker",
|
|
subItems: [
|
|
{ name: "Dashboard", path: "/linker" },
|
|
{ name: "Content", path: "/linker/content" },
|
|
],
|
|
});
|
|
}
|
|
|
|
// Add Optimizer if enabled
|
|
if (moduleEnabled('optimizer')) {
|
|
workflowItems.push({
|
|
icon: <BoltIcon />,
|
|
name: "Optimizer",
|
|
subItems: [
|
|
{ name: "Dashboard", path: "/optimizer" },
|
|
{ name: "Content", path: "/optimizer/content" },
|
|
],
|
|
});
|
|
}
|
|
|
|
// Add Automation if enabled
|
|
if (moduleEnabled('automation')) {
|
|
workflowItems.push({
|
|
icon: <BoltIcon />,
|
|
name: "Automation",
|
|
path: "/automation",
|
|
});
|
|
}
|
|
|
|
return [
|
|
{
|
|
label: "OVERVIEW",
|
|
items: [
|
|
{
|
|
icon: <GridIcon />,
|
|
name: "Dashboard",
|
|
path: "/",
|
|
},
|
|
{
|
|
icon: <DocsIcon />,
|
|
name: "Industry / Sectors",
|
|
path: "/reference/industries",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
label: "WORKFLOWS",
|
|
items: workflowItems,
|
|
},
|
|
{
|
|
label: "ACCOUNT & SETTINGS",
|
|
items: [
|
|
{
|
|
icon: <PlugInIcon />,
|
|
name: "Settings",
|
|
subItems: [
|
|
{ name: "General", path: "/settings" },
|
|
{ name: "Plans", path: "/settings/plans" },
|
|
{ name: "Integration", path: "/settings/integration" },
|
|
{ name: "Import / Export", path: "/settings/import-export" },
|
|
],
|
|
},
|
|
{
|
|
icon: <DollarLineIcon />,
|
|
name: "Billing",
|
|
subItems: [
|
|
{ name: "Credits", path: "/billing/credits" },
|
|
{ name: "Transactions", path: "/billing/transactions" },
|
|
{ name: "Usage", path: "/billing/usage" },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
label: "HELP",
|
|
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: <PlugInIcon />,
|
|
name: "User Management",
|
|
subItems: [
|
|
{ name: "Users", path: "/settings/users" },
|
|
{ name: "Subscriptions", path: "/settings/subscriptions" },
|
|
],
|
|
},
|
|
{
|
|
icon: <PlugInIcon />,
|
|
name: "Configuration",
|
|
subItems: [
|
|
{ name: "System Settings", path: "/settings/system" },
|
|
{ name: "Account Settings", path: "/settings/account" },
|
|
{ name: "Module Settings", path: "/settings/modules" },
|
|
],
|
|
},
|
|
{
|
|
icon: <BoltIcon />,
|
|
name: "AI Controls",
|
|
subItems: [
|
|
{ name: "AI Settings", path: "/settings/ai" },
|
|
],
|
|
},
|
|
{
|
|
icon: <PieChartIcon />,
|
|
name: "System Health",
|
|
subItems: [
|
|
{ name: "Status", path: "/settings/status" },
|
|
{ name: "API Monitor", path: "/settings/api-monitor" },
|
|
],
|
|
},
|
|
{
|
|
icon: <DocsIcon />,
|
|
name: "Testing Tools",
|
|
subItems: [
|
|
{ name: "Function Testing", path: "/help/function-testing" },
|
|
{ name: "System Testing", path: "/help/system-testing" },
|
|
],
|
|
},
|
|
{
|
|
icon: <PageIcon />,
|
|
name: "UI Elements",
|
|
subItems: [
|
|
{ name: "Alerts", path: "/ui-elements/alerts" },
|
|
{ name: "Avatar", path: "/ui-elements/avatars" },
|
|
{ name: "Badge", path: "/ui-elements/badges" },
|
|
{ name: "Breadcrumb", path: "/ui-elements/breadcrumb" },
|
|
{ name: "Buttons", path: "/ui-elements/buttons" },
|
|
{ name: "Buttons Group", path: "/ui-elements/buttons-group" },
|
|
{ name: "Cards", path: "/ui-elements/cards" },
|
|
{ name: "Carousel", path: "/ui-elements/carousel" },
|
|
{ name: "Dropdowns", path: "/ui-elements/dropdowns" },
|
|
{ name: "Images", path: "/ui-elements/images" },
|
|
{ name: "Links", path: "/ui-elements/links" },
|
|
{ name: "List", path: "/ui-elements/list" },
|
|
{ name: "Modals", path: "/ui-elements/modals" },
|
|
{ name: "Notification", path: "/ui-elements/notifications" },
|
|
{ name: "Pagination", path: "/ui-elements/pagination" },
|
|
{ name: "Popovers", path: "/ui-elements/popovers" },
|
|
{ name: "Pricing Table", path: "/ui-elements/pricing-table" },
|
|
{ name: "Progressbar", path: "/ui-elements/progressbar" },
|
|
{ name: "Ribbons", path: "/ui-elements/ribbons" },
|
|
{ name: "Spinners", path: "/ui-elements/spinners" },
|
|
{ name: "Tabs", path: "/ui-elements/tabs" },
|
|
{ name: "Tooltips", path: "/ui-elements/tooltips" },
|
|
{ name: "Videos", path: "/ui-elements/videos" },
|
|
{ name: "Components", path: "/components" },
|
|
],
|
|
},
|
|
],
|
|
}), []);
|
|
|
|
// 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}>
|
|
<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;
|