Initial commit
This commit is contained in:
279
frontend/src/components/Sidebar.tsx
Normal file
279
frontend/src/components/Sidebar.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { NavLink, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from '../contexts/LanguageContext';
|
||||
import { useSidebar } from '../contexts/SidebarContext';
|
||||
import { useViewMode } from '../contexts/ViewModeContext';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useModules } from '../contexts/ModulesContext';
|
||||
import { useSiteConfig } from '../contexts/SiteConfigContext';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
import { appModules } from '../modules';
|
||||
import UserMenu from './UserMenu';
|
||||
import '../styles/Sidebar.css';
|
||||
|
||||
export default function Sidebar() {
|
||||
const { t, language, setLanguage } = useTranslation();
|
||||
const { config } = useSiteConfig();
|
||||
const { sidebarStyle, theme, toggleTheme, darkModeLocation, languageLocation, showDarkModeToggle, showLanguageToggle } = useTheme();
|
||||
const {
|
||||
isCollapsed,
|
||||
isMobileOpen,
|
||||
sidebarMode,
|
||||
toggleCollapse,
|
||||
closeMobileMenu,
|
||||
isHovered,
|
||||
setIsHovered,
|
||||
showLogo: showLogoContext
|
||||
} = useSidebar();
|
||||
const { viewMode, toggleViewMode, isUserModeEnabled } = useViewMode();
|
||||
const { user } = useAuth();
|
||||
const { isModuleEnabled, isModuleEnabledForUser, hasInitialized: modulesInitialized } = useModules();
|
||||
|
||||
// When admin is in "user mode", show only user-permitted modules
|
||||
// Otherwise, show all globally enabled modules (admin view)
|
||||
const shouldUseUserPermissions = viewMode === 'user' || !user?.is_superuser;
|
||||
|
||||
// Don't show modules until initialization is complete to prevent flash
|
||||
const mainModules = !modulesInitialized ? [] : (appModules
|
||||
.find((cat) => cat.id === 'main')
|
||||
?.modules.filter((m) => {
|
||||
if (!m.enabled) return false;
|
||||
if (shouldUseUserPermissions) {
|
||||
return isModuleEnabledForUser(m.id, user?.permissions, user?.is_superuser || false);
|
||||
}
|
||||
return isModuleEnabled(m.id);
|
||||
}) || []);
|
||||
|
||||
const handleCollapseClick = () => {
|
||||
if (isMobileOpen) {
|
||||
closeMobileMenu();
|
||||
} else {
|
||||
toggleCollapse();
|
||||
}
|
||||
};
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleNavClick = (e: React.MouseEvent, path: string) => {
|
||||
// Close mobile menu when clicking navigation items
|
||||
if (isMobileOpen) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
closeMobileMenu();
|
||||
setTimeout(() => {
|
||||
navigate(path);
|
||||
}, 400);
|
||||
}
|
||||
};
|
||||
|
||||
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
|
||||
const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (hoverTimeoutRef.current) {
|
||||
clearTimeout(hoverTimeoutRef.current);
|
||||
hoverTimeoutRef.current = null;
|
||||
}
|
||||
if (sidebarMode === 'dynamic' && isCollapsed && !isMobileOpen) {
|
||||
setIsHovered(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
hoverTimeoutRef.current = setTimeout(() => {
|
||||
setIsHovered(false);
|
||||
}, 100);
|
||||
};
|
||||
|
||||
// derived state for expansion in dynamic mode
|
||||
const isDynamicExpanded = sidebarMode === 'dynamic' && isCollapsed && (isHovered || isUserMenuOpen);
|
||||
|
||||
const handleSidebarClick = (e: React.MouseEvent) => {
|
||||
// Only toggle if in toggle mode and not on mobile
|
||||
if (sidebarMode === 'toggle' && !isMobileOpen) {
|
||||
// Check if the click target is an interactive element or inside one
|
||||
const target = e.target as HTMLElement;
|
||||
const isInteractive = target.closest('a, button, [role="button"], input, select, textarea');
|
||||
|
||||
if (!isInteractive) {
|
||||
toggleCollapse();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Logo logic - use white logo on dark backgrounds, black logo on light backgrounds
|
||||
// sidebarStyle 'default' and 'dark' both use dark sidebar background
|
||||
// Only 'light' sidebarStyle uses a light background
|
||||
const isLightSidebar = sidebarStyle === 'light';
|
||||
const logoSrc = isLightSidebar ? '/logo_black.svg' : '/logo_white.svg';
|
||||
// Show toggle button ONLY on mobile
|
||||
const showToggle = isMobileOpen;
|
||||
// Show logo only if enabled in config AND toggle button is not present
|
||||
const showLogo = showLogoContext && !showToggle;
|
||||
|
||||
const [tooltip, setTooltip] = useState<{ text: string; top: number; visible: boolean }>({
|
||||
text: '',
|
||||
top: 0,
|
||||
visible: false,
|
||||
});
|
||||
|
||||
const handleItemMouseEnter = (text: string, e: React.MouseEvent) => {
|
||||
if (isCollapsed && !isMobileOpen && sidebarMode !== 'dynamic') {
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
setTooltip({
|
||||
text,
|
||||
top: rect.top + rect.height / 2,
|
||||
visible: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleItemMouseLeave = () => {
|
||||
setTooltip((prev) => ({ ...prev, visible: false }));
|
||||
};
|
||||
|
||||
const updateTooltipText = (text: string) => {
|
||||
setTooltip((prev) => prev.visible ? { ...prev, text } : prev);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile overlay */}
|
||||
<div className={`sidebar-overlay ${isMobileOpen ? 'visible' : ''}`} onClick={closeMobileMenu} />
|
||||
|
||||
{/* Sidebar Tooltip */}
|
||||
{tooltip.visible && (
|
||||
<div
|
||||
className="sidebar-tooltip"
|
||||
style={{
|
||||
top: tooltip.top,
|
||||
left: 'calc(80px + 1.5rem)', // Sidebar width + gap
|
||||
}}
|
||||
>
|
||||
{tooltip.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<aside
|
||||
className={`sidebar ${isCollapsed && !isMobileOpen ? 'collapsed' : ''} ${isMobileOpen ? 'open' : ''} ${sidebarMode === 'dynamic' ? 'dynamic' : ''} ${isDynamicExpanded ? 'expanded-force' : ''} ${sidebarMode === 'toggle' ? 'clickable' : ''}`}
|
||||
data-collapsed={isCollapsed}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onClick={handleSidebarClick}
|
||||
>
|
||||
<div className="sidebar-header">
|
||||
<div className="sidebar-header-content">
|
||||
{showLogo ? (
|
||||
<>
|
||||
<img
|
||||
src={logoSrc}
|
||||
alt={config.name}
|
||||
className="sidebar-logo"
|
||||
onMouseEnter={(e) => handleItemMouseEnter(config.name, e)}
|
||||
onMouseLeave={handleItemMouseLeave}
|
||||
/>
|
||||
{(!isCollapsed || isMobileOpen || sidebarMode === 'dynamic') && (
|
||||
<div className="sidebar-title">
|
||||
<h2>{config.name}</h2>
|
||||
<p className="sidebar-tagline">{config.tagline}</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
(!isCollapsed || isMobileOpen || sidebarMode === 'dynamic') && (
|
||||
<div className="sidebar-title">
|
||||
<h2>{config.name}</h2>
|
||||
<p className="sidebar-tagline">{config.tagline}</p>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{showToggle && (
|
||||
<button onClick={handleCollapseClick} className="btn-collapse" title={isMobileOpen ? 'Close' : (isCollapsed ? 'Expand' : 'Collapse')}>
|
||||
<span className="material-symbols-outlined">
|
||||
{isMobileOpen ? 'close' : (isCollapsed ? 'chevron_right' : 'chevron_left')}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="sidebar-nav">
|
||||
<div className="nav-section">
|
||||
{mainModules.map((module) => (
|
||||
<NavLink
|
||||
key={module.id}
|
||||
to={module.path}
|
||||
className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}
|
||||
onClick={(e) => handleNavClick(e, module.path)}
|
||||
onMouseEnter={(e) => handleItemMouseEnter(t.sidebar[module.id as keyof typeof t.sidebar], e)}
|
||||
onMouseLeave={handleItemMouseLeave}
|
||||
>
|
||||
<span className="nav-icon material-symbols-outlined">{module.icon}</span>
|
||||
<span className="nav-label">{t.sidebar[module.id as keyof typeof t.sidebar]}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="sidebar-footer">
|
||||
{user?.is_superuser && isUserModeEnabled && (
|
||||
<button
|
||||
className={`view-mode-toggle ${viewMode === 'user' ? 'user-mode' : 'admin-mode'}`}
|
||||
onClick={() => { toggleViewMode(); updateTooltipText(viewMode === 'admin' ? t.admin.userView : t.admin.adminView); }}
|
||||
title={viewMode === 'admin' ? t.admin.adminView : t.admin.userView}
|
||||
onMouseEnter={(e) => handleItemMouseEnter(viewMode === 'admin' ? t.admin.adminView : t.admin.userView, e)}
|
||||
onMouseLeave={handleItemMouseLeave}
|
||||
>
|
||||
<span className="material-symbols-outlined">
|
||||
{viewMode === 'admin' ? 'admin_panel_settings' : 'person'}
|
||||
</span>
|
||||
{(!isCollapsed || isMobileOpen || sidebarMode === 'dynamic') && (
|
||||
<span className="view-mode-label">
|
||||
{viewMode === 'admin' ? t.admin.adminView : t.admin.userView}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{showDarkModeToggle && darkModeLocation === 'sidebar' && (
|
||||
<button
|
||||
className="view-mode-toggle"
|
||||
onClick={() => { toggleTheme(); updateTooltipText(theme === 'dark' ? t.theme.lightMode : t.theme.darkMode); }}
|
||||
title={theme === 'dark' ? t.theme.darkMode : t.theme.lightMode}
|
||||
onMouseEnter={(e) => handleItemMouseEnter(theme === 'dark' ? t.theme.darkMode : t.theme.lightMode, e)}
|
||||
onMouseLeave={handleItemMouseLeave}
|
||||
>
|
||||
<span className="material-symbols-outlined">
|
||||
{theme === 'dark' ? 'dark_mode' : 'light_mode'}
|
||||
</span>
|
||||
{(!isCollapsed || isMobileOpen || sidebarMode === 'dynamic') && (
|
||||
<span className="view-mode-label">
|
||||
{theme === 'dark' ? t.theme.darkMode : t.theme.lightMode}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{showLanguageToggle && languageLocation === 'sidebar' && (
|
||||
<button
|
||||
className="view-mode-toggle"
|
||||
onClick={() => { setLanguage(language === 'it' ? 'en' : 'it'); updateTooltipText(language === 'it' ? t.settings.english : t.settings.italian); }}
|
||||
title={language === 'it' ? t.settings.italian : t.settings.english}
|
||||
onMouseEnter={(e) => handleItemMouseEnter(language === 'it' ? t.settings.italian : t.settings.english, e)}
|
||||
onMouseLeave={handleItemMouseLeave}
|
||||
>
|
||||
<span className="material-symbols-outlined">language</span>
|
||||
{(!isCollapsed || isMobileOpen || sidebarMode === 'dynamic') && (
|
||||
<span className="view-mode-label">
|
||||
{language === 'it' ? t.settings.italian : t.settings.english}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<UserMenu onOpenChange={setIsUserMenuOpen} />
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user