Files
bakery-ia/frontend/src/components/layout/Header/Header.tsx
2025-08-28 10:41:04 +02:00

454 lines
15 KiB
TypeScript

import React, { useState, useCallback, forwardRef } from 'react';
import { clsx } from 'clsx';
import { useAuthUser, useIsAuthenticated, useAuthActions } from '../../../stores';
import { useTheme } from '../../../contexts/ThemeContext';
import { Button } from '../../ui';
import { Avatar } from '../../ui';
import { Badge } from '../../ui';
import { Modal } from '../../ui';
import {
Menu,
Search,
Bell,
Sun,
Moon,
Computer,
Settings,
User,
LogOut,
ChevronDown,
X
} from 'lucide-react';
export interface HeaderProps {
className?: string;
/**
* Callback when menu button is clicked (for mobile sidebar toggle)
*/
onMenuClick?: () => void;
/**
* Whether the sidebar is currently collapsed (affects logo display)
*/
sidebarCollapsed?: boolean;
/**
* Show/hide search functionality
*/
showSearch?: boolean;
/**
* Show/hide notifications
*/
showNotifications?: boolean;
/**
* Show/hide theme toggle
*/
showThemeToggle?: boolean;
/**
* Show/hide user menu
*/
showUserMenu?: boolean;
/**
* Custom logo component
*/
logo?: React.ReactNode;
/**
* Custom search placeholder
*/
searchPlaceholder?: string;
/**
* Notification count
*/
notificationCount?: number;
/**
* Custom notification handler
*/
onNotificationClick?: () => void;
}
export interface HeaderRef {
focusSearch: () => void;
toggleUserMenu: () => void;
closeUserMenu: () => void;
}
/**
* Header - Top navigation header with logo, user menu, 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
* - Keyboard navigation support
*/
export const Header = forwardRef<HeaderRef, HeaderProps>(({
className,
onMenuClick,
sidebarCollapsed = false,
showSearch = true,
showNotifications = true,
showThemeToggle = true,
showUserMenu = true,
logo,
searchPlaceholder = 'Buscar...',
notificationCount = 0,
onNotificationClick,
}, ref) => {
const user = useAuthUser();
const isAuthenticated = useIsAuthenticated();
const { logout } = useAuthActions();
const { theme, resolvedTheme, setTheme } = useTheme();
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
const [isSearchFocused, setIsSearchFocused] = useState(false);
const [searchValue, setSearchValue] = useState('');
const [isThemeMenuOpen, setIsThemeMenuOpen] = useState(false);
const searchInputRef = React.useRef<HTMLInputElement>(null);
// 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]);
// Handle search
const handleSearchChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setSearchValue(e.target.value);
}, []);
const handleSearchSubmit = useCallback((e: React.FormEvent) => {
e.preventDefault();
if (searchValue.trim()) {
// TODO: Implement search functionality
console.log('Search:', searchValue);
}
}, [searchValue]);
const clearSearch = useCallback(() => {
setSearchValue('');
searchInputRef.current?.focus();
}, []);
// Handle logout
const handleLogout = useCallback(async () => {
await logout();
setIsUserMenuOpen(false);
}, [logout]);
// Keyboard shortcuts
React.useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Cmd/Ctrl + K for search
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
focusSearch();
}
// Escape to close menus
if (e.key === 'Escape') {
setIsUserMenuOpen(false);
setIsThemeMenuOpen(false);
if (isSearchFocused) {
searchInputRef.current?.blur();
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [focusSearch, isSearchFocused]);
// Close menus when clicking outside
React.useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Element;
if (!target.closest('[data-user-menu]')) {
setIsUserMenuOpen(false);
}
if (!target.closest('[data-theme-menu]')) {
setIsThemeMenuOpen(false);
}
};
document.addEventListener('click', handleClickOutside);
return () => document.removeEventListener('click', handleClickOutside);
}, []);
const themeIcons = {
light: Sun,
dark: Moon,
auto: Computer,
};
const ThemeIcon = themeIcons[theme] || Sun;
return (
<header
className={clsx(
'fixed top-0 left-0 right-0 h-[var(--header-height)]',
'bg-[var(--bg-primary)] border-b border-[var(--border-primary)]',
'flex items-center justify-between px-4 lg:px-6',
'transition-all duration-300 ease-in-out',
'backdrop-blur-sm bg-[var(--bg-primary)]/95',
className
)}
role="banner"
aria-label="Navegación principal"
>
{/* Left section */}
<div className="flex items-center gap-4 flex-1 min-w-0">
{/* Mobile menu button */}
<Button
variant="ghost"
size="sm"
onClick={onMenuClick}
className="lg:hidden p-2"
aria-label="Abrir menú de navegación"
>
<Menu className="h-5 w-5" />
</Button>
{/* Logo */}
<div className="flex items-center gap-3 min-w-0">
{logo || (
<>
<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">
PI
</div>
<h1 className={clsx(
'font-semibold text-[var(--text-primary)] transition-opacity duration-300',
'hidden sm:block',
sidebarCollapsed ? 'lg:block' : 'lg:hidden xl:block'
)}>
Panadería IA
</h1>
</>
)}
</div>
{/* Search */}
{showSearch && isAuthenticated && (
<form
onSubmit={handleSearchSubmit}
className="hidden md:flex items-center flex-1 max-w-md mx-4"
>
<div className="relative w-full">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-[var(--text-tertiary)]" />
<input
ref={searchInputRef}
type="text"
value={searchValue}
onChange={handleSearchChange}
onFocus={() => setIsSearchFocused(true)}
onBlur={() => setIsSearchFocused(false)}
placeholder={searchPlaceholder}
className={clsx(
'w-full pl-10 pr-10 py-2 text-sm',
'bg-[var(--bg-secondary)] border border-[var(--border-primary)]',
'rounded-lg transition-colors duration-200',
'focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]/20',
'focus:border-[var(--color-primary)]',
'placeholder:text-[var(--text-tertiary)]'
)}
aria-label="Buscar en la aplicación"
/>
{searchValue && (
<button
type="button"
onClick={clearSearch}
className="absolute right-3 top-1/2 transform -translate-y-1/2 p-1 hover:bg-[var(--bg-tertiary)] rounded-full transition-colors"
aria-label="Limpiar búsqueda"
>
<X className="h-3 w-3 text-[var(--text-tertiary)]" />
</button>
)}
<kbd className="absolute right-3 top-1/2 transform -translate-y-1/2 hidden lg:inline-flex items-center gap-1 text-xs text-[var(--text-tertiary)] font-mono">
K
</kbd>
</div>
</form>
)}
</div>
{/* Right section */}
{isAuthenticated && (
<div className="flex items-center gap-2">
{/* Mobile search */}
{showSearch && (
<Button
variant="ghost"
size="sm"
onClick={focusSearch}
className="md:hidden p-2"
aria-label="Buscar"
>
<Search className="h-5 w-5" />
</Button>
)}
{/* Theme toggle */}
{showThemeToggle && (
<div className="relative" data-theme-menu>
<Button
variant="ghost"
size="sm"
onClick={() => setIsThemeMenuOpen(!isThemeMenuOpen)}
className="p-2"
aria-label={`Tema actual: ${theme}`}
aria-expanded={isThemeMenuOpen}
aria-haspopup="true"
>
<ThemeIcon className="h-5 w-5" />
</Button>
{isThemeMenuOpen && (
<div className="absolute right-0 top-full mt-2 w-48 bg-[var(--bg-primary)] border border-[var(--border-primary)] rounded-lg shadow-lg py-1 z-[var(--z-dropdown)]">
{[
{ key: 'light' as const, label: 'Claro', icon: Sun },
{ key: 'dark' as const, label: 'Oscuro', icon: Moon },
{ key: 'auto' as const, label: 'Sistema', icon: Computer },
].map(({ key, label, icon: Icon }) => (
<button
key={key}
onClick={() => {
setTheme(key);
setIsThemeMenuOpen(false);
}}
className={clsx(
'w-full px-4 py-2 text-left text-sm flex items-center gap-3',
'hover:bg-[var(--bg-secondary)] transition-colors',
theme === key && 'bg-[var(--bg-secondary)] text-[var(--color-primary)]'
)}
>
<Icon className="h-4 w-4" />
{label}
</button>
))}
</div>
)}
</div>
)}
{/* Notifications */}
{showNotifications && (
<div className="relative">
<Button
variant="ghost"
size="sm"
onClick={onNotificationClick}
className="p-2 relative"
aria-label={`Notificaciones${notificationCount > 0 ? ` (${notificationCount})` : ''}`}
>
<Bell className="h-5 w-5" />
{notificationCount > 0 && (
<Badge
variant="error"
size="sm"
className="absolute -top-1 -right-1 min-w-[18px] h-[18px] text-xs flex items-center justify-center"
>
{notificationCount > 99 ? '99+' : notificationCount}
</Badge>
)}
</Button>
</div>
)}
{/* User menu */}
{showUserMenu && user && (
<div className="relative" data-user-menu>
<Button
variant="ghost"
size="sm"
onClick={toggleUserMenu}
className="flex items-center gap-2 pl-2 pr-3 py-1 h-auto"
aria-label="Menú de usuario"
aria-expanded={isUserMenuOpen}
aria-haspopup="true"
>
<Avatar
src={user.avatar_url}
alt={user.full_name}
fallback={user.full_name}
size="sm"
/>
<span className="hidden sm:block text-sm font-medium text-[var(--text-primary)] truncate max-w-[120px]">
{user.full_name}
</span>
<ChevronDown className="h-4 w-4 text-[var(--text-tertiary)]" />
</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-2 border-b border-[var(--border-primary)]">
<div className="text-sm font-medium text-[var(--text-primary)] truncate">
{user.full_name}
</div>
<div className="text-xs text-[var(--text-tertiary)] truncate">
{user.email}
</div>
</div>
{/* Menu items */}
<div className="py-1">
<button
onClick={() => {
// TODO: Navigate to 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={() => {
// TODO: Navigate to 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>
);
});
Header.displayName = 'Header';