Files
MyClub/frontend/src/components/Navbar.tsx
T
2025-10-24 18:15:36 +02:00

924 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useState, useMemo } from 'react';
import {
Box,
Flex,
Button,
useColorModeValue,
useColorMode,
IconButton,
Avatar,
Menu,
MenuButton,
MenuList,
MenuItem,
Text,
HStack,
Tooltip,
useDisclosure,
Drawer,
DrawerBody,
DrawerHeader,
DrawerOverlay,
DrawerContent,
DrawerCloseButton,
VStack,
Divider,
Container,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalCloseButton,
InputGroup,
InputLeftElement,
Input,
useToast,
} from '@chakra-ui/react';
import { MoonIcon, SunIcon, HamburgerIcon, EditIcon, ChevronDownIcon } from '@chakra-ui/icons';
import { FaFacebook, FaInstagram, FaYoutube, FaPhotoVideo, FaExternalLinkAlt, FaShoppingBag, FaCamera, FaSearch } from 'react-icons/fa';
import { Link as RouterLink, useLocation, useNavigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { usePublicSettings } from '../hooks/usePublicSettings';
import { useClubTheme } from '../contexts/ClubThemeContext';
import { Image } from '@chakra-ui/react';
import { getCategories, Category } from '../services/public';
import { FaSearch as FaSearchIcon } from 'react-icons/fa';
import { getNavigationItems, NavigationItem, seedDefaultNavigation } from '../services/navigation';
import { getEvents } from '../services/eventService';
import { getPlayers } from '../services/public';
import { getArticles } from '../services/articles';
import { getCachedYouTube } from '../services/youtube';
import { getZoneramaManifestWithFallbacks } from '../services/zonerama';
import { getMyNewsletterToken } from '../services/public/newsletter';
import { API_URL } from '../services/api';
type NavLink = { label: string; to?: string; items?: { label: string; to: string }[]; external?: boolean };
// Minimal normalization for social URLs so admins can input @handle or domain-less usernames
const normalizeSocialUrl = (network: 'facebook' | 'instagram' | 'youtube', raw?: string | null): string | null => {
let v = String(raw || '').trim();
if (!v) return null;
v = v.replace(/\s+/g, '');
if (v.startsWith('@')) {
const handle = v.slice(1);
if (network === 'facebook') return `https://www.facebook.com/${handle}`;
if (network === 'instagram') return `https://www.instagram.com/${handle}`;
if (network === 'youtube') return `https://www.youtube.com/@${handle}`;
}
if (!/^https?:\/\//i.test(v) && !v.includes('/') && !v.includes('.')) {
if (network === 'facebook') return `https://www.facebook.com/${v}`;
if (network === 'instagram') return `https://www.instagram.com/${v}`;
if (network === 'youtube') return `https://www.youtube.com/@${v}`;
}
if (!/^https?:\/\//i.test(v)) {
if (/^facebook\.com\//i.test(v)) return `https://www.${v}`;
if (/^instagram\.com\//i.test(v)) return `https://www.${v}`;
if (/^youtube\.com\//i.test(v)) return `https://www.${v}`;
}
return v;
};
// Mobile menu component
const MobileMenu = ({ isOpen, onClose, isAdmin, isAuthenticated, menuBg, dividerColor, settings, categories, galleryHref, galleryLabel, hasTables, hasActivities, hasPlayers, hasArticles, hasVideos, hasGallery, dynamicNavItems, navLoading }: {
isOpen: boolean;
onClose: () => void;
isAdmin: boolean;
isAuthenticated: boolean;
menuBg: string;
dividerColor: string;
settings?: any;
categories?: Category[] | null;
galleryHref?: string | null;
galleryLabel?: string;
hasTables?: boolean | null;
hasActivities?: boolean | null;
hasPlayers?: boolean | null;
hasArticles?: boolean | null;
hasVideos?: boolean | null;
hasGallery?: boolean | null;
dynamicNavItems: NavigationItem[];
navLoading: boolean;
}) => (
<Drawer isOpen={isOpen} placement="left" onClose={onClose}>
<DrawerOverlay />
<DrawerContent bg={menuBg}>
<DrawerCloseButton />
<DrawerHeader borderBottomWidth="1px" borderColor="border.subtle">Menu</DrawerHeader>
<DrawerBody>
<VStack align="stretch" spacing={2}>
{/* Dynamic navigation items in mobile */}
{(!navLoading && dynamicNavItems.length > 0) ? (
// Use dynamic navigation
dynamicNavItems.map((item, idx) => {
const linkIsExternal = item.type === 'external';
const hasChildren = item.type === 'dropdown' && item.children && item.children.length > 0;
const linkProps = linkIsExternal ? { href: item.url } : { to: item.url || '/' };
const Comp: any = linkIsExternal ? 'a' : RouterLink;
return (
<React.Fragment key={item.id || idx}>
<Button
as={Comp}
{...linkProps}
target={linkIsExternal ? '_blank' : undefined}
rel={linkIsExternal ? 'noreferrer' : undefined}
variant="ghost"
justifyContent="flex-start"
fontWeight={hasChildren ? 'bold' : 'normal'}
>
{item.label}
</Button>
{/* Render children for dropdown items */}
{hasChildren && (
<VStack align="stretch" pl={4} spacing={1}>
{item.children!.map((child) => {
const childIsExternal = child.type === 'external';
const childLinkProps = childIsExternal ? { href: child.url } : { to: child.url || '/' };
const ChildComp: any = childIsExternal ? 'a' : RouterLink;
return (
<Button
key={child.id}
as={ChildComp}
{...(childLinkProps as any)}
variant="ghost"
justifyContent="flex-start"
fontWeight="normal"
size="sm"
>
{child.label}
</Button>
);
})}
</VStack>
)}
</React.Fragment>
);
})
) : (
// Fallback to hardcoded navigation
<>
<Button as={RouterLink} to="/" variant="ghost" justifyContent="flex-start">Domů</Button>
{(settings?.show_about_in_nav ?? true) && (
<Button as={RouterLink} to="/o-klubu" variant="ghost" justifyContent="flex-start">O klubu</Button>
)}
<Button as={RouterLink} to="/kalendar" variant="ghost" justifyContent="flex-start">Kalendář</Button>
<Button as={RouterLink} to="/zapasy" variant="ghost" justifyContent="flex-start">Zápasy</Button>
{hasActivities !== false && (
<Button as={RouterLink} to="/aktivity" variant="ghost" justifyContent="flex-start">Aktivity</Button>
)}
{hasPlayers !== false && (
<Button as={RouterLink} to="/hraci" variant="ghost" justifyContent="flex-start">Hráči</Button>
)}
{hasTables ? (
<Button as={RouterLink} to="/tabulky" variant="ghost" justifyContent="flex-start">Tabulky</Button>
) : null}
{Array.isArray(settings?.custom_nav) && settings.custom_nav.length > 0 && settings.custom_nav.map((item: any, idx: number) => {
const customLinkIsExternal = typeof item?.url === 'string' && /^https?:\/\//i.test(item.url);
const linkProps = customLinkIsExternal ? { href: item.url } : { to: item.url || '/' };
const Comp: any = customLinkIsExternal ? 'a' : RouterLink;
return (
<Button
key={`custom-nav-${idx}-${item?.label || 'link'}`}
as={Comp}
{...linkProps}
target={customLinkIsExternal ? '_blank' : undefined}
rel={customLinkIsExternal ? 'noreferrer' : undefined}
variant="ghost"
justifyContent="flex-start"
>
{item?.label || 'Stránka'}
</Button>
);
})}
{hasArticles !== false && (
<>
<Button as={RouterLink} to="/blog" variant="ghost" justifyContent="flex-start" fontWeight="bold">Články</Button>
{Array.isArray(categories) && categories.length > 0 && (
<VStack align="stretch" pl={4} spacing={1}>
{categories.map((cat: any) => {
const catIsExternal = typeof cat.url === 'string' && /^https?:\/\//i.test(cat.url);
const catHref = cat.url || (cat.slug ? `/blog?category=${encodeURIComponent(cat.slug)}` : '/blog');
const catLinkProps = catIsExternal ? { href: catHref } : { to: catHref };
return (
<Button key={cat.slug || cat.name} as={catIsExternal ? 'a' : RouterLink} {...(catLinkProps as any)} variant="ghost" justifyContent="flex-start" fontWeight="normal" size="sm">
{cat.name}
</Button>
);
})}
</VStack>
)}
</>
)}
{hasVideos !== false && (
<Button as={RouterLink} to="/videa" variant="ghost" justifyContent="flex-start">Videa</Button>
)}
<Button as={RouterLink} to="/hledat" variant="ghost" justifyContent="flex-start">Hledat</Button>
{hasGallery !== false && (
<Button as={RouterLink} to="/galerie" variant="ghost" justifyContent="flex-start">{galleryLabel || 'Fotogalerie'}</Button>
)}
{settings?.shop_url && (
<Button as="a" href={settings.shop_url} target="_blank" rel="noreferrer" variant="ghost" justifyContent="flex-start">Fanshop</Button>
)}
<Button as={RouterLink} to="/sponzori" variant="ghost" justifyContent="flex-start">Sponzoři</Button>
<Button as={RouterLink} to="/kontakt" variant="ghost" justifyContent="flex-start">Kontakt</Button>
</>
)}
{isAdmin && (
<>
<Divider my={2} borderColor={dividerColor} />
<Text fontWeight="bold" mt={2} color={dividerColor}>Administrace</Text>
<Button as={RouterLink} to="/admin" variant="ghost" justifyContent="flex-start" colorScheme="blue">
Administrace
</Button>
</>
)}
{!isAuthenticated && (
<>
<Divider my={2} borderColor={dividerColor} />
<Button as={RouterLink} to="/login" colorScheme="blue" justifyContent="flex-start">
Přihlásit se
</Button>
<Button as={RouterLink} to="/register" variant="outline" justifyContent="flex-start">
Registrovat se
</Button>
</>
)}
</VStack>
</DrawerBody>
</DrawerContent>
</Drawer>
);
const Navbar: React.FC<{ fullWidth?: boolean }> = ({ fullWidth = false }) => {
const { colorMode, toggleColorMode } = useColorMode();
const { isAuthenticated, logout, user } = useAuth();
const { isOpen, onOpen, onClose } = useDisclosure();
const { isOpen: isSearchOpen, onOpen: onSearchOpen, onClose: onSearchClose } = useDisclosure();
const isAdmin = user?.role === 'admin';
const { data: settings } = usePublicSettings();
const theme = useClubTheme();
const location = useLocation();
const navigate = useNavigate();
const toast = useToast();
const menuBg = useColorModeValue('white', '#0f1115');
const dividerColor = useColorModeValue('gray.600', 'gray.300');
const hoverBg = useColorModeValue('blackAlpha.100', 'whiteAlpha.200');
const activeBg = useColorModeValue('blackAlpha.50', 'whiteAlpha.100');
const activeTextColor = useColorModeValue('brand.primary', 'brand.accent');
const navTextColor = useColorModeValue('gray.700', 'gray.200');
const topBarBg = useColorModeValue('gray.50', 'blackAlpha.500');
const [scrolled, setScrolled] = useState(false);
const [hasTables, setHasTables] = useState<boolean | null>(null);
const [hasActivities, setHasActivities] = useState<boolean | null>(null);
const [hasPlayers, setHasPlayers] = useState<boolean | null>(null);
const [hasArticles, setHasArticles] = useState<boolean | null>(null);
const [hasVideos, setHasVideos] = useState<boolean | null>(null);
const [hasGallery, setHasGallery] = useState<boolean | null>(null);
const [dynamicNavItems, setDynamicNavItems] = useState<NavigationItem[]>([]);
const [navLoading, setNavLoading] = useState(true);
const containerMaxW = fullWidth ? 'full' as const : '7xl' as const;
// Search modal state
const [query, setQuery] = useState('');
const submitSearch = () => {
const text = query.trim();
if (!text) return;
onSearchClose();
setQuery('');
navigate(`/hledat?q=${encodeURIComponent(text)}`);
};
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 8);
onScroll();
window.addEventListener('scroll', onScroll, { passive: true } as any);
return () => window.removeEventListener('scroll', onScroll as any);
}, []);
// Open newsletter preferences for logged-in user (fetch token and redirect)
const openMyNewsletterPrefs = async () => {
try {
const { token } = await getMyNewsletterToken();
navigate(`/newsletter/preferences?token=${encodeURIComponent(token)}`);
} catch (err: any) {
toast({
title: 'Chyba',
description: 'Nelze načíst odkaz na emailové preference. Zkuste to prosím znovu.',
status: 'error',
duration: 4000,
});
}
};
// Also set document title to club name ASAP (SEO component will refine further)
useEffect(() => {
const name = settings?.club_name || theme.name;
if (name && typeof document !== 'undefined') {
document.title = name;
}
}, [settings?.club_name, theme.name]);
// Set favicon/logo in head for fan pages (SPA)
useEffect(() => {
try {
let url = settings?.club_logo_url || theme.logoUrl || '/dist/img/logo-club-empty.svg';
if (!url) return;
// Normalize relative upload paths to API origin so favicon resolves on all pages
try {
const apiOrigin = new URL(API_URL, typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000').origin;
if (/^\/.+/.test(url) && !/^https?:\/\//i.test(url)) {
// If starts with /uploads or any absolute path, prefix API origin
url = apiOrigin + url;
}
} catch {}
const setIcon = (rel: string) => {
let link = document.querySelector<HTMLLinkElement>(`link[rel="${rel}"]`);
if (!link) {
link = document.createElement('link');
link.rel = rel as any;
document.head.appendChild(link);
}
link.href = url;
// Try to hint type if svg
if (url.endsWith('.svg')) link.type = 'image/svg+xml';
};
setIcon('icon');
setIcon('shortcut icon');
} catch {}
}, [settings?.club_logo_url, theme.logoUrl]);
// gallery link (generic first, fallback to zonerama)
const galleryHref = settings?.gallery_url || settings?.zonerama_url;
const galleryLabel = settings?.gallery_label || 'Fotogalerie';
// Load dynamic navigation from API
useEffect(() => {
let active = true;
(async () => {
try {
const items = await getNavigationItems();
if (active && Array.isArray(items)) {
// Filter out admin-only navigation items for public display
const publicItems = items.filter(item => !item.requires_admin);
// Auto-seed if navigation is empty (only if user is authenticated as admin)
if (publicItems.length === 0 && isAdmin) {
try {
console.log('Navigation empty, auto-seeding...');
await seedDefaultNavigation();
const newItems = await getNavigationItems();
if (active && Array.isArray(newItems)) {
const publicNewItems = newItems.filter(item => !item.requires_admin);
setDynamicNavItems(publicNewItems);
}
} catch (seedError) {
console.error('Auto-seed failed:', seedError);
// Continue with empty navigation
}
} else {
setDynamicNavItems(publicItems);
}
}
} catch (error) {
console.error('Failed to load navigation:', error);
} finally {
if (active) setNavLoading(false);
}
})();
return () => { active = false };
}, [isAdmin]);
// categories: prefer API, fallback to settings.categories
const [navCategories, setNavCategories] = useState<Category[] | null>(null);
useEffect(() => {
let active = true;
(async () => {
try {
const cats = await getCategories();
if (active && Array.isArray(cats) && cats.length > 0) {
setNavCategories(cats);
} else if (active && Array.isArray(settings?.categories)) {
setNavCategories(settings!.categories as any);
}
} catch {
if (active && Array.isArray(settings?.categories)) {
setNavCategories(settings!.categories as any);
}
}
})();
return () => { active = false };
}, [settings?.categories]);
// Determine if there is any table data available (prefetch snapshot)
useEffect(() => {
let disposed = false;
const resolveBackendUrl = (path: string) => {
try {
if (/^https?:\/\//i.test(path)) return path;
if (path.startsWith('/cache') || path.startsWith('/uploads') || path.startsWith('/api/')) {
const origin = new URL(API_URL, typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000').origin;
return new URL(path, origin).toString();
}
return path;
} catch { return path; }
};
(async () => {
try {
const res = await fetch(resolveBackendUrl('/cache/prefetch/facr_tables.json'), { cache: 'no-cache' });
if (!res.ok) { if (!disposed) setHasTables(false); return; }
const json = await res.json();
const anyRows = Array.isArray(json?.competitions) && json.competitions.some((c: any) => Array.isArray(c?.table?.overall) && c.table.overall.length > 0);
if (!disposed) setHasTables(!!anyRows);
} catch {
if (!disposed) setHasTables(false);
}
})();
return () => { disposed = true; };
}, []);
// Determine if there are any activities/events available
useEffect(() => {
let disposed = false;
(async () => {
try {
const events = await getEvents();
if (!disposed) setHasActivities(Array.isArray(events) && events.length > 0);
} catch {
if (!disposed) setHasActivities(false);
}
})();
return () => { disposed = true; };
}, []);
// Determine if there are any players available
useEffect(() => {
let disposed = false;
(async () => {
try {
const players = await getPlayers();
if (!disposed) setHasPlayers(Array.isArray(players) && players.length > 0);
} catch {
if (!disposed) setHasPlayers(false);
}
})();
return () => { disposed = true; };
}, []);
// Determine if there are any articles available
useEffect(() => {
let disposed = false;
(async () => {
try {
const result = await getArticles({ page: 1, page_size: 1, published: true });
if (!disposed) setHasArticles(result.total > 0);
} catch {
if (!disposed) setHasArticles(false);
}
})();
return () => { disposed = true; };
}, []);
// Determine if there are any videos available
useEffect(() => {
let disposed = false;
(async () => {
try {
const youtube = await getCachedYouTube();
if (!disposed) setHasVideos(youtube && Array.isArray(youtube.videos) && youtube.videos.length > 0);
} catch {
if (!disposed) setHasVideos(false);
}
})();
return () => { disposed = true; };
}, []);
// Determine if there is any gallery content available
useEffect(() => {
let disposed = false;
(async () => {
try {
const manifest = await getZoneramaManifestWithFallbacks();
if (!disposed) setHasGallery(Array.isArray(manifest) && manifest.length > 0);
} catch {
if (!disposed) setHasGallery(false);
}
})();
return () => { disposed = true; };
}, []);
const isPathActive = (to?: string) => {
if (!to) return false;
// Active when current pathname starts with target (handles subroutes)
return location.pathname === to || location.pathname.startsWith(to + '/');
};
// Convert NavigationItem to NavLink format
const convertToNavLink = (item: NavigationItem): NavLink => {
const link: NavLink = {
label: item.label,
to: item.url || '#',
external: item.type === 'external',
};
// Add children for dropdown items
if (item.type === 'dropdown' && item.children && item.children.length > 0) {
link.items = item.children.map(child => ({
label: child.label,
to: child.url || '#',
}));
}
return link;
};
// Build categories as items for Články dropdown (fallback)
const categoryItems = useMemo(() => {
const source = Array.isArray(navCategories) && navCategories.length > 0 ? navCategories : [];
return source.map((cat: any) => ({
label: cat.name,
to: cat.url || (cat.slug ? `/blog?category=${encodeURIComponent(cat.slug)}` : '/blog')
}));
}, [navCategories]);
// Use dynamic navigation if available, otherwise fallback to hardcoded
let NAV_LINKS: NavLink[] = useMemo(() => {
if (!navLoading && dynamicNavItems.length > 0) {
// Use dynamic navigation from API
const navLinks = dynamicNavItems.map(convertToNavLink);
// Inject categories into "Články" or "Blog" navigation item if it exists
if (categoryItems.length > 0) {
const articlesIndex = navLinks.findIndex(link =>
link.label === 'Články' ||
link.label === 'Blog' ||
link.to === '/blog'
);
if (articlesIndex !== -1) {
// Add or merge categories into the articles navigation item
navLinks[articlesIndex] = {
...navLinks[articlesIndex],
items: categoryItems
};
}
}
return navLinks;
}
// Fallback to hardcoded navigation
let links: NavLink[] = [
{ label: 'Domů', to: '/' },
...(settings?.show_about_in_nav === false ? [] : [{ label: 'O klubu', to: '/o-klubu' } as NavLink]),
{ label: 'Kalendář', to: '/kalendar' },
{ label: 'Zápasy', to: '/zapasy' },
{ label: 'Aktivity', to: '/aktivity' },
{ label: 'Hráči', to: '/hraci' },
{ label: 'Tabulky', to: '/tabulky' },
// Články with categories as subcategories
categoryItems.length > 0
? { label: 'Články', to: '/blog', items: categoryItems }
: { label: 'Články', to: '/blog' },
{ label: 'Videa', to: '/videa' },
{ label: galleryLabel, to: '/galerie' },
...(settings?.shop_url ? [{ label: 'Fanshop', to: settings.shop_url, external: true } as NavLink] : []),
{ label: 'Sponzoři', to: '/sponzori' },
{ label: 'Kontakt', to: '/kontakt' },
];
// Inject custom pages from settings.custom_nav (label + url + external?)
const customNav = Array.isArray((settings as any)?.custom_nav) ? ((settings as any).custom_nav as any[]) : [];
if (customNav.length > 0) {
const mapped: NavLink[] = customNav.map((it) => ({ label: String(it.label || 'Stránka'), to: String(it.url || '#'), external: Boolean(it.external) }));
const insertIdx = links.findIndex((n) => n.label === 'Tabulky');
if (insertIdx >= 0) {
links = [...links.slice(0, insertIdx + 1), ...mapped, ...links.slice(insertIdx + 1)];
} else {
links = [...links, ...mapped];
}
}
// Hide Tabulky when there is no table data
if (hasTables === false) {
links = links.filter((n) => n.label !== 'Tabulky');
}
// Hide Aktivity when there are no activities
if (hasActivities === false) {
links = links.filter((n) => n.label !== 'Aktivity');
}
// Hide Hráči when there are no players
if (hasPlayers === false) {
links = links.filter((n) => n.label !== 'Hráči');
}
// Hide Články when there are no articles
if (hasArticles === false) {
links = links.filter((n) => n.label !== 'Články');
}
// Hide Videa when there are no videos
if (hasVideos === false) {
links = links.filter((n) => n.label !== 'Videa');
}
// Hide Fotogalerie when there is no gallery content
if (hasGallery === false) {
links = links.filter((n) => n.label === galleryLabel).length === 0 ? links : links.filter((n) => n.label !== galleryLabel);
}
return links;
}, [dynamicNavItems, navLoading, settings, categoryItems, hasTables, hasActivities, hasPlayers, hasArticles, hasVideos, hasGallery, galleryLabel]);
return (
<Box position="sticky" top={0} zIndex={1000}>
{/* Top bar with socials and quick external links */}
{(settings?.facebook_url || settings?.instagram_url || settings?.youtube_url || settings?.shop_url) && (
<Box bg={topBarBg} borderBottomWidth="1px" borderColor="border.subtle" py={1}>
<Container maxW={containerMaxW}>
<Flex align="center" justify="space-between" gap={2}>
<HStack spacing={2}>
{settings?.shop_url && (
<Button as="a" href={settings.shop_url} target="_blank" rel="noreferrer" variant="link" size="xs" leftIcon={<FaShoppingBag />}>
Fanshop
</Button>
)}
</HStack>
<HStack spacing={1}>
{normalizeSocialUrl('facebook', settings?.facebook_url) && (
<IconButton as="a" href={normalizeSocialUrl('facebook', settings?.facebook_url) || undefined} target="_blank" rel="noreferrer" aria-label="Facebook" icon={<FaFacebook />} variant="ghost" size="xs" />
)}
{normalizeSocialUrl('instagram', settings?.instagram_url) && (
<IconButton as="a" href={normalizeSocialUrl('instagram', settings?.instagram_url) || undefined} target="_blank" rel="noreferrer" aria-label="Instagram" icon={<FaInstagram />} variant="ghost" size="xs" />
)}
{normalizeSocialUrl('youtube', settings?.youtube_url) && (
<IconButton as="a" href={normalizeSocialUrl('youtube', settings?.youtube_url) || undefined} target="_blank" rel="noreferrer" aria-label="YouTube" icon={<FaYoutube />} variant="ghost" size="xs" />
)}
</HStack>
</Flex>
</Container>
</Box>
)}
{/* Main Nav Bar */}
<Box
bg={useColorModeValue('rgba(255,255,255,0.9)', 'rgba(15,17,21,0.85)')}
backdropFilter="saturate(180%) blur(10px)"
borderBottomWidth="1px"
borderColor="border.subtle"
boxShadow={scrolled ? 'sm' : 'none'}
transition="box-shadow 0.2s ease, background-color 0.2s ease, backdrop-filter 0.2s ease"
>
<MobileMenu isOpen={isOpen} onClose={onClose} isAdmin={isAdmin} isAuthenticated={isAuthenticated} menuBg={menuBg} dividerColor={dividerColor} settings={settings} categories={navCategories} galleryHref={galleryHref} galleryLabel={galleryLabel} hasTables={hasTables} hasActivities={hasActivities} hasPlayers={hasPlayers} hasArticles={hasArticles} hasVideos={hasVideos} hasGallery={hasGallery} dynamicNavItems={dynamicNavItems} navLoading={navLoading} />
<Container maxW={containerMaxW}>
<Flex h={16} alignItems="center" justifyContent="space-between">
<HStack spacing={4} alignItems="center">
{/* Club Logo only */}
<HStack as={RouterLink} to="/" spacing={3} align="center">
{(settings?.club_logo_url || theme.logoUrl) && (
<Image
src={settings?.club_logo_url || theme.logoUrl}
alt={settings?.club_name || theme.name || 'Logo'}
boxSize={{ base: '36px', md: '40px' }}
objectFit="contain"
borderRadius="full"
borderWidth="2px"
borderColor="brand.primary"
style={{
padding: (settings?.club_logo_url || theme.logoUrl)?.includes('logoapi.sportcreative.eu') ? '4px' : '0px',
boxSizing: 'border-box'
}}
/>
)}
</HStack>
{/* Desktop navigation with hover dropdowns */}
<HStack as="nav" spacing={1} display={{ base: 'none', lg: 'flex' }} ml={4}>
{NAV_LINKS.map((nav) => {
const commonProps = {
variant: 'ghost' as const,
size: 'sm' as const,
px: 3,
_hover: { bg: hoverBg, transform: 'translateY(-1px)' },
fontWeight: isPathActive(nav.to) ? '700' : '600',
color: isPathActive(nav.to) ? activeTextColor : navTextColor,
bg: isPathActive(nav.to) ? activeBg : 'transparent',
transition: 'all 0.2s',
};
// Handle items with dropdown (like Články with categories)
if (nav.items && nav.items.length > 0) {
return (
<HoverMenu key={nav.label} label={nav.label} items={nav.items} isActive={isPathActive(nav.to)} />
);
}
if (nav.external && nav.to) {
return (
<Button key={nav.label} as="a" href={nav.to} target="_blank" rel="noreferrer" rightIcon={<FaExternalLinkAlt />} {...commonProps}>
{nav.label}
</Button>
);
}
return (
<Button key={nav.label} as={RouterLink} to={nav.to || '#'} {...commonProps}>
{nav.label}
</Button>
);
})}
</HStack>
</HStack>
<Flex alignItems="center">
{/* Mobile menu button */}
<IconButton
display={{ base: 'flex', md: 'none' }}
onClick={onOpen}
icon={<HamburgerIcon />}
aria-label="Otevřít menu"
variant="ghost"
mr={2}
/>
{/* Space reserved (socials moved to top bar) */}
<Box display={{ base: 'none', md: 'flex' }} mr={2} />
{/* Search button */}
<Tooltip label="Hledat" hasArrow>
<IconButton
aria-label="Hledat"
icon={<FaSearch />}
size="sm"
mr={2}
variant="ghost"
onClick={onSearchOpen}
/>
</Tooltip>
{/* Admin edit button */}
{isAdmin && (
<Tooltip label="Správa obsahu" hasArrow>
<IconButton
as={RouterLink}
to="/admin"
aria-label="Správa obsahu"
icon={<EditIcon />}
size="sm"
mr={2}
colorScheme="blue"
variant="ghost"
/>
</Tooltip>
)}
{/* Color mode toggle */}
<IconButton
size="md"
fontSize="lg"
aria-label="Přepnout barevné téma"
variant="ghost"
color="current"
onClick={toggleColorMode}
icon={colorMode === 'light' ? <MoonIcon /> : <SunIcon />}
/>
{/* Auth buttons (desktop) */}
{!isAuthenticated && (
<>
<Button
as={RouterLink}
to="/register"
size="sm"
variant="outline"
display={{ base: 'none', md: 'inline-flex' }}
ml={2}
mr={2}
>
Registrovat se
</Button>
<Button
as={RouterLink}
to="/login"
size="sm"
colorScheme="blue"
display={{ base: 'none', md: 'inline-flex' }}
mr={2}
>
Přihlásit se
</Button>
</>
)}
{isAuthenticated && (
<Menu>
<MenuButton
as={Button}
rounded="full"
variant="link"
cursor="pointer"
minW={0}
ml={2}
>
<Avatar size="sm" name={user?.name || 'Uživatel'} />
</MenuButton>
<MenuList>
<MenuItem as={RouterLink} to="/admin/nastaveni">Můj účet</MenuItem>
<MenuItem onClick={openMyNewsletterPrefs}>Emailové preference</MenuItem>
<MenuItem as={RouterLink} to="/profil/nastaveni">Nastavení stránky</MenuItem>
{isAdmin && <MenuItem as={RouterLink} to="/admin">Administrace</MenuItem>}
<MenuItem onClick={logout}>Odhlásit se</MenuItem>
</MenuList>
</Menu>
)}
</Flex>
{/* Close outer Flex */}
</Flex>
</Container>
{/* Search Modal */}
<Modal isOpen={isSearchOpen} onClose={onSearchClose} size="md" motionPreset="scale">
<ModalOverlay />
<ModalContent>
<ModalHeader>Vyhledávání</ModalHeader>
<ModalCloseButton />
<ModalBody pb={6}>
<form
onSubmit={(e) => {
e.preventDefault();
submitSearch();
}}
>
<VStack spacing={4}>
<InputGroup size="lg">
<InputLeftElement pointerEvents="none">
<FaSearchIcon />
</InputLeftElement>
<Input
placeholder="Hledat kluby, zápasy, články, hráče..."
value={query}
onChange={(e) => setQuery(e.target.value)}
autoFocus
/>
</InputGroup>
<Button type="submit" colorScheme="blue" size="lg" w="full" leftIcon={<FaSearchIcon />}>
Vyhledat
</Button>
</VStack>
</form>
<Text fontSize="sm" color="gray.500" mt={4} textAlign="center">
Zadejte klíčová slova pro vyhledávání
</Text>
</ModalBody>
</ModalContent>
</Modal>
</Box>
</Box>
);
};
// HoverMenu component for desktop dropdown nav
const HoverMenu = ({ label, items, isActive }: { label: string; items: { label: string; to: string }[]; isActive?: boolean }) => {
const { isOpen, onOpen, onClose } = useDisclosure();
const menuColorActive = useColorModeValue('brand.primary', 'brand.accent');
const menuColorInactive = useColorModeValue('gray.700', 'gray.200');
const menuBgActive = useColorModeValue('blackAlpha.50', 'whiteAlpha.100');
const menuHoverBg = useColorModeValue('blackAlpha.100', 'whiteAlpha.200');
return (
<Box onMouseEnter={onOpen} onMouseLeave={onClose}>
<Menu isOpen={isOpen} placement="bottom-start" gutter={4}>
<MenuButton
as={Button}
rightIcon={<ChevronDownIcon />}
variant="ghost"
size="sm"
px={3}
fontWeight={isActive ? '700' : '600'}
color={isActive ? menuColorActive : menuColorInactive}
bg={isActive ? menuBgActive : 'transparent'}
_hover={{ bg: menuHoverBg, transform: 'translateY(-1px)' }}
transition="all 0.2s"
>
{label}
</MenuButton>
<MenuList>
{items.map((it) => (
<MenuItem as={RouterLink} to={it.to} key={it.to}>
{it.label}
</MenuItem>
))}
</MenuList>
</Menu>
</Box>
);
};
export default Navbar;
// Search Modal rendered alongside Navbar content
// Note: We append the modal inside Navbar return to keep code compact