Initial commit: igny8 project

This commit is contained in:
igny8
2025-11-09 10:27:02 +00:00
commit 60b8188111
27265 changed files with 4360521 additions and 0 deletions

View File

@@ -0,0 +1,183 @@
import { useEffect, useRef, useState } from "react";
import { Link } from "react-router";
import { useSidebar } from "../context/SidebarContext";
import { ThemeToggleButton } from "../components/common/ThemeToggleButton";
import NotificationDropdown from "../components/header/NotificationDropdown";
import UserDropdown from "../components/header/UserDropdown";
import { HeaderMetrics } from "../components/header/HeaderMetrics";
import SiteSwitcher from "../components/header/SiteSwitcher";
import ResourceDebugToggle from "../components/debug/ResourceDebugToggle";
const AppHeader: React.FC = () => {
const [isApplicationMenuOpen, setApplicationMenuOpen] = useState(false);
const { isMobileOpen, toggleSidebar, toggleMobileSidebar } = useSidebar();
const handleToggle = () => {
if (window.innerWidth >= 1024) {
toggleSidebar();
} else {
toggleMobileSidebar();
}
};
const toggleApplicationMenu = () => {
setApplicationMenuOpen(!isApplicationMenuOpen);
};
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.key === "k") {
event.preventDefault();
inputRef.current?.focus();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
};
}, []);
return (
<header className="sticky top-0 flex w-full bg-white border-gray-200 z-99999 dark:border-gray-800 dark:bg-gray-900 lg:border-b">
<div className="flex flex-col items-center justify-between grow lg:flex-row lg:px-6">
<div className="flex items-center justify-between w-full gap-2 px-3 py-3 border-b border-gray-200 dark:border-gray-800 sm:gap-4 lg:justify-normal lg:border-b-0 lg:px-0 lg:py-4">
<button
className="items-center justify-center w-10 h-10 text-gray-500 border-gray-200 rounded-lg z-99999 dark:border-gray-800 lg:flex dark:text-gray-400 lg:h-11 lg:w-11 lg:border"
onClick={handleToggle}
aria-label="Toggle Sidebar"
>
{isMobileOpen ? (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M6.21967 7.28131C5.92678 6.98841 5.92678 6.51354 6.21967 6.22065C6.51256 5.92775 6.98744 5.92775 7.28033 6.22065L11.999 10.9393L16.7176 6.22078C17.0105 5.92789 17.4854 5.92788 17.7782 6.22078C18.0711 6.51367 18.0711 6.98855 17.7782 7.28144L13.0597 12L17.7782 16.7186C18.0711 17.0115 18.0711 17.4863 17.7782 17.7792C17.4854 18.0721 17.0105 18.0721 16.7176 17.7792L11.999 13.0607L7.28033 17.7794C6.98744 18.0722 6.51256 18.0722 6.21967 17.7794C5.92678 17.4865 5.92678 17.0116 6.21967 16.7187L10.9384 12L6.21967 7.28131Z"
fill="currentColor"
/>
</svg>
) : (
<svg
width="16"
height="12"
viewBox="0 0 16 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M0.583252 1C0.583252 0.585788 0.919038 0.25 1.33325 0.25H14.6666C15.0808 0.25 15.4166 0.585786 15.4166 1C15.4166 1.41421 15.0808 1.75 14.6666 1.75L1.33325 1.75C0.919038 1.75 0.583252 1.41422 0.583252 1ZM0.583252 11C0.583252 10.5858 0.919038 10.25 1.33325 10.25L14.6666 10.25C15.0808 10.25 15.4166 10.5858 15.4166 11C15.4166 11.4142 15.0808 11.75 14.6666 11.75L1.33325 11.75C0.919038 11.75 0.583252 11.4142 0.583252 11ZM1.33325 5.25C0.919038 5.25 0.583252 5.58579 0.583252 6C0.583252 6.41421 0.919038 6.75 1.33325 6.75L7.99992 6.75C8.41413 6.75 8.74992 6.41421 8.74992 6C8.74992 5.58579 8.41413 5.25 7.99992 5.25L1.33325 5.25Z"
fill="currentColor"
/>
</svg>
)}
{/* Cross Icon */}
</button>
<Link to="/" className="lg:hidden">
<img
className="dark:hidden"
src="./images/logo/logo.svg"
alt="Logo"
/>
<img
className="hidden dark:block"
src="./images/logo/logo-dark.svg"
alt="Logo"
/>
</Link>
<button
onClick={toggleApplicationMenu}
className="flex items-center justify-center w-10 h-10 text-gray-700 rounded-lg z-99999 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800 lg:hidden"
>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M5.99902 10.4951C6.82745 10.4951 7.49902 11.1667 7.49902 11.9951V12.0051C7.49902 12.8335 6.82745 13.5051 5.99902 13.5051C5.1706 13.5051 4.49902 12.8335 4.49902 12.0051V11.9951C4.49902 11.1667 5.1706 10.4951 5.99902 10.4951ZM17.999 10.4951C18.8275 10.4951 19.499 11.1667 19.499 11.9951V12.0051C19.499 12.8335 18.8275 13.5051 17.999 13.5051C17.1706 13.5051 16.499 12.8335 16.499 12.0051V11.9951C16.499 11.1667 17.1706 10.4951 17.999 10.4951ZM13.499 11.9951C13.499 11.1667 12.8275 10.4951 11.999 10.4951C11.1706 10.4951 10.499 11.1667 10.499 11.9951V12.0051C10.499 12.8335 11.1706 13.5051 11.999 13.5051C12.8275 13.5051 13.499 12.8335 13.499 12.0051V11.9951Z"
fill="currentColor"
/>
</svg>
</button>
<div className="hidden lg:block">
<form>
<div className="relative">
<span className="absolute -translate-y-1/2 pointer-events-none left-4 top-1/2">
<svg
className="fill-gray-500 dark:fill-gray-400"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M3.04175 9.37363C3.04175 5.87693 5.87711 3.04199 9.37508 3.04199C12.8731 3.04199 15.7084 5.87693 15.7084 9.37363C15.7084 12.8703 12.8731 15.7053 9.37508 15.7053C5.87711 15.7053 3.04175 12.8703 3.04175 9.37363ZM9.37508 1.54199C5.04902 1.54199 1.54175 5.04817 1.54175 9.37363C1.54175 13.6991 5.04902 17.2053 9.37508 17.2053C11.2674 17.2053 13.003 16.5344 14.357 15.4176L17.177 18.238C17.4699 18.5309 17.9448 18.5309 18.2377 18.238C18.5306 17.9451 18.5306 17.4703 18.2377 17.1774L15.418 14.3573C16.5365 13.0033 17.2084 11.2669 17.2084 9.37363C17.2084 5.04817 13.7011 1.54199 9.37508 1.54199Z"
fill=""
/>
</svg>
</span>
<input
ref={inputRef}
type="text"
placeholder="Search or type command..."
className="dark:bg-dark-900 h-11 w-full rounded-lg border border-gray-200 bg-transparent py-2.5 pl-12 pr-14 text-sm text-gray-800 shadow-theme-xs placeholder:text-gray-400 focus:border-brand-300 focus:outline-hidden focus:ring-3 focus:ring-brand-500/10 dark:border-gray-800 dark:bg-gray-900 dark:bg-white/[0.03] dark:text-white/90 dark:placeholder:text-white/30 dark:focus:border-brand-800 xl:w-[430px]"
/>
<button className="absolute right-2.5 top-1/2 inline-flex -translate-y-1/2 items-center gap-0.5 rounded-lg border border-gray-200 bg-gray-50 px-[7px] py-[4.5px] text-xs -tracking-[0.2px] text-gray-500 dark:border-gray-800 dark:bg-white/[0.03] dark:text-gray-400">
<span> </span>
<span> K </span>
</button>
</div>
</form>
</div>
</div>
<div
className={`${
isApplicationMenuOpen ? "flex" : "hidden"
} items-center justify-between w-full gap-4 px-5 py-4 lg:flex shadow-theme-md lg:justify-end lg:px-0 lg:shadow-none`}
>
<div className="flex items-center gap-2 2xsm:gap-3">
{/* <!-- Site Switcher (conditional - only on planner/writer pages) --> */}
<SiteSwitcher />
{/* <!-- Header Metrics (conditional) --> */}
<HeaderMetrics />
{/* <!-- Dark Mode Toggler --> */}
<ThemeToggleButton />
{/* <!-- Resource Debug Toggle (Admin only) --> */}
<ResourceDebugToggle />
{/* <!-- Notification Menu Area --> */}
<NotificationDropdown />
{/* <!-- Notification Menu Area --> */}
</div>
{/* <!-- User Area --> */}
<UserDropdown />
</div>
</div>
</header>
);
};
export default AppHeader;

View File

@@ -0,0 +1,195 @@
import { useEffect, useRef, useState } from "react";
import { SidebarProvider, useSidebar } from "../context/SidebarContext";
import { Outlet } from "react-router";
import AppHeader from "./AppHeader";
import Backdrop from "./Backdrop";
import AppSidebar from "./AppSidebar";
import { useSiteStore } from "../store/siteStore";
import { useSectorStore } from "../store/sectorStore";
import { useAuthStore } from "../store/authStore";
import { useErrorHandler } from "../hooks/useErrorHandler";
import { trackLoading } from "../components/common/LoadingStateMonitor";
import ResourceDebugOverlay from "../components/debug/ResourceDebugOverlay";
const LayoutContent: React.FC = () => {
const { isExpanded, isHovered, isMobileOpen } = useSidebar();
const { loadActiveSite, activeSite } = useSiteStore();
const { loadSectorsForSite } = useSectorStore();
const { refreshUser, isAuthenticated } = useAuthStore();
const { addError } = useErrorHandler('AppLayout');
const hasLoadedSite = useRef(false);
const lastSiteId = useRef<number | null>(null);
const isLoadingSite = useRef(false);
const isLoadingSector = useRef(false);
const [debugEnabled, setDebugEnabled] = useState(false);
const lastUserRefresh = useRef<number>(0);
// Initialize site store on mount - only once
useEffect(() => {
if (!hasLoadedSite.current && !isLoadingSite.current) {
hasLoadedSite.current = true;
isLoadingSite.current = true;
trackLoading('site-loading', true);
// Add timeout to prevent infinite loading
const timeoutId = setTimeout(() => {
if (isLoadingSite.current) {
console.error('AppLayout: Site loading timeout after 10 seconds');
trackLoading('site-loading', false);
isLoadingSite.current = false;
addError(new Error('Site loading timeout - check network connection'), 'AppLayout.loadActiveSite');
}
}, 10000);
loadActiveSite()
.catch((error) => {
console.error('AppLayout: Error loading active site:', error);
addError(error, 'AppLayout.loadActiveSite');
})
.finally(() => {
clearTimeout(timeoutId);
trackLoading('site-loading', false);
isLoadingSite.current = false;
});
}
}, []); // Empty deps - only run once on mount
// Load sectors when active site changes (by ID, not object reference)
useEffect(() => {
const currentSiteId = activeSite?.id ?? null;
// Only load if:
// 1. We have a site ID
// 2. The site is active (inactive sites can't have accessible sectors)
// 3. It's different from the last one we loaded
// 4. We're not already loading
if (currentSiteId && activeSite?.is_active && currentSiteId !== lastSiteId.current && !isLoadingSector.current) {
lastSiteId.current = currentSiteId;
isLoadingSector.current = true;
trackLoading('sector-loading', true);
// Add timeout to prevent infinite loading
const timeoutId = setTimeout(() => {
if (isLoadingSector.current) {
console.error('AppLayout: Sector loading timeout after 10 seconds');
trackLoading('sector-loading', false);
isLoadingSector.current = false;
addError(new Error('Sector loading timeout - check network connection'), 'AppLayout.loadSectorsForSite');
}
}, 10000);
loadSectorsForSite(currentSiteId)
.catch((error) => {
// Don't log 403/404 errors as they're expected for inactive sites
if (error.status !== 403 && error.status !== 404) {
console.error('AppLayout: Error loading sectors:', error);
addError(error, 'AppLayout.loadSectorsForSite');
}
})
.finally(() => {
clearTimeout(timeoutId);
trackLoading('sector-loading', false);
isLoadingSector.current = false;
});
} else if (currentSiteId && !activeSite?.is_active) {
// Site is inactive - clear sectors and reset lastSiteId
lastSiteId.current = null;
const { useSectorStore } = require('../store/sectorStore');
useSectorStore.getState().clearActiveSector();
}
}, [activeSite?.id, activeSite?.is_active]); // Depend on both ID and is_active
// Refresh user data on mount and periodically to get latest account/plan changes
// This ensures changes are reflected immediately without requiring re-login
useEffect(() => {
if (!isAuthenticated) return;
const refreshUserData = async () => {
const now = Date.now();
// Throttle: only refresh if last refresh was more than 30 seconds ago
if (now - lastUserRefresh.current < 30000) return;
try {
lastUserRefresh.current = now;
await refreshUser();
} catch (error) {
// Silently fail - user might still be authenticated
console.debug('User data refresh failed (non-critical):', error);
}
};
// Refresh on mount
refreshUserData();
// Refresh when window becomes visible (user switches back to tab)
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
refreshUserData();
}
};
// Refresh on window focus
const handleFocus = () => {
refreshUserData();
};
// Periodic refresh every 2 minutes
const intervalId = setInterval(refreshUserData, 120000);
document.addEventListener('visibilitychange', handleVisibilityChange);
window.addEventListener('focus', handleFocus);
return () => {
clearInterval(intervalId);
document.removeEventListener('visibilitychange', handleVisibilityChange);
window.removeEventListener('focus', handleFocus);
};
}, [isAuthenticated, refreshUser]);
// Listen for debug toggle changes
useEffect(() => {
const saved = localStorage.getItem('debug_resource_tracking_enabled');
setDebugEnabled(saved === 'true');
const handleToggle = (e: Event) => {
const customEvent = e as CustomEvent;
setDebugEnabled(customEvent.detail);
};
window.addEventListener('debug-resource-tracking-toggle', handleToggle);
return () => {
window.removeEventListener('debug-resource-tracking-toggle', handleToggle);
};
}, []);
return (
<div className="min-h-screen xl:flex">
<div>
<AppSidebar />
<Backdrop />
</div>
<div
className={`flex-1 transition-all duration-300 ease-in-out ${
isExpanded || isHovered ? "lg:ml-[290px]" : "lg:ml-[90px]"
} ${isMobileOpen ? "ml-0" : ""} w-full max-w-full min-[1440px]:max-w-[90%]`}
>
<AppHeader />
<div className="p-4 pb-20 md:p-6 md:pb-24">
<Outlet />
</div>
{/* Resource Debug Overlay - Only visible when enabled by admin */}
<ResourceDebugOverlay enabled={debugEnabled} />
</div>
</div>
);
};
const AppLayout: React.FC = () => {
return (
<SidebarProvider>
<LayoutContent />
</SidebarProvider>
);
};
export default AppLayout;

