ADD new frontend
This commit is contained in:
454
frontend/src/components/layout/Header/Header.tsx
Normal file
454
frontend/src/components/layout/Header/Header.tsx
Normal file
@@ -0,0 +1,454 @@
|
||||
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';
|
||||
Reference in New Issue
Block a user