mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 02:32:57 +00:00
924 lines
36 KiB
TypeScript
924 lines
36 KiB
TypeScript
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 e‑mailové 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}>E‑mailové 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
|