fix: sidebar is tree instead of two levels only
This commit is contained in:
parent
322ab7b890
commit
3827d8cc07
17 changed files with 1027 additions and 431 deletions
|
|
@ -1,10 +1,9 @@
|
||||||
import React, { useEffect, useRef } from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link, useLocation } from "react-router-dom";
|
||||||
import { IoIosArrowDown } from "react-icons/io";
|
import { IoIosArrowDown } from "react-icons/io";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
|
||||||
import styles from './SidebarStyles/SidebarItem.module.css';
|
import styles from './SidebarStyles/SidebarItem.module.css';
|
||||||
import SidebarSubmenu from "./SidebarSubmenu";
|
|
||||||
import { SidebarItemProps } from "./sidebarTypes";
|
import { SidebarItemProps } from "./sidebarTypes";
|
||||||
|
|
||||||
const SidebarItem: React.FC<SidebarItemProps> = React.memo(({
|
const SidebarItem: React.FC<SidebarItemProps> = React.memo(({
|
||||||
|
|
@ -14,10 +13,39 @@ const SidebarItem: React.FC<SidebarItemProps> = React.memo(({
|
||||||
isActive,
|
isActive,
|
||||||
isMinimized
|
isMinimized
|
||||||
}) => {
|
}) => {
|
||||||
|
const location = useLocation();
|
||||||
const Icon = item.icon as React.ComponentType<React.SVGProps<SVGSVGElement>>;
|
const Icon = item.icon as React.ComponentType<React.SVGProps<SVGSVGElement>>;
|
||||||
const hasSubItems = item.submenu && item.submenu.length > 0;
|
const hasSubItems = item.submenu && item.submenu.length > 0;
|
||||||
const isDisabled = item.moduleEnabled === false;
|
const isDisabled = item.moduleEnabled === false;
|
||||||
const iconContainerRef = useRef<HTMLDivElement>(null);
|
const iconContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const depth = item.depth || 0; // Get depth from item, default to 0
|
||||||
|
// CSS already has 27px padding on .menu li, so we only add extra for nested items
|
||||||
|
// Depth 0: 0px extra (uses CSS default 27px), Depth 1: 20px extra, Depth 2: 40px extra
|
||||||
|
const indentPx = depth === 0 ? 0 : (depth * 20);
|
||||||
|
|
||||||
|
// Local state for nested submenus (not tracked by sidebar logic)
|
||||||
|
const [nestedOpenStates, setNestedOpenStates] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
// Check if a submenu item is active
|
||||||
|
const isSubmenuItemActive = (itemPath?: string) => {
|
||||||
|
if (!itemPath) return false;
|
||||||
|
const currentPath = location.pathname;
|
||||||
|
// Exact match or prefix match at path segment boundary
|
||||||
|
if (currentPath === itemPath) return true;
|
||||||
|
if (currentPath.startsWith(itemPath)) {
|
||||||
|
const nextChar = currentPath[itemPath.length];
|
||||||
|
if (nextChar === '/' || nextChar === undefined) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Toggle nested submenu
|
||||||
|
const toggleNestedSubmenu = (itemId: string) => {
|
||||||
|
setNestedOpenStates(prev => ({
|
||||||
|
...prev,
|
||||||
|
[itemId]: !prev[itemId]
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
// Fix SVG dimensions when minimized - react-icons uses 1em which can be invisible
|
// Fix SVG dimensions when minimized - react-icons uses 1em which can be invisible
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -122,7 +150,6 @@ const SidebarItem: React.FC<SidebarItemProps> = React.memo(({
|
||||||
}
|
}
|
||||||
}, [isMinimized, isActive, item.name, hasSubItems]);
|
}, [isMinimized, isActive, item.name, hasSubItems]);
|
||||||
|
|
||||||
|
|
||||||
const toggleSubmenu = (e: React.MouseEvent) => {
|
const toggleSubmenu = (e: React.MouseEvent) => {
|
||||||
if (isDisabled) {
|
if (isDisabled) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -138,14 +165,132 @@ const SidebarItem: React.FC<SidebarItemProps> = React.memo(({
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// If item has submenu, prevent navigation and only toggle submenu
|
// If item has submenu or no link (navigation node), prevent navigation and only toggle submenu
|
||||||
if (hasSubItems) {
|
if (hasSubItems || !item.link) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onToggle();
|
onToggle();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Allow normal navigation for items without submenu
|
// Allow normal navigation for items without submenu and with a link
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render recursive submenu items
|
||||||
|
const renderSubmenuItems = () => {
|
||||||
|
if (!hasSubItems || !item.submenu) return null;
|
||||||
|
|
||||||
|
if (isMinimized) {
|
||||||
|
// Horizontal layout for minimized sidebar
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{isOpen && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1, transition: { delay: 0.3, duration: 0.25 } }}
|
||||||
|
exit={{ opacity: 0, transition: { duration: 0.25, delay: 0 } }}
|
||||||
|
className={styles.submenuHorizontalContainer}
|
||||||
|
>
|
||||||
|
<ul className={styles.submenuHorizontalList}>
|
||||||
|
{item.submenu.map(subitem => {
|
||||||
|
const SubIcon = subitem.icon as React.ComponentType<React.SVGProps<SVGSVGElement>>;
|
||||||
|
const subIsActive = isSubmenuItemActive(subitem.link);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={subitem.id} className={`${styles.submenuHorizontalItem} ${subIsActive ? styles.active : ''}`}>
|
||||||
|
<Link
|
||||||
|
to={subitem.link || '#'}
|
||||||
|
title={subitem.name}
|
||||||
|
className={`${styles.submenuHorizontalLink} ${subIsActive ? styles.activeLink : ''}`}
|
||||||
|
>
|
||||||
|
{SubIcon && (
|
||||||
|
<SubIcon
|
||||||
|
className={styles.submenuHorizontalIcon}
|
||||||
|
style={{
|
||||||
|
width: '16px',
|
||||||
|
height: '16px',
|
||||||
|
color: subIsActive ? 'white' : '#181818',
|
||||||
|
display: 'block'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Vertical layout for expanded sidebar
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{isOpen && (
|
||||||
|
<motion.div
|
||||||
|
layout
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{
|
||||||
|
height: "auto",
|
||||||
|
opacity: 1,
|
||||||
|
transition: {
|
||||||
|
height: { duration: 0.3, ease: "easeInOut", delay: 0 },
|
||||||
|
opacity: { duration: 0.25, ease: "easeInOut", delay: 0.3 }
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
exit={{
|
||||||
|
height: 0,
|
||||||
|
opacity: 0,
|
||||||
|
transition: {
|
||||||
|
opacity: { duration: 0.25, ease: "easeInOut", delay: 0 },
|
||||||
|
height: { duration: 0.3, ease: "easeInOut", delay: 0.25 }
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{ overflow: "visible" }}
|
||||||
|
className={styles.submenu}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
className={styles.submenuLineContainer}
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1, transition: { delay: 0.3, duration: 0.25 } }}
|
||||||
|
exit={{ opacity: 0, transition: { duration: 0.25, delay: 0 } }}
|
||||||
|
>
|
||||||
|
{/* Render items directly as flex column children - same structure as first level */}
|
||||||
|
{/* This allows nested submenus to push siblings down just like first level */}
|
||||||
|
{item.submenu.map(subitem => {
|
||||||
|
const subIsActive = isSubmenuItemActive(subitem.link);
|
||||||
|
const subIsOpen = nestedOpenStates[subitem.id] || false;
|
||||||
|
const subDepth = subitem.depth !== undefined ? subitem.depth : (depth === 0 ? 1 : depth + 1);
|
||||||
|
|
||||||
|
const sidebarItemData = {
|
||||||
|
id: subitem.id,
|
||||||
|
name: subitem.name,
|
||||||
|
link: subitem.link,
|
||||||
|
icon: subitem.icon,
|
||||||
|
submenu: subitem.submenu,
|
||||||
|
moduleEnabled: true,
|
||||||
|
depth: subDepth
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render ALL items as recursive SidebarItem - direct children of flex column
|
||||||
|
// This matches the first-level structure where SidebarItems are direct siblings
|
||||||
|
return (
|
||||||
|
<SidebarItem
|
||||||
|
key={subitem.id}
|
||||||
|
item={sidebarItemData}
|
||||||
|
isOpen={subIsOpen}
|
||||||
|
onToggle={() => toggleNestedSubmenu(subitem.id)}
|
||||||
|
isActive={subIsActive}
|
||||||
|
isMinimized={isMinimized}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -153,47 +298,108 @@ const SidebarItem: React.FC<SidebarItemProps> = React.memo(({
|
||||||
<li
|
<li
|
||||||
className={`${isActive ? styles.active : ""} ${isDisabled ? styles.disabledItem : ""}`}
|
className={`${isActive ? styles.active : ""} ${isDisabled ? styles.disabledItem : ""}`}
|
||||||
data-item-name={item.name}
|
data-item-name={item.name}
|
||||||
|
style={{ display: 'flex', alignItems: 'center', width: '100%' }}
|
||||||
>
|
>
|
||||||
{/* Icon - always render, CSS handles positioning */}
|
{/* Icon and text container with indentation - takes remaining space */}
|
||||||
{Icon && !isMinimized && (
|
<div
|
||||||
<Icon
|
style={{
|
||||||
className={`${styles.icon} ${isDisabled ? styles.disabledIcon : ''}`}
|
display: 'flex',
|
||||||
/>
|
alignItems: 'center',
|
||||||
)}
|
paddingLeft: `${indentPx}px`, // Add 20px per level for nested items (depth 0 = 0px, depth 1 = 20px, depth 2 = 40px)
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0, // Allow flex shrinking
|
||||||
|
overflow: 'hidden' // Prevent overflow
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Icon - always render, CSS handles positioning */}
|
||||||
|
{Icon && !isMinimized && (
|
||||||
|
<Icon
|
||||||
|
className={`${styles.icon} ${isDisabled ? styles.disabledIcon : ''}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Text and arrow - hidden when minimized */}
|
{/* Text - hidden when minimized */}
|
||||||
{!isMinimized && (
|
{!isMinimized && (
|
||||||
<>
|
<>
|
||||||
{hasSubItems ? (
|
{hasSubItems || !item.link ? (
|
||||||
// For items with submenu, make the entire area clickable to toggle
|
// For items with submenu or navigation nodes (no link)
|
||||||
<button
|
<span
|
||||||
onClick={toggleSubmenu}
|
className={`${styles.menuText} ${isDisabled ? styles.disabledText : ''}`}
|
||||||
className={`${styles.menuTextButton} ${isDisabled ? styles.disabledLink : ''}`}
|
style={{
|
||||||
aria-disabled={isDisabled}
|
flex: 1,
|
||||||
title={isDisabled ? `${item.name} (Module disabled)` : `Toggle ${item.name} submenu`}
|
minWidth: 0,
|
||||||
>
|
overflow: 'hidden',
|
||||||
<span className={`${styles.menuText} ${isDisabled ? styles.disabledText : ''}`}>
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap'
|
||||||
|
}}
|
||||||
|
>
|
||||||
{item.name}
|
{item.name}
|
||||||
</span>
|
</span>
|
||||||
<IoIosArrowDown className={`${styles.hassubmenu} ${isOpen ? styles.rotated : ''} ${isDisabled ? styles.disabledArrow : ''}`} />
|
) : (
|
||||||
</button>
|
// For items without submenu and with a link
|
||||||
) : (
|
<span
|
||||||
// For items without submenu, use normal link
|
className={`${styles.menuText} ${isDisabled ? styles.disabledText : ''}`}
|
||||||
<>
|
style={{
|
||||||
<Link
|
flex: 1,
|
||||||
to={isDisabled ? "#" : (item.link || "#")}
|
minWidth: 0,
|
||||||
className={`${styles.menuTextLink} ${isDisabled ? styles.disabledLink : ''}`}
|
overflow: 'hidden',
|
||||||
onClick={handleLinkClick}
|
textOverflow: 'ellipsis',
|
||||||
aria-disabled={isDisabled}
|
whiteSpace: 'nowrap'
|
||||||
title={isDisabled ? `${item.name} (Module disabled)` : item.name}
|
}}
|
||||||
>
|
>
|
||||||
<span className={`${styles.menuText} ${isDisabled ? styles.disabledText : ''}`}>
|
{item.name}
|
||||||
{item.name}
|
</span>
|
||||||
</span>
|
)}
|
||||||
</Link>
|
</>
|
||||||
</>
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expand button - always right-aligned, not indented */}
|
||||||
|
{!isMinimized && (hasSubItems || !item.link) && (
|
||||||
|
<button
|
||||||
|
onClick={toggleSubmenu}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: '0 8px 0 0',
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
flexShrink: 0, // Don't shrink
|
||||||
|
width: 'auto',
|
||||||
|
minWidth: 'auto',
|
||||||
|
color: 'inherit'
|
||||||
|
}}
|
||||||
|
aria-disabled={isDisabled}
|
||||||
|
title={isDisabled ? `${item.name} (Module disabled)` : `Toggle ${item.name} submenu`}
|
||||||
|
>
|
||||||
|
{hasSubItems && (
|
||||||
|
<IoIosArrowDown className={`${styles.hassubmenu} ${isOpen ? styles.rotated : ''} ${isDisabled ? styles.disabledArrow : ''}`} />
|
||||||
)}
|
)}
|
||||||
</>
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Link wrapper for items with link and no submenu - spans full width but text is indented */}
|
||||||
|
{!isMinimized && !hasSubItems && item.link && (
|
||||||
|
<Link
|
||||||
|
to={isDisabled ? "#" : (item.link || "#")}
|
||||||
|
className={`${styles.menuTextLink} ${isDisabled ? styles.disabledLink : ''}`}
|
||||||
|
onClick={handleLinkClick}
|
||||||
|
aria-disabled={isDisabled}
|
||||||
|
title={isDisabled ? `${item.name} (Module disabled)` : item.name}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingLeft: `${indentPx}px`, // Match indentation of text container
|
||||||
|
pointerEvents: isDisabled ? 'none' : 'auto'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -227,18 +433,18 @@ const SidebarItem: React.FC<SidebarItemProps> = React.memo(({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Clickable overlay for items without submenu */}
|
{/* Clickable overlay for items without submenu and with a link */}
|
||||||
{isMinimized && !isDisabled && !hasSubItems && (
|
{isMinimized && !isDisabled && !hasSubItems && item.link && (
|
||||||
<Link
|
<Link
|
||||||
to={item.link || "#"}
|
to={item.link}
|
||||||
className={styles.minimizedOverlay}
|
className={styles.minimizedOverlay}
|
||||||
title={item.name}
|
title={item.name}
|
||||||
onClick={handleLinkClick}
|
onClick={handleLinkClick}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Clickable overlay for items with submenu */}
|
{/* Clickable overlay for items with submenu or navigation nodes (no link) */}
|
||||||
{isMinimized && hasSubItems && !isDisabled && (
|
{isMinimized && (hasSubItems || !item.link) && !isDisabled && (
|
||||||
<button
|
<button
|
||||||
onClick={toggleSubmenu}
|
onClick={toggleSubmenu}
|
||||||
className={styles.minimizedSubmenuToggle}
|
className={styles.minimizedSubmenuToggle}
|
||||||
|
|
@ -247,7 +453,8 @@ const SidebarItem: React.FC<SidebarItemProps> = React.memo(({
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</li>
|
</li>
|
||||||
{hasSubItems && !isDisabled && <SidebarSubmenu item={item} isOpen={isOpen} isMinimized={isMinimized} />}
|
{/* Recursive submenu rendering */}
|
||||||
|
{hasSubItems && !isDisabled && renderSubmenuItems()}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,9 @@
|
||||||
overflow-x: hidden; /* Disable horizontal scrolling */
|
overflow-x: hidden; /* Disable horizontal scrolling */
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
/* Ensure sidebar can scroll when content exceeds container */
|
||||||
|
/* flex: 1 makes it take available space, min-height: 0 allows it to shrink */
|
||||||
|
/* Content will expand naturally and trigger scrolling */
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebarContainer.minimized .sidebar {
|
.sidebarContainer.minimized .sidebar {
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,11 @@
|
||||||
width: 250px;
|
width: 250px;
|
||||||
min-width: 250px;
|
min-width: 250px;
|
||||||
max-width: 250px;
|
max-width: 250px;
|
||||||
|
overflow: visible; /* Allow nested submenus to expand */
|
||||||
|
/* Ensure menu expands to fit content - critical for proper sidebar expansion */
|
||||||
|
/* Don't set height - let it be determined by content */
|
||||||
|
flex-shrink: 0; /* Prevent menu from shrinking */
|
||||||
|
flex-grow: 0; /* Don't grow beyond content */
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu.minimized {
|
.menu.minimized {
|
||||||
|
|
@ -361,4 +366,367 @@
|
||||||
opacity: 0.6 !important;
|
opacity: 0.6 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Submenu Styles (merged from SidebarSubmenu.module.css)
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
.submenu {
|
||||||
|
position: relative;
|
||||||
|
border: none;
|
||||||
|
border-top-right-radius: 25px;
|
||||||
|
border-bottom-right-radius: 25px;
|
||||||
|
margin: 0;
|
||||||
|
overflow: visible; /* Allow nested submenus to expand beyond this container */
|
||||||
|
width: 250px !important;
|
||||||
|
min-width: 250px !important;
|
||||||
|
max-width: 250px !important;
|
||||||
|
box-sizing: border-box;
|
||||||
|
flex-shrink: 0;
|
||||||
|
flex-grow: 0;
|
||||||
|
display: block; /* Ensure it's part of the document flow */
|
||||||
|
/* Ensure submenu expands parent container */
|
||||||
|
height: auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Motion div inside submenu - allow nested submenus to expand */
|
||||||
|
.submenu > div[style*="overflow"] {
|
||||||
|
overflow: visible !important; /* Override inline overflow:hidden for nested submenus */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* For nested submenus specifically, ensure they can expand */
|
||||||
|
.submenuList li .menu .submenu {
|
||||||
|
overflow: visible !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenuList li .menu .submenu > div {
|
||||||
|
overflow: visible !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenuLineContainer {
|
||||||
|
display: flex; /* Flex column - same as .sidebar for consistent behavior */
|
||||||
|
flex-direction: column; /* Stack items vertically */
|
||||||
|
align-items: flex-start; /* Match .sidebar alignment */
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
overflow: visible; /* Allow nested submenus to expand */
|
||||||
|
width: 100%; /* Full width */
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenuList {
|
||||||
|
margin: 0;
|
||||||
|
flex-grow: 1;
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
overflow: visible; /* Allow nested submenus to expand beyond container */
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenuList li {
|
||||||
|
width: 100%;
|
||||||
|
color: #181818;
|
||||||
|
margin: 0;
|
||||||
|
position: relative;
|
||||||
|
overflow: visible; /* Allow nested submenus to expand */
|
||||||
|
display: block; /* Block display allows nested submenus to extend properly */
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenuList li a {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding: 0 3px 0 27px; /* Base padding, indentation is added via inline styles on parent div */
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--font-family);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #181818;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenuList li a:hover {
|
||||||
|
background-color: var(--color-hover, rgba(0, 0, 0, 0.05));
|
||||||
|
}
|
||||||
|
|
||||||
|
.textContainer {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenuIcon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
color: #181818;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Horizontal layout for minimized sidebar submenus */
|
||||||
|
.submenu.minimized {
|
||||||
|
width: 100% !important;
|
||||||
|
margin: 0;
|
||||||
|
padding: 4px 0;
|
||||||
|
position: relative;
|
||||||
|
border-radius: 0;
|
||||||
|
overflow: hidden !important;
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
opacity: 1 !important;
|
||||||
|
visibility: visible !important;
|
||||||
|
z-index: 10 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenuHorizontalContainer {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
opacity: 1 !important;
|
||||||
|
visibility: visible !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenuHorizontalList {
|
||||||
|
display: flex !important;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
opacity: 1 !important;
|
||||||
|
visibility: visible !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenuHorizontalItem {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
list-style: none;
|
||||||
|
opacity: 1 !important;
|
||||||
|
visibility: visible !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenuHorizontalLink {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
justify-content: center !important;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
text-decoration: none;
|
||||||
|
color: #181818 !important;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
position: relative;
|
||||||
|
overflow: visible;
|
||||||
|
opacity: 1 !important;
|
||||||
|
visibility: visible !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenuHorizontalLink:hover {
|
||||||
|
background-color: var(--color-secondary);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenuHorizontalLink:hover .submenuHorizontalIcon {
|
||||||
|
color: white !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenuHorizontalLink:hover .submenuHorizontalIcon svg {
|
||||||
|
color: white !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenuHorizontalLink:hover .submenuHorizontalIcon svg path {
|
||||||
|
fill: currentColor !important;
|
||||||
|
stroke: currentColor !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenuHorizontalIcon {
|
||||||
|
display: block !important;
|
||||||
|
width: 16px !important;
|
||||||
|
height: 16px !important;
|
||||||
|
padding: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
flex-grow: 0;
|
||||||
|
color: #181818 !important;
|
||||||
|
margin: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
position: relative !important;
|
||||||
|
top: auto !important;
|
||||||
|
left: auto !important;
|
||||||
|
transform: none !important;
|
||||||
|
opacity: 1 !important;
|
||||||
|
visibility: visible !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenuHorizontalIcon svg {
|
||||||
|
width: 16px !important;
|
||||||
|
height: 16px !important;
|
||||||
|
min-width: 16px !important;
|
||||||
|
min-height: 16px !important;
|
||||||
|
max-width: 16px !important;
|
||||||
|
max-height: 16px !important;
|
||||||
|
display: block !important;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
opacity: 1 !important;
|
||||||
|
visibility: visible !important;
|
||||||
|
color: #181818 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenuHorizontalIcon svg path {
|
||||||
|
color: inherit !important;
|
||||||
|
fill: currentColor !important;
|
||||||
|
stroke: currentColor !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure submenu content is visible when sidebar is minimized */
|
||||||
|
.menu.minimized .submenu.minimized,
|
||||||
|
.menu.minimized .submenu.minimized * {
|
||||||
|
opacity: 1 !important;
|
||||||
|
visibility: visible !important;
|
||||||
|
color: #181818 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu.minimized .submenuHorizontalLink,
|
||||||
|
.menu.minimized .submenuHorizontalLink * {
|
||||||
|
opacity: 1 !important;
|
||||||
|
visibility: visible !important;
|
||||||
|
color: #181818 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Active state for submenu items */
|
||||||
|
.submenuList li.active {
|
||||||
|
background-color: var(--color-secondary);
|
||||||
|
border-radius: 0 25px 25px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenuList li.active a,
|
||||||
|
.submenuList li.active a span,
|
||||||
|
.submenuList li a.activeLink {
|
||||||
|
color: white !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenuList li.active .submenuIcon {
|
||||||
|
color: white !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Active state for horizontal (minimized) submenu items */
|
||||||
|
.submenuHorizontalItem.active .submenuHorizontalLink,
|
||||||
|
.submenuHorizontalLink.activeLink {
|
||||||
|
background-color: var(--color-secondary);
|
||||||
|
color: white !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenuHorizontalItem.active .submenuHorizontalIcon,
|
||||||
|
.submenuHorizontalLink.activeLink .submenuHorizontalIcon {
|
||||||
|
color: white !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenuHorizontalItem.active .submenuHorizontalIcon svg,
|
||||||
|
.submenuHorizontalLink.activeLink .submenuHorizontalIcon svg {
|
||||||
|
color: white !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenuHorizontalItem.active .submenuHorizontalIcon svg path,
|
||||||
|
.submenuHorizontalLink.activeLink .submenuHorizontalIcon svg path {
|
||||||
|
fill: white !important;
|
||||||
|
stroke: white !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nested submenu toggle button (for navigation nodes without links) */
|
||||||
|
.nestedToggleButton {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding: 0 3px 0 27px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--font-family);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #181818;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nestedToggleButton:hover {
|
||||||
|
background-color: var(--color-hover, rgba(0, 0, 0, 0.05));
|
||||||
|
}
|
||||||
|
|
||||||
|
.nestedToggleButton:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nested arrow icon */
|
||||||
|
.nestedArrow {
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: #181818;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nestedArrow.rotated {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Active state for nested toggle button */
|
||||||
|
.submenuList li.active .nestedToggleButton {
|
||||||
|
color: white !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenuList li.active .nestedArrow {
|
||||||
|
color: white !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nested SidebarItem within submenu - ensure full width and allow submenu to extend */
|
||||||
|
.submenuList li .menu {
|
||||||
|
width: 100% !important;
|
||||||
|
min-width: 100% !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
overflow: visible !important; /* Allow nested submenus to extend */
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenuList li .menu li {
|
||||||
|
width: 100% !important;
|
||||||
|
min-width: 100% !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
height: 44px; /* Set height only on the actual item li, not the wrapper */
|
||||||
|
display: flex !important; /* Ensure the item content displays correctly */
|
||||||
|
align-items: center !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure nested submenus can expand and are visible */
|
||||||
|
.submenuList li .menu .submenu {
|
||||||
|
position: relative;
|
||||||
|
overflow: visible !important;
|
||||||
|
z-index: 10; /* Ensure nested submenu appears above other content */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Allow the sidebar container to expand when nested items are open */
|
||||||
|
.sidebar {
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
border-top-right-radius: 25px;
|
border-top-right-radius: 25px;
|
||||||
border-bottom-right-radius: 25px;
|
border-bottom-right-radius: 25px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
overflow: hidden;
|
overflow: visible; /* Allow nested submenus to expand beyond this container */
|
||||||
width: 250px !important;
|
width: 250px !important;
|
||||||
min-width: 250px !important;
|
min-width: 250px !important;
|
||||||
max-width: 250px !important;
|
max-width: 250px !important;
|
||||||
|
|
@ -13,6 +13,20 @@
|
||||||
flex-grow: 0;
|
flex-grow: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Motion div inside submenu - allow nested submenus to expand */
|
||||||
|
.submenu > div[style*="overflow"] {
|
||||||
|
overflow: visible !important; /* Override inline overflow:hidden for nested submenus */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* For nested submenus specifically, ensure they can expand */
|
||||||
|
.submenuList li .menu .submenu {
|
||||||
|
overflow: visible !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenuList li .menu .submenu > div {
|
||||||
|
overflow: visible !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
.submenuLineContainer {
|
.submenuLineContainer {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -20,6 +34,7 @@
|
||||||
gap: 0;
|
gap: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
overflow: visible; /* Allow nested submenus to expand */
|
||||||
}
|
}
|
||||||
|
|
||||||
.submenuList {
|
.submenuList {
|
||||||
|
|
@ -27,6 +42,7 @@
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
overflow: visible; /* Allow nested submenus to expand beyond container */
|
||||||
}
|
}
|
||||||
|
|
||||||
.submenuList li {
|
.submenuList li {
|
||||||
|
|
@ -35,13 +51,15 @@
|
||||||
color: #181818;
|
color: #181818;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: visible; /* Allow nested submenus to expand */
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.submenuList li a {
|
.submenuList li a {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
padding: 0 3px 0 27px;
|
padding: 0 3px 0 27px; /* Base padding, indentation is added via inline styles on parent div */
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
|
@ -261,3 +279,75 @@
|
||||||
fill: white !important;
|
fill: white !important;
|
||||||
stroke: white !important;
|
stroke: white !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Nested submenu toggle button (for navigation nodes without links) */
|
||||||
|
.nestedToggleButton {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding: 0 3px 0 27px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--font-family);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #181818;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nestedToggleButton:hover {
|
||||||
|
background-color: var(--color-hover, rgba(0, 0, 0, 0.05));
|
||||||
|
}
|
||||||
|
|
||||||
|
.nestedToggleButton:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nested arrow icon */
|
||||||
|
.nestedArrow {
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: #181818;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nestedArrow.rotated {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Active state for nested toggle button */
|
||||||
|
.submenuList li.active .nestedToggleButton {
|
||||||
|
color: white !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenuList li.active .nestedArrow {
|
||||||
|
color: white !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nested SidebarItem within submenu - ensure full width */
|
||||||
|
.submenuList li .menu {
|
||||||
|
width: 100% !important;
|
||||||
|
min-width: 100% !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenuList li .menu li {
|
||||||
|
width: 100% !important;
|
||||||
|
min-width: 100% !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure nested submenus can expand and are visible */
|
||||||
|
.submenuList li .menu .submenu {
|
||||||
|
position: relative;
|
||||||
|
overflow: visible !important;
|
||||||
|
z-index: 10; /* Ensure nested submenu appears above other content */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Allow the sidebar container to expand when nested items are open */
|
||||||
|
.sidebar {
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
@ -1,182 +0,0 @@
|
||||||
import styles from './SidebarStyles/SidebarSubmenu.module.css';
|
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
|
||||||
import React, { useRef, useEffect, useState } from 'react';
|
|
||||||
import { SidebarSubmenuProps, SidebarSubmenuItemData } from './sidebarTypes';
|
|
||||||
|
|
||||||
// Separate component for submenu item to properly use hooks
|
|
||||||
interface SubmenuItemProps {
|
|
||||||
subitem: SidebarSubmenuItemData;
|
|
||||||
isActive: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SubmenuItem: React.FC<SubmenuItemProps> = ({ subitem, isActive }) => {
|
|
||||||
const textRef = useRef<HTMLSpanElement>(null);
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const [isOverflowing, setIsOverflowing] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const checkOverflow = () => {
|
|
||||||
if (textRef.current && containerRef.current) {
|
|
||||||
const textWidth = textRef.current.scrollWidth;
|
|
||||||
const containerWidth = containerRef.current.clientWidth;
|
|
||||||
setIsOverflowing(textWidth > containerWidth);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
checkOverflow();
|
|
||||||
// Also check on window resize
|
|
||||||
window.addEventListener('resize', checkOverflow);
|
|
||||||
return () => window.removeEventListener('resize', checkOverflow);
|
|
||||||
}, [subitem.name]);
|
|
||||||
|
|
||||||
const SubIcon = subitem.icon as React.ComponentType<React.SVGProps<SVGSVGElement>>;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<li className={isActive ? styles.active : ''}>
|
|
||||||
<Link
|
|
||||||
to={subitem.link || '#'}
|
|
||||||
title={subitem.name}
|
|
||||||
className={isActive ? styles.activeLink : ''}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
ref={containerRef}
|
|
||||||
className={styles.textContainer}
|
|
||||||
>
|
|
||||||
<motion.span
|
|
||||||
ref={textRef}
|
|
||||||
style={{
|
|
||||||
display: 'block',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
}}
|
|
||||||
initial={{ x: 0 }}
|
|
||||||
animate={{ x: 0 }}
|
|
||||||
{...(isOverflowing && {
|
|
||||||
whileHover: {
|
|
||||||
x: -(textRef.current?.scrollWidth || 0) + (containerRef.current?.clientWidth || 153),
|
|
||||||
transition: {
|
|
||||||
duration: 2,
|
|
||||||
ease: "linear"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', paddingRight: '10px' }}>
|
|
||||||
{SubIcon && <SubIcon className={styles.submenuIcon} />}
|
|
||||||
<span style={{ marginLeft: SubIcon ? '8px' : '0' }}>
|
|
||||||
{subitem.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</motion.span>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const SidebarSubmenu: React.FC<SidebarSubmenuProps> = ({ item, isOpen, isMinimized = false }) => {
|
|
||||||
const location = useLocation();
|
|
||||||
|
|
||||||
// Check if a submenu item is active
|
|
||||||
const isSubmenuItemActive = (itemPath?: string) => {
|
|
||||||
if (!itemPath) return false;
|
|
||||||
const currentPath = location.pathname;
|
|
||||||
// Exact match or prefix match at path segment boundary
|
|
||||||
if (currentPath === itemPath) return true;
|
|
||||||
if (currentPath.startsWith(itemPath)) {
|
|
||||||
const nextChar = currentPath[itemPath.length];
|
|
||||||
if (nextChar === '/' || nextChar === undefined) return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!item.submenu) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AnimatePresence>
|
|
||||||
{isOpen && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ height: 0, opacity: 0 }}
|
|
||||||
animate={{
|
|
||||||
height: "auto",
|
|
||||||
opacity: 1,
|
|
||||||
transition: {
|
|
||||||
height: { duration: 0.3, ease: "easeInOut", delay: 0 },
|
|
||||||
opacity: { duration: 0.25, ease: "easeInOut", delay: 0.3 }
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
exit={{
|
|
||||||
height: 0,
|
|
||||||
opacity: 0,
|
|
||||||
transition: {
|
|
||||||
opacity: { duration: 0.25, ease: "easeInOut", delay: 0 },
|
|
||||||
height: { duration: 0.3, ease: "easeInOut", delay: 0.25 }
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
style={{ overflow: "hidden" }}
|
|
||||||
className={`${styles.submenu} ${isMinimized ? styles.minimized : ''}`}
|
|
||||||
>
|
|
||||||
{isMinimized ? (
|
|
||||||
// Horizontal layout for minimized sidebar
|
|
||||||
<motion.div
|
|
||||||
className={styles.submenuHorizontalContainer}
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1, transition: { delay: 0.3, duration: 0.25 } }}
|
|
||||||
exit={{ opacity: 0, transition: { duration: 0.25, delay: 0 } }}
|
|
||||||
>
|
|
||||||
<ul className={styles.submenuHorizontalList}>
|
|
||||||
{item.submenu.map(subitem => {
|
|
||||||
const SubIcon = subitem.icon as React.ComponentType<React.SVGProps<SVGSVGElement>>;
|
|
||||||
const isActive = isSubmenuItemActive(subitem.link);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<li key={subitem.id} className={`${styles.submenuHorizontalItem} ${isActive ? styles.active : ''}`}>
|
|
||||||
<Link
|
|
||||||
to={subitem.link || '#'}
|
|
||||||
title={subitem.name}
|
|
||||||
className={`${styles.submenuHorizontalLink} ${isActive ? styles.activeLink : ''}`}
|
|
||||||
>
|
|
||||||
{SubIcon && (
|
|
||||||
<SubIcon
|
|
||||||
className={styles.submenuHorizontalIcon}
|
|
||||||
style={{
|
|
||||||
width: '16px',
|
|
||||||
height: '16px',
|
|
||||||
color: isActive ? 'white' : '#181818',
|
|
||||||
display: 'block'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
</motion.div>
|
|
||||||
) : (
|
|
||||||
// Vertical layout for expanded sidebar
|
|
||||||
<motion.div
|
|
||||||
className={styles.submenuLineContainer}
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1, transition: { delay: 0.3, duration: 0.25 } }}
|
|
||||||
exit={{ opacity: 0, transition: { duration: 0.25, delay: 0 } }}
|
|
||||||
>
|
|
||||||
<ul className={styles.submenuList}>
|
|
||||||
{item.submenu.map(subitem => (
|
|
||||||
<SubmenuItem
|
|
||||||
key={subitem.id}
|
|
||||||
subitem={subitem}
|
|
||||||
isActive={isSubmenuItemActive(subitem.link)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SidebarSubmenu;
|
|
||||||
|
|
@ -8,14 +8,17 @@ export interface SidebarItemData {
|
||||||
icon?: React.ComponentType<React.SVGProps<SVGSVGElement>>;
|
icon?: React.ComponentType<React.SVGProps<SVGSVGElement>>;
|
||||||
submenu?: SidebarSubmenuItemData[];
|
submenu?: SidebarSubmenuItemData[];
|
||||||
moduleEnabled?: boolean; // New property for module state
|
moduleEnabled?: boolean; // New property for module state
|
||||||
|
depth?: number; // Hierarchy depth for indentation (0 = top level)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Submenu item interface
|
// Submenu item interface - supports recursive nesting
|
||||||
export interface SidebarSubmenuItemData {
|
export interface SidebarSubmenuItemData {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
link?: string;
|
link?: string; // Optional - if undefined, it's a navigation node (not a page)
|
||||||
icon?: React.ComponentType<React.SVGProps<SVGSVGElement>>;
|
icon?: React.ComponentType<React.SVGProps<SVGSVGElement>>;
|
||||||
|
submenu?: SidebarSubmenuItemData[]; // Recursive support for nested submenus
|
||||||
|
depth?: number; // Hierarchy depth for indentation (0 = top level)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sidebar state interface
|
// Sidebar state interface
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||||
import { allPageData, SidebarItem } from './data';
|
import { allPageData, SidebarItem } from './data';
|
||||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
import { resolveLanguageText } from './pageInterface';
|
import { resolveLanguageText, GenericPageData } from './pageInterface';
|
||||||
import { usePermissions } from '../../hooks/usePermissions';
|
import { usePermissions } from '../../hooks/usePermissions';
|
||||||
import { FaHome, FaHatWizard, FaBriefcase } from 'react-icons/fa';
|
import { FaHome, FaHatWizard, FaBriefcase, FaBuilding } from 'react-icons/fa';
|
||||||
import { RiFolderSettingsFill } from 'react-icons/ri';
|
import { RiFolderSettingsFill } from 'react-icons/ri';
|
||||||
|
import { SidebarSubmenuItemData } from '../../components/Sidebar/sidebarTypes';
|
||||||
|
|
||||||
// Configuration for parent groups that don't have a page definition
|
// Configuration for parent groups that don't have a page definition
|
||||||
// Maps parentPath to icon and default order
|
// Maps parentPath (can be nested like "start.real-estate") to icon and default order
|
||||||
const parentGroupConfig: Record<string, {
|
const parentGroupConfig: Record<string, {
|
||||||
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
|
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
|
||||||
defaultOrder?: number;
|
defaultOrder?: number;
|
||||||
|
|
@ -16,6 +17,14 @@ const parentGroupConfig: Record<string, {
|
||||||
icon: FaHome,
|
icon: FaHome,
|
||||||
defaultOrder: 1
|
defaultOrder: 1
|
||||||
},
|
},
|
||||||
|
'start.real-estate': {
|
||||||
|
icon: FaBuilding,
|
||||||
|
defaultOrder: 1
|
||||||
|
},
|
||||||
|
'start.trustee': {
|
||||||
|
icon: FaBriefcase,
|
||||||
|
defaultOrder: 2
|
||||||
|
},
|
||||||
'trustee': {
|
'trustee': {
|
||||||
icon: FaBriefcase,
|
icon: FaBriefcase,
|
||||||
defaultOrder: 2
|
defaultOrder: 2
|
||||||
|
|
@ -60,136 +69,270 @@ export const SidebarProvider: React.FC<SidebarProviderProps> = ({ children }) =>
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const { canView, preloadUiPermissions } = usePermissions();
|
const { canView, preloadUiPermissions } = usePermissions();
|
||||||
|
|
||||||
|
// Helper type for navigation tree nodes
|
||||||
|
interface NavigationNode {
|
||||||
|
id: string;
|
||||||
|
pathSegment: string;
|
||||||
|
fullPath: string; // Full dot-notation path (e.g., "start.real-estate")
|
||||||
|
name: string;
|
||||||
|
icon?: React.ComponentType<React.SVGProps<SVGSVGElement>>;
|
||||||
|
order: number;
|
||||||
|
page?: GenericPageData; // If this node represents an actual page
|
||||||
|
children: Map<string, NavigationNode>; // Keyed by path segment
|
||||||
|
pages: GenericPageData[]; // Direct child pages
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to resolve node name
|
||||||
|
const resolveNodeName = (pathSegment: string, fullPath: string, page?: GenericPageData): string => {
|
||||||
|
if (page) {
|
||||||
|
return resolveLanguageText(page.name, t);
|
||||||
|
}
|
||||||
|
// Try translation key (e.g., "start.real-estate.title")
|
||||||
|
const translationKey = `${fullPath}.title`;
|
||||||
|
const translated = t(translationKey);
|
||||||
|
if (translated !== translationKey) {
|
||||||
|
return translated;
|
||||||
|
}
|
||||||
|
// Try just the segment (e.g., "real-estate.title")
|
||||||
|
const segmentKey = `${pathSegment}.title`;
|
||||||
|
const segmentTranslated = t(segmentKey);
|
||||||
|
if (segmentTranslated !== segmentKey) {
|
||||||
|
return segmentTranslated;
|
||||||
|
}
|
||||||
|
// Fallback to capitalized segment
|
||||||
|
return pathSegment.split('-').map(s => s.charAt(0).toUpperCase() + s.slice(1)).join(' ');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to resolve node icon
|
||||||
|
const resolveNodeIcon = (pathSegment: string, fullPath: string, page?: GenericPageData): React.ComponentType<React.SVGProps<SVGSVGElement>> | undefined => {
|
||||||
|
if (page?.icon) {
|
||||||
|
return page.icon;
|
||||||
|
}
|
||||||
|
// Check parentGroupConfig for nested paths first (e.g., "start.real-estate")
|
||||||
|
if (parentGroupConfig[fullPath]?.icon) {
|
||||||
|
return parentGroupConfig[fullPath].icon;
|
||||||
|
}
|
||||||
|
// Check parentGroupConfig for top-level segments (e.g., "start")
|
||||||
|
if (fullPath === pathSegment && parentGroupConfig[pathSegment]?.icon) {
|
||||||
|
return parentGroupConfig[pathSegment].icon;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to resolve node order
|
||||||
|
const resolveNodeOrder = (pathSegment: string, fullPath: string, page?: GenericPageData, childPages: GenericPageData[] = []): number => {
|
||||||
|
if (page?.order !== undefined) {
|
||||||
|
return page.order;
|
||||||
|
}
|
||||||
|
// Check parentGroupConfig for top-level segments
|
||||||
|
if (fullPath === pathSegment && parentGroupConfig[pathSegment]?.defaultOrder !== undefined) {
|
||||||
|
return parentGroupConfig[pathSegment].defaultOrder!;
|
||||||
|
}
|
||||||
|
// Use minimum order of child pages
|
||||||
|
if (childPages.length > 0) {
|
||||||
|
const childOrders = childPages.map(p => p.order ?? 0);
|
||||||
|
return Math.min(...childOrders);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build navigation tree from page data
|
||||||
|
const buildNavigationTree = (): Map<string, NavigationNode> => {
|
||||||
|
const rootNodes = new Map<string, NavigationNode>();
|
||||||
|
|
||||||
|
// Process all pages with parent paths
|
||||||
|
const pagesWithParents = allPageData.filter(
|
||||||
|
page => page.parentPath && !page.hide && page.showInSidebar !== false
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const page of pagesWithParents) {
|
||||||
|
if (!page.parentPath) continue;
|
||||||
|
|
||||||
|
// Parse parent path segments (e.g., "start.real-estate" -> ["start", "real-estate"])
|
||||||
|
const pathSegments = page.parentPath.split('.');
|
||||||
|
|
||||||
|
// Build path to root, creating nodes as needed
|
||||||
|
let currentMap = rootNodes;
|
||||||
|
let currentFullPath = '';
|
||||||
|
|
||||||
|
for (let i = 0; i < pathSegments.length; i++) {
|
||||||
|
const segment = pathSegments[i];
|
||||||
|
currentFullPath = currentFullPath ? `${currentFullPath}.${segment}` : segment;
|
||||||
|
|
||||||
|
// Get or create node for this segment
|
||||||
|
if (!currentMap.has(segment)) {
|
||||||
|
// Check if there's a page for this path segment
|
||||||
|
const segmentPage = allPageData.find(
|
||||||
|
p => p.path === currentFullPath && !p.hide
|
||||||
|
);
|
||||||
|
|
||||||
|
const node: NavigationNode = {
|
||||||
|
id: segmentPage?.id || currentFullPath,
|
||||||
|
pathSegment: segment,
|
||||||
|
fullPath: currentFullPath,
|
||||||
|
name: '', // Will be resolved later
|
||||||
|
icon: undefined, // Will be resolved later
|
||||||
|
order: 0, // Will be resolved later
|
||||||
|
page: segmentPage,
|
||||||
|
children: new Map(),
|
||||||
|
pages: []
|
||||||
|
};
|
||||||
|
|
||||||
|
currentMap.set(segment, node);
|
||||||
|
}
|
||||||
|
|
||||||
|
const node = currentMap.get(segment)!;
|
||||||
|
|
||||||
|
// If this is the last segment, add the page as a child page
|
||||||
|
if (i === pathSegments.length - 1) {
|
||||||
|
node.pages.push(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move to next level
|
||||||
|
currentMap = node.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve names, icons, and orders for all nodes
|
||||||
|
const resolveNode = (node: NavigationNode): void => {
|
||||||
|
// Resolve children first (bottom-up)
|
||||||
|
for (const childNode of node.children.values()) {
|
||||||
|
resolveNode(childNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve this node
|
||||||
|
node.name = resolveNodeName(node.pathSegment, node.fullPath, node.page);
|
||||||
|
node.icon = resolveNodeIcon(node.pathSegment, node.fullPath, node.page);
|
||||||
|
|
||||||
|
// Collect all child pages (from direct pages and nested children)
|
||||||
|
const allChildPages = [...node.pages];
|
||||||
|
for (const childNode of node.children.values()) {
|
||||||
|
if (childNode.page) {
|
||||||
|
allChildPages.push(childNode.page);
|
||||||
|
}
|
||||||
|
allChildPages.push(...childNode.pages);
|
||||||
|
}
|
||||||
|
|
||||||
|
node.order = resolveNodeOrder(node.pathSegment, node.fullPath, node.page, allChildPages);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Resolve all root nodes
|
||||||
|
for (const node of rootNodes.values()) {
|
||||||
|
resolveNode(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
return rootNodes;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Convert navigation tree node to sidebar submenu item (recursive)
|
||||||
|
const nodeToSubmenuItem = async (node: NavigationNode, depth: number = 0): Promise<SidebarSubmenuItemData | null> => {
|
||||||
|
// Filter child pages by RBAC and privilegeChecker
|
||||||
|
const accessiblePages: GenericPageData[] = [];
|
||||||
|
for (const page of node.pages) {
|
||||||
|
try {
|
||||||
|
const hasRBACAccess = await canView('UI', page.path);
|
||||||
|
if (!hasRBACAccess) continue;
|
||||||
|
|
||||||
|
if (page.privilegeChecker) {
|
||||||
|
try {
|
||||||
|
const hasPrivilege = await page.privilegeChecker();
|
||||||
|
if (!hasPrivilege) continue;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error checking privilegeChecker for page ${page.path}:`, error);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
accessiblePages.push(page);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error checking RBAC access for page ${page.path}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process child nodes recursively (increment depth)
|
||||||
|
const accessibleChildren: SidebarSubmenuItemData[] = [];
|
||||||
|
for (const childNode of node.children.values()) {
|
||||||
|
const childItem = await nodeToSubmenuItem(childNode, depth + 1);
|
||||||
|
if (childItem) {
|
||||||
|
accessibleChildren.push(childItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combine pages and child nodes, assigning depth
|
||||||
|
const allChildren: SidebarSubmenuItemData[] = [
|
||||||
|
...accessiblePages.map(page => ({
|
||||||
|
id: page.id,
|
||||||
|
name: resolveLanguageText(page.name, t),
|
||||||
|
link: `/${page.path}`,
|
||||||
|
icon: page.icon,
|
||||||
|
depth: depth + 1 // Child pages are one level deeper
|
||||||
|
})),
|
||||||
|
...accessibleChildren
|
||||||
|
];
|
||||||
|
|
||||||
|
// If no accessible children, don't create this node
|
||||||
|
if (allChildren.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If this node has a page itself, it shouldn't be a navigation node
|
||||||
|
// But according to requirements: if it has subpages, it is NOT a page itself
|
||||||
|
// So we create a navigation node without a link
|
||||||
|
return {
|
||||||
|
id: node.id,
|
||||||
|
name: node.name,
|
||||||
|
link: undefined, // Navigation node - not a clickable page
|
||||||
|
icon: node.icon,
|
||||||
|
submenu: allChildren.length > 0 ? allChildren : undefined,
|
||||||
|
depth: depth // Current depth level
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Convert navigation tree to sidebar items
|
||||||
|
const treeToSidebarItems = async (tree: Map<string, NavigationNode>): Promise<SidebarItem[]> => {
|
||||||
|
const items: SidebarItem[] = [];
|
||||||
|
|
||||||
|
// Process each root node (depth 0 for top-level items)
|
||||||
|
for (const node of tree.values()) {
|
||||||
|
const submenuItem = await nodeToSubmenuItem(node, 0);
|
||||||
|
if (submenuItem && submenuItem.submenu && submenuItem.submenu.length > 0) {
|
||||||
|
items.push({
|
||||||
|
id: node.id,
|
||||||
|
name: node.name,
|
||||||
|
link: undefined, // Navigation node - not a clickable page
|
||||||
|
icon: node.icon,
|
||||||
|
moduleEnabled: true,
|
||||||
|
order: node.order,
|
||||||
|
submenu: submenuItem.submenu,
|
||||||
|
depth: 0 // Top-level items have depth 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
};
|
||||||
|
|
||||||
// Get sidebar items from page data
|
// Get sidebar items from page data
|
||||||
const getSidebarItems = async (): Promise<SidebarItem[]> => {
|
const getSidebarItems = async (): Promise<SidebarItem[]> => {
|
||||||
const items: SidebarItem[] = [];
|
const items: SidebarItem[] = [];
|
||||||
|
|
||||||
// Get all unique parent paths from pages that have subpages
|
// Build navigation tree
|
||||||
const parentPaths = new Set<string>();
|
const navigationTree = buildNavigationTree();
|
||||||
allPageData.forEach(page => {
|
|
||||||
if (page.parentPath && !page.hide && page.showInSidebar !== false) {
|
|
||||||
parentPaths.add(page.parentPath);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create parent groups for each parentPath (even if no page exists for that path)
|
// Convert tree to sidebar items
|
||||||
const parentGroups = new Map<string, {
|
const treeItems = await treeToSidebarItems(navigationTree);
|
||||||
id: string;
|
items.push(...treeItems);
|
||||||
name: string;
|
|
||||||
icon?: React.ComponentType<React.SVGProps<SVGSVGElement>>;
|
|
||||||
order: number;
|
|
||||||
subpages: typeof allPageData;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
for (const parentPath of parentPaths) {
|
|
||||||
// Check if a page exists for this parent path
|
|
||||||
const parentPage = allPageData.find(p => p.path === parentPath && !p.hide);
|
|
||||||
|
|
||||||
// Get all subpages for this parent
|
|
||||||
const subpages = allPageData.filter(p =>
|
|
||||||
p.parentPath === parentPath &&
|
|
||||||
!p.hide &&
|
|
||||||
p.showInSidebar !== false
|
|
||||||
);
|
|
||||||
|
|
||||||
if (subpages.length > 0) {
|
|
||||||
// Use parent page data if it exists, otherwise create a virtual parent
|
|
||||||
// Try to resolve name from translation key (e.g., "start.title") or use capitalized path
|
|
||||||
let parentName: string;
|
|
||||||
if (parentPage) {
|
|
||||||
parentName = resolveLanguageText(parentPage.name, t);
|
|
||||||
} else {
|
|
||||||
// Try to resolve as translation key first (e.g., "start.title")
|
|
||||||
const translationKey = `${parentPath}.title`;
|
|
||||||
const translated = t(translationKey);
|
|
||||||
parentName = translated !== translationKey ? translated : parentPath.charAt(0).toUpperCase() + parentPath.slice(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get icon: use parent page icon if exists, otherwise use config, or undefined
|
|
||||||
const parentIcon = parentPage?.icon || parentGroupConfig[parentPath]?.icon;
|
|
||||||
|
|
||||||
// Determine order: use parent page order if exists, otherwise use config default,
|
|
||||||
// then minimum order of subpages, or default to 0
|
|
||||||
let parentOrder = parentPage?.order;
|
|
||||||
if (parentOrder === undefined) {
|
|
||||||
parentOrder = parentGroupConfig[parentPath]?.defaultOrder;
|
|
||||||
if (parentOrder === undefined) {
|
|
||||||
const subpageOrders = subpages.map(s => s.order ?? 0);
|
|
||||||
parentOrder = subpageOrders.length > 0 ? Math.min(...subpageOrders) : 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
parentGroups.set(parentPath, {
|
|
||||||
id: parentPage?.id || parentPath,
|
|
||||||
name: parentName,
|
|
||||||
icon: parentIcon,
|
|
||||||
order: parentOrder,
|
|
||||||
subpages: subpages
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process parent groups
|
|
||||||
for (const [_parentPath, parentGroup] of parentGroups.entries()) {
|
|
||||||
// Filter subpages by RBAC access and privilegeChecker
|
|
||||||
const accessibleSubpages = [];
|
|
||||||
for (const subpage of parentGroup.subpages) {
|
|
||||||
try {
|
|
||||||
// Check RBAC access
|
|
||||||
const hasSubpageRBACAccess = await canView('UI', subpage.path);
|
|
||||||
if (!hasSubpageRBACAccess) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check client-side privilegeChecker if provided
|
|
||||||
if (subpage.privilegeChecker) {
|
|
||||||
try {
|
|
||||||
const hasPrivilege = await subpage.privilegeChecker();
|
|
||||||
if (!hasPrivilege) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error checking privilegeChecker for subpage ${subpage.path}:`, error);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
accessibleSubpages.push(subpage);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error checking RBAC access for subpage ${subpage.path}:`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (accessibleSubpages.length > 0) {
|
|
||||||
// Create parent item with submenu (no link since it's not a real page)
|
|
||||||
items.push({
|
|
||||||
id: parentGroup.id,
|
|
||||||
name: parentGroup.name,
|
|
||||||
link: undefined, // No link - parent is not a clickable page
|
|
||||||
icon: parentGroup.icon,
|
|
||||||
moduleEnabled: true,
|
|
||||||
order: parentGroup.order,
|
|
||||||
submenu: accessibleSubpages.map(subpage => ({
|
|
||||||
id: subpage.id,
|
|
||||||
name: resolveLanguageText(subpage.name, t),
|
|
||||||
link: `/${subpage.path}`,
|
|
||||||
icon: subpage.icon
|
|
||||||
}))
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get main pages (no parent path)
|
// Get main pages (no parent path)
|
||||||
const mainPages = allPageData
|
const mainPages = allPageData
|
||||||
.filter(page => !page.parentPath && !page.hide && page.showInSidebar !== false)
|
.filter(page => !page.parentPath && !page.hide && page.showInSidebar !== false)
|
||||||
.sort((a, b) => (a.order || 0) - (b.order || 0));
|
.sort((a, b) => (a.order || 0) - (b.order || 0));
|
||||||
|
|
||||||
// Process each main page (permissions already bulk-loaded)
|
// Process each main page
|
||||||
for (const pageData of mainPages) {
|
for (const pageData of mainPages) {
|
||||||
// Check RBAC permissions (from cache - no API call)
|
// Check RBAC permissions
|
||||||
try {
|
try {
|
||||||
const hasRBACAccess = await canView('UI', pageData.path);
|
const hasRBACAccess = await canView('UI', pageData.path);
|
||||||
|
|
||||||
if (!hasRBACAccess) {
|
if (!hasRBACAccess) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -211,7 +354,7 @@ export const SidebarProvider: React.FC<SidebarProviderProps> = ({ children }) =>
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if this page has subpages
|
// Check if this page has subpages (legacy support)
|
||||||
if (pageData.hasSubpages) {
|
if (pageData.hasSubpages) {
|
||||||
// Find all subpages for this parent
|
// Find all subpages for this parent
|
||||||
const allSubpages = allPageData.filter(p =>
|
const allSubpages = allPageData.filter(p =>
|
||||||
|
|
@ -221,66 +364,52 @@ export const SidebarProvider: React.FC<SidebarProviderProps> = ({ children }) =>
|
||||||
);
|
);
|
||||||
|
|
||||||
// Filter subpages by RBAC access
|
// Filter subpages by RBAC access
|
||||||
const accessibleSubpages = [];
|
const accessibleSubpages: GenericPageData[] = [];
|
||||||
console.log('📋 SidebarProvider: Checking subpages for:', {
|
|
||||||
parentPath: pageData.path,
|
|
||||||
totalSubpages: allSubpages.length
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const subpage of allSubpages) {
|
for (const subpage of allSubpages) {
|
||||||
try {
|
try {
|
||||||
console.log('🔍 SidebarProvider: Checking subpage access:', {
|
|
||||||
parentPath: pageData.path,
|
|
||||||
subpagePath: subpage.path,
|
|
||||||
subpageName: subpage.name
|
|
||||||
});
|
|
||||||
|
|
||||||
const hasSubpageRBACAccess = await canView('UI', subpage.path);
|
const hasSubpageRBACAccess = await canView('UI', subpage.path);
|
||||||
console.log('🔍 SidebarProvider: Subpage RBAC result:', {
|
|
||||||
subpagePath: subpage.path,
|
|
||||||
hasAccess: hasSubpageRBACAccess
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!hasSubpageRBACAccess) {
|
if (!hasSubpageRBACAccess) {
|
||||||
console.log('⛔ SidebarProvider: Subpage hidden due to RBAC:', subpage.path);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check client-side privilegeChecker if provided
|
|
||||||
if (subpage.privilegeChecker) {
|
if (subpage.privilegeChecker) {
|
||||||
try {
|
try {
|
||||||
const hasPrivilege = await subpage.privilegeChecker();
|
const hasPrivilege = await subpage.privilegeChecker();
|
||||||
if (!hasPrivilege) {
|
if (!hasPrivilege) {
|
||||||
console.log('⛔ SidebarProvider: Subpage hidden due to privilegeChecker:', subpage.path);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`❌ SidebarProvider: Error checking privilegeChecker for subpage ${subpage.path}:`, error);
|
console.error(`Error checking privilegeChecker for subpage ${subpage.path}:`, error);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
accessibleSubpages.push(subpage);
|
accessibleSubpages.push(subpage);
|
||||||
console.log('✅ SidebarProvider: Subpage added:', subpage.path);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`❌ SidebarProvider: Error checking RBAC access for subpage ${subpage.path}:`, error);
|
console.error(`Error checking RBAC access for subpage ${subpage.path}:`, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('📋 SidebarProvider: Subpage filtering complete:', {
|
|
||||||
parentPath: pageData.path,
|
|
||||||
totalSubpages: allSubpages.length,
|
|
||||||
accessibleSubpages: accessibleSubpages.length,
|
|
||||||
accessiblePaths: accessibleSubpages.map(s => s.path)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (accessibleSubpages.length > 0) {
|
if (accessibleSubpages.length > 0) {
|
||||||
console.log('✅ SidebarProvider: Adding parent page with subpages:', {
|
// Create item with submenu (no link since it has subpages)
|
||||||
path: pageData.path,
|
items.push({
|
||||||
name: pageData.name,
|
id: pageData.id,
|
||||||
subpagesCount: accessibleSubpages.length
|
name: resolveLanguageText(pageData.name, t),
|
||||||
|
link: undefined, // No link - has subpages, so it's a navigation node
|
||||||
|
icon: pageData.icon,
|
||||||
|
moduleEnabled: pageData.moduleEnabled ?? true,
|
||||||
|
order: pageData.order || 0,
|
||||||
|
depth: 0, // Top-level items have depth 0
|
||||||
|
submenu: accessibleSubpages.map(subpage => ({
|
||||||
|
id: subpage.id,
|
||||||
|
name: resolveLanguageText(subpage.name, t),
|
||||||
|
link: `/${subpage.path}`,
|
||||||
|
icon: subpage.icon,
|
||||||
|
depth: 1 // First level of submenu
|
||||||
|
}))
|
||||||
});
|
});
|
||||||
// Create expandable item with submenu
|
} else {
|
||||||
|
// No accessible subpages, show as regular item
|
||||||
items.push({
|
items.push({
|
||||||
id: pageData.id,
|
id: pageData.id,
|
||||||
name: resolveLanguageText(pageData.name, t),
|
name: resolveLanguageText(pageData.name, t),
|
||||||
|
|
@ -288,41 +417,19 @@ export const SidebarProvider: React.FC<SidebarProviderProps> = ({ children }) =>
|
||||||
icon: pageData.icon,
|
icon: pageData.icon,
|
||||||
moduleEnabled: pageData.moduleEnabled ?? true,
|
moduleEnabled: pageData.moduleEnabled ?? true,
|
||||||
order: pageData.order || 0,
|
order: pageData.order || 0,
|
||||||
submenu: accessibleSubpages.map(subpage => ({
|
depth: 0 // Top-level items have depth 0
|
||||||
id: subpage.id,
|
|
||||||
name: resolveLanguageText(subpage.name, t),
|
|
||||||
link: `/${subpage.path}`,
|
|
||||||
icon: subpage.icon
|
|
||||||
}))
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// No accessible subpages, show as regular item
|
|
||||||
console.log('✅ SidebarProvider: Adding parent page without accessible subpages:', {
|
|
||||||
path: pageData.path,
|
|
||||||
name: pageData.name
|
|
||||||
});
|
|
||||||
items.push({
|
|
||||||
id: pageData.id,
|
|
||||||
name: resolveLanguageText(pageData.name, t),
|
|
||||||
link: `/${pageData.path}`,
|
|
||||||
icon: pageData.icon,
|
|
||||||
moduleEnabled: pageData.moduleEnabled ?? true,
|
|
||||||
order: pageData.order || 0
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Regular items without subpages
|
// Regular items without subpages
|
||||||
console.log('✅ SidebarProvider: Adding regular page:', {
|
|
||||||
path: pageData.path,
|
|
||||||
name: pageData.name
|
|
||||||
});
|
|
||||||
items.push({
|
items.push({
|
||||||
id: pageData.id,
|
id: pageData.id,
|
||||||
name: resolveLanguageText(pageData.name, t),
|
name: resolveLanguageText(pageData.name, t),
|
||||||
link: `/${pageData.path}`,
|
link: `/${pageData.path}`,
|
||||||
icon: pageData.icon,
|
icon: pageData.icon,
|
||||||
moduleEnabled: pageData.moduleEnabled ?? true,
|
moduleEnabled: pageData.moduleEnabled ?? true,
|
||||||
order: pageData.order || 0
|
order: pageData.order || 0,
|
||||||
|
depth: 0 // Top-level items have depth 0
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ export { workflowsPageData } from './workflows';
|
||||||
export { connectionsPageData } from './connections';
|
export { connectionsPageData } from './connections';
|
||||||
export { teamMembersPageData } from './admin/team-members';
|
export { teamMembersPageData } from './admin/team-members';
|
||||||
export { promptsPageData } from './prompts';
|
export { promptsPageData } from './prompts';
|
||||||
export { speechPageData } from './speech';
|
|
||||||
export { settingsPageData } from './settings';
|
export { settingsPageData } from './settings';
|
||||||
export { pekPageData } from './pek';
|
export { pekPageData } from './pek';
|
||||||
export { pekTablesPageData } from './pek-tables';
|
export { pekTablesPageData } from './pek-tables';
|
||||||
|
|
@ -31,7 +30,6 @@ import { workflowsPageData } from './workflows';
|
||||||
import { connectionsPageData } from './connections';
|
import { connectionsPageData } from './connections';
|
||||||
import { teamMembersPageData } from './admin/team-members';
|
import { teamMembersPageData } from './admin/team-members';
|
||||||
import { promptsPageData } from './prompts';
|
import { promptsPageData } from './prompts';
|
||||||
import { speechPageData } from './speech';
|
|
||||||
import { settingsPageData } from './settings';
|
import { settingsPageData } from './settings';
|
||||||
import { pekPageData } from './pek';
|
import { pekPageData } from './pek';
|
||||||
import { pekTablesPageData } from './pek-tables';
|
import { pekTablesPageData } from './pek-tables';
|
||||||
|
|
@ -48,7 +46,6 @@ export const allPageData = [
|
||||||
workflowsPageData,
|
workflowsPageData,
|
||||||
connectionsPageData,
|
connectionsPageData,
|
||||||
promptsPageData,
|
promptsPageData,
|
||||||
speechPageData,
|
|
||||||
settingsPageData,
|
settingsPageData,
|
||||||
pekPageData,
|
pekPageData,
|
||||||
pekTablesPageData,
|
pekTablesPageData,
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,12 @@ import { getUserDataCache } from '../../../../utils/userCache';
|
||||||
|
|
||||||
export const pekTablesPageData: GenericPageData = {
|
export const pekTablesPageData: GenericPageData = {
|
||||||
id: 'pek-tables',
|
id: 'pek-tables',
|
||||||
path: 'start/pek-tables',
|
path: 'start/real-estate/pek-tables',
|
||||||
name: 'Projektmanagement',
|
name: 'Projektmanagement',
|
||||||
description: 'Projektmanagement mit Tabellen',
|
description: 'Projektmanagement mit Tabellen',
|
||||||
|
|
||||||
// Parent page
|
// Parent page
|
||||||
parentPath: 'start',
|
parentPath: 'start.real-estate',
|
||||||
|
|
||||||
// Visual
|
// Visual
|
||||||
icon: FaTable,
|
icon: FaTable,
|
||||||
|
|
|
||||||
|
|
@ -35,12 +35,12 @@ const createPekHook = () => {
|
||||||
|
|
||||||
export const pekPageData: GenericPageData = {
|
export const pekPageData: GenericPageData = {
|
||||||
id: 'pek',
|
id: 'pek',
|
||||||
path: 'start/pek',
|
path: 'start/real-estate/pek',
|
||||||
name: 'projects.title',
|
name: 'projects.title',
|
||||||
description: 'projects.description',
|
description: 'projects.description',
|
||||||
|
|
||||||
// Parent page
|
// Parent page
|
||||||
parentPath: 'start',
|
parentPath: 'start.real-estate',
|
||||||
|
|
||||||
// Visual
|
// Visual
|
||||||
icon: FaBuilding,
|
icon: FaBuilding,
|
||||||
|
|
|
||||||
|
|
@ -108,7 +108,7 @@ export const trusteeAccessPageData: GenericPageData = {
|
||||||
path: 'trustee/access',
|
path: 'trustee/access',
|
||||||
name: 'trustee.access.title',
|
name: 'trustee.access.title',
|
||||||
description: 'trustee.access.description',
|
description: 'trustee.access.description',
|
||||||
parentPath: 'trustee',
|
parentPath: 'start.trustee',
|
||||||
|
|
||||||
icon: FaKey,
|
icon: FaKey,
|
||||||
title: 'trustee.access.title',
|
title: 'trustee.access.title',
|
||||||
|
|
|
||||||
|
|
@ -108,7 +108,7 @@ export const trusteeContractsPageData: GenericPageData = {
|
||||||
path: 'trustee/contracts',
|
path: 'trustee/contracts',
|
||||||
name: 'trustee.contracts.title',
|
name: 'trustee.contracts.title',
|
||||||
description: 'trustee.contracts.description',
|
description: 'trustee.contracts.description',
|
||||||
parentPath: 'trustee',
|
parentPath: 'start.trustee',
|
||||||
|
|
||||||
icon: FaFileContract,
|
icon: FaFileContract,
|
||||||
title: 'trustee.contracts.title',
|
title: 'trustee.contracts.title',
|
||||||
|
|
|
||||||
|
|
@ -113,7 +113,7 @@ export const trusteeDocumentsPageData: GenericPageData = {
|
||||||
path: 'trustee/documents',
|
path: 'trustee/documents',
|
||||||
name: 'trustee.documents.title',
|
name: 'trustee.documents.title',
|
||||||
description: 'trustee.documents.description',
|
description: 'trustee.documents.description',
|
||||||
parentPath: 'trustee',
|
parentPath: 'start.trustee',
|
||||||
|
|
||||||
icon: FaFile,
|
icon: FaFile,
|
||||||
title: 'trustee.documents.title',
|
title: 'trustee.documents.title',
|
||||||
|
|
|
||||||
|
|
@ -110,7 +110,7 @@ export const trusteeOrganisationsPageData: GenericPageData = {
|
||||||
path: 'trustee/organisations',
|
path: 'trustee/organisations',
|
||||||
name: 'trustee.organisations.title',
|
name: 'trustee.organisations.title',
|
||||||
description: 'trustee.organisations.description',
|
description: 'trustee.organisations.description',
|
||||||
parentPath: 'trustee',
|
parentPath: 'start.trustee',
|
||||||
|
|
||||||
icon: FaBuilding,
|
icon: FaBuilding,
|
||||||
title: 'trustee.organisations.title',
|
title: 'trustee.organisations.title',
|
||||||
|
|
|
||||||
|
|
@ -112,7 +112,7 @@ export const trusteePositionsPageData: GenericPageData = {
|
||||||
path: 'trustee/positions',
|
path: 'trustee/positions',
|
||||||
name: 'trustee.positions.title',
|
name: 'trustee.positions.title',
|
||||||
description: 'trustee.positions.description',
|
description: 'trustee.positions.description',
|
||||||
parentPath: 'trustee',
|
parentPath: 'start.trustee',
|
||||||
|
|
||||||
icon: FaReceipt,
|
icon: FaReceipt,
|
||||||
title: 'trustee.positions.title',
|
title: 'trustee.positions.title',
|
||||||
|
|
|
||||||
|
|
@ -108,7 +108,7 @@ export const trusteeRolesPageData: GenericPageData = {
|
||||||
path: 'trustee/roles',
|
path: 'trustee/roles',
|
||||||
name: 'trustee.roles.title',
|
name: 'trustee.roles.title',
|
||||||
description: 'trustee.roles.description',
|
description: 'trustee.roles.description',
|
||||||
parentPath: 'trustee',
|
parentPath: 'start.trustee',
|
||||||
|
|
||||||
icon: FaUserTag,
|
icon: FaUserTag,
|
||||||
title: 'trustee.roles.title',
|
title: 'trustee.roles.title',
|
||||||
|
|
|
||||||
|
|
@ -402,6 +402,7 @@ export interface SidebarItem {
|
||||||
moduleEnabled: boolean;
|
moduleEnabled: boolean;
|
||||||
order: number;
|
order: number;
|
||||||
submenu?: SidebarSubmenuItemData[];
|
submenu?: SidebarSubmenuItemData[];
|
||||||
|
depth?: number; // Hierarchy depth for indentation (0 = top level)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sidebar submenu item data interface
|
// Sidebar submenu item data interface
|
||||||
|
|
@ -410,6 +411,8 @@ export interface SidebarSubmenuItemData {
|
||||||
name: string;
|
name: string;
|
||||||
link: string;
|
link: string;
|
||||||
icon?: IconType;
|
icon?: IconType;
|
||||||
|
submenu?: SidebarSubmenuItemData[]; // Recursive support for nested submenus
|
||||||
|
depth?: number; // Hierarchy depth for indentation (0 = top level)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Page instance for PageManager
|
// Page instance for PageManager
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue