Add i18 support
This commit is contained in:
@@ -1,23 +1,20 @@
|
||||
import React, { useState, useCallback, forwardRef } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuthUser, useIsAuthenticated, useAuthActions } from '../../../stores';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthUser, useIsAuthenticated } from '../../../stores';
|
||||
import { useTheme } from '../../../contexts/ThemeContext';
|
||||
import { useNotifications } from '../../../hooks/useNotifications';
|
||||
import { Button } from '../../ui';
|
||||
import { Avatar } from '../../ui';
|
||||
import { Badge } from '../../ui';
|
||||
import { Modal } from '../../ui';
|
||||
import { TenantSwitcher } from '../../ui/TenantSwitcher';
|
||||
import { ThemeToggle } from '../../ui/ThemeToggle';
|
||||
import { NotificationPanel } from '../../ui/NotificationPanel/NotificationPanel';
|
||||
import { CompactLanguageSelector } from '../../ui/LanguageSelector';
|
||||
import {
|
||||
Menu,
|
||||
Search,
|
||||
Bell,
|
||||
Settings,
|
||||
User,
|
||||
LogOut,
|
||||
X
|
||||
} from 'lucide-react';
|
||||
|
||||
@@ -43,10 +40,6 @@ export interface HeaderProps {
|
||||
* Show/hide theme toggle
|
||||
*/
|
||||
showThemeToggle?: boolean;
|
||||
/**
|
||||
* Show/hide user menu
|
||||
*/
|
||||
showUserMenu?: boolean;
|
||||
/**
|
||||
* Custom logo component
|
||||
*/
|
||||
@@ -67,17 +60,14 @@ export interface HeaderProps {
|
||||
|
||||
export interface HeaderRef {
|
||||
focusSearch: () => void;
|
||||
toggleUserMenu: () => void;
|
||||
closeUserMenu: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Header - Top navigation header with logo, user menu, notifications, theme toggle
|
||||
*
|
||||
* Header - Top navigation header with logo, notifications, theme toggle
|
||||
*
|
||||
* Features:
|
||||
* - Logo/brand area with responsive sizing
|
||||
* - Global search functionality with keyboard shortcuts
|
||||
* - User avatar with dropdown menu
|
||||
* - Notifications bell with badge count
|
||||
* - Theme toggle button (light/dark/system)
|
||||
* - Mobile hamburger menu integration
|
||||
@@ -90,16 +80,15 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
|
||||
showSearch = true,
|
||||
showNotifications = true,
|
||||
showThemeToggle = true,
|
||||
showUserMenu = true,
|
||||
logo,
|
||||
searchPlaceholder = 'Buscar...',
|
||||
searchPlaceholder,
|
||||
notificationCount = 0,
|
||||
onNotificationClick,
|
||||
}, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const user = useAuthUser();
|
||||
const isAuthenticated = useIsAuthenticated();
|
||||
const { logout } = useAuthActions();
|
||||
const { theme, resolvedTheme, setTheme } = useTheme();
|
||||
const {
|
||||
notifications,
|
||||
@@ -111,34 +100,22 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
|
||||
clearAll
|
||||
} = useNotifications();
|
||||
|
||||
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
|
||||
const [isSearchFocused, setIsSearchFocused] = useState(false);
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const [isNotificationPanelOpen, setIsNotificationPanelOpen] = useState(false);
|
||||
|
||||
const searchInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const defaultSearchPlaceholder = searchPlaceholder || t('common:forms.search_placeholder', 'Search...');
|
||||
|
||||
// Focus search input
|
||||
const focusSearch = useCallback(() => {
|
||||
searchInputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
// Toggle user menu
|
||||
const toggleUserMenu = useCallback(() => {
|
||||
setIsUserMenuOpen(prev => !prev);
|
||||
}, []);
|
||||
|
||||
// Close user menu
|
||||
const closeUserMenu = useCallback(() => {
|
||||
setIsUserMenuOpen(false);
|
||||
}, []);
|
||||
|
||||
// Expose ref methods
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
focusSearch,
|
||||
toggleUserMenu,
|
||||
closeUserMenu,
|
||||
}), [focusSearch, toggleUserMenu, closeUserMenu]);
|
||||
}), [focusSearch]);
|
||||
|
||||
// Handle search
|
||||
const handleSearchChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -158,11 +135,6 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
|
||||
searchInputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
// Handle logout
|
||||
const handleLogout = useCallback(async () => {
|
||||
await logout();
|
||||
setIsUserMenuOpen(false);
|
||||
}, [logout]);
|
||||
|
||||
// Keyboard shortcuts
|
||||
React.useEffect(() => {
|
||||
@@ -175,7 +147,6 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
|
||||
|
||||
// Escape to close menus
|
||||
if (e.key === 'Escape') {
|
||||
setIsUserMenuOpen(false);
|
||||
setIsNotificationPanelOpen(false);
|
||||
if (isSearchFocused) {
|
||||
searchInputRef.current?.blur();
|
||||
@@ -191,9 +162,6 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
|
||||
React.useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as Element;
|
||||
if (!target.closest('[data-user-menu]')) {
|
||||
setIsUserMenuOpen(false);
|
||||
}
|
||||
if (!target.closest('[data-notification-panel]')) {
|
||||
setIsNotificationPanelOpen(false);
|
||||
}
|
||||
@@ -287,7 +255,7 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
|
||||
onChange={handleSearchChange}
|
||||
onFocus={() => setIsSearchFocused(true)}
|
||||
onBlur={() => setIsSearchFocused(false)}
|
||||
placeholder={searchPlaceholder}
|
||||
placeholder={defaultSearchPlaceholder}
|
||||
className={clsx(
|
||||
'w-full pl-10 pr-12 py-2.5 text-sm',
|
||||
'bg-[var(--bg-secondary)] border border-[var(--border-primary)]',
|
||||
@@ -297,14 +265,14 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
|
||||
'placeholder:text-[var(--text-tertiary)]',
|
||||
'h-9'
|
||||
)}
|
||||
aria-label="Buscar en la aplicación"
|
||||
aria-label={t('common:accessibility.search', 'Search in the application')}
|
||||
/>
|
||||
{searchValue ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearSearch}
|
||||
className="absolute right-3 top-0 bottom-0 flex items-center p-1 hover:bg-[var(--bg-tertiary)] rounded-full transition-colors"
|
||||
aria-label="Limpiar búsqueda"
|
||||
aria-label={t('common:actions.clear', 'Clear search')}
|
||||
>
|
||||
<X className="h-3 w-3 text-[var(--text-tertiary)]" />
|
||||
</button>
|
||||
@@ -330,12 +298,15 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
|
||||
size="sm"
|
||||
onClick={focusSearch}
|
||||
className="md:hidden w-10 h-10 p-0 flex items-center justify-center"
|
||||
aria-label="Buscar"
|
||||
aria-label={t('common:actions.search', 'Search')}
|
||||
>
|
||||
<Search className="h-5 w-5" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Language selector */}
|
||||
<CompactLanguageSelector className="w-auto min-w-[60px]" />
|
||||
|
||||
{/* Theme toggle */}
|
||||
{showThemeToggle && (
|
||||
<ThemeToggle variant="button" size="md" />
|
||||
@@ -353,8 +324,8 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
|
||||
!isConnected && "opacity-50",
|
||||
isNotificationPanelOpen && "bg-[var(--bg-secondary)]"
|
||||
)}
|
||||
aria-label={`Notificaciones${unreadCount > 0 ? ` (${unreadCount})` : ''}${!isConnected ? ' - Desconectado' : ''}`}
|
||||
title={!isConnected ? 'Sin conexión en tiempo real' : undefined}
|
||||
aria-label={`${t('common:navigation.notifications', 'Notifications')}${unreadCount > 0 ? ` (${unreadCount})` : ''}${!isConnected ? ` - ${t('common:status.disconnected', 'Disconnected')}` : ''}`}
|
||||
title={!isConnected ? t('common:status.no_realtime_connection', 'No real-time connection') : undefined}
|
||||
aria-expanded={isNotificationPanelOpen}
|
||||
aria-haspopup="true"
|
||||
>
|
||||
@@ -385,93 +356,6 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* User menu */}
|
||||
{showUserMenu && user && (
|
||||
<div className="relative" data-user-menu>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={toggleUserMenu}
|
||||
className={clsx(
|
||||
"flex items-center gap-2 pl-2 pr-3 py-1.5 h-9 min-w-0 rounded-lg",
|
||||
"hover:bg-[var(--bg-secondary)] hover:ring-2 hover:ring-[var(--color-primary)]/20",
|
||||
"transition-all duration-200 ease-in-out",
|
||||
"active:scale-95",
|
||||
isUserMenuOpen && "bg-[var(--bg-secondary)] ring-2 ring-[var(--color-primary)]/20"
|
||||
)}
|
||||
aria-label="Menú de usuario"
|
||||
aria-expanded={isUserMenuOpen}
|
||||
aria-haspopup="true"
|
||||
>
|
||||
<Avatar
|
||||
src={user.avatar}
|
||||
alt={user.name}
|
||||
name={user.name}
|
||||
size="xs"
|
||||
className={clsx(
|
||||
"flex-shrink-0 transition-all duration-200",
|
||||
"hover:ring-2 hover:ring-[var(--color-primary)]/30",
|
||||
isUserMenuOpen && "ring-2 ring-[var(--color-primary)]/30"
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
{isUserMenuOpen && (
|
||||
<div className="absolute right-0 top-full mt-2 w-56 bg-[var(--bg-primary)] border border-[var(--border-primary)] rounded-lg shadow-lg py-2 z-[var(--z-dropdown)]">
|
||||
{/* User info */}
|
||||
<div className="px-4 py-3 border-b border-[var(--border-primary)] flex items-center gap-3">
|
||||
<Avatar
|
||||
src={user.avatar || undefined}
|
||||
alt={user.name}
|
||||
name={user.name}
|
||||
size="sm"
|
||||
className="flex-shrink-0"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-xs text-[var(--text-tertiary)] truncate">
|
||||
{user.email}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Menu items */}
|
||||
<div className="py-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
navigate('/app/settings/profile');
|
||||
setIsUserMenuOpen(false);
|
||||
}}
|
||||
className="w-full px-4 py-2 text-left text-sm flex items-center gap-3 hover:bg-[var(--bg-secondary)] transition-colors"
|
||||
>
|
||||
<User className="h-4 w-4" />
|
||||
Perfil
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
navigate('/app/settings');
|
||||
setIsUserMenuOpen(false);
|
||||
}}
|
||||
className="w-full px-4 py-2 text-left text-sm flex items-center gap-3 hover:bg-[var(--bg-secondary)] transition-colors"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
Configuración
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Logout */}
|
||||
<div className="border-t border-[var(--border-primary)] pt-1">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full px-4 py-2 text-left text-sm flex items-center gap-3 hover:bg-[var(--bg-secondary)] transition-colors text-[var(--color-error)]"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Cerrar Sesión
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import React, { useState, useCallback, forwardRef, useMemo } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useAuthUser, useIsAuthenticated } from '../../../stores';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthUser, useIsAuthenticated, useAuthActions } from '../../../stores';
|
||||
import { useCurrentTenantAccess } from '../../../stores/tenant.store';
|
||||
import { getNavigationRoutes, canAccessRoute, ROUTES } from '../../../router/routes.config';
|
||||
import { useSubscriptionAwareRoutes } from '../../../hooks/useSubscriptionAwareRoutes';
|
||||
import { Button } from '../../ui';
|
||||
import { Badge } from '../../ui';
|
||||
import { Tooltip } from '../../ui';
|
||||
import { Avatar } from '../../ui';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Package,
|
||||
@@ -28,7 +30,10 @@ import {
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Dot,
|
||||
Menu
|
||||
Menu,
|
||||
LogOut,
|
||||
MoreHorizontal,
|
||||
X
|
||||
} from 'lucide-react';
|
||||
|
||||
export interface SidebarProps {
|
||||
@@ -127,36 +132,71 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
|
||||
showCollapseButton = true,
|
||||
showFooter = true,
|
||||
}, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const user = useAuthUser();
|
||||
const isAuthenticated = useIsAuthenticated();
|
||||
const currentTenantAccess = useCurrentTenantAccess();
|
||||
const { logout } = useAuthActions();
|
||||
|
||||
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
|
||||
const [hoveredItem, setHoveredItem] = useState<string | null>(null);
|
||||
const [isProfileMenuOpen, setIsProfileMenuOpen] = useState(false);
|
||||
const sidebarRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
// Get subscription-aware navigation routes
|
||||
const baseNavigationRoutes = useMemo(() => getNavigationRoutes(), []);
|
||||
const { filteredRoutes: subscriptionFilteredRoutes } = useSubscriptionAwareRoutes(baseNavigationRoutes);
|
||||
|
||||
// Map route paths to translation keys
|
||||
const getTranslationKey = (routePath: string): string => {
|
||||
const pathMappings: Record<string, string> = {
|
||||
'/app/dashboard': 'navigation.dashboard',
|
||||
'/app/operations': 'navigation.operations',
|
||||
'/app/operations/procurement': 'navigation.procurement',
|
||||
'/app/operations/production': 'navigation.production',
|
||||
'/app/operations/pos': 'navigation.pos',
|
||||
'/app/bakery': 'navigation.bakery',
|
||||
'/app/bakery/recipes': 'navigation.recipes',
|
||||
'/app/database': 'navigation.data',
|
||||
'/app/database/inventory': 'navigation.inventory',
|
||||
'/app/analytics': 'navigation.analytics',
|
||||
'/app/analytics/forecasting': 'navigation.forecasting',
|
||||
'/app/analytics/sales': 'navigation.sales',
|
||||
'/app/analytics/performance': 'navigation.performance',
|
||||
'/app/ai': 'navigation.insights',
|
||||
'/app/communications': 'navigation.communications',
|
||||
'/app/communications/notifications': 'navigation.notifications',
|
||||
'/app/communications/alerts': 'navigation.alerts',
|
||||
};
|
||||
|
||||
return pathMappings[routePath] || routePath;
|
||||
};
|
||||
|
||||
// Convert routes to navigation items - memoized
|
||||
const navigationItems = useMemo(() => {
|
||||
const convertRoutesToItems = (routes: typeof subscriptionFilteredRoutes): NavigationItem[] => {
|
||||
return routes.map(route => ({
|
||||
id: route.path,
|
||||
label: route.title,
|
||||
path: route.path,
|
||||
icon: route.icon ? iconMap[route.icon] : undefined,
|
||||
requiredPermissions: route.requiredPermissions,
|
||||
requiredRoles: route.requiredRoles,
|
||||
children: route.children ? convertRoutesToItems(route.children) : undefined,
|
||||
}));
|
||||
return routes.map(route => {
|
||||
const translationKey = getTranslationKey(route.path);
|
||||
const label = translationKey.startsWith('navigation.')
|
||||
? t(`common:${translationKey}`, route.title)
|
||||
: route.title;
|
||||
|
||||
return {
|
||||
id: route.path,
|
||||
label,
|
||||
path: route.path,
|
||||
icon: route.icon ? iconMap[route.icon] : undefined,
|
||||
requiredPermissions: route.requiredPermissions,
|
||||
requiredRoles: route.requiredRoles,
|
||||
children: route.children ? convertRoutesToItems(route.children) : undefined,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
return customItems || convertRoutesToItems(subscriptionFilteredRoutes);
|
||||
}, [customItems, subscriptionFilteredRoutes]);
|
||||
}, [customItems, subscriptionFilteredRoutes, t]);
|
||||
|
||||
// Filter items based on user permissions - memoized to prevent infinite re-renders
|
||||
const visibleItems = useMemo(() => {
|
||||
@@ -219,6 +259,17 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
|
||||
}
|
||||
}, [navigate, onClose]);
|
||||
|
||||
// Handle logout
|
||||
const handleLogout = useCallback(async () => {
|
||||
await logout();
|
||||
setIsProfileMenuOpen(false);
|
||||
}, [logout]);
|
||||
|
||||
// Handle profile menu toggle
|
||||
const handleProfileMenuToggle = useCallback(() => {
|
||||
setIsProfileMenuOpen(prev => !prev);
|
||||
}, []);
|
||||
|
||||
// Scroll to item
|
||||
const scrollToItem = useCallback((path: string) => {
|
||||
const element = sidebarRef.current?.querySelector(`[data-path="${path}"]`);
|
||||
@@ -277,8 +328,12 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
|
||||
// Handle keyboard navigation and touch gestures
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isOpen && onClose) {
|
||||
onClose();
|
||||
if (e.key === 'Escape') {
|
||||
if (isProfileMenuOpen) {
|
||||
setIsProfileMenuOpen(false);
|
||||
} else if (isOpen && onClose) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -310,13 +365,28 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
document.addEventListener('touchstart', handleTouchStart, { passive: true });
|
||||
document.addEventListener('touchmove', handleTouchMove, { passive: true });
|
||||
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
document.removeEventListener('touchstart', handleTouchStart);
|
||||
document.removeEventListener('touchmove', handleTouchMove);
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
}, [isOpen, onClose, isProfileMenuOpen]);
|
||||
|
||||
// Handle click outside profile menu
|
||||
React.useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as Element;
|
||||
if (!target.closest('[data-profile-menu]')) {
|
||||
setIsProfileMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isProfileMenuOpen) {
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
return () => document.removeEventListener('click', handleClickOutside);
|
||||
}
|
||||
}, [isProfileMenuOpen]);
|
||||
|
||||
// Render submenu overlay for collapsed sidebar
|
||||
const renderSubmenuOverlay = (item: NavigationItem) => {
|
||||
@@ -383,14 +453,14 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
|
||||
>
|
||||
<div className="relative">
|
||||
{ItemIcon && (
|
||||
<ItemIcon
|
||||
<ItemIcon
|
||||
className={clsx(
|
||||
'flex-shrink-0 transition-colors duration-200',
|
||||
isCollapsed ? 'w-5 h-5' : 'w-4 h-4 mr-3',
|
||||
isActive
|
||||
? 'text-[var(--color-primary)]'
|
||||
isCollapsed && level === 0 ? 'w-5 h-5' : 'w-4 h-4 mr-3',
|
||||
isActive
|
||||
? 'text-[var(--color-primary)]'
|
||||
: 'text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)]'
|
||||
)}
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -466,7 +536,7 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
|
||||
isActive && 'bg-[var(--color-primary)]/10 border-l-2 border-[var(--color-primary)]',
|
||||
!isActive && 'hover:bg-[var(--bg-secondary)]',
|
||||
item.disabled && 'opacity-50 cursor-not-allowed',
|
||||
isCollapsed && !hasChildren ? 'flex justify-center items-center p-2 mx-1' : 'p-3'
|
||||
isCollapsed && level === 0 ? 'flex justify-center items-center p-3 aspect-square' : 'p-3'
|
||||
)}
|
||||
aria-expanded={hasChildren ? isExpanded : undefined}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
@@ -524,15 +594,99 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
|
||||
isCollapsed ? 'w-[var(--sidebar-collapsed-width)]' : 'w-[var(--sidebar-width)]',
|
||||
className
|
||||
)}
|
||||
aria-label="Navegación principal"
|
||||
aria-label={t('common:accessibility.menu', 'Main navigation')}
|
||||
>
|
||||
{/* Navigation */}
|
||||
<nav className={clsx('flex-1 overflow-y-auto', isCollapsed ? 'px-1 py-4' : 'p-4')}>
|
||||
<ul className={clsx(isCollapsed ? 'space-y-1' : 'space-y-2')}>
|
||||
<nav className={clsx('flex-1 overflow-y-auto', isCollapsed ? 'px-2 py-4' : 'p-4')}>
|
||||
<ul className={clsx(isCollapsed ? 'space-y-2' : 'space-y-2')}>
|
||||
{visibleItems.map(item => renderItem(item))}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
{/* Profile section */}
|
||||
{user && (
|
||||
<div className="border-t border-[var(--border-primary)]">
|
||||
<div className="relative" data-profile-menu>
|
||||
<button
|
||||
onClick={handleProfileMenuToggle}
|
||||
className={clsx(
|
||||
'w-full flex items-center transition-all duration-200',
|
||||
'hover:bg-[var(--bg-secondary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]/20',
|
||||
isCollapsed ? 'justify-center p-3' : 'p-4 gap-3',
|
||||
isProfileMenuOpen && 'bg-[var(--bg-secondary)]'
|
||||
)}
|
||||
aria-label="Menú de perfil"
|
||||
aria-expanded={isProfileMenuOpen}
|
||||
aria-haspopup="true"
|
||||
>
|
||||
<Avatar
|
||||
src={user.avatar}
|
||||
alt={user.name}
|
||||
name={user.name}
|
||||
size={isCollapsed ? "sm" : "md"}
|
||||
className="flex-shrink-0"
|
||||
/>
|
||||
{!isCollapsed && (
|
||||
<>
|
||||
<div className="flex-1 min-w-0 text-left">
|
||||
<div className="text-sm font-medium text-[var(--text-primary)] truncate">
|
||||
{user.name}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-tertiary)] truncate">
|
||||
{user.email}
|
||||
</div>
|
||||
</div>
|
||||
<MoreHorizontal className="w-4 h-4 text-[var(--text-tertiary)] flex-shrink-0" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Profile menu dropdown */}
|
||||
{isProfileMenuOpen && (
|
||||
<div className={clsx(
|
||||
'absolute bg-[var(--bg-primary)] border border-[var(--border-primary)] rounded-lg shadow-lg py-2 z-[var(--z-dropdown)]',
|
||||
isCollapsed ? 'left-full bottom-0 ml-2 min-w-[200px]' : 'left-4 right-4 bottom-full mb-2'
|
||||
)}>
|
||||
<div className="py-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
navigate('/app/settings/profile');
|
||||
setIsProfileMenuOpen(false);
|
||||
if (onClose) onClose();
|
||||
}}
|
||||
className="w-full px-4 py-2 text-left text-sm flex items-center gap-3 hover:bg-[var(--bg-secondary)] transition-colors"
|
||||
>
|
||||
<User className="h-4 w-4" />
|
||||
Perfil
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
navigate('/app/settings');
|
||||
setIsProfileMenuOpen(false);
|
||||
if (onClose) onClose();
|
||||
}}
|
||||
className="w-full px-4 py-2 text-left text-sm flex items-center gap-3 hover:bg-[var(--bg-secondary)] transition-colors"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
Configuración
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-[var(--border-primary)] pt-1">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full px-4 py-2 text-left text-sm flex items-center gap-3 hover:bg-[var(--bg-secondary)] transition-colors text-[var(--color-error)]"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Cerrar Sesión
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Collapse button */}
|
||||
{showCollapseButton && (
|
||||
<div className={clsx('border-t border-[var(--border-primary)]', isCollapsed ? 'p-2' : 'p-4')}>
|
||||
@@ -541,60 +695,57 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
|
||||
size="sm"
|
||||
onClick={onToggleCollapse}
|
||||
className={clsx(
|
||||
'w-full flex items-center justify-center',
|
||||
isCollapsed ? 'p-2' : 'px-4 py-2'
|
||||
'w-full flex items-center transition-colors duration-200',
|
||||
isCollapsed ? 'justify-center p-3 aspect-square' : 'justify-start px-4 py-2'
|
||||
)}
|
||||
aria-label={isCollapsed ? 'Expandir sidebar' : 'Contraer sidebar'}
|
||||
aria-label={isCollapsed ? t('common:actions.expand', 'Expand sidebar') : t('common:actions.collapse', 'Collapse sidebar')}
|
||||
>
|
||||
{isCollapsed ? (
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
) : (
|
||||
<>
|
||||
<ChevronLeft className="w-4 h-4 mr-2" />
|
||||
<span className="text-sm">Contraer</span>
|
||||
<span className="text-sm">{t('common:actions.collapse', 'Collapse')}</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
{showFooter && !isCollapsed && (
|
||||
<div className="p-4 border-t border-[var(--border-primary)]">
|
||||
<div className="text-xs text-[var(--text-tertiary)] text-center">
|
||||
Panadería IA v2.0.0
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
{/* Mobile Drawer */}
|
||||
<aside
|
||||
className={clsx(
|
||||
'fixed inset-y-0 left-0 w-[var(--sidebar-width)] max-w-[85vw]',
|
||||
'fixed inset-y-0 left-0 w-[var(--sidebar-width)] max-w-[90vw]',
|
||||
'bg-[var(--bg-primary)] border-r border-[var(--border-primary)]',
|
||||
'transition-transform duration-300 ease-in-out z-[var(--z-modal)]',
|
||||
'lg:hidden flex flex-col',
|
||||
'shadow-xl',
|
||||
'shadow-2xl backdrop-blur-sm',
|
||||
isOpen ? 'translate-x-0' : '-translate-x-full'
|
||||
)}
|
||||
aria-label="Navegación principal"
|
||||
aria-label={t('common:accessibility.menu', 'Main navigation')}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
{/* Mobile header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-[var(--border-primary)]">
|
||||
<h2 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
Navegación
|
||||
</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-gradient-to-br from-[var(--color-primary)] to-[var(--color-primary-dark)] rounded-lg flex items-center justify-center text-white font-bold text-sm flex-shrink-0">
|
||||
PI
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
Panadería IA
|
||||
</h2>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClose}
|
||||
className="p-2"
|
||||
className="p-2 hover:bg-[var(--bg-secondary)]"
|
||||
aria-label="Cerrar navegación"
|
||||
>
|
||||
<Menu className="w-4 h-4" />
|
||||
<X className="w-5 h-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -605,14 +756,82 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
{/* Footer */}
|
||||
{showFooter && (
|
||||
<div className="p-4 border-t border-[var(--border-primary)]">
|
||||
<div className="text-xs text-[var(--text-tertiary)] text-center">
|
||||
Panadería IA v2.0.0
|
||||
{/* Profile section - Mobile */}
|
||||
{user && (
|
||||
<div className="border-t border-[var(--border-primary)]">
|
||||
<div className="relative" data-profile-menu>
|
||||
<button
|
||||
onClick={handleProfileMenuToggle}
|
||||
className={clsx(
|
||||
'w-full flex items-center p-4 gap-3 transition-all duration-200',
|
||||
'hover:bg-[var(--bg-secondary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]/20',
|
||||
isProfileMenuOpen && 'bg-[var(--bg-secondary)]'
|
||||
)}
|
||||
aria-label="Menú de perfil"
|
||||
aria-expanded={isProfileMenuOpen}
|
||||
aria-haspopup="true"
|
||||
>
|
||||
<Avatar
|
||||
src={user.avatar}
|
||||
alt={user.name}
|
||||
name={user.name}
|
||||
size="md"
|
||||
className="flex-shrink-0"
|
||||
/>
|
||||
<div className="flex-1 min-w-0 text-left">
|
||||
<div className="text-sm font-medium text-[var(--text-primary)] truncate">
|
||||
{user.name}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-tertiary)] truncate">
|
||||
{user.email}
|
||||
</div>
|
||||
</div>
|
||||
<MoreHorizontal className="w-4 h-4 text-[var(--text-tertiary)] flex-shrink-0" />
|
||||
</button>
|
||||
|
||||
{/* Profile menu dropdown - Mobile */}
|
||||
{isProfileMenuOpen && (
|
||||
<div className="mx-4 mb-2 bg-[var(--bg-secondary)] border border-[var(--border-primary)] rounded-lg py-2">
|
||||
<div className="py-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
navigate('/app/settings/profile');
|
||||
setIsProfileMenuOpen(false);
|
||||
if (onClose) onClose();
|
||||
}}
|
||||
className="w-full px-4 py-2 text-left text-sm flex items-center gap-3 hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||
>
|
||||
<User className="h-4 w-4" />
|
||||
Perfil
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
navigate('/app/settings');
|
||||
setIsProfileMenuOpen(false);
|
||||
if (onClose) onClose();
|
||||
}}
|
||||
className="w-full px-4 py-2 text-left text-sm flex items-center gap-3 hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
Configuración
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-[var(--border-primary)] pt-1">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full px-4 py-2 text-left text-sm flex items-center gap-3 hover:bg-[var(--bg-tertiary)] transition-colors text-[var(--color-error)]"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Cerrar Sesión
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user