Files
app-service/frontend/src/components/Sidebar.tsx
matteoscrugli 3074f1685f Improve Features page ordering and mobile UI consistency
- 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>
2025-12-18 22:15:40 +01:00

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>
</>
);
}