- Fix module ordering with local state tracking for immediate UI updates - Add tab centering when selected (scroll to center) - Use finally block in handleApplyOrder to ensure state reset - Add cancelOrder translation key - Increase order card min-width for better readability - Normalize mobile top bar height with min-height constraint - Add display:flex to mobile title sections for proper layout 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
335 lines
13 KiB
TypeScript
335 lines
13 KiB
TypeScript
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 { useNotifications } from '../contexts/NotificationsContext';
|
|
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, hasInitializedSettings: themeInitialized } = useTheme();
|
|
const {
|
|
isCollapsed,
|
|
isMobileOpen,
|
|
sidebarMode,
|
|
toggleCollapse,
|
|
closeMobileMenu,
|
|
isHovered,
|
|
setIsHovered,
|
|
showLogo: showLogoContext
|
|
} = useSidebar();
|
|
const { viewMode, toggleViewMode, isUserModeEnabled } = useViewMode();
|
|
const { user } = useAuth();
|
|
const { isModuleEnabled, isModuleEnabledForUser, moduleOrder, moduleStates, hasInitialized: modulesInitialized } = useModules();
|
|
const { unreadCount } = useNotifications();
|
|
|
|
// 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 mainModulesFiltered = !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);
|
|
}) || []);
|
|
|
|
// Sort modules based on moduleOrder
|
|
const sortedModules = [...mainModulesFiltered].sort((a, b) => {
|
|
const aIndex = moduleOrder.indexOf(a.id);
|
|
const bIndex = moduleOrder.indexOf(b.id);
|
|
|
|
// If both are in the order array, sort by their position
|
|
if (aIndex !== -1 && bIndex !== -1) return aIndex - bIndex;
|
|
// If only one is in the order array, it comes first
|
|
if (aIndex !== -1) return -1;
|
|
if (bIndex !== -1) return 1;
|
|
// If neither is in the order array, maintain original order
|
|
return 0;
|
|
});
|
|
|
|
// Split modules by position (top = main nav, bottom = above footer)
|
|
const topModules = sortedModules.filter(m => {
|
|
const state = moduleStates[m.id as keyof typeof moduleStates];
|
|
return !state || state.position === 'top';
|
|
});
|
|
|
|
const bottomModules = sortedModules.filter(m => {
|
|
const state = moduleStates[m.id as keyof typeof moduleStates];
|
|
return state && state.position === 'bottom';
|
|
});
|
|
|
|
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);
|
|
// In toggle mode, collapse sidebar when mouse leaves (if expanded)
|
|
if (sidebarMode === 'toggle' && !isCollapsed && !isMobileOpen) {
|
|
toggleCollapse();
|
|
}
|
|
}, 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">
|
|
{topModules.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>
|
|
{module.id === 'notifications' && unreadCount > 0 && (
|
|
<span className="nav-badge" aria-label={`${unreadCount} unread notifications`}>
|
|
{unreadCount > 99 ? '99+' : unreadCount}
|
|
</span>
|
|
)}
|
|
</NavLink>
|
|
))}
|
|
</div>
|
|
</nav>
|
|
|
|
<div className="sidebar-footer">
|
|
{bottomModules.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>
|
|
{module.id === 'notifications' && unreadCount > 0 && (
|
|
<span className="nav-badge" aria-label={`${unreadCount} unread notifications`}>
|
|
{unreadCount > 99 ? '99+' : unreadCount}
|
|
</span>
|
|
)}
|
|
</NavLink>
|
|
))}
|
|
|
|
{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>
|
|
)}
|
|
|
|
{themeInitialized && 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>
|
|
)}
|
|
|
|
{themeInitialized && 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>
|
|
</>
|
|
);
|
|
}
|