View File

@@ -0,0 +1,531 @@
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,
TimeIcon,
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";
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 } = useAuthStore();
// 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
);
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]
);
// Define menu sections with useMemo to prevent recreation on every render
const menuSections: MenuSection[] = useMemo(() => [
{
label: "OVERVIEW",
items: [
{
icon: <GridIcon />,
name: "Dashboard",
path: "/",
},
{
icon: <PieChartIcon />,
name: "Analytics",
path: "/analytics",
},
{
icon: <DocsIcon />,
name: "Industry / Sectors",
path: "/reference/industries",
},
],
},
{
label: "WORKFLOWS",
items: [
{
icon: <PlugInIcon />,
name: "Setup",
subItems: [
{ name: "Sites", path: "/settings/sites" },
{ name: "Keywords Opportunities", path: "/planner/keyword-opportunities" },
],
},
{
icon: <ListIcon />,
name: "Planner",
subItems: [
{ name: "Dashboard", path: "/planner" },
{ name: "Keywords", path: "/planner/keywords" },
{ name: "Clusters", path: "/planner/clusters" },
{ name: "Ideas", path: "/planner/ideas" },
],
},
{
icon: <TaskIcon />,
name: "Writer",
subItems: [
{ name: "Dashboard", path: "/writer" },
{ name: "Tasks", path: "/writer/tasks" },
{ name: "Content", path: "/writer/content" },
{ name: "Drafts", path: "/writer/drafts" },
{ name: "Images", path: "/writer/images" },
{ name: "Published", path: "/writer/published" },
],
},
{
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" },
],
},
{
icon: <TimeIcon />,
name: "Schedules",
path: "/schedules",
},
],
},
{
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 & Support",
path: "/help",
},
{
icon: <DocsIcon />,
name: "Documentation",
path: "/help/docs",
},
],
},
], []);
// 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" },
],
},
{
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({
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) => ({
...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">
<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;

View File

@@ -0,0 +1,16 @@
import { useSidebar } from "../context/SidebarContext";
const Backdrop: React.FC = () => {
const { isMobileOpen, toggleMobileSidebar } = useSidebar();
if (!isMobileOpen) return null;
return (
<div
className="fixed inset-0 z-40 bg-gray-900/50 lg:hidden"
onClick={toggleMobileSidebar}
/>
);
};
export default Backdrop;

View File

@@ -0,0 +1,15 @@
export default function SidebarWidget() {
return (
<div
className={`
mx-auto mb-10 w-full max-w-60 rounded-2xl bg-blue-50 px-4 py-5 text-center dark:bg-blue-900/20`}
>
<ul className="space-y-1 text-sm text-gray-700 dark:text-gray-300">
<li>Infinite.</li>
<li>Writing.</li>
<li>Refreshing.</li>
<li>Ranking.</li>
</ul>
</div>
);
}