454 lines
15 KiB
TypeScript
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'; |