This commit is contained in:
Tomáš Dvořák
2025-10-16 13:32:05 +02:00
commit 12cba639b9
663 changed files with 168914 additions and 0 deletions
+123
View File
@@ -0,0 +1,123 @@
import { Box, Button, Flex, Text, Link } from '@chakra-ui/react';
import { useEffect, useState } from 'react';
const STORAGE_KEY = 'cookie_consent';
type Consent = {
version: number;
necessary: true; // always true
preferences: boolean;
analytics: boolean;
marketing: boolean;
timestamp: string; // ISO
};
const defaultConsent: Consent = {
version: 1,
necessary: true,
preferences: false,
analytics: false,
marketing: false,
timestamp: new Date().toISOString(),
};
const CookieBanner: React.FC = () => {
const [visible, setVisible] = useState(false);
const [managing, setManaging] = useState(false);
const [consent, setConsent] = useState<Consent>(defaultConsent);
useEffect(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
const parsed = JSON.parse(saved) as Consent;
setConsent(parsed);
setVisible(false);
} else {
setVisible(true);
}
} catch {
setVisible(true);
}
}, []);
const saveAndClose = (c: Consent) => {
const payload = { ...c, timestamp: new Date().toISOString() };
localStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
setConsent(payload);
setVisible(false);
setManaging(false);
// Dispatch a small custom event so analytics loaders can react
window.dispatchEvent(new CustomEvent('cookie-consent-change', { detail: payload }));
};
const acceptAll = () => {
saveAndClose({ ...defaultConsent, preferences: true, analytics: true, marketing: true });
};
const rejectNonEssential = () => {
saveAndClose({ ...defaultConsent });
};
if (!visible) return null;
return (
<Box role="dialog" aria-live="polite" position="fixed" bottom={0} left={0} right={0} bg="gray.900" color="gray.100" zIndex={1000} py={4} px={4}>
<Flex align="start" justify="space-between" gap={6} wrap="wrap">
<Box maxW={{ base: '100%', md: '70%' }}>
<Text fontSize="sm" mb={2}>
Tento web používá soubory cookies pro zajištění správného fungování (nezbytné) a za účelem vylepšení obsahu.
O vybraných kategoriích rozhodujete vy. Podrobnosti najdete v&nbsp;
<Link href="/pravidla-cookies" color="blue.300" textDecoration="underline">Pravidlech cookies</Link>.
</Text>
{managing && (
<Box mt={3} bg="gray.800" borderRadius="md" p={3} border="1px solid" borderColor="gray.700">
<Text fontWeight="semibold" mb={2}>Nastavení preferencí</Text>
<Flex direction="column" gap={2}>
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input type="checkbox" checked readOnly />
<Text fontSize="sm">Nezbytné cookies (vždy aktivní)</Text>
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
type="checkbox"
checked={!!consent.preferences}
onChange={(e) => setConsent((c) => ({ ...c, preferences: e.target.checked }))}
/>
<Text fontSize="sm">Preferenční cookies (např. zapamatování voleb)</Text>
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
type="checkbox"
checked={!!consent.analytics}
onChange={(e) => setConsent((c) => ({ ...c, analytics: e.target.checked }))}
/>
<Text fontSize="sm">Analytické cookies (anonymní měření návštěvnosti)</Text>
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
type="checkbox"
checked={!!consent.marketing}
onChange={(e) => setConsent((c) => ({ ...c, marketing: e.target.checked }))}
/>
<Text fontSize="sm">Marketingové cookies</Text>
</label>
<Flex gap={2} mt={2} wrap="wrap">
<Button size="sm" colorScheme="blue" onClick={() => saveAndClose(consent)}>Uložit nastavení</Button>
<Button size="sm" variant="outline" onClick={() => setManaging(false)}>Zpět</Button>
</Flex>
</Flex>
</Box>
)}
</Box>
<Flex gap={2} align="center" wrap="wrap">
<Button size="sm" onClick={() => setManaging((v) => !v)} variant="outline">Nastavit</Button>
<Button size="sm" onClick={rejectNonEssential} variant="ghost">Odmítnout nepovinné</Button>
<Button size="sm" colorScheme="blue" onClick={acceptAll}>Přijmout vše</Button>
</Flex>
</Flex>
</Box>
);
};
export default CookieBanner;
+717
View File
@@ -0,0 +1,717 @@
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,
} 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';
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, menuBg, dividerColor, settings, categories, galleryHref, galleryLabel, hasTables, dynamicNavItems, navLoading }: {
isOpen: boolean;
onClose: () => void;
isAdmin: boolean;
menuBg: string;
dividerColor: string;
settings?: any;
categories?: Category[] | null;
galleryHref?: string | null;
galleryLabel?: string;
hasTables?: 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>
<Button as={RouterLink} to="/aktivity" variant="ghost" justifyContent="flex-start">Aktivity</Button>
<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>
);
})}
<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>
)}
<Button as={RouterLink} to="/videa" variant="ghost" justifyContent="flex-start">Videa</Button>
<Button as={RouterLink} to="/hledat" variant="ghost" justifyContent="flex-start">Hledat</Button>
<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>
</>
)}
</VStack>
</DrawerBody>
</DrawerContent>
</Drawer>
);
const Navbar = () => {
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 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 [scrolled, setScrolled] = useState(false);
const [hasTables, setHasTables] = useState<boolean | null>(null);
const [dynamicNavItems, setDynamicNavItems] = useState<NavigationItem[]>([]);
const [navLoading, setNavLoading] = useState(true);
// 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);
}, []);
// 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 apiUrl = process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1';
const apiOrigin = new URL(apiUrl).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 base = process.env.REACT_APP_API_BASE_URL || process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1';
const origin = new URL(base).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; };
}, []);
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
return dynamicNavItems.map(convertToNavLink);
}
// 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');
}
return links;
}, [dynamicNavItems, navLoading, settings, categoryItems, hasTables, 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={useColorModeValue('gray.50', 'blackAlpha.500')} borderBottomWidth="1px" borderColor="border.subtle" py={1}>
<Container maxW="7xl">
<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} menuBg={menuBg} dividerColor={dividerColor} settings={settings} categories={navCategories} galleryHref={galleryHref} galleryLabel={galleryLabel} hasTables={hasTables} dynamicNavItems={dynamicNavItems} navLoading={navLoading} />
<Container maxW="7xl">
<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 />}
/>
{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>
{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();
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={useColorModeValue(isActive ? 'brand.primary' : 'gray.700', isActive ? 'brand.accent' : 'gray.200')}
bg={isActive ? useColorModeValue('blackAlpha.50', 'whiteAlpha.100') : 'transparent'}
_hover={{ bg: useColorModeValue('blackAlpha.100', 'whiteAlpha.200'), 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
@@ -0,0 +1,57 @@
import { Navigate, useLocation } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { useEffect, useState } from 'react';
import { getSetupStatus } from '../services/setup';
interface ProtectedRouteProps {
children: JSX.Element;
requiredRole?: string;
}
const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children, requiredRole }) => {
const { isAuthenticated, isLoading, user } = useAuth();
const location = useLocation();
const [checkingSetup, setCheckingSetup] = useState(true);
const [requiresSetup, setRequiresSetup] = useState<boolean>(false);
// Check if setup is required
useEffect(() => {
let mounted = true;
(async () => {
try {
const s = await getSetupStatus();
if (mounted) setRequiresSetup(!!s.requires_setup);
} catch (_) {
if (mounted) setRequiresSetup(false);
} finally {
if (mounted) setCheckingSetup(false);
}
})();
return () => { mounted = false; };
}, []);
if (isLoading || checkingSetup) {
// Show loading spinner or skeleton
return <div>Načítání</div>;
}
// If setup is required, redirect to setup page
if (requiresSetup) {
return <Navigate to="/setup" replace />;
}
if (!isAuthenticated) {
// Redirect to login page, but save the current location
return <Navigate to="/login" state={{ from: location }} replace />;
}
// Role-based access control
if (requiredRole && user && user.role && user.role !== requiredRole && user.role !== 'admin') {
// Redirect to 403 Forbidden page
return <Navigate to="/403" state={{ from: location.pathname }} replace />;
}
return children;
};
export default ProtectedRoute;
+11
View File
@@ -0,0 +1,11 @@
// Deprecated: use `components/admin/AdminSidebar` via `layouts/AdminLayout`.
// This thin wrapper keeps backward compatibility by rendering the new AdminSidebar.
import AdminSidebar from './admin/AdminSidebar';
const Sidebar = () => {
return (
<AdminSidebar isOpen={true} onClose={() => {}} />
);
};
export default Sidebar;
+34
View File
@@ -0,0 +1,34 @@
import { Box, HStack, Image, Link, Spinner, Text, useColorModeValue } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import api from '../services/api';
import { assetUrl } from '../utils/url';
type Sponsor = { id: number; name: string; logo_url?: string; website_url?: string };
type SponsorsResponse = Sponsor[] | { data: Sponsor[] };
const fetchSponsors = async (): Promise<Sponsor[]> => {
const res = await api.get<SponsorsResponse>('/sponsors');
const data = Array.isArray(res.data) ? res.data : res.data.data;
return data || [];
};
const SponsorsStrip: React.FC = () => {
const { data, isLoading, isError } = useQuery({ queryKey: ['sponsors'], queryFn: fetchSponsors });
return (
<Box bg={useColorModeValue('white', 'gray.800')} borderTopWidth="1px" borderColor={useColorModeValue('gray.200', 'gray.700')} mt={8} py={4}>
<HStack spacing={6} overflowX="auto" px={4}>
{isLoading && <Spinner />}
{isError && <Text color="red.500">Chyba při načítání sponzorů</Text>}
{data?.map((s) => (
<Link key={s.id} href={s.website_url || '#'} isExternal>
<Image src={assetUrl(s.logo_url) || '/logo192.png'} alt={s.name} height="50px" objectFit="contain" />
</Link>
))}
</HStack>
</Box>
);
};
export default SponsorsStrip;
@@ -0,0 +1,45 @@
import { Box, BoxProps, useColorModeValue } from '@chakra-ui/react';
import { ReactNode } from 'react';
interface AdminCardProps extends BoxProps {
children: ReactNode;
variant?: 'outline' | 'filled' | 'unstyled';
hoverEffect?: boolean;
}
export const AdminCard = ({
children,
variant = 'outline',
hoverEffect = false,
...props
}: AdminCardProps) => {
const bg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
const hoverBg = useColorModeValue('gray.50', 'gray.750');
const variants = {
outline: {
bg,
border: '1px solid',
borderColor,
},
filled: {
bg: useColorModeValue('gray.50', 'gray.750'),
},
unstyled: {},
} as const;
return (
<Box
borderRadius="lg"
p={6}
boxShadow="sm"
transition="all 0.2s"
_hover={hoverEffect ? { transform: 'translateY(-2px)', boxShadow: 'md' } : {}}
{...variants[variant]}
{...props}
>
{children}
</Box>
);
};
@@ -0,0 +1,321 @@
import React, { useEffect, useState, useCallback } from 'react';
import { useKeyboardShortcuts } from '../../hooks/useKeyboardShortcuts';
import { useLocalStorage } from '../../hooks/useLocalStorage';
import { FiCommand, FiSave, FiRefreshCw, FiSearch } from 'react-icons/fi';
import { useToast } from '@chakra-ui/react';
/**
* AdminEnhancer - Adds admin-specific functionality
* - Keyboard shortcuts (Ctrl+S, Ctrl+K, etc.)
* - Auto-save drafts
* - Unsaved changes warning
* - Quick search
* - Keyboard shortcuts help
*/
interface AdminEnhancerProps {
children: React.ReactNode;
onSave?: () => void | Promise<void>;
onRefresh?: () => void | Promise<void>;
onSearch?: () => void;
hasUnsavedChanges?: boolean;
}
const AdminEnhancer: React.FC<AdminEnhancerProps> = ({
children,
onSave,
onRefresh,
onSearch,
hasUnsavedChanges = false,
}) => {
const [showShortcuts, setShowShortcuts] = useState(false);
const [lastSaved, setLastSaved] = useLocalStorage('admin-last-saved', '');
const toast = useToast();
// Warn before leaving with unsaved changes
useEffect(() => {
if (!hasUnsavedChanges) return;
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
e.preventDefault();
e.returnValue = '';
return '';
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
}, [hasUnsavedChanges]);
// Handle save
const handleSave = useCallback(async () => {
if (!onSave) return;
try {
await onSave();
const now = new Date().toISOString();
setLastSaved(now);
toast({
title: 'Uloženo',
description: 'Změny byly úspěšně uloženy',
status: 'success',
duration: 2000,
position: 'bottom-right',
});
} catch (error) {
toast({
title: 'Chyba',
description: 'Nepodařilo se uložit změny',
status: 'error',
duration: 3000,
position: 'bottom-right',
});
}
}, [onSave, setLastSaved, toast]);
// Handle refresh
const handleRefresh = useCallback(async () => {
if (!onRefresh) return;
try {
await onRefresh();
toast({
title: 'Obnoveno',
description: 'Data byla aktualizována',
status: 'info',
duration: 2000,
position: 'bottom-right',
});
} catch (error) {
toast({
title: 'Chyba',
description: 'Nepodařilo se obnovit data',
status: 'error',
duration: 3000,
position: 'bottom-right',
});
}
}, [onRefresh, toast]);
// Admin keyboard shortcuts
useKeyboardShortcuts([
{
key: 's',
ctrlKey: true,
callback: () => {
handleSave();
},
description: 'Uložit změny',
},
{
key: 'k',
ctrlKey: true,
callback: () => {
if (onSearch) onSearch();
},
description: 'Otevřít vyhledávání',
},
{
key: 'r',
ctrlKey: true,
callback: () => {
handleRefresh();
},
description: 'Obnovit data',
},
{
key: '?',
shiftKey: true,
callback: () => setShowShortcuts(true),
description: 'Zobrazit klávesové zkratky',
},
{
key: 'Escape',
callback: () => setShowShortcuts(false),
},
]);
return (
<>
{children}
{/* Unsaved changes indicator */}
{hasUnsavedChanges && (
<div
style={{
position: 'fixed',
bottom: 24,
left: 24,
padding: '12px 16px',
background: '#f59e0b',
color: 'white',
borderRadius: 8,
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
display: 'flex',
alignItems: 'center',
gap: 8,
zIndex: 9999,
animation: 'pulse 2s infinite',
}}
>
<span style={{ fontSize: 14, fontWeight: 600 }}>
Máte neuložené změny
</span>
{onSave && (
<button
onClick={handleSave}
style={{
background: 'white',
color: '#f59e0b',
border: 'none',
padding: '6px 12px',
borderRadius: 4,
fontSize: 14,
fontWeight: 600,
cursor: 'pointer',
}}
>
Uložit nyní
</button>
)}
</div>
)}
{/* Keyboard shortcuts modal */}
{showShortcuts && (
<>
<div
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.6)',
backdropFilter: 'blur(4px)',
zIndex: 10000,
}}
onClick={() => setShowShortcuts(false)}
/>
<div
style={{
position: 'fixed',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
background: 'white',
borderRadius: 12,
padding: 24,
maxWidth: 500,
width: '90%',
maxHeight: '80vh',
overflow: 'auto',
zIndex: 10001,
boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 20 }}>
<FiCommand size={24} />
<h2 style={{ margin: 0, fontSize: 20, fontWeight: 700 }}>
Klávesové zkratky
</h2>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<ShortcutItem keys={['Ctrl', 'S']} description="Uložit změny" icon={<FiSave />} />
<ShortcutItem keys={['Ctrl', 'K']} description="Vyhledávání" icon={<FiSearch />} />
<ShortcutItem keys={['Ctrl', 'R']} description="Obnovit data" icon={<FiRefreshCw />} />
<ShortcutItem keys={['?']} description="Zobrazit zkratky" icon={<FiCommand />} />
<ShortcutItem keys={['Esc']} description="Zavřít modál" />
<ShortcutItem keys={['Home']} description="Na začátek stránky" />
<ShortcutItem keys={['End']} description="Na konec stránky" />
</div>
<button
onClick={() => setShowShortcuts(false)}
style={{
marginTop: 20,
width: '100%',
padding: '10px 20px',
background: 'var(--primary, #C53030)',
color: 'white',
border: 'none',
borderRadius: 8,
fontSize: 14,
fontWeight: 600,
cursor: 'pointer',
}}
>
Zavřít
</button>
</div>
</>
)}
{/* Shortcut hint button */}
<button
onClick={() => setShowShortcuts(true)}
style={{
position: 'fixed',
bottom: 24,
right: 24,
width: 48,
height: 48,
borderRadius: '50%',
background: 'white',
border: '2px solid #e2e8f0',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
transition: 'all 0.3s ease',
zIndex: 9998,
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'translateY(-4px)';
e.currentTarget.style.boxShadow = '0 6px 16px rgba(0,0,0,0.15)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = '0 4px 12px rgba(0,0,0,0.1)';
}}
title="Klávesové zkratky (Shift + ?)"
>
<FiCommand size={24} color="#4a5568" />
</button>
</>
);
};
const ShortcutItem: React.FC<{ keys: string[]; description: string; icon?: React.ReactNode }> = ({
keys,
description,
icon,
}) => (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 0' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{icon && <span style={{ color: '#718096' }}>{icon}</span>}
<span style={{ fontSize: 14, color: '#2d3748' }}>{description}</span>
</div>
<div style={{ display: 'flex', gap: 4 }}>
{keys.map((key, i) => (
<React.Fragment key={key}>
<kbd
style={{
padding: '4px 8px',
background: '#e2e8f0',
borderRadius: 4,
fontSize: 12,
fontFamily: 'monospace',
fontWeight: 600,
color: '#4a5568',
border: '1px solid #cbd5e0',
}}
>
{key}
</kbd>
{i < keys.length - 1 && <span style={{ color: '#a0aec0' }}>+</span>}
</React.Fragment>
))}
</div>
</div>
);
export default AdminEnhancer;
@@ -0,0 +1,142 @@
import {
Box,
Flex,
IconButton,
useColorMode,
Text,
Menu,
MenuButton,
MenuList,
MenuItem,
Avatar,
useColorModeValue,
HStack,
BoxProps,
Tooltip,
Link as ChakraLink
} from '@chakra-ui/react';
import { FaBars, FaMoon, FaSun, FaSignOutAlt, FaUserCog, FaBook } from 'react-icons/fa';
import { useAuth } from '../../contexts/AuthContext';
import { User } from '../../types';
import { ReactNode } from 'react';
import { Link as RouterLink } from 'react-router-dom';
interface AdminHeaderProps extends BoxProps {
onMenuToggle: () => void;
rightContent?: ReactNode;
}
const AdminHeader = ({ onMenuToggle, rightContent, ...rest }: AdminHeaderProps) => {
const { colorMode, toggleColorMode } = useColorMode();
const { user, logout } = useAuth();
const bg = useColorModeValue('white', '#1a1d29');
const borderColor = useColorModeValue('gray.200', 'rgba(255, 255, 255, 0.12)');
const textColor = useColorModeValue('gray.800', '#e2e8f0');
const userData = user as User | null;
const headerShadow = useColorModeValue('sm', 'none');
return (
<Box
as="header"
position="sticky"
top={0}
left={0}
right={0}
bg={bg}
borderBottomWidth="1px"
borderColor={borderColor}
zIndex={20}
height="60px"
px={{ base: 3, md: 6 }}
boxShadow={headerShadow}
transition="all 0.2s"
{...rest}
>
<Flex h="100%" alignItems="center" justifyContent="space-between">
<Flex alignItems="center">
<IconButton
display={{ base: 'flex', md: 'none' }}
aria-label="Otevřít menu"
icon={<FaBars />}
variant="ghost"
onClick={onMenuToggle}
mr={2}
/>
<Text fontSize="xl" fontWeight="bold" display={{ base: 'none', md: 'block' }}>
Fotbal Admin
</Text>
</Flex>
<HStack spacing={4}>
{rightContent || (
<>
<Tooltip label="Dokumentace" hasArrow>
<ChakraLink as={RouterLink} to="/admin/docs">
<IconButton
aria-label="Dokumentace"
icon={<FaBook />}
variant="ghost"
size="sm"
mr={1}
/>
</ChakraLink>
</Tooltip>
<IconButton
aria-label="Přepnout barevné schéma"
icon={colorMode === 'light' ? <FaMoon /> : <FaSun />}
variant="ghost"
onClick={toggleColorMode}
size="sm"
/>
<Menu>
<MenuButton>
<Avatar
size="sm"
name={userData?.name || 'Uživatel'}
src={userData?.avatar}
cursor="pointer"
border="2px solid"
borderColor={useColorModeValue('gray.200', 'gray.600')}
_hover={{
transform: 'scale(1.05)',
transition: 'transform 0.2s'
}}
/>
</MenuButton>
<MenuList zIndex={30}>
{userData?.name && (
<Box px={3} py={2} borderBottomWidth="1px" borderColor={borderColor}>
<Text fontWeight="medium">{userData.name}</Text>
<Text fontSize="sm" color="gray.500">{userData.email}</Text>
</Box>
)}
<MenuItem
icon={<FaUserCog />}
_hover={{
bg: useColorModeValue('gray.100', 'gray.700')
}}
>
Můj účet
</MenuItem>
<MenuItem
icon={<FaSignOutAlt />}
color="red.500"
_hover={{
bg: useColorModeValue('red.50', 'red.900')
}}
onClick={logout}
>
Odhlásit se
</MenuItem>
</MenuList>
</Menu>
</>
)}
</HStack>
</Flex>
</Box>
);
};
export default AdminHeader;
@@ -0,0 +1,19 @@
import { Box, Text, Link, Alert, AlertIcon } from '@chakra-ui/react';
const AdminHelp: React.FC = () => {
return (
<Box mt={6}>
<Alert status="info" borderRadius="md">
<AlertIcon />
<Text>
Pro kompletní dokumentaci navštivte{' '}
<Link href="/docs" color="blue.600" fontWeight="semibold" textDecoration="underline">
dokumentaci administrace
</Link>
</Text>
</Alert>
</Box>
);
};
export default AdminHelp;
@@ -0,0 +1,193 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalCloseButton,
ModalBody,
InputGroup,
InputLeftElement,
Input,
List,
ListItem,
HStack,
Text,
Badge,
Icon,
Box,
Kbd,
} from '@chakra-ui/react';
import { FaSearch, FaCog, FaNewspaper, FaUsers, FaImage, FaHandshake, FaEnvelope, FaAward, FaSyncAlt, FaVideo, FaCalendarAlt, FaPalette, FaCommentAlt, FaKey, FaChartLine, FaBook, FaTools, FaBell, FaBars } from 'react-icons/fa';
export type AdminSearchItem = {
label: string;
path: string;
section: string;
keywords?: string[];
icon?: any;
};
const adminIndex: AdminSearchItem[] = [
{ label: 'Dashboard', path: '/admin', section: 'Core', keywords: ['overview', 'stat', 'dashboard'], icon: FaTools },
{ label: 'Články', path: '/admin/clanky', section: 'Obsah', keywords: ['articles', 'posts', 'blog'], icon: FaNewspaper },
{ label: 'Hráči', path: '/admin/hraci', section: 'Kádry', keywords: ['players'], icon: FaUsers },
{ label: 'Týmy', path: '/admin/tymy', section: 'Kádry', keywords: ['teams'], icon: FaUsers },
{ label: 'Zápasy', path: '/admin/zapasy', section: 'FAČR', keywords: ['matches', 'facr'], icon: FaCalendarAlt },
{ label: 'Média', path: '/admin/media', section: 'Obsah', keywords: ['uploads', 'images'], icon: FaImage },
{ label: 'Sponzoři', path: '/admin/sponzori', section: 'Marketing', keywords: ['sponsors', 'partners'], icon: FaHandshake },
{ label: 'Bannery', path: '/admin/bannery', section: 'Marketing', keywords: ['banners'], icon: FaImage },
{ label: 'Kategorie', path: '/admin/kategorie', section: 'Obsah', keywords: ['categories'], icon: FaAward },
{ label: 'Nastavení', path: '/admin/nastaveni', section: 'Systém', keywords: ['settings', 'config'], icon: FaCog },
{ label: 'Newsletter', path: '/admin/newsletter', section: 'Komunikace', keywords: ['email', 'campaign'], icon: FaEnvelope },
{ label: 'Uživatelé', path: '/admin/uzivatele', section: 'Systém', keywords: ['users', 'accounts'], icon: FaKey },
{ label: 'Prefetch', path: '/admin/prefetch', section: 'Systém', keywords: ['cache', 'fetch'], icon: FaSyncAlt },
{ label: 'Galerie', path: '/admin/galerie', section: 'Média', keywords: ['gallery', 'zonerama'], icon: FaImage },
{ label: 'Videa', path: '/admin/videa', section: 'Média', keywords: ['youtube', 'videos'], icon: FaVideo },
{ label: 'Analytika', path: '/admin/analytika', section: 'SEO', keywords: ['analytics', 'umami'], icon: FaChartLine },
{ label: 'O klubu', path: '/admin/o-klubu', section: 'Obsah', keywords: ['about'], icon: FaPalette },
{ label: 'Navigace', path: '/admin/navigace', section: 'Systém', keywords: ['navigation', 'menu', 'sidebar'], icon: FaBars },
{ label: 'Notifikace: Zápasy', path: '/admin/notifications', section: 'Komunikace', keywords: ['notifications', 'match'], icon: FaBell },
// Docs
{ label: 'Dokumentace (Úvod)', path: '/admin/docs#uvod', section: 'Docs', keywords: ['docs', 'documentation'], icon: FaBook },
{ label: 'Dokumentace (Nastavení)', path: '/admin/docs#nastaveni', section: 'Docs', keywords: ['docs', 'settings'], icon: FaBook },
{ label: 'Dokumentace (Články)', path: '/admin/docs#clanky', section: 'Docs', keywords: ['docs', 'articles'], icon: FaBook },
{ label: 'Dokumentace (Newsletter)', path: '/admin/docs#newsletter', section: 'Docs', keywords: ['docs', 'email'], icon: FaBook },
{ label: 'Dokumentace (Řešení problémů)', path: '/admin/docs#troubleshooting', section: 'Docs', keywords: ['docs', 'troubleshooting'], icon: FaBook },
];
function highlight(text: string, q: string) {
if (!q) return text;
try {
const esc = q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const re = new RegExp(esc, 'gi');
const parts = text.split(re);
const matches = text.match(re) || [];
const out: any[] = [];
parts.forEach((p, idx) => {
out.push(p);
if (idx < matches.length) out.push(<mark key={idx} style={{ backgroundColor: '#fde68a' }}>{matches[idx]}</mark>);
});
return <>{out}</>;
} catch {
return text;
}
}
function score(item: AdminSearchItem, q: string) {
const t = (item.label || '').toLowerCase();
const b = q.toLowerCase();
const kws = (item.keywords || []).join(' ').toLowerCase();
let s = 0;
if (!b) return s;
if (t === b) s += 200;
if (t.startsWith(b)) s += 120;
if (t.includes(b)) s += 80 - t.indexOf(b);
if (kws.includes(b)) s += 40;
// small preference for Docs when # present
if (item.section === 'Docs' && item.path.includes('#')) s += 5;
return s;
}
export default function AdminSearchModal({ isOpen, onClose, onSelectPath }: { isOpen: boolean; onClose: () => void; onSelectPath: (path: string) => void }) {
const [q, setQ] = useState('');
const [debounced, setDebounced] = useState('');
const [idx, setIdx] = useState(-1);
const inputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
const id = setTimeout(() => setDebounced(q.trim()), 250);
return () => clearTimeout(id);
}, [q]);
useEffect(() => {
if (isOpen) {
setQ('');
setDebounced('');
setIdx(-1);
setTimeout(() => inputRef.current?.focus(), 50);
}
}, [isOpen]);
const results = useMemo(() => {
const arr = adminIndex.map((it) => ({ it, s: score(it, debounced) }))
.filter((r) => r.s > 0 || !debounced)
.sort((a, b) => b.s - a.s || a.it.label.localeCompare(b.it.label))
.slice(0, 12)
.map((r) => r.it);
return arr;
}, [debounced]);
const onSelect = useCallback((path: string) => {
onClose();
onSelectPath(path);
}, [onClose, onSelectPath]);
const onKeyDown: React.KeyboardEventHandler<HTMLInputElement> = (e) => {
const n = results.length;
if (e.key === 'ArrowDown') { e.preventDefault(); setIdx((i) => Math.min(n - 1, i + 1)); }
else if (e.key === 'ArrowUp') { e.preventDefault(); setIdx((i) => Math.max(-1, i - 1)); }
else if (e.key === 'Enter') {
const chosen = idx >= 0 ? results[idx] : results[0];
if (chosen) onSelect(chosen.path);
} else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
e.preventDefault(); onClose();
} else if (e.key === 'Escape') {
onClose();
}
};
return (
<Modal isOpen={isOpen} onClose={onClose} size="lg" motionPreset="scale">
<ModalOverlay />
<ModalContent>
<ModalHeader>
Admin vyhledávání
<Box as="span" ml={3} color="gray.500" fontSize="sm">
<Kbd>Ctrl</Kbd>+<Kbd>K</Kbd>
</Box>
</ModalHeader>
<ModalCloseButton />
<ModalBody pb={4}>
<InputGroup size="lg">
<InputLeftElement pointerEvents="none">
<Icon as={FaSearch} />
</InputLeftElement>
<Input
placeholder="Hledat v administraci (stránky, nastavení, dokumentace)"
value={q}
onChange={(e) => { setQ(e.target.value); setIdx(-1); }}
onKeyDown={onKeyDown}
ref={inputRef}
autoFocus
/>
</InputGroup>
<List mt={4} spacing={1}>
{results.map((r, i) => (
<ListItem
key={r.path}
px={3}
py={2}
borderRadius="md"
cursor="pointer"
bg={i === idx ? 'blackAlpha.50' : 'transparent'}
_hover={{ bg: 'blackAlpha.50' }}
onClick={() => onSelect(r.path)}
>
<HStack>
{r.icon ? <Icon as={r.icon} color="blue.500" /> : null}
<Text fontWeight="semibold">{highlight(r.label, debounced)}</Text>
<Badge ml="auto" colorScheme="gray">{r.section}</Badge>
</HStack>
</ListItem>
))}
{results.length === 0 && (
<Box color="gray.500" fontSize="sm" px={1} py={2}>Žádné výsledky</Box>
)}
</List>
</ModalBody>
</ModalContent>
</Modal>
);
}
@@ -0,0 +1,631 @@
import { Box, VStack, Text, useColorModeValue, Icon, Link as ChakraLink, Divider, Image, Flex, Spinner } from '@chakra-ui/react';
import { Link as RouterLink, useLocation } from 'react-router-dom';
import { useEffect, useRef, useCallback, useState } from 'react';
import {
FaTachometerAlt,
FaUsers,
FaFutbol,
FaCalendarAlt,
FaNewspaper,
FaHandshake,
FaImage,
FaEnvelope,
FaCog,
FaPalette,
FaHome,
FaSignOutAlt,
FaPaperPlane,
FaAward,
FaSyncAlt,
FaBook,
FaMobileAlt,
FaChartBar,
FaFolder,
FaAddressBook,
FaBars,
FaPoll,
FaPaintBrush,
FaVideo,
FaCamera,
FaTshirt,
FaBullhorn,
FaUserShield,
FaFileAlt
} from 'react-icons/fa';
import { useAuth } from '../../contexts/AuthContext';
import { useQuery } from '@tanstack/react-query';
import { getUpcomingEvents } from '../../services/eventService';
import { getAllNavigationItems, NavigationItem, seedDefaultNavigation } from '../../services/navigation';
interface NavItemProps {
icon: any;
to?: string;
children: React.ReactNode;
onClick?: (e?: React.MouseEvent) => void;
}
const NavItem = ({ icon, to, children, onClick }: NavItemProps) => {
const location = useLocation();
const isActive = to ? location.pathname.startsWith(to) : false;
const activeBg = useColorModeValue('blue.50', 'blue.900');
const activeColor = useColorModeValue('blue.600', 'blue.300');
const handleClick = (e: React.MouseEvent) => {
// Call the onClick handler first
if (onClick) {
onClick(e);
// If onClick called preventDefault, respect it
if (e.isDefaultPrevented()) {
return;
}
}
// Allow RouterLink to handle navigation normally
};
// If onClick is provided without `to`, render as a button-like link
const LinkComponent = to ? RouterLink : 'a';
const linkProps = to ? { to } : { href: '#' };
return (
<ChakraLink
as={LinkComponent}
{...linkProps}
display="flex"
alignItems="center"
px={3}
py={2.5}
borderRadius="lg"
bg={isActive ? activeBg : 'transparent'}
color={isActive ? activeColor : 'inherit'}
fontWeight={isActive ? 'semibold' : 'medium'}
fontSize="sm"
_hover={{
textDecoration: 'none',
bg: isActive ? activeBg : useColorModeValue('gray.100', 'gray.700'),
transform: 'translateX(2px)',
}}
transition="all 0.2s ease"
onClick={handleClick}
data-navitem="true"
data-active={isActive ? 'true' : undefined}
position="relative"
_before={isActive ? {
content: '""',
position: 'absolute',
left: 0,
top: '50%',
transform: 'translateY(-50%)',
width: '3px',
height: '60%',
bg: activeColor,
borderRadius: 'full',
} : {}}
>
<Icon as={icon} mr={3} boxSize={4} />
<Text flex={1}>{children}</Text>
</ChakraLink>
);
};
interface AdminSidebarProps {
isOpen: boolean;
onClose: () => void;
bg?: string;
borderRight?: string;
borderColor?: string;
}
// Icon mapping for navigation items
const getIconForPageType = (pageType?: string): any => {
const iconMap: Record<string, any> = {
dashboard: FaTachometerAlt,
analytics: FaChartBar,
teams: FaUsers,
matches: FaCalendarAlt,
activities: FaCalendarAlt,
players: FaFutbol,
articles: FaNewspaper,
categories: FaFileAlt,
about: FaBook,
videos: FaVideo,
gallery: FaImage,
scoreboard: FaTachometerAlt,
scoreboard_remote: FaMobileAlt,
clothing: FaTshirt,
sponsors: FaHandshake,
banners: FaBullhorn,
messages: FaEnvelope,
contacts: FaAddressBook,
newsletter: FaPaperPlane,
polls: FaPoll,
navigation: FaBars,
competition_aliases: FaAward,
prefetch: FaSyncAlt,
users: FaUserShield,
settings: FaPalette,
files: FaFolder,
docs: FaBook,
};
return iconMap[pageType || ''] || FaFileAlt;
};
const AdminSidebar = ({
isOpen,
onClose,
bg: bgProp,
borderRight = '1px',
borderColor: borderColorProp
}: AdminSidebarProps) => {
const { logout, user } = useAuth();
const isAdmin = (user as any)?.role === 'admin';
const defaultBg = useColorModeValue('white', '#1a1d29');
const defaultBorderColor = useColorModeValue('gray.200', 'rgba(255, 255, 255, 0.12)');
const textColor = useColorModeValue('gray.800', '#e2e8f0');
const bg = bgProp || defaultBg;
const borderColor = borderColorProp || defaultBorderColor;
// Upcoming events count for badge
const { data: upcomingEvents } = useQuery({ queryKey: ['admin-sidebar-upcoming-events'], queryFn: getUpcomingEvents });
const upcomingCount = Array.isArray(upcomingEvents) ? upcomingEvents.length : 0;
const scrollRef = useRef<HTMLDivElement | null>(null);
const location = useLocation();
const STORAGE_KEY = 'admin-sidebar-scroll';
// Dynamic navigation state
const [navItems, setNavItems] = useState<NavigationItem[]>([]);
const [navLoading, setNavLoading] = useState(true);
// Restore scroll on mount
useEffect(() => {
const node = scrollRef.current;
if (!node) return;
const saved = sessionStorage.getItem(STORAGE_KEY);
if (saved) {
try {
const top = parseInt(saved, 10);
if (!Number.isNaN(top)) {
node.scrollTop = top;
}
} catch {}
}
}, []);
// Save scroll on scroll
const handleScroll = useCallback(() => {
const node = scrollRef.current;
if (!node) return;
sessionStorage.setItem(STORAGE_KEY, String(node.scrollTop));
}, []);
// Load dynamic navigation from API
useEffect(() => {
let active = true;
(async () => {
try {
const items = await getAllNavigationItems();
if (active && Array.isArray(items)) {
// Filter only admin navigation items
const adminItems = items.filter(item => item.requires_admin);
// Auto-seed if admin navigation is empty and user is admin
if (adminItems.length === 0 && isAdmin) {
try {
console.log('Admin navigation empty, auto-seeding...');
await seedDefaultNavigation();
const newItems = await getAllNavigationItems();
if (active && Array.isArray(newItems)) {
const newAdminItems = newItems.filter(item => item.requires_admin);
setNavItems(newAdminItems);
}
} catch (seedError) {
console.error('Auto-seed failed:', seedError);
// Continue with empty navigation (will show fallback)
setNavItems(adminItems);
}
} else {
setNavItems(adminItems);
}
}
} catch (error) {
console.error('Failed to load admin navigation:', error);
} finally {
if (active) setNavLoading(false);
}
})();
return () => { active = false };
}, [isAdmin]);
// Keep active item in view upon route change - but only if it's not visible
useEffect(() => {
const node = scrollRef.current;
if (!node) return;
const active = node.querySelector('[data-navitem][data-active="true"]') as HTMLElement | null;
if (active) {
// Check if the active item is already visible in the viewport
const containerRect = node.getBoundingClientRect();
const activeRect = active.getBoundingClientRect();
const isVisible = (
activeRect.top >= containerRect.top &&
activeRect.bottom <= containerRect.bottom
);
// Only scroll if the active item is not fully visible
if (!isVisible) {
active.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'smooth' });
}
}
}, [location.pathname]);
return (
<Box
as="nav"
position="fixed"
left={0}
top={0}
bottom={0}
width="260px"
bg={bg}
borderRightWidth={borderRight}
borderColor={borderColor}
pt={5}
display={{ base: isOpen ? 'block' : 'none', md: 'block' }}
zIndex={10}
overflowY="auto"
overflowX="hidden"
boxShadow="lg"
transition="all 0.3s cubic-bezier(0.4, 0, 0.2, 1)"
ref={scrollRef}
onScroll={handleScroll}
css={{
'&::-webkit-scrollbar': { width: '4px' },
'&::-webkit-scrollbar-track': { background: 'transparent' },
'&::-webkit-scrollbar-thumb': { background: useColorModeValue('gray.300', 'gray.600'), borderRadius: '2px' },
'&::-webkit-scrollbar-thumb:hover': { background: useColorModeValue('gray.400', 'gray.500') },
}}
>
<VStack align="stretch" spacing={1} px={3} pb={6}>
<Box px={3} mb={8}>
<Flex align="center" gap={3} mb={2}>
<Image
src="/api/logo"
alt="Club Logo"
boxSize="48px"
objectFit="contain"
fallbackSrc="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Crect width='100' height='100' fill='%23e2e8f0'/%3E%3Ctext x='50' y='55' text-anchor='middle' font-size='40' fill='%23718096'%3EMC%3C/text%3E%3C/svg%3E"
borderRadius="md"
/>
<VStack align="start" spacing={0}>
<Text
fontSize="xl"
fontWeight="extrabold"
color={useColorModeValue('gray.800', 'white')}
letterSpacing="tight"
>
My Club
</Text>
<Text fontSize="xs" color={useColorModeValue('gray.500', 'gray.400')} fontWeight="semibold" textTransform="uppercase" letterSpacing="wider">
Admin Panel
</Text>
</VStack>
</Flex>
</Box>
<NavItem
icon={FaHome}
to="/"
onClick={onClose}
>
Zpět na web
</NavItem>
<Divider my={2} />
{/* Dynamic Navigation */}
{navLoading ? (
<Flex justify="center" py={8}>
<Spinner size="sm" />
</Flex>
) : navItems.length > 0 ? (
// Render dynamic navigation
<>
{navItems.filter(item => item.visible).map((item, index) => {
const itemIcon = getIconForPageType(item.page_type);
const itemUrl = item.url || '#';
// Add badge for activities showing upcoming count
const isActivities = item.page_type === 'activities';
const showBadge = isActivities && upcomingCount > 0;
return (
<NavItem
key={item.id || index}
icon={itemIcon}
to={itemUrl}
onClick={onClose}
>
<Text as="span">
{item.label}
{showBadge && (
<Text as="span" ml={2} fontSize="10px" px={2} py={0.5} borderRadius="full" bg={useColorModeValue('green.100','green.900')} color={useColorModeValue('green.700','green.200')} borderWidth="1px" borderColor={useColorModeValue('green.200','green.700')}>
{upcomingCount}
</Text>
)}
</Text>
</NavItem>
);
})}
{/* MyUIbrix Editor - Special item */}
<NavItem
icon={FaPaintBrush}
onClick={(e) => {
e?.preventDefault();
window.open('/?myuibrix=edit', '_blank');
}}
>
MyUIbrix Editor
</NavItem>
</>
) : (
// Fallback to hardcoded navigation
<>
<Text fontSize="xs" fontWeight="bold" px={4} py={2} color={useColorModeValue('gray.500', 'gray.400')} textTransform="uppercase" letterSpacing="wider">
Hlavní
</Text>
<NavItem
icon={FaTachometerAlt}
to="/admin"
onClick={onClose}
>
Nástěnka
</NavItem>
{isAdmin && (
<NavItem
icon={FaChartBar}
to="/admin/analytika"
onClick={onClose}
>
Analytika
</NavItem>
)}
<Text fontSize="xs" fontWeight="bold" px={4} py={2} color={useColorModeValue('gray.500', 'gray.400')} textTransform="uppercase" letterSpacing="wider" mt={4}>
Obsah
</Text>
{/* Core sports entities first */}
<NavItem
icon={FaUsers}
to="/admin/tymy"
onClick={onClose}
>
Týmy
</NavItem>
<NavItem
icon={FaCalendarAlt}
to="/admin/zapasy"
onClick={onClose}
>
{/* Add subtle scroller hint */}
<Text as="span">
Zápasy
<Text as="span" ml={2} fontSize="10px" px={2} py={0.5} borderRadius="full" bg={useColorModeValue('gray.100','whiteAlpha.200')} color={useColorModeValue('gray.700','gray.300')} borderWidth="1px" borderColor={useColorModeValue('gray.200','whiteAlpha.300')}>
scroller
</Text>
</Text>
</NavItem>
<NavItem
icon={FaCalendarAlt}
to="/admin/aktivity"
onClick={onClose}
>
<Text as="span">
Aktivity
{upcomingCount > 0 && (
<Text as="span" ml={2} fontSize="10px" px={2} py={0.5} borderRadius="full" bg={useColorModeValue('green.100','green.900')} color={useColorModeValue('green.700','green.200')} borderWidth="1px" borderColor={useColorModeValue('green.200','green.700')}>
{upcomingCount}
</Text>
)}
</Text>
</NavItem>
<NavItem
icon={FaFutbol}
to="/admin/hraci"
onClick={onClose}
>
Hráči
</NavItem>
{/* Other content */}
<NavItem
icon={FaNewspaper}
to="/admin/clanky"
onClick={onClose}
>
Články
</NavItem>
<NavItem
icon={FaFileAlt}
to="/admin/kategorie"
onClick={onClose}
>
Kategorie
</NavItem>
<NavItem
icon={FaBook}
to="/admin/o-klubu"
onClick={onClose}
>
O klubu
</NavItem>
<NavItem
icon={FaImage}
to="/admin/videa"
onClick={onClose}
>
Videa
</NavItem>
<NavItem
icon={FaImage}
to="/admin/galerie"
onClick={onClose}
>
Galerie (Zonerama)
</NavItem>
<NavItem
icon={FaTachometerAlt}
to="/admin/scoreboard"
onClick={onClose}
>
Tabule (Scoreboard)
</NavItem>
<NavItem
icon={FaMobileAlt}
to="/admin/scoreboard/remote"
onClick={onClose}
>
Scoreboard Remote
</NavItem>
<NavItem
icon={FaPalette}
to="/admin/obleceni"
onClick={onClose}
>
Oblečení
</NavItem>
<NavItem
icon={FaHandshake}
to="/admin/sponzori"
onClick={onClose}
>
Sponzoři
</NavItem>
<NavItem
icon={FaImage}
to="/admin/bannery"
onClick={onClose}
>
Bannery
</NavItem>
<NavItem
icon={FaEnvelope}
to="/admin/zpravy"
onClick={onClose}
>
Zprávy
</NavItem>
<NavItem
icon={FaAddressBook}
to="/admin/kontakty"
onClick={onClose}
>
Kontakty
</NavItem>
<NavItem
icon={FaPaperPlane}
to="/admin/newsletter"
onClick={onClose}
>
Zpravodaj
</NavItem>
<NavItem
icon={FaPoll}
to="/admin/ankety"
onClick={onClose}
>
Ankety
</NavItem>
<Divider my={2} />
{isAdmin && (
<>
<Text fontSize="xs" fontWeight="bold" px={4} py={2} color={useColorModeValue('gray.500', 'gray.400')} textTransform="uppercase" letterSpacing="wider" mt={4}>
Nastavení
</Text>
<NavItem
icon={FaPaintBrush}
onClick={(e) => {
e?.preventDefault();
window.open('/?myuibrix=edit', '_blank');
}}
>
MyUIbrix Editor
</NavItem>
<NavItem
icon={FaBars}
to="/admin/navigace"
onClick={onClose}
>
Navigace
</NavItem>
<NavItem
icon={FaAward}
to="/admin/aliasy-soutezi"
onClick={onClose}
>
Alias soutěží
</NavItem>
<NavItem
icon={FaSyncAlt}
to="/admin/prefetch"
onClick={onClose}
>
Prefetch & Cache
</NavItem>
<NavItem
icon={FaUsers}
to="/admin/uzivatele"
onClick={onClose}
>
Uživatelé
</NavItem>
<NavItem
icon={FaPalette}
to="/admin/nastaveni"
onClick={onClose}
>
Nastavení
</NavItem>
<NavItem
icon={FaFolder}
to="/admin/soubory"
onClick={onClose}
>
Soubory
</NavItem>
</>
)}
</>
)}
<Box mt="auto" mb={4} px={2}>
<ChakraLink
as="button"
display="flex"
alignItems="center"
w="100%"
px={4}
py={2}
borderRadius="md"
_hover={{
textDecoration: 'none',
bg: useColorModeValue('red.50', 'red.900'),
color: 'red.500',
}}
onClick={logout}
color={useColorModeValue('red.500', 'red.300')}
>
<Icon as={FaSignOutAlt} mr={3} />
<Text>Odhlásit se</Text>
</ChakraLink>
</Box>
</VStack>
</Box>
);
};
export default AdminSidebar;
@@ -0,0 +1,149 @@
import {
Table as ChakraTable,
Thead,
Tbody,
Tr,
Th,
Td,
TableProps as ChakraTableProps,
TableContainer,
Text,
Skeleton,
useColorModeValue,
Box,
} from '@chakra-ui/react';
import { ReactNode } from 'react';
export interface Column<T> {
header: string;
accessor: keyof T | ((item: T) => ReactNode);
isNumeric?: boolean;
width?: string | number;
cellProps?: (item: T) => Record<string, any>;
}
interface AdminTableProps<T> extends ChakraTableProps {
columns: Column<T>[];
data: T[] | undefined;
isLoading?: boolean;
emptyMessage?: string;
onRowClick?: (item: T) => void;
rowHoverEffect?: boolean;
}
export function AdminTable<T>({
columns,
data,
isLoading = false,
emptyMessage = 'No data available',
onRowClick,
rowHoverEffect = true,
...props
}: AdminTableProps<T>) {
const borderColor = useColorModeValue('gray.200', 'gray.700');
const hoverBg = useColorModeValue('gray.50', 'gray.700');
const headerBg = useColorModeValue('gray.50', 'gray.700');
const headerColor = useColorModeValue('gray.600', 'gray.300');
if (isLoading) {
return (
<TableContainer>
<ChakraTable variant="simple" {...props}>
<Thead>
<Tr>
{columns.map((column, index) => (
<Th key={index} bg={headerBg} color={headerColor}>
{column.header}
</Th>
))}
</Tr>
</Thead>
<Tbody>
{[1, 2, 3].map((row) => (
<Tr key={row}>
{columns.map((column, colIndex) => (
<Td key={colIndex}>
<Skeleton height="20px" />
</Td>
))}
</Tr>
))}
</Tbody>
</ChakraTable>
</TableContainer>
);
}
if (!data || data.length === 0) {
return (
<Box
p={8}
textAlign="center"
borderWidth="1px"
borderRadius="md"
borderColor={borderColor}
>
<Text color="gray.500">{emptyMessage}</Text>
</Box>
);
}
return (
<TableContainer
borderWidth="1px"
borderRadius="md"
borderColor={borderColor}
overflowX="auto"
>
<ChakraTable variant="simple" {...props}>
<Thead>
<Tr>
{columns.map((column, index) => (
<Th
key={index}
isNumeric={column.isNumeric}
width={column.width}
bg={headerBg}
color={headerColor}
textTransform="uppercase"
fontSize="xs"
letterSpacing="wider"
borderBottomWidth="1px"
borderColor={borderColor}
>
{column.header}
</Th>
))}
</Tr>
</Thead>
<Tbody>
{data.map((item, rowIndex) => (
<Tr
key={rowIndex}
onClick={() => onRowClick?.(item)}
cursor={onRowClick ? 'pointer' : 'default'}
_hover={rowHoverEffect ? { bg: hoverBg } : {}}
transition="background-color 0.2s"
>
{columns.map((column, colIndex) => {
const cellProps = column.cellProps?.(item) || {};
return (
<Td
key={colIndex}
isNumeric={column.isNumeric}
borderColor={borderColor}
{...cellProps}
>
{typeof column.accessor === 'function'
? column.accessor(item)
: (item[column.accessor] as ReactNode)}
</Td>
);
})}
</Tr>
))}
</Tbody>
</ChakraTable>
</TableContainer>
);
}
@@ -0,0 +1,327 @@
import React, { useState } from 'react';
import {
Box,
Button,
VStack,
HStack,
Input,
Text,
SimpleGrid,
Image,
Checkbox,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalFooter,
ModalCloseButton,
useToast,
Spinner,
Badge,
FormControl,
FormLabel,
FormHelperText,
} from '@chakra-ui/react';
import { ExternalLink, Download } from 'lucide-react';
import { getZoneramaAlbum } from '../../services/zonerama';
interface Photo {
id: string;
page_url: string;
image_1500: string;
}
interface Album {
id: string;
title: string;
url: string;
date: string;
photos_count: number;
photos: Photo[];
}
interface AlbumPhotoPickerProps {
isOpen: boolean;
onClose: () => void;
onPhotosSelected: (photos: Photo[], albumInfo: Album) => void;
}
const AlbumPhotoPicker: React.FC<AlbumPhotoPickerProps> = ({
isOpen,
onClose,
onPhotosSelected,
}) => {
const [albumLink, setAlbumLink] = useState('');
const [loading, setLoading] = useState(false);
const [album, setAlbum] = useState<Album | null>(null);
const [selectedPhotos, setSelectedPhotos] = useState<Set<string>>(new Set());
const toast = useToast();
const handleFetchAlbum = async () => {
if (!albumLink.trim()) {
toast({
title: 'Zadejte URL alba',
status: 'warning',
duration: 3000,
});
return;
}
if (!albumLink.includes('/Album/')) {
toast({
title: 'Neplatný odkaz',
description: 'URL musí obsahovat "/Album/"',
status: 'error',
duration: 3000,
});
return;
}
setLoading(true);
try {
const result = await getZoneramaAlbum(albumLink, { photo_limit: 100 }) as any;
// Handle both response formats: { album, photos } and { albums: [{ photos }] }
let albumData: any = null;
let photos: any[] = [];
if (result.albums && Array.isArray(result.albums) && result.albums.length > 0) {
// New format: { albums: [{ id, title, url, date, photos }] }
albumData = result.albums[0];
photos = albumData.photos || [];
} else if (result.album && result.photos) {
// Old format: { album: {...}, photos: [...] }
albumData = result.album;
photos = result.photos;
} else {
throw new Error('Album nenalezeno - neplatná odpověď ze serveru');
}
if (!albumData) {
throw new Error('Album nenalezeno');
}
const mappedPhotos = photos.map(p => ({
id: p.id,
page_url: p.page_url,
image_1500: p.image_1500 || '',
}));
setAlbum({
id: albumData.id || '',
title: albumData.title || '',
url: albumData.url || albumLink,
date: albumData.date || '', // Now properly extracting date
photos_count: mappedPhotos.length,
photos: mappedPhotos,
});
setSelectedPhotos(new Set());
toast({
title: 'Album načteno',
description: `${mappedPhotos.length} fotografií`,
status: 'success',
duration: 2000,
});
} catch (error: any) {
console.error('Album fetch error:', error);
toast({
title: 'Chyba načítání alba',
description: error.message || 'Nepodařilo se načíst album',
status: 'error',
duration: 5000,
});
} finally {
setLoading(false);
}
};
const togglePhoto = (photoId: string) => {
const newSelected = new Set(selectedPhotos);
if (newSelected.has(photoId)) {
newSelected.delete(photoId);
} else {
newSelected.add(photoId);
}
setSelectedPhotos(newSelected);
};
const handleSelectAll = () => {
if (album) {
if (selectedPhotos.size === album.photos.length) {
setSelectedPhotos(new Set());
} else {
setSelectedPhotos(new Set(album.photos.map(p => p.id)));
}
}
};
const handleConfirm = () => {
if (!album || selectedPhotos.size === 0) {
toast({
title: 'Vyberte fotografie',
status: 'warning',
duration: 3000,
});
return;
}
const selected = album.photos.filter(p => selectedPhotos.has(p.id));
onPhotosSelected(selected, album);
handleClose();
};
const handleClose = () => {
setAlbumLink('');
setAlbum(null);
setSelectedPhotos(new Set());
onClose();
};
return (
<Modal isOpen={isOpen} onClose={handleClose} size="6xl">
<ModalOverlay />
<ModalContent maxH="90vh">
<ModalHeader>Vybrat fotografie z alba</ModalHeader>
<ModalCloseButton />
<ModalBody overflowY="auto">
<VStack align="stretch" spacing={4}>
{/* Album URL Input */}
<FormControl>
<FormLabel>URL Zonerama alba</FormLabel>
<HStack>
<Input
value={albumLink}
onChange={(e) => setAlbumLink(e.target.value)}
placeholder="https://eu.zonerama.com/Account/Album/12345"
onKeyPress={(e) => e.key === 'Enter' && handleFetchAlbum()}
/>
<Button
onClick={handleFetchAlbum}
isLoading={loading}
colorScheme="blue"
leftIcon={<Download size={18} />}
>
Načíst
</Button>
</HStack>
<FormHelperText>
Vložte odkaz na Zonerama album (musí obsahovat /Album/)
</FormHelperText>
</FormControl>
{/* Loading State */}
{loading && (
<VStack py={8}>
<Spinner size="xl" color="blue.500" />
<Text color="gray.600">Načítám album...</Text>
</VStack>
)}
{/* Album Info & Photos */}
{album && !loading && (
<>
{/* Album Header */}
<Box
p={4}
bg="blue.50"
borderRadius="md"
borderWidth="1px"
borderColor="blue.200"
>
<VStack align="start" spacing={2}>
<HStack justify="space-between" w="full">
<Text fontWeight="bold" fontSize="lg">
{album.title}
</Text>
<Button
as="a"
href={album.url}
target="_blank"
rel="noopener noreferrer"
size="sm"
variant="ghost"
rightIcon={<ExternalLink size={14} />}
>
Zonerama
</Button>
</HStack>
<HStack spacing={4} fontSize="sm" color="gray.700">
{album.date && <Text>📅 {album.date}</Text>}
<Badge colorScheme="blue">{album.photos.length} fotografií</Badge>
</HStack>
</VStack>
</Box>
{/* Select All */}
<HStack justify="space-between">
<Checkbox
isChecked={selectedPhotos.size === album.photos.length}
isIndeterminate={
selectedPhotos.size > 0 && selectedPhotos.size < album.photos.length
}
onChange={handleSelectAll}
>
Vybrat vše ({selectedPhotos.size}/{album.photos.length})
</Checkbox>
</HStack>
{/* Photos Grid */}
<SimpleGrid columns={{ base: 3, md: 4, lg: 5 }} spacing={3}>
{album.photos.map((photo) => (
<Box
key={photo.id}
position="relative"
cursor="pointer"
onClick={() => togglePhoto(photo.id)}
borderRadius="md"
overflow="hidden"
borderWidth="2px"
borderColor={selectedPhotos.has(photo.id) ? 'blue.500' : 'transparent'}
transition="all 0.2s"
_hover={{ transform: 'scale(1.05)' }}
>
<Image
src={photo.image_1500}
alt={photo.id}
w="100%"
h="150px"
objectFit="cover"
/>
<Checkbox
position="absolute"
top={2}
right={2}
isChecked={selectedPhotos.has(photo.id)}
pointerEvents="none"
bg="white"
borderRadius="sm"
/>
</Box>
))}
</SimpleGrid>
</>
)}
</VStack>
</ModalBody>
<ModalFooter>
<HStack spacing={3}>
<Button variant="ghost" onClick={handleClose}>
Zrušit
</Button>
<Button
colorScheme="blue"
onClick={handleConfirm}
isDisabled={!album || selectedPhotos.size === 0}
>
Vybrat ({selectedPhotos.size})
</Button>
</HStack>
</ModalFooter>
</ModalContent>
</Modal>
);
};
export default AlbumPhotoPicker;
@@ -0,0 +1,277 @@
import React, { useEffect, useState } from 'react';
import {
Box,
SimpleGrid,
Stat,
StatLabel,
StatNumber,
StatHelpText,
StatArrow,
Heading,
Text,
Table,
Thead,
Tbody,
Tr,
Th,
Td,
Badge,
VStack,
HStack,
Spinner,
Icon,
Card,
CardBody,
Link,
} from '@chakra-ui/react';
import { FiEye, FiTrendingUp, FiFileText, FiUsers } from 'react-icons/fi';
import api from '../../services/api';
import { Link as RouterLink } from 'react-router-dom';
type AnalyticsStats = {
total_page_views: number;
unique_visitors: number;
total_articles: number;
published_articles: number;
page_views_today: number;
page_views_week: number;
unique_visitors_week: number;
avg_time_on_site: number;
};
type TopPage = {
page_path: string;
page_name: string;
view_count: number;
unique_visitors: number;
};
type TopArticle = {
id: number;
title: string;
slug: string;
view_count: number;
published_at: string;
};
const AnalyticsDashboard: React.FC = () => {
const [loading, setLoading] = useState(true);
const [stats, setStats] = useState<AnalyticsStats | null>(null);
const [topPages, setTopPages] = useState<TopPage[]>([]);
const [topArticles, setTopArticles] = useState<TopArticle[]>([]);
useEffect(() => {
loadAnalytics();
}, []);
const loadAnalytics = async () => {
setLoading(true);
try {
// Load overview stats
const statsRes = await api.get('/admin/analytics/overview');
setStats(statsRes.data);
// Load top pages
const pagesRes = await api.get('/admin/analytics/top-pages?limit=10');
setTopPages(pagesRes.data || []);
// Load top articles
const articlesRes = await api.get('/admin/analytics/top-articles?limit=10');
setTopArticles(articlesRes.data || []);
} catch (error) {
console.error('Failed to load analytics:', error);
} finally {
setLoading(false);
}
};
if (loading) {
return (
<Box textAlign="center" py={10}>
<Spinner size="xl" />
<Text mt={4} color="gray.600">Načítám statistiky...</Text>
</Box>
);
}
return (
<VStack align="stretch" spacing={6}>
<Heading size="lg">Analytika a statistiky</Heading>
{/* Overview Stats */}
<SimpleGrid columns={{ base: 1, md: 2, lg: 4 }} spacing={4}>
<Card>
<CardBody>
<Stat>
<HStack>
<Icon as={FiEye} boxSize={6} color="blue.500" />
<StatLabel>Celkem zobrazení</StatLabel>
</HStack>
<StatNumber>{stats?.total_page_views || 0}</StatNumber>
<StatHelpText>
<StatArrow type="increase" />
{stats?.page_views_today || 0} dnes
</StatHelpText>
</Stat>
</CardBody>
</Card>
<Card>
<CardBody>
<Stat>
<HStack>
<Icon as={FiUsers} boxSize={6} color="green.500" />
<StatLabel>Unikátní návštěvníci</StatLabel>
</HStack>
<StatNumber>{stats?.unique_visitors || 0}</StatNumber>
<StatHelpText>
{stats?.unique_visitors_week || 0} tento týden
</StatHelpText>
</Stat>
</CardBody>
</Card>
<Card>
<CardBody>
<Stat>
<HStack>
<Icon as={FiFileText} boxSize={6} color="purple.500" />
<StatLabel>Publikované články</StatLabel>
</HStack>
<StatNumber>{stats?.published_articles || 0}</StatNumber>
<StatHelpText>
z {stats?.total_articles || 0} celkem
</StatHelpText>
</Stat>
</CardBody>
</Card>
<Card>
<CardBody>
<Stat>
<HStack>
<Icon as={FiTrendingUp} boxSize={6} color="orange.500" />
<StatLabel>Zobrazení (týden)</StatLabel>
</HStack>
<StatNumber>{stats?.page_views_week || 0}</StatNumber>
<StatHelpText>
Ø {Math.round((stats?.page_views_week || 0) / 7)} / den
</StatHelpText>
</Stat>
</CardBody>
</Card>
</SimpleGrid>
{/* Top Articles */}
<Box>
<Heading size="md" mb={4}>Nejčtenější články</Heading>
<Card>
<CardBody>
{topArticles.length === 0 ? (
<Text color="gray.600">Zatím žádná data</Text>
) : (
<Table size="sm">
<Thead>
<Tr>
<Th>Článek</Th>
<Th isNumeric>Zobrazení</Th>
<Th>Datum publikace</Th>
<Th>Akce</Th>
</Tr>
</Thead>
<Tbody>
{topArticles.map((article, index) => (
<Tr key={article.id}>
<Td>
<HStack>
<Badge colorScheme="blue">{index + 1}</Badge>
<Text fontWeight="medium">{article.title}</Text>
</HStack>
</Td>
<Td isNumeric>
<Badge colorScheme="green">{article.view_count}</Badge>
</Td>
<Td>
<Text fontSize="sm" color="gray.600">
{new Date(article.published_at).toLocaleDateString('cs-CZ')}
</Text>
</Td>
<Td>
<HStack spacing={2}>
<Link
as={RouterLink}
to={`/blog/${article.slug}`}
fontSize="sm"
color="blue.600"
>
Zobrazit
</Link>
<Link
as={RouterLink}
to={`/admin/clanky?edit=${article.id}`}
fontSize="sm"
color="purple.600"
>
Upravit
</Link>
</HStack>
</Td>
</Tr>
))}
</Tbody>
</Table>
)}
</CardBody>
</Card>
</Box>
{/* Top Pages */}
<Box>
<Heading size="md" mb={4}>Nejnavštěvovanější stránky</Heading>
<Card>
<CardBody>
{topPages.length === 0 ? (
<Text color="gray.600">Zatím žádná data</Text>
) : (
<Table size="sm">
<Thead>
<Tr>
<Th>Stránka</Th>
<Th>Cesta</Th>
<Th isNumeric>Zobrazení</Th>
<Th isNumeric>Unikátní</Th>
</Tr>
</Thead>
<Tbody>
{topPages.map((page, index) => (
<Tr key={page.page_path}>
<Td>
<HStack>
<Badge colorScheme="purple">{index + 1}</Badge>
<Text>{page.page_name || page.page_path}</Text>
</HStack>
</Td>
<Td>
<Text fontSize="sm" color="gray.600" fontFamily="mono">
{page.page_path}
</Text>
</Td>
<Td isNumeric>
<Badge colorScheme="blue">{page.view_count}</Badge>
</Td>
<Td isNumeric>
<Badge colorScheme="green">{page.unique_visitors}</Badge>
</Td>
</Tr>
))}
</Tbody>
</Table>
)}
</CardBody>
</Card>
</Box>
</VStack>
);
};
export default AnalyticsDashboard;
@@ -0,0 +1,339 @@
import React, { useState, useEffect } from 'react';
import {
Box,
Button,
FormControl,
FormLabel,
FormHelperText,
Input,
VStack,
HStack,
Text,
Alert,
AlertIcon,
AlertTitle,
AlertDescription,
Badge,
Divider,
Link,
useColorModeValue,
} from '@chakra-ui/react';
import MapStyleSelector from './MapStyleSelector';
import { FiMapPin, FiCheck, FiX, FiExternalLink } from 'react-icons/fi';
import { parseMapUrl, MapCoordinates, validateCoordinates, reverseGeocode } from '../../utils/mapUrlParser';
import ContactMap from '../home/ContactMap';
interface MapLinkImporterProps {
onImport: (coordinates: MapCoordinates) => void;
currentLatitude?: number;
currentLongitude?: number;
currentZoom?: number;
mapStyle?: string;
onMapStyleChange?: (style: string) => void;
clubPrimaryColor?: string;
clubSecondaryColor?: string;
clubName?: string;
}
const MapLinkImporter: React.FC<MapLinkImporterProps> = ({
onImport,
currentLatitude,
currentLongitude,
currentZoom,
mapStyle,
onMapStyleChange,
clubPrimaryColor,
clubSecondaryColor,
clubName,
}) => {
const [urlInput, setUrlInput] = useState('');
const [parsedData, setParsedData] = useState<MapCoordinates | null>(null);
const [error, setError] = useState<string | null>(null);
const [previewCoords, setPreviewCoords] = useState<MapCoordinates | null>(null);
const bgColor = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
useEffect(() => {
// Initialize preview with current coordinates if available
if (currentLatitude && currentLongitude) {
setPreviewCoords({
latitude: currentLatitude,
longitude: currentLongitude,
zoom: currentZoom,
source: 'unknown',
});
}
}, [currentLatitude, currentLongitude, currentZoom]);
const handleUrlChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setUrlInput(value);
setError(null);
setParsedData(null);
if (!value.trim()) {
return;
}
// Try to parse the URL
const result = parseMapUrl(value);
if (result) {
if (validateCoordinates(result.latitude, result.longitude)) {
// Perform reverse geocoding to get detailed address
try {
const addressDetails = await reverseGeocode(result.latitude, result.longitude);
const enrichedResult = { ...result, ...addressDetails };
setParsedData(enrichedResult);
setPreviewCoords(enrichedResult);
setError(null);
} catch (err) {
// If geocoding fails, still use the basic data
setParsedData(result);
setPreviewCoords(result);
setError(null);
}
} else {
setError('Souřadnice jsou mimo platný rozsah');
setParsedData(null);
}
} else {
setError('Nepodařilo se rozpoznat URL mapy. Podporované: mapy.cz, Google Maps');
setParsedData(null);
}
};
const handleImport = () => {
if (parsedData) {
onImport(parsedData);
setUrlInput('');
setParsedData(null);
setError(null);
}
};
const handleClear = () => {
setUrlInput('');
setParsedData(null);
setError(null);
// Reset preview to current coordinates
if (currentLatitude && currentLongitude) {
setPreviewCoords({
latitude: currentLatitude,
longitude: currentLongitude,
zoom: currentZoom,
source: 'unknown',
});
} else {
setPreviewCoords(null);
}
};
return (
<VStack spacing={4} align="stretch">
<Box>
<FormControl>
<FormLabel display="flex" alignItems="center" gap={2}>
<FiMapPin /> Importovat z URL mapy
</FormLabel>
<Input
placeholder="Vložte URL z mapy.cz nebo Google Maps..."
value={urlInput}
onChange={handleUrlChange}
size="md"
/>
<FormHelperText>
Podporované formáty:
<Text as="span" fontWeight="semibold" ml={1}>mapy.cz</Text> (mapy.com/en/letecka?x=...&y=...),
<Text as="span" fontWeight="semibold" ml={1}>Google Maps</Text> (google.com/maps/place/@lat,lng,zoom)
</FormHelperText>
<HStack mt={2} spacing={3} fontSize="sm">
<Text color="gray.600">Quick links:</Text>
<Link
href="https://mapy.com/cs/"
isExternal
color="blue.500"
display="flex"
alignItems="center"
gap={1}
_hover={{ color: 'blue.600', textDecoration: 'underline' }}
>
Mapy.cz <FiExternalLink size={12} />
</Link>
<Text color="gray.400"></Text>
<Link
href="https://www.google.com/maps/"
isExternal
color="blue.500"
display="flex"
alignItems="center"
gap={1}
_hover={{ color: 'blue.600', textDecoration: 'underline' }}
>
Google Maps <FiExternalLink size={12} />
</Link>
</HStack>
</FormControl>
{parsedData && (
<Alert status="success" mt={3} borderRadius="md">
<AlertIcon />
<Box flex="1">
<AlertTitle>Úspěšně rozpoznáno!</AlertTitle>
<AlertDescription display="block">
<VStack align="start" spacing={1} mt={2}>
<HStack>
<Badge colorScheme="green">
{parsedData.source === 'mapy.cz' ? 'Mapy.cz' : 'Google Maps'}
</Badge>
</HStack>
<Text fontSize="sm">
<strong>Šířka:</strong> {parsedData.latitude.toFixed(7)}
</Text>
<Text fontSize="sm">
<strong>Délka:</strong> {parsedData.longitude.toFixed(7)}
</Text>
{parsedData.zoom && (
<Text fontSize="sm">
<strong>Zoom:</strong> {parsedData.zoom}
</Text>
)}
{parsedData.street && (
<Text fontSize="sm">
<strong>Ulice:</strong> {parsedData.street}
</Text>
)}
{parsedData.city && (
<Text fontSize="sm">
<strong>Město:</strong> {parsedData.city}
</Text>
)}
{parsedData.zip && (
<Text fontSize="sm">
<strong>PSČ:</strong> {parsedData.zip}
</Text>
)}
{parsedData.country && (
<Text fontSize="sm">
<strong>Země:</strong> {parsedData.country}
</Text>
)}
{parsedData.address && (
<Text fontSize="sm">
<strong>Celá adresa:</strong> {parsedData.address}
</Text>
)}
</VStack>
</AlertDescription>
</Box>
<HStack ml={2}>
<Button
leftIcon={<FiCheck />}
colorScheme="green"
size="sm"
onClick={handleImport}
>
Importovat
</Button>
<Button
leftIcon={<FiX />}
variant="ghost"
size="sm"
onClick={handleClear}
>
Zrušit
</Button>
</HStack>
</Alert>
)}
{error && (
<Alert status="error" mt={3} borderRadius="md">
<AlertIcon />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
</Box>
{/* Map Preview */}
{previewCoords && (
<>
<Divider />
<Box>
<Text fontWeight="semibold" mb={2}>
Náhled mapy
</Text>
<Box
borderRadius="md"
overflow="hidden"
borderWidth="1px"
borderColor={borderColor}
>
<ContactMap
latitude={previewCoords.latitude}
longitude={previewCoords.longitude}
zoom={previewCoords.zoom || 15}
address={previewCoords.address}
clubName={clubName}
mapStyle={mapStyle || 'positron'}
clubPrimaryColor={clubPrimaryColor}
clubSecondaryColor={clubSecondaryColor}
height={300}
/>
</Box>
<Text fontSize="xs" color="gray.500" mt={2}>
Souřadnice: {previewCoords.latitude.toFixed(6)}, {previewCoords.longitude.toFixed(6)}
{previewCoords.zoom && ` | Zoom: ${previewCoords.zoom}`}
</Text>
</Box>
{/* Map Style Selector */}
{onMapStyleChange && (
<>
<Divider />
<Box>
<Text fontWeight="semibold" mb={2}>
Styl mapy
</Text>
<Text fontSize="sm" color="gray.600" mb={3}>
Vyberte vzhled mapy, který se zobrazí na vašem webu.
</Text>
<MapStyleSelector
value={mapStyle || 'positron'}
onChange={onMapStyleChange}
clubPrimaryColor={clubPrimaryColor}
clubSecondaryColor={clubSecondaryColor}
showPreview={false}
/>
</Box>
</>
)}
</>
)}
{/* Example URLs */}
<Box
bg={bgColor}
p={3}
borderRadius="md"
borderWidth="1px"
borderColor={borderColor}
fontSize="sm"
>
<Text fontWeight="semibold" mb={2}>Příklady podporovaných URL:</Text>
<VStack align="start" spacing={1}>
<Text fontSize="xs" color="gray.600">
<strong>Mapy.cz:</strong><br />
mapy.cz/en/letecka?x=17.6996859&y=50.0947150&z=19
</Text>
<Text fontSize="xs" color="gray.600">
<strong>Google Maps:</strong><br />
google.com/maps/place/@50.0948669,17.7001456,226m
</Text>
</VStack>
</Box>
</VStack>
);
};
export default MapLinkImporter;
@@ -0,0 +1,178 @@
import React, { useState } from 'react';
import {
Box,
FormControl,
FormLabel,
Select,
SimpleGrid,
Text,
VStack,
Badge,
Image,
HStack,
useColorModeValue,
} from '@chakra-ui/react';
import { MAP_STYLES } from '../home/ContactMap';
import ContactMap from '../home/ContactMap';
interface MapStyleSelectorProps {
value: string;
onChange: (value: string) => void;
clubPrimaryColor?: string;
clubSecondaryColor?: string;
showPreview?: boolean;
}
const MapStyleSelector: React.FC<MapStyleSelectorProps> = ({
value,
onChange,
clubPrimaryColor,
clubSecondaryColor,
showPreview = true,
}) => {
const previewBg = useColorModeValue('gray.50', 'gray.700');
const tipsBg = useColorModeValue('blue.50', 'blue.900');
const tipsBorder = useColorModeValue('blue.200', 'blue.700');
const textColor = useColorModeValue('gray.700', 'gray.300');
const secondaryText = useColorModeValue('gray.600', 'gray.400');
const selectBg = useColorModeValue('white', 'gray.700');
const styleCategories = {
'Light & Minimal': ['positron', 'positron-no-labels', 'default'],
'Dark Themes': ['dark', 'dark-no-labels'],
'Black & White': ['toner', 'toner-lite'],
'Colorful': ['voyager', 'terrain', 'watercolor'],
'Satellite': ['satellite'],
};
const selectedStyle = MAP_STYLES[value as keyof typeof MAP_STYLES] || MAP_STYLES.default;
return (
<VStack align="stretch" spacing={4}>
<FormControl>
<FormLabel>Styl mapy</FormLabel>
<Select value={value} onChange={(e) => onChange(e.target.value)} bg={selectBg}>
{Object.entries(styleCategories).map(([category, styles]) => (
<optgroup key={category} label={category}>
{styles.map((styleKey) => {
const style = MAP_STYLES[styleKey as keyof typeof MAP_STYLES];
return (
<option key={styleKey} value={styleKey}>
{style.name}
</option>
);
})}
</optgroup>
))}
</Select>
</FormControl>
{showPreview && (
<VStack align="stretch" spacing={3}>
<Box
p={4}
borderWidth="1px"
borderRadius="md"
bg={previewBg}
>
<VStack align="stretch" spacing={3}>
<HStack justify="space-between">
<Text fontWeight="semibold">{selectedStyle.name}</Text>
<Badge colorScheme="blue">Náhled stylu</Badge>
</HStack>
<Text fontSize="sm" color={secondaryText}>
{selectedStyle.description}
</Text>
{clubPrimaryColor && (
<Box>
<Text fontSize="sm" fontWeight="medium" mb={2}>
Barvy klubu:
</Text>
<HStack>
<Box
w="40px"
h="40px"
borderRadius="md"
bg={clubPrimaryColor}
borderWidth="1px"
borderColor="gray.300"
/>
{clubSecondaryColor && (
<Box
w="40px"
h="40px"
borderRadius="md"
bg={clubSecondaryColor}
borderWidth="1px"
borderColor="gray.300"
/>
)}
<Text fontSize="xs" color={secondaryText}>
Použity pro marker a overlay
</Text>
</HStack>
</Box>
)}
</VStack>
</Box>
{/* Interactive Map Preview */}
<Box
borderWidth="1px"
borderRadius="md"
overflow="hidden"
boxShadow="sm"
>
<ContactMap
latitude={50.0755}
longitude={14.4378}
zoom={13}
address="Praha, Česká republika"
clubName="Náhled mapy"
mapStyle={value}
clubPrimaryColor={clubPrimaryColor}
clubSecondaryColor={clubSecondaryColor}
height={300}
/>
</Box>
<Text fontSize="xs" color={secondaryText} textAlign="center">
Náhled interaktivní mapy se zvoleným stylem
</Text>
</VStack>
)}
<Box
p={3}
bg={tipsBg}
borderWidth="1px"
borderColor={tipsBorder}
borderRadius="md"
>
<Text fontSize="sm" fontWeight="semibold" mb={1}>
💡 Tipy pro výběr stylu:
</Text>
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={2}>
<Text fontSize="xs" color={textColor}>
<strong>Positron/Toner Lite</strong> - nejlepší pro barevné markery
</Text>
<Text fontSize="xs" color={textColor}>
<strong>Dark Matter</strong> - skvělé pro tmavý design
</Text>
<Text fontSize="xs" color={textColor}>
<strong>Toner B&W</strong> - vysoký kontrast, elegantní
</Text>
<Text fontSize="xs" color={textColor}>
<strong>Voyager</strong> - vyváženě pro všechny případy
</Text>
</SimpleGrid>
</Box>
<Text fontSize="xs" color={secondaryText}>
Všechny mapy jsou open-source a bezplatné.
{clubPrimaryColor && ' Mapa bude automaticky obarvena barvami klubu.'}
</Text>
</VStack>
);
};
export default MapStyleSelector;
@@ -0,0 +1,276 @@
import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalFooter,
ModalBody,
ModalCloseButton,
Button,
VStack,
Text,
Divider,
HStack,
Badge,
useClipboard,
useToast,
Box,
Input,
FormControl,
FormLabel,
useDisclosure,
Popover,
PopoverTrigger,
PopoverContent,
PopoverHeader,
PopoverBody,
PopoverArrow,
PopoverCloseButton,
} from '@chakra-ui/react';
import { EmailIcon, CopyIcon, CheckIcon, DeleteIcon, ArrowForwardIcon } from '@chakra-ui/icons';
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { forwardMessage } from '../../services/admin/contactMessages';
import { format } from 'date-fns';
import { cs } from 'date-fns/locale';
interface MessageDetailModalProps {
isOpen: boolean;
onClose: () => void;
message: {
id: string;
name: string;
email: string;
subject?: string;
message: string;
source?: string;
ipAddress?: string;
userAgent?: string;
isRead: boolean;
createdAt: string;
};
onDelete: () => void;
onMarkAsRead: () => void;
}
export default function MessageDetailModal({
isOpen,
onClose,
message,
onDelete,
onMarkAsRead,
}: MessageDetailModalProps) {
const toast = useToast();
const queryClient = useQueryClient();
const { hasCopied, onCopy } = useClipboard(message.email);
const { isOpen: isPopoverOpen, onOpen: onPopoverOpen, onClose: onPopoverClose } = useDisclosure();
const [forwardEmail, setForwardEmail] = useState('');
const formatDate = (dateString: string) => {
return format(new Date(dateString), 'd. M. yyyy HH:mm', { locale: cs });
};
const handleCopyEmail = () => {
onCopy();
toast({
title: 'E-mail zkopírován do schránky',
status: 'success',
duration: 2000,
isClosable: true,
});
};
const forwardMutation = useMutation({
mutationFn: (toEmail: string) => forwardMessage(message.id, toEmail),
onSuccess: () => {
toast({
title: 'Zpráva přeposílána',
description: `Zpráva bude odeslána na ${forwardEmail}`,
status: 'success',
duration: 3000,
isClosable: true,
});
setForwardEmail('');
onPopoverClose();
},
onError: () => {
toast({
title: 'Chyba',
description: 'Nepodařilo se přeposlat zprávu',
status: 'error',
duration: 3000,
isClosable: true,
});
},
});
const handleForward = () => {
if (!forwardEmail || !forwardEmail.includes('@')) {
toast({
title: 'Chyba',
description: 'Zadejte platnou e-mailovou adresu',
status: 'error',
duration: 3000,
});
return;
}
forwardMutation.mutate(forwardEmail);
};
return (
<Modal isOpen={isOpen} onClose={onClose} size="xl">
<ModalOverlay />
<ModalContent>
<ModalHeader>
<Box>
<Text fontSize="lg" fontWeight="bold">
{message.subject || 'Bez předmětu'}
</Text>
<HStack mt={1} fontSize="sm" color="gray.500">
<Text>{formatDate(message.createdAt)}</Text>
{!message.isRead && (
<Badge colorScheme="blue">
Nová zpráva
</Badge>
)}
{message.source && (
<Badge colorScheme={message.source === 'sponsor' ? 'purple' : 'gray'}>
{message.source === 'sponsor' ? 'Sponzor' : 'Kontakt'}
</Badge>
)}
</HStack>
</Box>
</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack align="stretch" spacing={4}>
<Box>
<Text fontWeight="bold" mb={1}>
Od:
</Text>
<HStack>
<Text>{message.name}</Text>
<HStack
as="button"
onClick={handleCopyEmail}
color="blue.500"
_hover={{ textDecoration: 'underline' }}
spacing={1}
>
<Text>&lt;{message.email}&gt;</Text>
{hasCopied ? (
<CheckIcon boxSize={3} />
) : (
<CopyIcon boxSize={3} />
)}
</HStack>
</HStack>
</Box>
<Divider />
<Box>
<Text fontWeight="bold" mb={2}>
Zpráva:
</Text>
<Text whiteSpace="pre-wrap" p={3} bg="gray.50" borderRadius="md">
{message.message}
</Text>
</Box>
{(message.ipAddress || message.userAgent) && (
<Box mt={4} fontSize="sm" color="gray.500">
<Text fontWeight="bold" mb={1}>
Technické informace:
</Text>
{message.ipAddress && (
<Text>
<Text as="span" fontWeight="medium">IP adresa:</Text>{' '}
{message.ipAddress}
</Text>
)}
{message.userAgent && (
<Text mt={1} isTruncated title={message.userAgent}>
<Text as="span" fontWeight="medium">Prohlížeč:</Text>{' '}
{message.userAgent.length > 50
? `${message.userAgent.substring(0, 47)}...`
: message.userAgent}
</Text>
)}
</Box>
)}
</VStack>
</ModalBody>
<ModalFooter>
<HStack spacing={2} flexWrap="wrap" justify="flex-end" w="full">
<Button
variant="outline"
colorScheme="red"
leftIcon={<DeleteIcon />}
onClick={onDelete}
size={{ base: "sm", md: "md" }}
>
Smazat
</Button>
<Popover isOpen={isPopoverOpen} onClose={onPopoverClose}>
<PopoverTrigger>
<Button
colorScheme="teal"
leftIcon={<ArrowForwardIcon />}
onClick={onPopoverOpen}
size={{ base: "sm", md: "md" }}
>
Přeposlat
</Button>
</PopoverTrigger>
<PopoverContent>
<PopoverArrow />
<PopoverCloseButton />
<PopoverHeader>Přeposlat zprávu</PopoverHeader>
<PopoverBody>
<VStack spacing={3}>
<FormControl>
<FormLabel fontSize="sm">E-mailová adresa</FormLabel>
<Input
placeholder="prijemce@email.cz"
value={forwardEmail}
onChange={(e) => setForwardEmail(e.target.value)}
type="email"
size="sm"
/>
</FormControl>
<Button
size="sm"
colorScheme="teal"
width="full"
onClick={handleForward}
isLoading={forwardMutation.isLoading}
>
Odeslat
</Button>
</VStack>
</PopoverBody>
</PopoverContent>
</Popover>
{!message.isRead && (
<Button
colorScheme="blue"
leftIcon={<EmailIcon />}
onClick={onMarkAsRead}
size={{ base: "sm", md: "md" }}
>
Označit jako přečtené
</Button>
)}
<Button variant="ghost" onClick={onClose} size={{ base: "sm", md: "md" }}>
Zavřít
</Button>
</HStack>
</ModalFooter>
</ModalContent>
</Modal>
);
}
@@ -0,0 +1,235 @@
import React, { useState, useRef } from 'react';
import {
Box,
Button,
VStack,
HStack,
Text,
IconButton,
Progress,
Badge,
List,
ListItem,
Icon,
useToast,
Flex,
} from '@chakra-ui/react';
import { FiUpload, FiX, FiFile, FiImage, FiFileText } from 'react-icons/fi';
import { uploadFile } from '../../services/articles';
export type UploadedFile = {
url: string;
name: string;
type: string;
size: number;
};
interface MultiFileUploadProps {
onFilesUploaded: (files: UploadedFile[]) => void;
existingFiles?: UploadedFile[];
acceptedTypes?: string;
maxFiles?: number;
}
const MultiFileUpload: React.FC<MultiFileUploadProps> = ({
onFilesUploaded,
existingFiles = [],
acceptedTypes = 'image/*,application/pdf,.doc,.docx,.xls,.xlsx',
maxFiles = 10,
}) => {
const [files, setFiles] = useState<UploadedFile[]>(existingFiles);
const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const fileInputRef = useRef<HTMLInputElement>(null);
const toast = useToast();
const getFileIcon = (type: string) => {
if (type.startsWith('image/')) return FiImage;
if (type.includes('pdf')) return FiFileText;
return FiFile;
};
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
};
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFiles = e.target.files;
if (!selectedFiles || selectedFiles.length === 0) return;
if (files.length + selectedFiles.length > maxFiles) {
toast({
title: 'Příliš mnoho souborů',
description: `Můžete nahrát maximálně ${maxFiles} souborů`,
status: 'warning',
});
return;
}
setUploading(true);
setUploadProgress(0);
const uploadedFiles: UploadedFile[] = [];
const totalFiles = selectedFiles.length;
try {
for (let i = 0; i < selectedFiles.length; i++) {
const file = selectedFiles[i];
// Check file size (max 10MB)
if (file.size > 10 * 1024 * 1024) {
toast({
title: 'Soubor je příliš velký',
description: `${file.name} překračuje limit 10MB`,
status: 'error',
});
continue;
}
try {
const result = await uploadFile(file);
uploadedFiles.push({
url: result.url,
name: file.name,
type: file.type,
size: file.size,
});
setUploadProgress(((i + 1) / totalFiles) * 100);
} catch (error: any) {
toast({
title: 'Chyba při nahrávání',
description: `${file.name}: ${error.message}`,
status: 'error',
});
}
}
const updatedFiles = [...files, ...uploadedFiles];
setFiles(updatedFiles);
onFilesUploaded(updatedFiles);
if (uploadedFiles.length > 0) {
toast({
title: 'Úspěšně nahráno',
description: `${uploadedFiles.length} souborů bylo nahráno`,
status: 'success',
});
}
} finally {
setUploading(false);
setUploadProgress(0);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}
};
const handleRemoveFile = (index: number) => {
const updatedFiles = files.filter((_, i) => i !== index);
setFiles(updatedFiles);
onFilesUploaded(updatedFiles);
};
const handleCopyUrl = (url: string) => {
navigator.clipboard.writeText(url);
toast({
title: 'Zkopírováno',
description: 'URL byla zkopírována do schránky',
status: 'success',
duration: 2000,
});
};
return (
<Box>
<input
ref={fileInputRef}
type="file"
accept={acceptedTypes}
multiple
style={{ display: 'none' }}
onChange={handleFileSelect}
/>
<Button
leftIcon={<FiUpload />}
onClick={() => fileInputRef.current?.click()}
isLoading={uploading}
isDisabled={files.length >= maxFiles}
colorScheme="blue"
variant="outline"
size="sm"
>
Nahrát soubory ({files.length}/{maxFiles})
</Button>
{uploading && (
<Progress value={uploadProgress} size="sm" colorScheme="blue" mt={2} />
)}
{files.length > 0 && (
<List spacing={2} mt={4}>
{files.map((file, index) => (
<ListItem
key={index}
p={2}
borderWidth="1px"
borderRadius="md"
bg="gray.50"
_hover={{ bg: 'gray.100' }}
>
<Flex align="center" justify="space-between">
<HStack spacing={3} flex={1}>
<Icon as={getFileIcon(file.type)} boxSize={5} color="blue.500" />
<VStack align="start" spacing={0} flex={1}>
<Text fontSize="sm" fontWeight="medium" noOfLines={1}>
{file.name}
</Text>
<HStack spacing={2}>
<Badge fontSize="xs" colorScheme="gray">
{formatFileSize(file.size)}
</Badge>
{file.type.startsWith('image/') && (
<Badge fontSize="xs" colorScheme="blue">Obrázek</Badge>
)}
{file.type.includes('pdf') && (
<Badge fontSize="xs" colorScheme="red">PDF</Badge>
)}
</HStack>
</VStack>
</HStack>
<HStack>
<Button
size="xs"
variant="ghost"
onClick={() => handleCopyUrl(file.url)}
>
Kopírovat URL
</Button>
<IconButton
aria-label="Odstranit"
icon={<FiX />}
size="xs"
colorScheme="red"
variant="ghost"
onClick={() => handleRemoveFile(index)}
/>
</HStack>
</Flex>
</ListItem>
))}
</List>
)}
<Text fontSize="xs" color="gray.600" mt={2}>
Podporované formáty: obrázky, PDF, Word, Excel (max 10MB na soubor)
</Text>
</Box>
);
};
export default MultiFileUpload;
@@ -0,0 +1,111 @@
import { Box, Heading, Text, HStack, Button, ButtonProps, useColorModeValue, VStack, Icon, Flex, Badge } from '@chakra-ui/react';
import { ReactElement, ReactNode } from 'react';
interface PageHeaderProps {
title: string;
description?: string;
icon?: any; // Icon component
badge?: {
label: string;
colorScheme?: string;
};
action?: {
label: string;
icon?: ReactElement;
onClick: () => void;
colorScheme?: ButtonProps['colorScheme'];
isLoading?: boolean;
isDisabled?: boolean;
};
children?: ReactNode;
}
export const PageHeader = ({
title,
description,
icon,
badge,
action,
children,
}: PageHeaderProps) => {
const borderColor = useColorModeValue('gray.200', 'gray.700');
const iconBg = useColorModeValue('blue.50', 'blue.900');
const iconColor = useColorModeValue('blue.600', 'blue.300');
return (
<Box
mb={8}
pb={6}
borderBottomWidth="1px"
borderColor={borderColor}
>
<Flex justify="space-between" align="flex-start" wrap="wrap" gap={4}>
<HStack spacing={4} align="flex-start" flex={1}>
{icon && (
<Box
p={3}
bg={iconBg}
borderRadius="xl"
display={{ base: 'none', md: 'block' }}
>
<Icon as={icon} boxSize={6} color={iconColor} />
</Box>
)}
<VStack align="flex-start" spacing={2} flex={1}>
<HStack spacing={3} wrap="wrap">
<Heading
size="xl"
fontWeight="extrabold"
bgGradient={useColorModeValue(
'linear(to-r, gray.800, gray.600)',
'linear(to-r, white, gray.300)'
)}
bgClip="text"
>
{title}
</Heading>
{badge && (
<Badge
colorScheme={badge.colorScheme || 'blue'}
fontSize="sm"
px={3}
py={1}
borderRadius="full"
>
{badge.label}
</Badge>
)}
</HStack>
{description && (
<Text
color={useColorModeValue('gray.600', 'gray.400')}
fontSize="md"
maxW="2xl"
>
{description}
</Text>
)}
</VStack>
</HStack>
{action && (
<Button
leftIcon={action.icon}
onClick={action.onClick}
colorScheme={action.colorScheme || 'blue'}
isLoading={action.isLoading}
isDisabled={action.isDisabled}
size="lg"
shadow="sm"
_hover={{ shadow: 'md', transform: 'translateY(-1px)' }}
transition="all 0.2s"
>
{action.label}
</Button>
)}
</Flex>
{children}
</Box>
);
};
@@ -0,0 +1,292 @@
import React, { useState } from 'react';
import {
Box,
VStack,
HStack,
FormControl,
FormLabel,
Button,
Badge,
Text,
useToast,
Select,
Spinner,
Alert,
AlertIcon,
IconButton,
useColorModeValue,
Collapse,
Divider,
} from '@chakra-ui/react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { AddIcon, DeleteIcon, ChevronDownIcon, ChevronUpIcon } from '@chakra-ui/icons';
import { getPolls, createPoll, updatePoll, Poll, CreatePollRequest } from '../../services/polls';
interface PollLinkerProps {
articleId?: number;
eventId?: number;
onPollsChanged?: () => void;
}
/**
* PollLinker - Component to manage poll associations with articles/events
* Can be embedded in article and activity admin pages
*/
const PollLinker: React.FC<PollLinkerProps> = ({ articleId, eventId, onPollsChanged }) => {
const toast = useToast();
const queryClient = useQueryClient();
const [isExpanded, setIsExpanded] = useState(false);
const [selectedPollId, setSelectedPollId] = useState<string>('');
const bgBox = useColorModeValue('gray.50', 'gray.700');
const borderColor = useColorModeValue('gray.200', 'gray.600');
// Query for existing polls
const queryParams = articleId ? { article_id: articleId } : eventId ? { event_id: eventId } : {};
const { data: linkedPolls, isLoading: isLoadingLinked } = useQuery({
queryKey: ['linked-polls', queryParams],
queryFn: () => getPolls(queryParams),
enabled: !!(articleId || eventId),
});
// Query for all available polls
const { data: allPolls, isLoading: isLoadingAll } = useQuery({
queryKey: ['all-admin-polls'],
queryFn: () => getPolls({ status: 'active' }),
});
// Mutation to link existing poll
const linkPollMutation = useMutation({
mutationFn: async (pollId: number) => {
const updateData: any = {};
if (articleId) updateData.related_article_id = articleId;
if (eventId) updateData.related_event_id = eventId;
return updatePoll(pollId, updateData);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['linked-polls'] });
queryClient.invalidateQueries({ queryKey: ['all-admin-polls'] });
toast({
title: 'Anketa propojena',
status: 'success',
duration: 3000,
});
setSelectedPollId('');
if (onPollsChanged) onPollsChanged();
},
onError: (error: any) => {
toast({
title: 'Chyba',
description: error.response?.data?.error || 'Nepodařilo se propojit anketu',
status: 'error',
duration: 5000,
});
},
});
// Mutation to unlink poll
const unlinkPollMutation = useMutation({
mutationFn: async (pollId: number) => {
const updateData: any = {};
if (articleId) updateData.related_article_id = null;
if (eventId) updateData.related_event_id = null;
return updatePoll(pollId, updateData);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['linked-polls'] });
queryClient.invalidateQueries({ queryKey: ['all-admin-polls'] });
toast({
title: 'Anketa odpojena',
status: 'success',
duration: 3000,
});
if (onPollsChanged) onPollsChanged();
},
onError: (error: any) => {
toast({
title: 'Chyba',
description: error.response?.data?.error || 'Nepodařilo se odpojit anketu',
status: 'error',
duration: 5000,
});
},
});
const handleLinkPoll = () => {
if (!selectedPollId) {
toast({
title: 'Vyberte anketu',
description: 'Prosím vyberte anketu ze seznamu',
status: 'warning',
duration: 3000,
});
return;
}
linkPollMutation.mutate(parseInt(selectedPollId));
};
const handleUnlinkPoll = (pollId: number) => {
if (window.confirm('Opravdu chcete odpojit tuto anketu?')) {
unlinkPollMutation.mutate(pollId);
}
};
// Filter out polls that are already linked
const linkedPollIds = new Set(linkedPolls?.map(p => p.id) || []);
const availablePolls = allPolls?.filter(p => !linkedPollIds.has(p.id)) || [];
if (!articleId && !eventId) {
return null;
}
return (
<Box
borderWidth="1px"
borderColor={borderColor}
borderRadius="md"
p={4}
bg={bgBox}
>
<VStack align="stretch" spacing={3}>
<HStack justify="space-between">
<HStack>
<Text fontWeight="bold" fontSize="sm">
Ankety ({linkedPolls?.length || 0})
</Text>
{(linkedPolls?.length || 0) > 0 && (
<Badge colorScheme="blue">{linkedPolls!.length} připojeno</Badge>
)}
</HStack>
<IconButton
aria-label={isExpanded ? 'Skrýt' : 'Zobrazit'}
icon={isExpanded ? <ChevronUpIcon /> : <ChevronDownIcon />}
size="sm"
variant="ghost"
onClick={() => setIsExpanded(!isExpanded)}
/>
</HStack>
<Collapse in={isExpanded}>
<VStack spacing={4} align="stretch">
{isLoadingLinked ? (
<HStack justify="center" py={4}>
<Spinner size="sm" />
<Text fontSize="sm">Načítání anket...</Text>
</HStack>
) : linkedPolls && linkedPolls.length > 0 ? (
<VStack spacing={2} align="stretch">
<Text fontSize="xs" fontWeight="bold" color="gray.500">
Připojené ankety:
</Text>
{linkedPolls.map((poll) => (
<HStack
key={poll.id}
p={2}
borderWidth="1px"
borderRadius="md"
justify="space-between"
bg="white"
_dark={{ bg: 'gray.800' }}
>
<VStack align="start" spacing={0} flex={1}>
<Text fontSize="sm" fontWeight="medium">
{poll.title}
</Text>
<HStack spacing={2}>
<Badge size="sm" colorScheme={poll.status === 'active' ? 'green' : 'gray'}>
{poll.status}
</Badge>
<Text fontSize="xs" color="gray.500">
{poll.total_votes} hlasů
</Text>
</HStack>
</VStack>
<IconButton
aria-label="Odpojit anketu"
icon={<DeleteIcon />}
size="sm"
colorScheme="red"
variant="ghost"
onClick={() => handleUnlinkPoll(poll.id)}
isLoading={unlinkPollMutation.isPending}
/>
</HStack>
))}
</VStack>
) : (
<Alert status="info" size="sm">
<AlertIcon />
<Text fontSize="sm">Žádné ankety nejsou připojeny</Text>
</Alert>
)}
<Divider />
{isLoadingAll ? (
<HStack justify="center" py={4}>
<Spinner size="sm" />
</HStack>
) : availablePolls.length > 0 ? (
<VStack spacing={3} align="stretch">
<Text fontSize="xs" fontWeight="bold" color="gray.500">
Připojit existující anketu:
</Text>
<HStack>
<Select
value={selectedPollId}
onChange={(e) => setSelectedPollId(e.target.value)}
placeholder="Vyberte anketu..."
size="sm"
flex={1}
>
{availablePolls.map((poll) => (
<option key={poll.id} value={poll.id}>
{poll.title} ({poll.status}) - {poll.total_votes} hlasů
</option>
))}
</Select>
<Button
leftIcon={<AddIcon />}
onClick={handleLinkPoll}
size="sm"
colorScheme="blue"
isLoading={linkPollMutation.isPending}
isDisabled={!selectedPollId}
>
Připojit
</Button>
</HStack>
</VStack>
) : (
<Alert status="warning" size="sm">
<AlertIcon />
<Text fontSize="sm">
Žádné dostupné ankety. Vytvořte novou v{' '}
<Button
as="a"
href="/admin/ankety"
target="_blank"
variant="link"
size="sm"
colorScheme="blue"
>
správě anket
</Button>
</Text>
</Alert>
)}
<Text fontSize="xs" color="gray.500">
💡 Tip: Ankety se zobrazí automaticky na konci {articleId ? 'článku' : 'aktivity'}
</Text>
</VStack>
</Collapse>
</VStack>
</Box>
);
};
export default PollLinker;
@@ -0,0 +1,207 @@
import React from 'react';
import {
Box,
FormControl,
FormLabel,
Select,
SimpleGrid,
Text,
VStack,
Badge,
HStack,
Switch,
Code,
Alert,
AlertIcon,
Link,
} from '@chakra-ui/react';
import { VECTOR_STYLES } from '../home/VectorMap';
import { ExternalLinkIcon } from '@chakra-ui/icons';
interface VectorMapStyleSelectorProps {
value: string;
onChange: (value: string) => void;
clubPrimaryColor?: string;
clubSecondaryColor?: string;
showPreview?: boolean;
useVectorMaps?: boolean;
onToggleVectorMaps?: (enabled: boolean) => void;
}
const VectorMapStyleSelector: React.FC<VectorMapStyleSelectorProps> = ({
value,
onChange,
clubPrimaryColor,
clubSecondaryColor,
showPreview = true,
useVectorMaps = false,
onToggleVectorMaps,
}) => {
const selectedStyle = VECTOR_STYLES[value as keyof typeof VECTOR_STYLES] || VECTOR_STYLES.positron;
return (
<VStack align="stretch" spacing={4}>
{onToggleVectorMaps && (
<FormControl display="flex" alignItems="center">
<FormLabel mb={0} htmlFor="vector-maps-toggle">
Použít vektorové mapy (MapLibre GL)
</FormLabel>
<Switch
id="vector-maps-toggle"
isChecked={useVectorMaps}
onChange={(e) => onToggleVectorMaps(e.target.checked)}
colorScheme="purple"
/>
<Badge ml={2} colorScheme="purple">Experimentální</Badge>
</FormControl>
)}
{useVectorMaps && (
<Alert status="info" borderRadius="md">
<AlertIcon />
<Box fontSize="sm">
<Text fontWeight="bold" mb={1}>Vektorové mapy aktivovány!</Text>
<Text>
Využíváte MapLibre GL s OpenMapTiles. Výhody: lepší výkon, ostřejší zobrazení,
možnost plné customizace stylů přes JSON.
</Text>
</Box>
</Alert>
)}
<FormControl>
<FormLabel>Styl mapy</FormLabel>
<Select value={value} onChange={(e) => onChange(e.target.value)}>
<option value="positron">Positron (Light)</option>
<option value="dark-matter">Dark Matter</option>
<option value="osm-bright">OSM Bright</option>
<option value="klokantech-basic">Basic</option>
</Select>
<Text fontSize="sm" color="gray.600" mt={1}>
{selectedStyle.description}
</Text>
</FormControl>
{showPreview && useVectorMaps && (
<Box
p={4}
borderWidth="1px"
borderRadius="md"
bg="gray.50"
>
<VStack align="stretch" spacing={3}>
<HStack justify="space-between">
<Text fontWeight="semibold">{selectedStyle.name}</Text>
<Badge colorScheme="purple">Vector Tiles</Badge>
</HStack>
<Text fontSize="sm" color="gray.600">
{selectedStyle.description}
</Text>
{clubPrimaryColor && (
<Box>
<Text fontSize="sm" fontWeight="medium" mb={2}>
Barvy klubu (aplikovány dynamicky):
</Text>
<HStack>
<Box
w="40px"
h="40px"
borderRadius="md"
bg={clubPrimaryColor}
borderWidth="1px"
borderColor="gray.300"
/>
{clubSecondaryColor && (
<Box
w="40px"
h="40px"
borderRadius="md"
bg={clubSecondaryColor}
borderWidth="1px"
borderColor="gray.300"
/>
)}
<Text fontSize="xs" color="gray.500">
Marker + vodní plochy
</Text>
</HStack>
</Box>
)}
</VStack>
</Box>
)}
<Box
p={3}
bg="purple.50"
borderWidth="1px"
borderColor="purple.200"
borderRadius="md"
>
<Text fontSize="sm" fontWeight="semibold" mb={2}>
🚀 Výhody vektorových map:
</Text>
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={2}>
<Text fontSize="xs" color="gray.700">
<strong>Lepší výkon</strong> - rychlejší načítání
</Text>
<Text fontSize="xs" color="gray.700">
<strong>Ostrý obraz</strong> - perfektní na Retina
</Text>
<Text fontSize="xs" color="gray.700">
<strong>Dynamické styly</strong> - změna za běhu
</Text>
<Text fontSize="xs" color="gray.700">
<strong>Club colors</strong> - automatická integrace
</Text>
</SimpleGrid>
</Box>
<Box
p={3}
bg="blue.50"
borderWidth="1px"
borderColor="blue.200"
borderRadius="md"
>
<Text fontSize="sm" fontWeight="semibold" mb={1}>
📚 Vlastní styly:
</Text>
<Text fontSize="xs" color="gray.700" mb={2}>
Pro pokročilé použití můžete vytvořit vlastní style JSON podle{' '}
<Link
href="https://github.com/openmaptiles/positron-gl-style"
isExternal
color="blue.600"
>
Positron GL Style <ExternalLinkIcon mx="2px" />
</Link>
</Text>
<Code fontSize="xs" p={2} borderRadius="md" display="block">
customStyleUrl: "https://your-server.com/style.json"
</Code>
</Box>
<Text fontSize="xs" color="gray.500">
Používáme MapLibre GL JS (open-source) s MapTiler tiles.
{clubPrimaryColor && ' Barvy klubu jsou aplikovány automaticky na markery a vybrané prvky mapy.'}
</Text>
{useVectorMaps && (
<Alert status="warning" borderRadius="md" fontSize="sm">
<AlertIcon />
<Text>
<strong>API Key:</strong> Nastavte <Code>REACT_APP_MAPTILER_KEY</Code> v <Code>.env</Code> souboru
pro produkční použití. Získejte zdarma na{' '}
<Link href="https://www.maptiler.com/" isExternal color="blue.600">
maptiler.com <ExternalLinkIcon mx="2px" />
</Link>
</Text>
</Alert>
)}
</VStack>
);
};
export default VectorMapStyleSelector;
@@ -0,0 +1,385 @@
import React, { useMemo, useState, useEffect } from 'react';
import {
Box,
Card,
CardBody,
CardHeader,
Flex,
Heading,
Skeleton,
Text,
useColorModeValue,
VStack,
Tooltip,
Icon,
} from '@chakra-ui/react';
import { FaInfoCircle } from 'react-icons/fa';
import { ComposableMap, Geographies, Geography } from 'react-simple-maps';
import type { Feature } from 'geojson';
import { useClubTheme } from '../../contexts/ClubThemeContext';
const GEO_URL = 'https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json';
type CountryDatum = {
code: string;
value: number;
name?: string | null;
};
type VisitorCountriesMapProps = {
title?: string;
metrics: CountryDatum[];
isLoading?: boolean;
height?: number;
onCountryClick?: (countryCode: string, countryName: string, value: number) => void;
clearSelection?: boolean;
};
type HoverState = {
name: string;
value: number;
};
type ClickedState = {
name: string;
value: number;
code: string;
};
const mixChannel = (start: number, end: number, factor: number) => {
return Math.round(start + (end - start) * factor);
};
const hexToRgb = (hex: string) => {
const normalized = hex.replace('#', '');
const bigint = parseInt(normalized, 16);
return {
r: (bigint >> 16) & 255,
g: (bigint >> 8) & 255,
b: bigint & 255,
};
};
const interpolateColor = (startHex: string, endHex: string, factor: number) => {
const start = hexToRgb(startHex);
const end = hexToRgb(endHex);
const clamped = Math.min(Math.max(factor, 0), 1);
const r = mixChannel(start.r, end.r, clamped);
const g = mixChannel(start.g, end.g, clamped);
const b = mixChannel(start.b, end.b, clamped);
return `rgb(${r}, ${g}, ${b})`;
};
const getDisplayNames = () => {
if (typeof Intl !== 'undefined' && typeof (Intl as any).DisplayNames === 'function') {
try {
return new Intl.DisplayNames(['cs', 'en'], { type: 'region' });
} catch (error) {
// Some browsers might throw when the locale is unsupported
return new Intl.DisplayNames(['en'], { type: 'region' });
}
}
return null;
};
export const VisitorCountriesMap: React.FC<VisitorCountriesMapProps> = ({
title = 'Mapa návštěvníků podle země',
metrics = [],
isLoading = false,
height = 400,
onCountryClick,
clearSelection = false,
}) => {
const [hovered, setHovered] = useState<HoverState | null>(null);
const [clicked, setClicked] = useState<ClickedState | null>(null);
const displayNames = useMemo(getDisplayNames, []);
const numberFormatter = useMemo(() => new Intl.NumberFormat('cs-CZ'), []);
const clubTheme = useClubTheme();
// Clear selection when clearSelection prop changes
useEffect(() => {
if (clearSelection) {
setClicked(null);
}
}, [clearSelection]);
const dataMap = useMemo(() => {
const map = new Map<string, { value: number; name: string }>();
metrics.forEach((item) => {
if (!item?.code || typeof item.value !== 'number') return;
const normalizedCode = item.code.toUpperCase();
const fallbackName = item.name ||
(normalizedCode.length === 2 ? displayNames?.of(normalizedCode) ?? normalizedCode : normalizedCode);
map.set(normalizedCode, {
value: item.value,
name: fallbackName || normalizedCode,
});
});
return map;
}, [metrics, displayNames]);
const maxValue = useMemo(() => {
let max = 0;
dataMap.forEach(({ value }) => {
if (value > max) max = value;
});
return max;
}, [dataMap]);
const borderColor = useColorModeValue('gray.200', 'gray.700');
const defaultFill = useColorModeValue('#EDF2F7', '#2D3748');
// Use club colors for the map gradient
const startFill = useColorModeValue(
hexToRgb(clubTheme.primary).r > 200 ? clubTheme.secondary : clubTheme.primary,
clubTheme.primary
);
const endFill = useColorModeValue(
clubTheme.accent || clubTheme.primary,
clubTheme.secondary || clubTheme.primary
);
const tooltipBg = useColorModeValue('white', 'gray.800');
const tooltipBorder = useColorModeValue('gray.200', 'gray.600');
// Enhanced border color for better visibility
const countryBorderColor = useColorModeValue('#CBD5E0', '#4A5568');
const hoveredBorderColor = clubTheme.secondary || '#F6AD55';
const getDatumForGeo = (geo: Feature) => {
const properties = (geo.properties || {}) as Record<string, any>;
const iso2 = (properties.ISO_A2 || properties.iso_a2 || '').toUpperCase();
const iso3 = (properties.ISO_A3 || properties.iso_a3 || '').toUpperCase();
return (iso2 && dataMap.get(iso2)) || (iso3 && dataMap.get(iso3)) || null;
};
const getFillForDatum = (datum: { value: number } | null, isHovered: boolean = false, isClicked: boolean = false) => {
if (!datum || maxValue <= 0) return defaultFill;
const ratio = datum.value / maxValue;
const baseColor = interpolateColor(startFill, endFill, ratio);
// Enhanced visual feedback
if (isClicked) {
// Make clicked country more prominent
const rgb = hexToRgb(baseColor.startsWith('#') ? baseColor : '#000000');
return `rgb(${Math.min(rgb.r + 50, 255)}, ${Math.min(rgb.g + 50, 255)}, ${Math.min(rgb.b + 50, 255)})`;
} else if (isHovered) {
// Brighten on hover
const rgb = hexToRgb(baseColor.startsWith('#') ? baseColor : '#000000');
return `rgb(${Math.min(rgb.r + 30, 255)}, ${Math.min(rgb.g + 30, 255)}, ${Math.min(rgb.b + 30, 255)})`;
}
return baseColor;
};
const hasData = metrics?.some((item) => item.value > 0);
return (
<Card borderColor={borderColor} overflow="hidden">
<CardHeader>
<Flex align="center" justify="space-between">
<Heading size="md">{title}</Heading>
<Tooltip
label="Klikněte na zemi pro zobrazení detailních analytických dat"
placement="top"
hasArrow
>
<Icon as={FaInfoCircle} color="gray.400" cursor="help" />
</Tooltip>
</Flex>
</CardHeader>
<CardBody>
{isLoading ? (
<Skeleton height={`${height}px`} borderRadius="md" />
) : !hasData ? (
<Box textAlign="center" py={12} color="gray.500">
<Text>Pro vybraný rozsah nejsou k dispozici data o zemích návštěvníků.</Text>
</Box>
) : (
<Box
position="relative"
width="100%"
overflow="hidden"
borderRadius="md"
boxShadow="sm"
transition="all 0.3s ease-in-out"
_hover={{
boxShadow: 'md',
}}
>
<ComposableMap
projectionConfig={{
scale: 150,
center: [0, 20],
}}
height={height}
width={800}
style={{
width: '100%',
height: 'auto',
filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.1))',
}}
>
<Geographies geography={GEO_URL}>
{({ geographies }: { geographies: Feature[] }) =>
geographies.map((geo: Feature) => {
const datum = getDatumForGeo(geo);
const properties = (geo.properties || {}) as Record<string, any>;
const countryCode = (properties.ISO_A2 || properties.iso_a2 || '').toUpperCase();
const isHovered = hovered?.name === datum?.name;
const isClicked = clicked?.code === countryCode;
const hasData = datum !== null;
return (
<Geography
key={geo.rsmKey}
geography={geo}
stroke={isClicked ? clubTheme.primary : (isHovered ? hoveredBorderColor : countryBorderColor)}
strokeWidth={isClicked ? 2.5 : (isHovered ? 1.5 : 0.7)}
fill={getFillForDatum(datum, isHovered, isClicked)}
style={{
default: {
outline: 'none',
cursor: hasData ? 'pointer' : 'default',
transition: 'all 0.2s ease-in-out',
},
hover: {
outline: 'none',
cursor: hasData ? 'pointer' : 'default',
transition: 'all 0.2s ease-in-out',
},
pressed: {
outline: 'none',
cursor: hasData ? 'pointer' : 'default',
transition: 'all 0.2s ease-in-out',
},
}}
onMouseEnter={() => {
if (!datum) {
setHovered(null);
return;
}
setHovered({
name: datum.name,
value: datum.value,
});
}}
onMouseLeave={() => setHovered(null)}
onClick={() => {
if (datum && onCountryClick) {
setClicked({
name: datum.name,
value: datum.value,
code: countryCode,
});
onCountryClick(countryCode, datum.name, datum.value);
}
}}
/>
);
})
}
</Geographies>
</ComposableMap>
{hovered && (
<Box
position="absolute"
bottom={4}
left={4}
bg={tooltipBg}
border="1px solid"
borderColor={tooltipBorder}
borderRadius="md"
px={3}
py={2}
boxShadow="lg"
zIndex={10}
>
<VStack spacing={1} align="start">
<Text fontWeight="semibold">{hovered.name}</Text>
<Text fontSize="sm" color="gray.500">
{numberFormatter.format(hovered.value)} návštěv
</Text>
<Text fontSize="xs" color="blue.500" fontWeight="medium">
Klikněte pro detaily
</Text>
</VStack>
</Box>
)}
{clicked && (
<Box
position="absolute"
top={4}
right={4}
bg={clubTheme.primary}
color="white"
borderRadius="md"
px={3}
py={2}
boxShadow="lg"
zIndex={10}
>
<VStack spacing={1} align="start">
<Text fontWeight="semibold" fontSize="sm">
Vybraná země
</Text>
<Text fontSize="sm">{clicked.name}</Text>
<Text fontSize="xs" opacity={0.9}>
{numberFormatter.format(clicked.value)} návštěv
</Text>
</VStack>
</Box>
)}
</Box>
)}
{hasData && !isLoading && (
<VStack spacing={3} mt={4} align="stretch">
<Flex justify="space-between" align="center">
<Text fontSize="sm" color="gray.500" fontWeight="medium">
Méně návštěv
</Text>
<Text fontSize="xs" color="gray.400" textAlign="center">
Intenzita návštěvnosti
</Text>
<Text fontSize="sm" color="gray.500" fontWeight="medium">
Více návštěv
</Text>
</Flex>
<Box
height="12px"
borderRadius="full"
bgGradient={`linear(to-r, ${defaultFill}, ${startFill}, ${endFill})`}
boxShadow="inset 0 1px 2px rgba(0,0,0,0.1)"
position="relative"
overflow="hidden"
>
<Box
position="absolute"
top={0}
left={0}
right={0}
bottom={0}
bgGradient="linear(to-r, transparent, rgba(255,255,255,0.2), transparent)"
borderRadius="full"
/>
</Box>
{clicked && (
<Text fontSize="xs" color="blue.500" textAlign="center" fontWeight="medium">
💡 Klikněte na jinou zemi pro porovnání dat
</Text>
)}
</VStack>
)}
</CardBody>
</Card>
);
};
export default VisitorCountriesMap;
@@ -0,0 +1,25 @@
import { Navigate, useLocation } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
import { ReactNode } from 'react';
interface ProtectedRouteProps {
children: ReactNode;
}
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
const { isAuthenticated, isLoading } = useAuth();
const location = useLocation();
if (isLoading) {
return <div>Loading...</div>; // Or a loading spinner
}
if (!isAuthenticated) {
// Redirect to login page, saving the current location they were trying to go to
return <Navigate to="/login" state={{ from: location }} replace />;
}
return <>{children}</>;
};
export default ProtectedRoute;
@@ -0,0 +1,135 @@
import React from 'react';
import { Box, Link as ChakraLink } from '@chakra-ui/react';
import { assetUrl } from '../../utils/url';
export interface Banner {
id: number | string;
name: string;
image: string;
url?: string;
placement?: string;
width?: number;
height?: number;
is_active?: boolean;
}
interface BannerDisplayProps {
banners: Banner[];
placement: 'homepage_top' | 'homepage_middle' | 'homepage_sidebar' | 'homepage_footer' | 'article_inline';
containerStyle?: React.CSSProperties;
}
const BannerDisplay: React.FC<BannerDisplayProps> = ({ banners, placement, containerStyle }) => {
// Filter active banners for this placement
const activeBanners = (banners || []).filter(
b => b.placement === placement && (b.is_active !== false)
);
if (activeBanners.length === 0) {
return null;
}
const getContainerClass = () => {
switch (placement) {
case 'homepage_top':
return 'banner-top';
case 'homepage_middle':
return 'banner-middle';
case 'homepage_sidebar':
return 'banner-sidebar';
case 'homepage_footer':
return 'banner-footer';
case 'article_inline':
return 'banner-article';
default:
return 'banner';
}
};
const getDefaultContainerStyle = (): React.CSSProperties => {
const base: React.CSSProperties = {
margin: '24px 0',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
gap: '16px',
flexWrap: 'wrap',
};
switch (placement) {
case 'homepage_top':
return {
...base,
width: '100vw',
position: 'relative',
left: '50%',
right: '50%',
marginLeft: '-50vw',
marginRight: '-50vw',
backgroundColor: 'rgba(0, 0, 0, 0.02)',
padding: '16px',
borderTop: '1px solid rgba(0, 0, 0, 0.05)',
borderBottom: '1px solid rgba(0, 0, 0, 0.05)',
};
case 'homepage_footer':
return {
...base,
width: '100vw',
position: 'relative',
left: '50%',
right: '50%',
marginLeft: '-50vw',
marginRight: '-50vw',
backgroundColor: 'rgba(0, 0, 0, 0.02)',
padding: '24px 16px',
borderTop: '1px solid rgba(0, 0, 0, 0.05)',
};
case 'homepage_sidebar':
return {
display: 'block',
margin: '24px 0',
};
default:
return base;
}
};
const finalContainerStyle = { ...getDefaultContainerStyle(), ...containerStyle };
return (
<Box
as="section"
className={getContainerClass()}
sx={finalContainerStyle}
>
{activeBanners.map((banner) => (
<ChakraLink
key={banner.id}
href={banner.url || '#'}
isExternal={!!banner.url}
target={banner.url ? '_blank' : undefined}
rel={banner.url ? 'noopener noreferrer' : undefined}
display="inline-block"
_hover={{ opacity: 0.9, transform: 'translateY(-2px)' }}
transition="all 0.2s"
>
<img
src={assetUrl(banner.image) || banner.image}
alt={banner.name}
style={{
maxWidth: '100%',
width: banner.width ? `${banner.width}px` : 'auto',
height: banner.height ? `${banner.height}px` : 'auto',
objectFit: 'contain',
borderRadius: placement === 'homepage_sidebar' ? '8px' : '4px',
boxShadow: placement === 'homepage_sidebar' ? '0 2px 8px rgba(0,0,0,0.1)' : 'none',
}}
loading="lazy"
/>
</ChakraLink>
))}
</Box>
);
};
export default BannerDisplay;
@@ -0,0 +1,199 @@
import React, { useState, useEffect } from 'react';
import { format, startOfWeek, addDays, isSameDay, parseISO, isBefore } from 'date-fns';
import { cs } from 'date-fns/locale';
import { Event } from '../../types/event';
import { getEvents } from '../../services/eventService';
import { getMatches } from '../../services/public';
interface CalendarProps {
onEventClick?: (event: Event) => void;
}
const Calendar: React.FC<CalendarProps> = ({ onEventClick }) => {
const [currentDate, setCurrentDate] = useState(new Date());
const [events, setEvents] = useState<Event[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [latestResults, setLatestResults] = useState<any[]>([]);
useEffect(() => {
const fetchEvents = async () => {
try {
const data = await getEvents();
setEvents(data);
} catch (err) {
setError('Nepodařilo se načíst události');
console.error('Error fetching events:', err);
} finally {
setLoading(false);
}
};
fetchEvents();
}, []);
// Fetch latest results (small sidebar)
useEffect(() => {
let active = true;
(async () => {
try {
const matches = await getMatches();
// Expecting items with date or date_time and score/result
const now = new Date();
const normalized = (Array.isArray(matches) ? matches : []).map((m: any) => {
const dt = parseISO(m.date_time || m.date || m.match_date || m.datetime || '');
return { ...m, __dt: isNaN(dt as any) ? null : dt };
}).filter((m: any) => m.__dt && isBefore(m.__dt, now));
normalized.sort((a: any, b: any) => (b.__dt as any) - (a.__dt as any));
const recent = normalized.slice(0, 6);
if (active) setLatestResults(recent);
} catch (e) {
// silent fail for sidebar
}
})();
return () => { active = false };
}, []);
const startDate = startOfWeek(currentDate, { weekStartsOn: 1 }); // Start on Monday
const days = [];
for (let i = 0; i < 7; i++) {
const day = addDays(startDate, i);
days.push(day);
}
const getEventsForDay = (day: Date) => {
return events.filter(event => {
const eventDate = parseISO(event.start_time);
return isSameDay(eventDate, day);
});
};
const getEventTypeColor = (type: string) => {
switch (type) {
case 'match':
return 'bg-red-100 text-red-800 border-red-200';
case 'training':
return 'bg-blue-100 text-blue-800 border-blue-200';
case 'meeting':
return 'bg-green-100 text-green-800 border-green-200';
default:
return 'bg-gray-100 text-gray-800 border-gray-200';
}
};
if (loading) return <div>Načítám kalendář...</div>;
if (error) return <div className="text-red-500">{error}</div>;
return (
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
{/* Main Calendar (more prominent) */}
<div className="lg:col-span-3">
<div className="bg-white rounded-lg shadow overflow-hidden">
{/* Week header */}
<div className="grid grid-cols-7 gap-px bg-gray-200">
{days.map((day, i) => {
const isToday = isSameDay(day, new Date());
return (
<div key={i} className={`bg-white p-3 text-center ${isToday ? 'ring-2 ring-blue-500 ring-offset-2 ring-offset-white' : ''}`}>
<div className="font-semibold text-gray-900 uppercase tracking-wide text-xs">
{format(day, 'EEEE', { locale: cs })}
</div>
<div className={`mt-1 text-xl font-bold ${isToday ? 'text-blue-600' : 'text-gray-900'}`}>
{format(day, 'd')}
</div>
</div>
);
})}
</div>
{/* Grouped events by day */}
<div className="p-4">
{days.map((day, i) => {
const dayEvents = getEventsForDay(day);
const isToday = isSameDay(day, new Date());
return (
<div key={i} className="mb-5">
<div className={`sticky top-0 z-10 px-3 py-2 ${isToday ? 'bg-blue-50 border-l-4 border-blue-500' : 'bg-gray-50 border-l-4 border-gray-300'}`}>
<h3 className={`text-sm md:text-base font-semibold tracking-wide ${isToday ? 'text-blue-700' : 'text-gray-800'}`}>
{format(day, 'EEEE d. M. yyyy', { locale: cs })}
{isToday && <span className="ml-2 text-[10px] md:text-xs font-bold text-blue-700 uppercase bg-blue-100 px-2 py-0.5 rounded-full">Dnes</span>}
</h3>
</div>
{dayEvents.length > 0 ? (
<div className="mt-2 space-y-2">
{dayEvents.map((event) => (
<div
key={event.id}
onClick={() => onEventClick?.(event)}
className={`p-3 rounded-md border cursor-pointer transition-colors ${getEventTypeColor(event.type)} hover:opacity-95`}
style={{ borderColor: 'rgba(0,0,0,0.08)' }}
>
<div className="flex items-center justify-between">
<div className="font-medium truncate pr-2">{event.title}</div>
<div className="text-[10px] md:text-xs px-2 py-0.5 rounded-full bg-white/70 border border-black/10 text-gray-700">
{event.type}
</div>
</div>
<div className="text-sm text-gray-700">
{format(parseISO(event.start_time), 'H:mm', { locale: cs })}
{event.location && `${event.location}`}
</div>
</div>
))}
</div>
) : (
<p className="mt-2 text-gray-500 text-sm">Žádné události</p>
)}
</div>
);
})}
</div>
</div>
</div>
{/* Sidebar: Latest Results (compact, space-saving) */}
<aside className="lg:col-span-1">
<div className="bg-white rounded-lg shadow p-2 lg:sticky lg:top-2">
<h3 className="text-xs font-semibold text-gray-800 mb-2 tracking-wide uppercase">Nejnovější výsledky</h3>
{latestResults.length === 0 ? (
<p className="text-gray-500 text-xs">Zatím žádné výsledky</p>
) : (
<ul className="space-y-1">
{latestResults.slice(0,6).map((m: any, idx: number) => {
const nameHome = (m.home || m.home_team) || 'Domácí';
const nameAway = (m.away || m.away_team) || 'Hosté';
const score = m.score || (typeof m.result_home === 'number' && typeof m.result_away === 'number' ? `${m.result_home}:${m.result_away}` : '-');
const dtRaw = (m.date_time || m.date || m.match_date || m.datetime || '') as string;
let shortDate = '';
try {
const parsed = parseISO(dtRaw);
if (!isNaN((parsed as any))) {
shortDate = format(parsed as any, 'd.M.', { locale: cs });
}
} catch {}
return (
<li key={idx} className="flex items-center justify-between gap-2 p-1.5 rounded border border-gray-200/70 hover:bg-gray-50">
<div className="flex-1 min-w-0 text-[11px] font-medium text-gray-900 truncate">
<span className="truncate inline-block max-w-[46%] align-bottom">{nameHome}</span>
<span className="mx-1 text-gray-500">vs</span>
<span className="truncate inline-block max-w-[46%] align-bottom">{nameAway}</span>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
{shortDate && (
<span className="hidden sm:inline-block text-[10px] text-gray-600 bg-gray-100 border border-gray-200 rounded px-1 py-0.5">{shortDate}</span>
)}
<span className="text-[10px] font-extrabold bg-gray-800 text-white rounded px-1.5 py-0.5">{score}</span>
</div>
</li>
);
})}
</ul>
)}
</div>
</aside>
</div>
);
};
export default Calendar;
@@ -0,0 +1,80 @@
import {
AlertDialog,
AlertDialogBody,
AlertDialogContent,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogOverlay,
Button,
useDisclosure,
useToast,
} from '@chakra-ui/react';
import { useRef } from 'react';
interface ConfirmationDialogProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
title: string;
message: string;
confirmText?: string;
cancelText?: string;
isDanger?: boolean;
isLoading?: boolean;
}
export default function ConfirmationDialog({
isOpen,
onClose,
onConfirm,
title,
message,
confirmText = 'Potvrdit',
cancelText = 'Zrušit',
isDanger = false,
isLoading = false,
}: ConfirmationDialogProps) {
const cancelRef = useRef<HTMLButtonElement>(null);
const handleConfirm = () => {
onConfirm();
};
return (
<AlertDialog
isOpen={isOpen}
leastDestructiveRef={cancelRef}
onClose={onClose}
isCentered
>
<AlertDialogOverlay>
<AlertDialogContent>
<AlertDialogHeader fontSize="lg" fontWeight="bold">
{title}
</AlertDialogHeader>
<AlertDialogBody>{message}</AlertDialogBody>
<AlertDialogFooter>
<Button
ref={cancelRef}
onClick={onClose}
isDisabled={isLoading}
>
{cancelText}
</Button>
<Button
colorScheme={isDanger ? 'red' : 'blue'}
onClick={handleConfirm}
ml={3}
isLoading={isLoading}
loadingText="Zpracovávám..."
>
{confirmText}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialogOverlay>
</AlertDialog>
);
}
@@ -0,0 +1,956 @@
import React, { useRef, useCallback, useState, useEffect } from 'react';
import {
Box,
Button,
HStack,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalCloseButton,
ModalBody,
ModalFooter,
FormControl,
FormLabel,
FormHelperText,
Input,
Text,
SimpleGrid,
useToast,
VStack,
useColorModeValue,
ButtonGroup,
IconButton,
Tooltip,
} from '@chakra-ui/react';
import ReactQuill from 'react-quill';
import ReactCrop, { Crop } from 'react-image-crop';
import DOMPurify from 'dompurify';
import 'react-quill/dist/quill.snow.css';
import 'react-image-crop/dist/ReactCrop.css';
import '../../styles/custom-editor.css';
import {
Image as ImageIcon, Code, Type, Trash2, AlignLeft, AlignCenter, AlignRight,
RotateCw, RotateCcw, FlipHorizontal, FlipVertical, Sun, Droplets, Eye,
Sparkles, Contrast, ZoomIn, ZoomOut, Move, Maximize2, Settings,
Circle, Square, X, Check, Filter
} from 'lucide-react';
interface ImageFilters {
brightness: number;
contrast: number;
saturation: number;
blur: number;
grayscale: number;
sepia: number;
hueRotate: number;
rotation: number;
flipH: boolean;
flipV: boolean;
}
interface CustomRichEditorProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
height?: string;
readOnly?: boolean;
onImageUpload?: (file: File) => Promise<{ url: string }>;
showImageResize?: boolean;
toolbar?: 'full' | 'basic' | 'minimal';
}
const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
value,
onChange,
placeholder = 'Začněte psát...',
height = '400px',
readOnly = false,
onImageUpload,
showImageResize = true,
toolbar = 'full',
}) => {
const toast = useToast();
const quillRef = useRef<ReactQuill | null>(null);
const [editorMode, setEditorMode] = useState<'rich' | 'html'>('rich');
// Crop modal state
const [cropOpen, setCropOpen] = useState(false);
const [cropSrc, setCropSrc] = useState<string | null>(null);
const [crop, setCrop] = useState<Crop>({ unit: '%', width: 80, height: 80, x: 10, y: 10 });
const [cropQuality, setCropQuality] = useState<number>(85);
const [cropMaxWidth, setCropMaxWidth] = useState<number>(1500);
const imgRef = useRef<HTMLImageElement | null>(null);
const borderColor = useColorModeValue('gray.200', 'gray.600');
const bgColor = useColorModeValue('white', 'gray.800');
const hoverBg = useColorModeValue('gray.50', 'gray.700');
const toolbarBg = useColorModeValue('white', 'gray.800');
const toolbarBorder = useColorModeValue('gray.200', 'gray.700');
// Image editing state
const [selectedImageElement, setSelectedImageElement] = useState<HTMLImageElement | null>(null);
const [imageFilters, setImageFilters] = useState<ImageFilters>({
brightness: 100,
contrast: 100,
saturation: 100,
blur: 0,
grayscale: 0,
sepia: 0,
hueRotate: 0,
rotation: 0,
flipH: false,
flipV: false,
});
const [showImageToolbar, setShowImageToolbar] = useState(false);
const [toolbarPosition, setToolbarPosition] = useState({ top: 0, left: 0 });
// Define toolbar configurations
const toolbarConfigs = {
full: [
[{ header: [1, 2, 3, false] }],
['bold', 'italic', 'underline', 'strike'],
[{ color: [] }, { background: [] }],
[{ list: 'ordered' }, { list: 'bullet' }],
[{ align: [] }],
['link', 'image', 'video'],
['blockquote', 'code-block'],
['clean'],
],
basic: [
[{ header: [1, 2, 3, false] }],
['bold', 'italic', 'underline'],
[{ list: 'ordered' }, { list: 'bullet' }],
[{ align: [] }],
['link', 'image'],
['clean'],
],
minimal: [
['bold', 'italic', 'underline'],
[{ list: 'bullet' }],
['link'],
['clean'],
],
};
const getToolbarConfig = () => {
return toolbarConfigs[toolbar] || toolbarConfigs.full;
};
// Image upload handler
const handleImageUpload = useCallback(() => {
const input = document.createElement('input');
input.setAttribute('type', 'file');
input.setAttribute('accept', 'image/*');
input.onchange = async () => {
const file = (input.files || [])[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
setCropSrc(reader.result as string);
setCropOpen(true);
};
reader.readAsDataURL(file);
};
input.click();
}, []);
// Get cropped blob
const getCroppedBlob = (image: HTMLImageElement, cropPixels: { x: number; y: number; width: number; height: number }): Promise<Blob> => {
const canvas = document.createElement('canvas');
const scaleX = image.naturalWidth / image.width;
const scaleY = image.naturalHeight / image.height;
let outputWidth = Math.max(1, Math.round(cropPixels.width * scaleX));
let outputHeight = Math.max(1, Math.round(cropPixels.height * scaleY));
if (outputWidth > cropMaxWidth) {
const scale = cropMaxWidth / outputWidth;
outputWidth = cropMaxWidth;
outputHeight = Math.round(outputHeight * scale);
}
canvas.width = outputWidth;
canvas.height = outputHeight;
const ctx = canvas.getContext('2d', { alpha: false });
if (!ctx) throw new Error('Canvas not supported');
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(
image,
Math.round(cropPixels.x * scaleX),
Math.round(cropPixels.y * scaleY),
Math.round(cropPixels.width * scaleX),
Math.round(cropPixels.height * scaleY),
0,
0,
outputWidth,
outputHeight
);
return new Promise((resolve) => {
canvas.toBlob((blob) => resolve(blob as Blob), 'image/jpeg', cropQuality / 100);
});
};
// Confirm crop and insert
const confirmCropAndInsert = async () => {
try {
if (!imgRef.current) {
toast({ title: 'Chyba', description: 'Obrázek není načten', status: 'error' });
return;
}
if (!crop.width || !crop.height || crop.width <= 0 || crop.height <= 0) {
toast({ title: 'Chyba', description: 'Vyberte oblast k oříznutí', status: 'warning' });
return;
}
const img = imgRef.current;
const percToPx = (val: number, size: number) => (crop.unit === '%' ? (val / 100) * size : val);
const cropPx = {
x: Math.max(0, percToPx(crop.x || 0, img.width)),
y: Math.max(0, percToPx(crop.y || 0, img.height)),
width: Math.min(img.width, percToPx(crop.width || img.width, img.width)),
height: Math.min(img.height, percToPx(crop.height || img.height, img.height)),
};
if (cropPx.x + cropPx.width > img.width) {
cropPx.width = img.width - cropPx.x;
}
if (cropPx.y + cropPx.height > img.height) {
cropPx.height = img.height - cropPx.y;
}
const blob = await getCroppedBlob(img, cropPx);
const file = new File([blob], 'cropped-image.jpg', { type: 'image/jpeg' });
if (onImageUpload) {
toast({ title: 'Nahrávám obrázek...', status: 'info', duration: 2000 });
const res = await onImageUpload(file);
if (!res.url) {
throw new Error('Upload failed - no URL returned');
}
const quill = quillRef.current?.getEditor();
if (quill) {
const range = quill.getSelection(true);
const index = range ? range.index : quill.getLength();
quill.insertEmbed(index, 'image', res.url, 'user');
quill.setSelection(index + 1, 0);
toast({ title: 'Obrázek vložen', status: 'success', duration: 2000 });
}
}
} catch (e: any) {
console.error('Crop and insert error:', e);
toast({ title: 'Zpracování obrázku selhalo', description: e?.message || 'Chyba', status: 'error' });
} finally {
setCropOpen(false);
setCropSrc(null);
setCrop({ unit: '%', width: 80, height: 80, x: 10, y: 10 });
setCropQuality(85);
setCropMaxWidth(1500);
}
};
// Make images draggable and resizable
useEffect(() => {
const editor = quillRef.current?.getEditor();
if (!editor || readOnly) return;
let selectedImage: HTMLImageElement | null = null;
let resizeHandle: HTMLDivElement | null = null;
let isResizing = false;
let isDragging = false;
let startX = 0;
let startY = 0;
let startWidth = 0;
const createResizeHandle = (img: HTMLImageElement) => {
removeResizeHandle();
const handle = document.createElement('div');
handle.className = 'custom-image-resize-handle';
handle.style.cssText = `
position: absolute;
width: 14px;
height: 14px;
background: linear-gradient(135deg, #3182ce 0%, #2c5aa0 100%);
border: 2px solid white;
border-radius: 50%;
cursor: nwse-resize;
z-index: 1000;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
transition: transform 0.2s;
`;
const updateHandlePosition = () => {
const rect = img.getBoundingClientRect();
const editorRect = editor.root.getBoundingClientRect();
handle.style.left = `${rect.right - editorRect.left - 7}px`;
handle.style.top = `${rect.bottom - editorRect.top - 7}px`;
};
updateHandlePosition();
editor.root.style.position = 'relative';
editor.root.appendChild(handle);
resizeHandle = handle;
handle.addEventListener('mouseenter', () => {
handle.style.transform = 'scale(1.2)';
});
handle.addEventListener('mouseleave', () => {
handle.style.transform = 'scale(1)';
});
handle.addEventListener('mousedown', (e) => {
e.preventDefault();
e.stopPropagation();
isResizing = true;
startX = e.clientX;
startWidth = img.offsetWidth;
const onMouseMove = (e: MouseEvent) => {
if (!isResizing) return;
const deltaX = e.clientX - startX;
const newWidth = Math.max(50, startWidth + deltaX);
img.style.width = `${newWidth}px`;
img.style.maxWidth = '100%';
updateHandlePosition();
};
const onMouseUp = () => {
isResizing = false;
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
onChange(editor.root.innerHTML);
};
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
});
return handle;
};
const removeResizeHandle = () => {
if (resizeHandle && resizeHandle.parentNode) {
resizeHandle.parentNode.removeChild(resizeHandle);
resizeHandle = null;
}
};
const selectImage = (img: HTMLImageElement) => {
if (selectedImage) {
selectedImage.style.outline = '';
selectedImage.style.cursor = '';
selectedImage.style.boxShadow = '';
}
selectedImage = img;
img.style.outline = '3px solid #3182ce';
img.style.cursor = 'move';
img.style.boxShadow = '0 4px 12px rgba(49, 130, 206, 0.3)';
createResizeHandle(img);
// Set selected image state and load filters
setSelectedImageElement(img);
const filtersData = img.getAttribute('data-filters');
if (filtersData) {
try {
const savedFilters = JSON.parse(filtersData);
setImageFilters(savedFilters);
} catch {
// If parsing fails, use defaults
}
}
// Show toolbar and position it
const rect = img.getBoundingClientRect();
const editorRect = editor.root.getBoundingClientRect();
setToolbarPosition({
top: rect.top - editorRect.top - 50,
left: rect.left - editorRect.left,
});
setShowImageToolbar(true);
};
const deselectImage = () => {
if (selectedImage) {
selectedImage.style.outline = '';
selectedImage.style.cursor = '';
selectedImage.style.boxShadow = '';
selectedImage = null;
}
removeResizeHandle();
setSelectedImageElement(null);
setShowImageToolbar(false);
};
const handleImageClick = (e: Event) => {
const target = e.target as HTMLElement;
if (target.tagName === 'IMG') {
e.preventDefault();
e.stopPropagation();
selectImage(target as HTMLImageElement);
} else if (!target.classList.contains('custom-image-resize-handle')) {
deselectImage();
}
};
const handleMouseDown = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (target.tagName === 'IMG' && selectedImage === target) {
e.preventDefault();
isDragging = true;
startX = e.clientX;
startY = e.clientY;
const onMouseMove = (e: MouseEvent) => {
if (!isDragging || !selectedImage) return;
const deltaX = e.clientX - startX;
if (Math.abs(deltaX) > 20) {
if (deltaX > 0) {
selectedImage.style.display = 'block';
selectedImage.style.marginLeft = 'auto';
selectedImage.style.marginRight = '0';
} else {
selectedImage.style.display = 'block';
selectedImage.style.marginLeft = '0';
selectedImage.style.marginRight = 'auto';
}
}
};
const onMouseUp = () => {
isDragging = false;
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
if (selectedImage) {
onChange(editor.root.innerHTML);
}
};
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
}
};
// Delete selected image on Delete key
const handleKeyDown = (e: KeyboardEvent) => {
if (selectedImage && (e.key === 'Delete' || e.key === 'Backspace')) {
e.preventDefault();
selectedImage.remove();
deselectImage();
onChange(editor.root.innerHTML);
toast({ title: 'Obrázek odstraněn', status: 'info', duration: 1500 });
}
};
editor.root.addEventListener('click', handleImageClick);
editor.root.addEventListener('mousedown', handleMouseDown);
document.addEventListener('keydown', handleKeyDown);
return () => {
editor.root.removeEventListener('click', handleImageClick);
editor.root.removeEventListener('mousedown', handleMouseDown);
document.removeEventListener('keydown', handleKeyDown);
removeResizeHandle();
deselectImage();
};
}, [value, onChange, readOnly, toast]);
// Apply filters to selected image
const applyFiltersToImage = useCallback((img: HTMLImageElement, filters: ImageFilters) => {
const filterString = `
brightness(${filters.brightness}%)
contrast(${filters.contrast}%)
saturate(${filters.saturation}%)
blur(${filters.blur}px)
grayscale(${filters.grayscale}%)
sepia(${filters.sepia}%)
hue-rotate(${filters.hueRotate}deg)
`.trim();
const transform = `
rotate(${filters.rotation}deg)
scaleX(${filters.flipH ? -1 : 1})
scaleY(${filters.flipV ? -1 : 1})
`.trim();
img.style.filter = filterString;
img.style.transform = transform;
img.setAttribute('data-filters', JSON.stringify(filters));
}, []);
// Reset filters
const resetFilters = useCallback(() => {
const defaultFilters: ImageFilters = {
brightness: 100,
contrast: 100,
saturation: 100,
blur: 0,
grayscale: 0,
sepia: 0,
hueRotate: 0,
rotation: 0,
flipH: false,
flipV: false,
};
setImageFilters(defaultFilters);
if (selectedImageElement) {
applyFiltersToImage(selectedImageElement, defaultFilters);
}
}, [selectedImageElement, applyFiltersToImage]);
// Update filter and apply to image
const updateFilter = useCallback((key: keyof ImageFilters, value: any) => {
setImageFilters(prev => {
const newFilters = { ...prev, [key]: value };
if (selectedImageElement) {
applyFiltersToImage(selectedImageElement, newFilters);
}
return newFilters;
});
}, [selectedImageElement, applyFiltersToImage]);
// Sanitize HTML on change
const handleChange = (content: string) => {
const cleaned = DOMPurify.sanitize(content, {
USE_PROFILES: { html: true },
ADD_TAGS: ['iframe'],
ADD_ATTR: ['target', 'rel', 'allow', 'allowfullscreen', 'style', 'data-filters'],
});
onChange(cleaned);
};
return (
<Box>
{/* Editor Controls */}
{!readOnly && (
<HStack mb={2} spacing={2} justify="space-between" flexWrap="wrap">
<ButtonGroup size="sm" isAttached variant="outline">
<Button
leftIcon={<Type size={16} />}
variant={editorMode === 'rich' ? 'solid' : 'outline'}
colorScheme={editorMode === 'rich' ? 'blue' : 'gray'}
onClick={() => setEditorMode('rich')}
>
Editor
</Button>
<Button
leftIcon={<Code size={16} />}
variant={editorMode === 'html' ? 'solid' : 'outline'}
colorScheme={editorMode === 'html' ? 'blue' : 'gray'}
onClick={() => setEditorMode('html')}
>
HTML
</Button>
</ButtonGroup>
{editorMode === 'rich' && onImageUpload && (
<Button
size="sm"
leftIcon={<ImageIcon size={16} />}
colorScheme="purple"
onClick={handleImageUpload}
>
Vložit obrázek
</Button>
)}
</HStack>
)}
{editorMode === 'rich' ? (
<Box
borderWidth="1px"
borderColor={borderColor}
borderRadius="md"
overflow="hidden"
bg={bgColor}
sx={{
'.ql-toolbar': {
borderBottom: '1px solid',
borderColor: borderColor,
bg: hoverBg,
},
'.ql-container': {
fontSize: '16px',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif',
},
'.ql-editor': {
minHeight: height,
maxHeight: '70vh',
overflowY: 'auto',
'&::-webkit-scrollbar': {
width: '8px',
},
'&::-webkit-scrollbar-track': {
bg: 'gray.100',
},
'&::-webkit-scrollbar-thumb': {
bg: 'gray.400',
borderRadius: '4px',
},
img: {
cursor: 'pointer',
maxWidth: '100%',
height: 'auto',
display: 'block',
margin: '12px 0',
transition: 'all 0.2s ease',
borderRadius: '4px',
userSelect: 'none',
'&:hover': {
opacity: 0.95,
transform: 'scale(1.01)',
},
},
},
'.ql-editor.ql-blank::before': {
color: 'gray.400',
fontStyle: 'italic',
},
}}
>
<ReactQuill
theme="snow"
value={value}
onChange={handleChange}
readOnly={readOnly}
placeholder={placeholder}
ref={quillRef}
modules={{
toolbar: {
container: getToolbarConfig(),
handlers: {
image: onImageUpload ? handleImageUpload : undefined,
},
},
clipboard: {
matchVisual: false,
},
}}
/>
</Box>
) : (
<Box
as="textarea"
value={value}
onChange={(e: any) => onChange(e.target.value)}
fontFamily="mono"
fontSize="sm"
p={4}
borderWidth="1px"
borderColor={borderColor}
borderRadius="md"
bg={bgColor}
resize="vertical"
minH={height}
maxH="70vh"
width="100%"
/>
)}
{!readOnly && editorMode === 'rich' && (
<Text fontSize="xs" color="gray.500" mt={2}>
💡 Tip: Klikněte na obrázek pro výběr a úpravu. Používejte nástrojovou lištu pro filtry a transformace.
</Text>
)}
{/* Floating Image Editing Toolbar */}
{showImageToolbar && selectedImageElement && !readOnly && (
<Box
position="absolute"
top={`${toolbarPosition.top}px`}
left={`${toolbarPosition.left}px`}
bg={toolbarBg}
borderWidth="1px"
borderColor={toolbarBorder}
borderRadius="lg"
boxShadow="lg"
p={3}
zIndex={1500}
minW="320px"
maxW="400px"
>
<VStack align="stretch" spacing={3}>
{/* Toolbar Header */}
<HStack justify="space-between">
<HStack spacing={2}>
<Settings size={16} />
<Text fontWeight="bold" fontSize="sm">Úprava obrázku</Text>
</HStack>
<IconButton
aria-label="Close"
icon={<X size={16} />}
size="xs"
onClick={() => setShowImageToolbar(false)}
variant="ghost"
/>
</HStack>
{/* Transform Buttons */}
<HStack spacing={2} flexWrap="wrap">
<Tooltip label="Otočit doleva">
<IconButton
aria-label="Rotate left"
icon={<RotateCcw size={16} />}
size="sm"
onClick={() => updateFilter('rotation', (imageFilters.rotation - 90) % 360)}
colorScheme="blue"
variant="outline"
/>
</Tooltip>
<Tooltip label="Otočit doprava">
<IconButton
aria-label="Rotate right"
icon={<RotateCw size={16} />}
size="sm"
onClick={() => updateFilter('rotation', (imageFilters.rotation + 90) % 360)}
colorScheme="blue"
variant="outline"
/>
</Tooltip>
<Tooltip label="Převrátit horizontálně">
<IconButton
aria-label="Flip horizontal"
icon={<FlipHorizontal size={16} />}
size="sm"
onClick={() => updateFilter('flipH', !imageFilters.flipH)}
colorScheme="blue"
variant={imageFilters.flipH ? 'solid' : 'outline'}
/>
</Tooltip>
<Tooltip label="Převrátit vertikálně">
<IconButton
aria-label="Flip vertical"
icon={<FlipVertical size={16} />}
size="sm"
onClick={() => updateFilter('flipV', !imageFilters.flipV)}
colorScheme="blue"
variant={imageFilters.flipV ? 'solid' : 'outline'}
/>
</Tooltip>
<Tooltip label="Resetovat vše">
<IconButton
aria-label="Reset filters"
icon={<RotateCcw size={16} />}
size="sm"
onClick={resetFilters}
colorScheme="red"
variant="outline"
/>
</Tooltip>
</HStack>
{/* Filter Sliders */}
<VStack align="stretch" spacing={2}>
<FormControl>
<HStack justify="space-between">
<HStack spacing={1}>
<Sun size={14} />
<FormLabel fontSize="xs" mb={0}>Jas</FormLabel>
</HStack>
<Text fontSize="xs" color="gray.500">{imageFilters.brightness}%</Text>
</HStack>
<input
type="range"
min="0"
max="200"
value={imageFilters.brightness}
onChange={(e) => updateFilter('brightness', Number(e.target.value))}
style={{ width: '100%' }}
/>
</FormControl>
<FormControl>
<HStack justify="space-between">
<HStack spacing={1}>
<Contrast size={14} />
<FormLabel fontSize="xs" mb={0}>Kontrast</FormLabel>
</HStack>
<Text fontSize="xs" color="gray.500">{imageFilters.contrast}%</Text>
</HStack>
<input
type="range"
min="0"
max="200"
value={imageFilters.contrast}
onChange={(e) => updateFilter('contrast', Number(e.target.value))}
style={{ width: '100%' }}
/>
</FormControl>
<FormControl>
<HStack justify="space-between">
<HStack spacing={1}>
<Droplets size={14} />
<FormLabel fontSize="xs" mb={0}>Sytost</FormLabel>
</HStack>
<Text fontSize="xs" color="gray.500">{imageFilters.saturation}%</Text>
</HStack>
<input
type="range"
min="0"
max="200"
value={imageFilters.saturation}
onChange={(e) => updateFilter('saturation', Number(e.target.value))}
style={{ width: '100%' }}
/>
</FormControl>
<FormControl>
<HStack justify="space-between">
<HStack spacing={1}>
<Eye size={14} />
<FormLabel fontSize="xs" mb={0}>Rozostření</FormLabel>
</HStack>
<Text fontSize="xs" color="gray.500">{imageFilters.blur}px</Text>
</HStack>
<input
type="range"
min="0"
max="10"
step="0.5"
value={imageFilters.blur}
onChange={(e) => updateFilter('blur', Number(e.target.value))}
style={{ width: '100%' }}
/>
</FormControl>
</VStack>
{/* Quick Filters */}
<HStack spacing={2} flexWrap="wrap">
<Button
size="xs"
onClick={() => {
updateFilter('grayscale', imageFilters.grayscale === 100 ? 0 : 100);
}}
colorScheme={imageFilters.grayscale === 100 ? 'purple' : 'gray'}
variant={imageFilters.grayscale === 100 ? 'solid' : 'outline'}
leftIcon={<Filter size={12} />}
>
Černobílá
</Button>
<Button
size="xs"
onClick={() => {
updateFilter('sepia', imageFilters.sepia === 100 ? 0 : 100);
}}
colorScheme={imageFilters.sepia === 100 ? 'orange' : 'gray'}
variant={imageFilters.sepia === 100 ? 'solid' : 'outline'}
leftIcon={<Sparkles size={12} />}
>
Sepia
</Button>
</HStack>
</VStack>
</Box>
)}
{/* Crop Modal */}
<Modal isOpen={cropOpen} onClose={() => { setCropOpen(false); setCropSrc(null); }} size="6xl">
<ModalOverlay bg="blackAlpha.700" backdropFilter="blur(4px)" />
<ModalContent maxW="90vw" maxH="90vh">
<ModalHeader>Oříznout a upravit obrázek</ModalHeader>
<ModalCloseButton />
<ModalBody maxH="calc(90vh - 140px)" overflowY="auto" overflowX="hidden">
<VStack align="stretch" spacing={4}>
{cropSrc && (
<Box
display="flex"
justifyContent="center"
alignItems="center"
p={4}
bg={useColorModeValue('gray.50', 'gray.900')}
borderRadius="md"
>
<ReactCrop
crop={crop}
onChange={(c: Crop) => setCrop(c)}
minWidth={50}
minHeight={50}
keepSelection
>
<img
ref={imgRef as any}
src={cropSrc}
alt="Crop preview"
style={{
maxWidth: '100%',
maxHeight: '60vh',
display: 'block',
margin: 'auto'
}}
/>
</ReactCrop>
</Box>
)}
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
<FormControl>
<FormLabel fontSize="sm">Max. šířka (px)</FormLabel>
<HStack>
<Input
type="number"
value={cropMaxWidth}
onChange={(e) => setCropMaxWidth(Math.max(100, Math.min(3000, Number(e.target.value))))}
min={100}
max={3000}
step={100}
size="sm"
/>
<Text fontSize="sm" color="gray.600" whiteSpace="nowrap">px</Text>
</HStack>
<FormHelperText fontSize="xs">
Větší obrázky budou zmenšeny (optimalizace výkonu)
</FormHelperText>
</FormControl>
<FormControl>
<FormLabel fontSize="sm">Kvalita JPEG</FormLabel>
<HStack>
<Input
type="number"
value={cropQuality}
onChange={(e) => setCropQuality(Math.max(1, Math.min(100, Number(e.target.value))))}
min={1}
max={100}
step={5}
size="sm"
/>
<Text fontSize="sm" color="gray.600" whiteSpace="nowrap">%</Text>
</HStack>
<FormHelperText fontSize="xs">
85% je doporučená hodnota (menší velikost souboru)
</FormHelperText>
</FormControl>
</SimpleGrid>
<Text fontSize="sm" color="gray.600">
💡 Přetáhněte rohy a hrany modré oblasti pro výběr části obrázku k oříznutí.
</Text>
</VStack>
</ModalBody>
<ModalFooter>
<Button variant="ghost" mr={3} onClick={() => { setCropOpen(false); setCropSrc(null); }}>
Zrušit
</Button>
<Button colorScheme="blue" onClick={confirmCropAndInsert}>
Oříznout a vložit
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</Box>
);
};
export default CustomRichEditor;
@@ -0,0 +1,14 @@
import React from 'react';
import NewsletterSubscribe from '../newsletter/NewsletterSubscribe';
const NewsletterCTA: React.FC = () => {
return (
<section className="newsletter-cta" style={{ marginTop: 24, marginBottom: 24 }}>
<div className="card" style={{ maxWidth: 960, margin: '0 auto' }}>
<NewsletterSubscribe />
</div>
</section>
);
};
export default NewsletterCTA;
@@ -0,0 +1,97 @@
import React, { useEffect, useState, useCallback } from 'react';
import { useLocation } from 'react-router-dom';
import { useSmoothScroll } from '../../hooks/useSmoothScroll';
import { useKeyboardShortcuts } from '../../hooks/useKeyboardShortcuts';
import { FiArrowUp } from 'react-icons/fi';
/**
* PageEnhancer - Adds universal functionality to all pages
* - Back to top button
* - Keyboard shortcuts
* - Scroll to top on route change
* - Skip to content link
*/
interface PageEnhancerProps {
children: React.ReactNode;
}
const PageEnhancer: React.FC<PageEnhancerProps> = ({ children }) => {
const [showBackToTop, setShowBackToTop] = useState(false);
const { scrollToTop, scrollToElement } = useSmoothScroll();
const location = useLocation();
// Scroll to top when route changes
useEffect(() => {
window.scrollTo(0, 0);
}, [location.pathname]);
// Show/hide back to top button
useEffect(() => {
const handleScroll = () => {
setShowBackToTop(window.scrollY > 300);
};
window.addEventListener('scroll', handleScroll, { passive: true });
return () => window.removeEventListener('scroll', handleScroll);
}, []);
// Handle back to top click
const handleBackToTop = useCallback(() => {
scrollToTop();
}, [scrollToTop]);
// Global keyboard shortcuts
useKeyboardShortcuts([
{
key: 'Home',
callback: scrollToTop,
description: 'Scroll to top',
},
{
key: 'End',
callback: () => {
window.scrollTo({
top: document.body.scrollHeight,
behavior: 'smooth',
});
},
description: 'Scroll to bottom',
},
{
key: 'Escape',
callback: () => {
// Close any open modals (implement based on your modal system)
const event = new CustomEvent('closeAllModals');
window.dispatchEvent(event);
},
description: 'Close modals',
},
]);
return (
<>
{/* Skip to main content link for accessibility */}
<a href="#main-content" className="skip-to-content">
Přeskočit na hlavní obsah
</a>
{/* Main content with ID for skip link */}
<div id="main-content">
{children}
</div>
{/* Back to top button */}
<button
className={`back-to-top ${showBackToTop ? 'visible' : ''}`}
onClick={handleBackToTop}
aria-label="Zpět nahoru"
title="Zpět nahoru"
>
<FiArrowUp size={24} />
</button>
</>
);
};
export default PageEnhancer;
@@ -0,0 +1,99 @@
import { Button, HStack, Text } from '@chakra-ui/react';
import { ChevronLeftIcon, ChevronRightIcon } from '@chakra-ui/icons';
interface PaginationProps {
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
maxVisiblePages?: number;
}
export default function Pagination({
currentPage,
totalPages,
onPageChange,
maxVisiblePages = 5,
}: PaginationProps) {
if (totalPages <= 1) return null;
const getPageNumbers = () => {
const pages = [];
let startPage = Math.max(1, currentPage - Math.floor(maxVisiblePages / 2));
const endPage = Math.min(totalPages, startPage + maxVisiblePages - 1);
if (endPage - startPage + 1 < maxVisiblePages) {
startPage = Math.max(1, endPage - maxVisiblePages + 1);
}
for (let i = startPage; i <= endPage; i++) {
pages.push(i);
}
return pages;
};
const pages = getPageNumbers();
return (
<HStack spacing={1}>
<Button
size="sm"
variant="outline"
onClick={() => onPageChange(currentPage - 1)}
isDisabled={currentPage === 1}
aria-label="Předchozí stránka"
>
<ChevronLeftIcon />
</Button>
{!pages.includes(1) && (
<>
<Button
size="sm"
variant={currentPage === 1 ? 'solid' : 'outline'}
onClick={() => onPageChange(1)}
>
1
</Button>
{!pages.includes(2) && <Text>...</Text>}
</>
)}
{pages.map((page) => (
<Button
key={page}
size="sm"
variant={currentPage === page ? 'solid' : 'outline'}
colorScheme={currentPage === page ? 'blue' : 'gray'}
onClick={() => onPageChange(page)}
aria-current={currentPage === page ? 'page' : undefined}
>
{page}
</Button>
))}
{!pages.includes(totalPages) && (
<>
{!pages.includes(totalPages - 1) && <Text>...</Text>}
<Button
size="sm"
variant={currentPage === totalPages ? 'solid' : 'outline'}
onClick={() => onPageChange(totalPages)}
>
{totalPages}
</Button>
</>
)}
<Button
size="sm"
variant="outline"
onClick={() => onPageChange(currentPage + 1)}
isDisabled={currentPage === totalPages}
aria-label="Další stránka"
>
<ChevronRightIcon />
</Button>
</HStack>
);
}
@@ -0,0 +1,49 @@
import React from 'react';
import CustomRichEditor from './CustomRichEditor';
import { uploadFile } from '../../services/articles';
import { assetUrl } from '../../utils/url';
interface RichTextEditorProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
height?: string;
readOnly?: boolean;
onImageUpload?: (file: File) => Promise<{ url: string }>;
showImageResize?: boolean;
toolbar?: 'full' | 'basic' | 'minimal' | string;
}
const RichTextEditor: React.FC<RichTextEditorProps> = ({
value,
onChange,
placeholder = 'Začněte psát...',
height = '400px',
readOnly = false,
onImageUpload = uploadFile,
showImageResize = true,
toolbar = 'full',
}) => {
// Wrapper function to handle URL transformation
const handleImageUpload = async (file: File) => {
const res = await onImageUpload(file);
// Transform URL if needed
const url = assetUrl(res.url) || res.url;
return { url };
};
return (
<CustomRichEditor
value={value}
onChange={onChange}
placeholder={placeholder}
height={height}
readOnly={readOnly}
onImageUpload={handleImageUpload}
showImageResize={showImageResize}
toolbar={toolbar as 'full' | 'basic' | 'minimal'}
/>
);
};
export default RichTextEditor;
@@ -0,0 +1,153 @@
import React, { useEffect, useState } from 'react';
import { assetUrl } from '../../utils/url';
interface Sponsor {
id: number | string;
name: string;
logo: string;
url?: string;
tier?: string;
}
interface SponsorsSectionProps {
layout?: 'grid' | 'slider' | 'scroller' | 'pyramid';
theme?: 'dark' | 'light';
}
const resolveBackendUrl = (path: string) => {
try {
if (/^https?:\/\//i.test(path)) return path;
if (path.startsWith('/cache') || path.startsWith('/uploads') || path.startsWith('/api/')) {
const base = process.env.REACT_APP_API_BASE_URL || process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1';
const b = new URL(base);
const abs = new URL(path, `${b.protocol}//${b.host}`);
return abs.toString();
}
return path;
} catch {
return path;
}
};
const SponsorsSection: React.FC<SponsorsSectionProps> = ({
layout = 'grid',
theme = 'light'
}) => {
const [sponsors, setSponsors] = useState<Sponsor[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
const fetchSponsors = async () => {
try {
// Try API first
const apiRes = await fetch(`${process.env.REACT_APP_API_BASE_URL || process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1'}/public/sponsors`);
if (apiRes.ok) {
const data = await apiRes.json();
if (!cancelled && Array.isArray(data)) {
const mapped = data.map((s: any) => ({
id: s.id,
name: s.name,
logo: assetUrl(s.logo_url) || '/images/sponsors/placeholder.png',
url: s.website_url || undefined,
tier: s.tier,
}));
setSponsors(mapped);
setLoading(false);
return;
}
}
} catch {}
// Fallback to cache
try {
const cacheRes = await fetch(resolveBackendUrl('/cache/prefetch/settings.json'), { cache: 'no-cache' });
if (cacheRes.ok) {
const settings = await cacheRes.json();
if (!cancelled) {
const sponsorsData = settings?.sponsors || settings?.partners || [];
if (Array.isArray(sponsorsData) && sponsorsData.length) {
setSponsors(
sponsorsData.map((s: any, i: number) => ({
id: s.id ?? i + 1,
name: s.name || 'Sponsor',
logo: s.logo_url || s.logoUrl || s.logo || '/images/sponsors/placeholder.png',
url: s.url || s.website || s.link || '#',
tier: s.tier,
}))
);
}
}
}
} catch {}
if (!cancelled) {
setLoading(false);
}
};
fetchSponsors();
return () => { cancelled = true; };
}, []);
if (loading || sponsors.length === 0) {
return null;
}
const title = sponsors.find((s: any) => s.tier === 'title') || sponsors[0];
const others = sponsors.filter((s) => s !== title);
return (
<section
className={`sponsors ${theme === 'dark' ? 'dark' : ''}`}
style={{
width: '100vw',
position: 'relative',
left: '50%',
right: '50%',
transform: 'translateX(-50%)',
paddingLeft: 'max(16px, calc((100vw - 1200px) / 2))',
paddingRight: 'max(16px, calc((100vw - 1200px) / 2))',
boxSizing: 'border-box',
marginTop: '32px',
marginBottom: '32px',
}}
>
<div className="section-head">
<h3>Sponzoři</h3>
</div>
{layout === 'grid' ? (
<>
{title && (
<div className="title-sponsor">
<a className="sponsor-tile" href={title.url || '#'} target="_blank" rel="noreferrer noopener">
<img src={title.logo} alt={title.name} />
</a>
</div>
)}
<div className="divider" aria-hidden />
<div className="sponsors-grid">
{others.map((s) => (
<a key={s.id} className="sponsor-tile" href={s.url || '#'} target="_blank" rel="noreferrer noopener">
<img src={s.logo} alt={s.name} />
</a>
))}
</div>
</>
) : (
<div className="sponsors-slider">
<div className="track">
{[...sponsors, ...sponsors].map((s, idx) => (
<a key={`${s.id}-${idx}`} className="sponsor-tile" href={s.url || '#'} target="_blank" rel="noreferrer noopener">
<img src={s.logo} alt={s.name} />
</a>
))}
</div>
</div>
)}
</section>
);
};
export default SponsorsSection;
+126
View File
@@ -0,0 +1,126 @@
import React, { useEffect, useState } from 'react';
import { Image, ImageProps, Skeleton } from '@chakra-ui/react';
import { getTeamLogo } from '../../utils/sportLogosAPI';
import { getLogoStyle, getLogoClassName } from '../../utils/logoUtils';
import '../../styles/logos.css';
interface TeamLogoProps extends Omit<ImageProps, 'src'> {
teamId?: string;
teamName?: string;
facrLogo?: string;
size?: 'small' | 'medium' | 'large' | 'custom';
fallbackIcon?: React.ReactElement;
}
/**
* TeamLogo component with automatic logoapi.sportcreative.eu integration
* Features:
* - Fetches from logoapi first (with local caching)
* - Falls back to FACR logo if logoapi doesn't have it
* - Properly centers and formats logos
* - Handles SVG optimization
*/
export const TeamLogo: React.FC<TeamLogoProps> = ({
teamId,
teamName,
facrLogo,
size = 'medium',
fallbackIcon,
alt,
...imageProps
}) => {
const [logoUrl, setLogoUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
useEffect(() => {
let mounted = true;
const fetchLogo = async () => {
try {
setLoading(true);
setError(false);
const url = await getTeamLogo(teamId, teamName, facrLogo);
if (mounted) {
setLogoUrl(url);
}
} catch (e) {
console.error('Failed to fetch logo:', e);
if (mounted) {
setError(true);
// Fallback to FACR or placeholder
setLogoUrl(facrLogo || '/logo192.png');
}
} finally {
if (mounted) {
setLoading(false);
}
}
};
fetchLogo();
return () => {
mounted = false;
};
}, [teamId, teamName, facrLogo]);
// Size mapping
const sizeMap = {
small: { boxSize: '24px' },
medium: { boxSize: '32px' },
large: { boxSize: '48px' },
custom: {},
};
const sizeProps = size !== 'custom' ? sizeMap[size] : {};
// Class name based on size
const className = `match-logo-${size} ${imageProps.className || ''}`.trim();
if (loading) {
return (
<Skeleton
{...sizeProps}
borderRadius="4px"
className="logo-loading"
/>
);
}
// Check if this is a circular container
const isCircular = imageProps.borderRadius === 'full' || imageProps.style?.borderRadius === '50%';
// Get appropriate styling and className using utility functions
// Only pass size to utils if it's not 'custom' (utils only accept standard sizes)
const utilSize = size !== 'custom' ? size : 'medium';
const logoStyle = getLogoStyle(logoUrl, isCircular, utilSize);
const logoClassName = getLogoClassName(logoUrl, isCircular, utilSize);
return (
<Image
src={logoUrl || '/logo192.png'}
alt={alt || teamName || 'Team logo'}
{...sizeProps}
{...imageProps}
className={`${className} ${logoClassName}`}
objectFit="contain"
loading="lazy"
fallback={fallbackIcon}
style={{
...imageProps.style,
...logoStyle
}}
onError={() => {
if (!error) {
setError(true);
setLogoUrl(facrLogo || '/logo192.png');
}
}}
/>
);
};
export default TeamLogo;
@@ -0,0 +1,333 @@
import React from 'react';
import {
VStack,
HStack,
FormControl,
FormLabel,
Input,
Select,
Slider,
SliderTrack,
SliderFilledTrack,
SliderThumb,
Text,
Box,
Divider,
Button,
IconButton,
Popover,
PopoverTrigger,
PopoverContent,
PopoverBody,
PopoverArrow,
SimpleGrid,
useColorModeValue,
} from '@chakra-ui/react';
import { FiRefreshCw } from 'react-icons/fi';
interface AdvancedStyleControlsProps {
elementName: string;
settings?: Record<string, any>;
onChange?: (settings: Record<string, any>) => void;
}
const AdvancedStyleControls: React.FC<AdvancedStyleControlsProps> = ({
elementName,
settings = {},
onChange,
}) => {
const updateSetting = (key: string, value: any) => {
if (onChange) {
onChange({ ...settings, [key]: value });
}
};
const presetColors = [
'#FF6B6B', '#4ECDC4', '#45B7D1', '#FFA07A', '#98D8C8',
'#F7DC6F', '#BB8FCE', '#85C1E2', '#F8B739', '#52B788',
];
return (
<VStack align="stretch" spacing={4}>
{/* Spacing Controls */}
<Box>
<Text fontWeight="bold" fontSize="sm" mb={2}>Spacing</Text>
<VStack spacing={3}>
<FormControl>
<HStack justify="space-between">
<FormLabel fontSize="xs" mb={0}>Margin Top</FormLabel>
<Text fontSize="xs" color="gray.500">{settings.marginTop || 0}px</Text>
</HStack>
<Slider
value={settings.marginTop || 0}
min={0}
max={100}
step={4}
onChange={(val) => updateSetting('marginTop', val)}
>
<SliderTrack>
<SliderFilledTrack />
</SliderTrack>
<SliderThumb />
</Slider>
</FormControl>
<FormControl>
<HStack justify="space-between">
<FormLabel fontSize="xs" mb={0}>Margin Bottom</FormLabel>
<Text fontSize="xs" color="gray.500">{settings.marginBottom || 0}px</Text>
</HStack>
<Slider
value={settings.marginBottom || 0}
min={0}
max={100}
step={4}
onChange={(val) => updateSetting('marginBottom', val)}
>
<SliderTrack>
<SliderFilledTrack />
</SliderTrack>
<SliderThumb />
</Slider>
</FormControl>
<FormControl>
<HStack justify="space-between">
<FormLabel fontSize="xs" mb={0}>Padding</FormLabel>
<Text fontSize="xs" color="gray.500">{settings.padding || 0}px</Text>
</HStack>
<Slider
value={settings.padding || 0}
min={0}
max={100}
step={4}
onChange={(val) => updateSetting('padding', val)}
>
<SliderTrack>
<SliderFilledTrack />
</SliderTrack>
<SliderThumb />
</Slider>
</FormControl>
</VStack>
</Box>
<Divider />
{/* Background Controls */}
<Box>
<Text fontWeight="bold" fontSize="sm" mb={2}>Background</Text>
<VStack spacing={3}>
<FormControl>
<FormLabel fontSize="xs">Background Color</FormLabel>
<HStack>
<Input
type="color"
value={settings.backgroundColor || '#ffffff'}
onChange={(e) => updateSetting('backgroundColor', e.target.value)}
size="sm"
w="60px"
h="40px"
p={1}
cursor="pointer"
/>
<Input
value={settings.backgroundColor || '#ffffff'}
onChange={(e) => updateSetting('backgroundColor', e.target.value)}
size="sm"
placeholder="#ffffff"
flex={1}
/>
</HStack>
</FormControl>
{/* Color Presets */}
<SimpleGrid columns={5} spacing={2}>
{presetColors.map((color) => (
<Box
key={color}
w="100%"
h="30px"
bg={color}
borderRadius="md"
cursor="pointer"
border="2px"
borderColor={settings.backgroundColor === color ? 'blue.500' : 'transparent'}
_hover={{ transform: 'scale(1.1)' }}
transition="all 0.2s"
onClick={() => updateSetting('backgroundColor', color)}
/>
))}
</SimpleGrid>
<FormControl>
<HStack justify="space-between">
<FormLabel fontSize="xs" mb={0}>Background Opacity</FormLabel>
<Text fontSize="xs" color="gray.500">{settings.backgroundOpacity || 100}%</Text>
</HStack>
<Slider
value={settings.backgroundOpacity || 100}
min={0}
max={100}
onChange={(val) => updateSetting('backgroundOpacity', val)}
>
<SliderTrack>
<SliderFilledTrack />
</SliderTrack>
<SliderThumb />
</Slider>
</FormControl>
</VStack>
</Box>
<Divider />
{/* Border Controls */}
<Box>
<Text fontWeight="bold" fontSize="sm" mb={2}>Border</Text>
<VStack spacing={3}>
<FormControl>
<HStack justify="space-between">
<FormLabel fontSize="xs" mb={0}>Border Width</FormLabel>
<Text fontSize="xs" color="gray.500">{settings.borderWidth || 0}px</Text>
</HStack>
<Slider
value={settings.borderWidth || 0}
min={0}
max={10}
onChange={(val) => updateSetting('borderWidth', val)}
>
<SliderTrack>
<SliderFilledTrack />
</SliderTrack>
<SliderThumb />
</Slider>
</FormControl>
<FormControl>
<FormLabel fontSize="xs">Border Color</FormLabel>
<HStack>
<Input
type="color"
value={settings.borderColor || '#000000'}
onChange={(e) => updateSetting('borderColor', e.target.value)}
size="sm"
w="60px"
h="40px"
p={1}
cursor="pointer"
/>
<Input
value={settings.borderColor || '#000000'}
onChange={(e) => updateSetting('borderColor', e.target.value)}
size="sm"
placeholder="#000000"
flex={1}
/>
</HStack>
</FormControl>
<FormControl>
<HStack justify="space-between">
<FormLabel fontSize="xs" mb={0}>Border Radius</FormLabel>
<Text fontSize="xs" color="gray.500">{settings.borderRadius || 0}px</Text>
</HStack>
<Slider
value={settings.borderRadius || 0}
min={0}
max={50}
onChange={(val) => updateSetting('borderRadius', val)}
>
<SliderTrack>
<SliderFilledTrack />
</SliderTrack>
<SliderThumb />
</Slider>
</FormControl>
</VStack>
</Box>
<Divider />
{/* Shadow Controls */}
<Box>
<Text fontWeight="bold" fontSize="sm" mb={2}>Shadow</Text>
<VStack spacing={3}>
<FormControl>
<FormLabel fontSize="xs">Shadow Type</FormLabel>
<Select
value={settings.boxShadow || 'none'}
onChange={(e) => updateSetting('boxShadow', e.target.value)}
size="sm"
>
<option value="none">None</option>
<option value="sm">Small</option>
<option value="md">Medium</option>
<option value="lg">Large</option>
<option value="xl">Extra Large</option>
</Select>
</FormControl>
</VStack>
</Box>
<Divider />
{/* Animation Controls */}
<Box>
<Text fontWeight="bold" fontSize="sm" mb={2}>Animation</Text>
<VStack spacing={3}>
<FormControl>
<FormLabel fontSize="xs">Entrance Animation</FormLabel>
<Select
value={settings.animation || 'none'}
onChange={(e) => updateSetting('animation', e.target.value)}
size="sm"
>
<option value="none">None</option>
<option value="fadeIn">Fade In</option>
<option value="slideInUp">Slide In Up</option>
<option value="slideInDown">Slide In Down</option>
<option value="slideInLeft">Slide In Left</option>
<option value="slideInRight">Slide In Right</option>
<option value="zoomIn">Zoom In</option>
<option value="bounceIn">Bounce In</option>
</Select>
</FormControl>
<FormControl>
<HStack justify="space-between">
<FormLabel fontSize="xs" mb={0}>Animation Duration</FormLabel>
<Text fontSize="xs" color="gray.500">{settings.animationDuration || 1000}ms</Text>
</HStack>
<Slider
value={settings.animationDuration || 1000}
min={200}
max={3000}
step={100}
onChange={(val) => updateSetting('animationDuration', val)}
>
<SliderTrack>
<SliderFilledTrack />
</SliderTrack>
<SliderThumb />
</Slider>
</FormControl>
</VStack>
</Box>
<Divider />
{/* Reset Button */}
<Button
size="sm"
leftIcon={<FiRefreshCw />}
variant="outline"
onClick={() => onChange && onChange({})}
>
Reset to Default
</Button>
</VStack>
);
};
export default AdvancedStyleControls;
@@ -0,0 +1,17 @@
import React, { ReactNode } from 'react';
interface ConditionalElementProps {
visible: boolean;
children: ReactNode;
}
/**
* Wrapper that conditionally renders children based on visibility
* Used with Elementor editor to show/hide elements
*/
const ConditionalElement: React.FC<ConditionalElementProps> = ({ visible, children }) => {
if (!visible) return null;
return <>{children}</>;
};
export default ConditionalElement;
@@ -0,0 +1,32 @@
import React, { ReactNode } from 'react';
import { Box } from '@chakra-ui/react';
interface EditableElementProps {
elementName: string;
children: ReactNode;
className?: string;
style?: React.CSSProperties;
}
/**
* Wrapper component that marks an element as editable in the visual editor
*/
const EditableElement: React.FC<EditableElementProps> = ({
elementName,
children,
className,
style
}) => {
return (
<Box
data-element={elementName}
className={className}
style={style}
position="relative"
>
{children}
</Box>
);
};
export default EditableElement;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,293 @@
import React, { useState, useEffect } from 'react';
import {
Box,
Flex,
IconButton,
VStack,
HStack,
Text,
Select,
Button,
useToast,
Tooltip,
Badge,
Collapse,
useDisclosure,
Drawer,
DrawerBody,
DrawerHeader,
DrawerOverlay,
DrawerContent,
DrawerCloseButton,
FormControl,
FormLabel,
Switch,
Divider,
} from '@chakra-ui/react';
import { FiEdit, FiSave, FiX, FiEye, FiEyeOff, FiSettings } from 'react-icons/fi';
import {
PageElementConfig,
getPageElementConfigs,
batchUpdatePageElementConfigs,
ELEMENT_VARIANTS
} from '../../services/pageElements';
import { useAuth } from '../../contexts/AuthContext';
interface VisualPageEditorProps {
pageType: string; // e.g., 'homepage', 'about'
onConfigChange?: (configs: PageElementConfig[]) => void;
}
const VisualPageEditor: React.FC<VisualPageEditorProps> = ({ pageType, onConfigChange }) => {
const { user } = useAuth();
const isAdmin = user?.role === 'admin';
const [isEditing, setIsEditing] = useState(false);
const [configs, setConfigs] = useState<PageElementConfig[]>([]);
const [localChanges, setLocalChanges] = useState<Record<string, string>>({});
const [hasChanges, setHasChanges] = useState(false);
const [isVisible, setIsVisible] = useState(true);
const { isOpen, onOpen, onClose } = useDisclosure();
const toast = useToast();
// Load configurations
useEffect(() => {
if (!isAdmin) return;
const loadConfigs = async () => {
try {
const data = await getPageElementConfigs(pageType);
setConfigs(data);
// Initialize local changes from existing configs
const changes: Record<string, string> = {};
data.forEach(cfg => {
changes[cfg.element_name] = cfg.variant;
});
setLocalChanges(changes);
} catch (error) {
console.error('Failed to load page element configs:', error);
}
};
loadConfigs();
}, [pageType, isAdmin]);
// Notify parent of config changes
useEffect(() => {
if (onConfigChange) {
onConfigChange(configs);
}
}, [configs, onConfigChange]);
const handleVariantChange = (elementName: string, variant: string) => {
setLocalChanges(prev => ({
...prev,
[elementName]: variant,
}));
setHasChanges(true);
};
const handleSave = async () => {
try {
// Build configs array from local changes
const configsToSave: PageElementConfig[] = Object.entries(localChanges).map(([elementName, variant]) => ({
page_type: pageType,
element_name: elementName,
variant,
}));
const result = await batchUpdatePageElementConfigs(configsToSave);
toast({
title: 'Changes saved',
description: `Updated ${result.updated} configs, created ${result.created} new`,
status: 'success',
duration: 3000,
isClosable: true,
});
// Reload configs
const updated = await getPageElementConfigs(pageType);
setConfigs(updated);
setHasChanges(false);
// Reload the page to apply changes
window.location.reload();
} catch (error) {
toast({
title: 'Failed to save changes',
description: 'An error occurred while saving',
status: 'error',
duration: 5000,
isClosable: true,
});
}
};
const handleCancel = () => {
// Reset local changes to match configs
const changes: Record<string, string> = {};
configs.forEach(cfg => {
changes[cfg.element_name] = cfg.variant;
});
setLocalChanges(changes);
setHasChanges(false);
setIsEditing(false);
};
if (!isAdmin) return null;
return (
<>
{/* Floating control button */}
<Box
position="fixed"
right={4}
bottom={20}
zIndex={9999}
display={isVisible ? 'block' : 'none'}
>
<Tooltip label="Visual Page Editor" hasArrow placement="left">
<IconButton
aria-label="Open visual editor"
icon={<FiSettings />}
colorScheme="purple"
size="lg"
borderRadius="full"
boxShadow="lg"
onClick={onOpen}
_hover={{ transform: 'scale(1.1)' }}
transition="all 0.2s"
/>
</Tooltip>
</Box>
{/* Editor Drawer */}
<Drawer isOpen={isOpen} placement="right" onClose={onClose} size="md">
<DrawerOverlay />
<DrawerContent>
<DrawerCloseButton />
<DrawerHeader borderBottomWidth="1px">
<VStack align="stretch" spacing={2}>
<HStack justify="space-between">
<Text>Visual Page Editor</Text>
<Badge colorScheme="purple">Admin</Badge>
</HStack>
<Text fontSize="sm" fontWeight="normal" color="gray.500">
Configure visual variants for page elements
</Text>
</VStack>
</DrawerHeader>
<DrawerBody>
<VStack align="stretch" spacing={6} py={4}>
{/* Editor Status */}
<Flex justify="space-between" align="center">
<Text fontSize="sm" color="gray.600">
Page: <strong>{pageType}</strong>
</Text>
<HStack>
<Switch
size="sm"
isChecked={isEditing}
onChange={(e) => setIsEditing(e.target.checked)}
/>
<Text fontSize="sm" fontWeight="bold">
{isEditing ? 'Editing' : 'Preview'}
</Text>
</HStack>
</Flex>
<Divider />
{/* Element Controls */}
{Object.entries(ELEMENT_VARIANTS).map(([elementName, variants]) => (
<Box key={elementName}>
<FormControl>
<FormLabel fontSize="sm" fontWeight="bold" textTransform="capitalize">
{elementName}
</FormLabel>
<Select
value={localChanges[elementName] || variants[0].value}
onChange={(e) => handleVariantChange(elementName, e.target.value)}
size="md"
isDisabled={!isEditing}
>
{variants.map((variant) => (
<option key={variant.value} value={variant.value}>
{variant.label} - {variant.description}
</option>
))}
</Select>
{/* Show current variant info */}
{localChanges[elementName] && (
<Text fontSize="xs" color="gray.500" mt={1}>
Current: {variants.find(v => v.value === localChanges[elementName])?.description || localChanges[elementName]}
</Text>
)}
</FormControl>
</Box>
))}
<Divider />
{/* Action Buttons */}
<VStack spacing={3}>
{hasChanges && (
<Badge colorScheme="orange" fontSize="sm" p={2} borderRadius="md" w="full" textAlign="center">
You have unsaved changes
</Badge>
)}
<HStack spacing={2} w="full">
<Button
leftIcon={<FiSave />}
colorScheme="green"
onClick={handleSave}
isDisabled={!hasChanges || !isEditing}
flex={1}
>
Save & Reload
</Button>
<Button
leftIcon={<FiX />}
variant="outline"
onClick={handleCancel}
isDisabled={!hasChanges}
flex={1}
>
Cancel
</Button>
</HStack>
<Button
size="sm"
variant="ghost"
onClick={() => setIsVisible(!isVisible)}
leftIcon={isVisible ? <FiEyeOff /> : <FiEye />}
w="full"
>
{isVisible ? 'Hide' : 'Show'} Editor Button
</Button>
</VStack>
{/* Help Text */}
<Box bg="blue.50" p={3} borderRadius="md" fontSize="sm">
<Text fontWeight="bold" mb={1}>How to use:</Text>
<VStack align="stretch" spacing={1} fontSize="xs">
<Text>1. Toggle editing mode ON</Text>
<Text>2. Select variants for each element</Text>
<Text>3. Click "Save & Reload" to apply</Text>
<Text>4. Page will reload with new styles</Text>
</VStack>
</Box>
</VStack>
</DrawerBody>
</DrawerContent>
</Drawer>
</>
);
};
export default VisualPageEditor;
@@ -0,0 +1,713 @@
import React, { useState } from 'react';
import {
Box,
VStack,
HStack,
Text,
Tabs,
TabList,
TabPanels,
Tab,
TabPanel,
FormControl,
FormLabel,
Input,
Slider,
SliderTrack,
SliderFilledTrack,
SliderThumb,
Select,
Switch,
NumberInput,
NumberInputField,
NumberInputStepper,
NumberIncrementStepper,
NumberDecrementStepper,
IconButton,
Divider,
Button,
useColorModeValue,
} from '@chakra-ui/react';
import { FiType, FiLayout, FiBox, FiDroplet, FiGrid, FiSmartphone, FiBarChart2, FiSidebar } from 'react-icons/fi';
import { FaRegNewspaper, FaRegSquare, FaColumns } from 'react-icons/fa';
import { useClubTheme } from '../../contexts/ClubThemeContext';
interface VisualStylePanelProps {
elementName: string;
onStyleChange: (styles: Record<string, any>) => void;
currentStyles?: Record<string, any>;
}
const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
elementName,
onStyleChange,
currentStyles = {},
}) => {
const bgColor = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.600');
const clubTheme = useClubTheme();
const primaryColor = clubTheme.primary || '#0b5cff';
const [styles, setStyles] = useState({
// Typography
fontFamily: currentStyles.fontFamily || 'Inter',
fontSize: currentStyles.fontSize || 16,
fontWeight: currentStyles.fontWeight || 400,
lineHeight: currentStyles.lineHeight || 1.5,
letterSpacing: currentStyles.letterSpacing || 0,
textTransform: currentStyles.textTransform || 'none',
// Colors
color: currentStyles.color || '#000000',
backgroundColor: currentStyles.backgroundColor || '#ffffff',
// Spacing
paddingTop: currentStyles.paddingTop || 0,
paddingRight: currentStyles.paddingRight || 0,
paddingBottom: currentStyles.paddingBottom || 0,
paddingLeft: currentStyles.paddingLeft || 0,
marginTop: currentStyles.marginTop || 0,
marginRight: currentStyles.marginRight || 0,
marginBottom: currentStyles.marginBottom || 0,
marginLeft: currentStyles.marginLeft || 0,
// Layout
width: currentStyles.width || 'auto',
height: currentStyles.height || 'auto',
display: currentStyles.display || 'block',
// Grid Layout
gridTemplateColumns: currentStyles.gridTemplateColumns || 'repeat(3, 1fr)',
gridTemplateRows: currentStyles.gridTemplateRows || 'auto',
gridColumnGap: currentStyles.gridColumnGap || 16,
gridRowGap: currentStyles.gridRowGap || 16,
gridAutoFlow: currentStyles.gridAutoFlow || 'row',
alignItems: currentStyles.alignItems || 'stretch',
justifyItems: currentStyles.justifyItems || 'stretch',
...currentStyles,
});
const updateStyle = (key: string, value: any) => {
const newStyles = { ...styles, [key]: value };
setStyles(newStyles);
onStyleChange(newStyles);
};
return (
<Box
width="280px"
bg={bgColor}
borderRight="1px"
borderColor={primaryColor}
height="100vh"
overflowY="auto"
pt="60px"
>
<Tabs size="sm" colorScheme="blue">
<TabList px={2}>
<Tab><FiType /> <Text ml={1}>Content</Text></Tab>
<Tab><FiLayout /> <Text ml={1}>Style</Text></Tab>
<Tab><FiGrid /> <Text ml={1}>Grid</Text></Tab>
<Tab><FiBox /> <Text ml={1}>Advanced</Text></Tab>
</TabList>
<TabPanels>
{/* Content Tab */}
<TabPanel>
<VStack align="stretch" spacing={4}>
<Text fontWeight="bold" fontSize="sm" textTransform="uppercase" color="gray.500">
Typography
</Text>
{/* Font Family */}
<FormControl>
<FormLabel fontSize="xs">Font Family</FormLabel>
<Select
size="sm"
value={styles.fontFamily}
onChange={(e) => updateStyle('fontFamily', e.target.value)}
>
<option value="Inter">Inter</option>
<option value="Roboto">Roboto</option>
<option value="Open Sans">Open Sans</option>
<option value="Lato">Lato</option>
<option value="Montserrat">Montserrat</option>
<option value="Poppins">Poppins</option>
<option value="Georgia">Georgia</option>
<option value="Times New Roman">Times New Roman</option>
</Select>
</FormControl>
{/* Font Size */}
<FormControl>
<FormLabel fontSize="xs">Size (px)</FormLabel>
<HStack>
<NumberInput
size="sm"
value={styles.fontSize}
min={8}
max={128}
onChange={(_, val) => updateStyle('fontSize', val)}
flex={1}
>
<NumberInputField />
<NumberInputStepper>
<NumberIncrementStepper />
<NumberDecrementStepper />
</NumberInputStepper>
</NumberInput>
</HStack>
</FormControl>
{/* Font Weight */}
<FormControl>
<FormLabel fontSize="xs">Weight</FormLabel>
<HStack spacing={2}>
<Slider
value={styles.fontWeight}
min={100}
max={900}
step={100}
onChange={(val) => updateStyle('fontWeight', val)}
flex={1}
>
<SliderTrack>
<SliderFilledTrack bg={primaryColor} />
</SliderTrack>
<SliderThumb />
</Slider>
<Text fontSize="xs" minW="40px">{styles.fontWeight}</Text>
</HStack>
</FormControl>
{/* Line Height */}
<FormControl>
<FormLabel fontSize="xs">Line Height</FormLabel>
<HStack spacing={2}>
<Slider
value={styles.lineHeight}
min={0.5}
max={3}
step={0.1}
onChange={(val) => updateStyle('lineHeight', val)}
flex={1}
>
<SliderTrack>
<SliderFilledTrack bg={primaryColor} />
</SliderTrack>
<SliderThumb />
</Slider>
<Text fontSize="xs" minW="40px">{styles.lineHeight.toFixed(1)}</Text>
</HStack>
</FormControl>
{/* Letter Spacing */}
<FormControl>
<FormLabel fontSize="xs">Letter Spacing (px)</FormLabel>
<HStack spacing={2}>
<Slider
value={styles.letterSpacing}
min={-5}
max={10}
step={0.1}
onChange={(val) => updateStyle('letterSpacing', val)}
flex={1}
>
<SliderTrack>
<SliderFilledTrack bg={primaryColor} />
</SliderTrack>
<SliderThumb />
</Slider>
<Text fontSize="xs" minW="40px">{styles.letterSpacing.toFixed(1)}</Text>
</HStack>
</FormControl>
{/* Text Transform */}
<FormControl>
<FormLabel fontSize="xs">Transform</FormLabel>
<Select
size="sm"
value={styles.textTransform}
onChange={(e) => updateStyle('textTransform', e.target.value)}
>
<option value="none">None</option>
<option value="uppercase">UPPERCASE</option>
<option value="lowercase">lowercase</option>
<option value="capitalize">Capitalize</option>
</Select>
</FormControl>
</VStack>
</TabPanel>
{/* Style Tab */}
<TabPanel>
<VStack align="stretch" spacing={4}>
<Text fontWeight="bold" fontSize="sm" textTransform="uppercase" color="gray.500">
Colors
</Text>
{/* Text Color */}
<FormControl>
<FormLabel fontSize="xs">Text Color</FormLabel>
<HStack>
<Input
type="color"
value={styles.color}
onChange={(e) => updateStyle('color', e.target.value)}
size="sm"
w="60px"
p={1}
/>
<Input
value={styles.color}
onChange={(e) => updateStyle('color', e.target.value)}
size="sm"
placeholder="#000000"
/>
</HStack>
</FormControl>
{/* Background Color */}
<FormControl>
<FormLabel fontSize="xs">Background Color</FormLabel>
<HStack>
<Input
type="color"
value={styles.backgroundColor}
onChange={(e) => updateStyle('backgroundColor', e.target.value)}
size="sm"
w="60px"
p={1}
/>
<Input
value={styles.backgroundColor}
onChange={(e) => updateStyle('backgroundColor', e.target.value)}
size="sm"
placeholder="#ffffff"
/>
</HStack>
</FormControl>
<Divider my={2} />
<Text fontWeight="bold" fontSize="sm" textTransform="uppercase" color="gray.500">
Spacing
</Text>
{/* Padding */}
<FormControl>
<FormLabel fontSize="xs">Padding (px)</FormLabel>
<VStack spacing={2}>
<HStack width="100%">
<Text fontSize="xs" minW="20px">T</Text>
<NumberInput
size="xs"
value={styles.paddingTop}
min={0}
onChange={(_, val) => updateStyle('paddingTop', val)}
flex={1}
>
<NumberInputField />
</NumberInput>
</HStack>
<HStack width="100%">
<Text fontSize="xs" minW="20px">R</Text>
<NumberInput
size="xs"
value={styles.paddingRight}
min={0}
onChange={(_, val) => updateStyle('paddingRight', val)}
flex={1}
>
<NumberInputField />
</NumberInput>
</HStack>
<HStack width="100%">
<Text fontSize="xs" minW="20px">B</Text>
<NumberInput
size="xs"
value={styles.paddingBottom}
min={0}
onChange={(_, val) => updateStyle('paddingBottom', val)}
flex={1}
>
<NumberInputField />
</NumberInput>
</HStack>
<HStack width="100%">
<Text fontSize="xs" minW="20px">L</Text>
<NumberInput
size="xs"
value={styles.paddingLeft}
min={0}
onChange={(_, val) => updateStyle('paddingLeft', val)}
flex={1}
>
<NumberInputField />
</NumberInput>
</HStack>
</VStack>
</FormControl>
{/* Margin */}
<FormControl>
<FormLabel fontSize="xs">Margin (px)</FormLabel>
<VStack spacing={2}>
<HStack width="100%">
<Text fontSize="xs" minW="20px">T</Text>
<NumberInput
size="xs"
value={styles.marginTop}
onChange={(_, val) => updateStyle('marginTop', val)}
flex={1}
>
<NumberInputField />
</NumberInput>
</HStack>
<HStack width="100%">
<Text fontSize="xs" minW="20px">R</Text>
<NumberInput
size="xs"
value={styles.marginRight}
onChange={(_, val) => updateStyle('marginRight', val)}
flex={1}
>
<NumberInputField />
</NumberInput>
</HStack>
<HStack width="100%">
<Text fontSize="xs" minW="20px">B</Text>
<NumberInput
size="xs"
value={styles.marginBottom}
onChange={(_, val) => updateStyle('marginBottom', val)}
flex={1}
>
<NumberInputField />
</NumberInput>
</HStack>
<HStack width="100%">
<Text fontSize="xs" minW="20px">L</Text>
<NumberInput
size="xs"
value={styles.marginLeft}
onChange={(_, val) => updateStyle('marginLeft', val)}
flex={1}
>
<NumberInputField />
</NumberInput>
</HStack>
</VStack>
</FormControl>
</VStack>
</TabPanel>
{/* Grid Tab */}
<TabPanel>
<VStack align="stretch" spacing={4}>
<Text fontWeight="bold" fontSize="sm" textTransform="uppercase" color="gray.500">
Grid Layout
</Text>
{/* Enable Grid */}
<FormControl display="flex" alignItems="center">
<FormLabel fontSize="xs" mb={0} flex={1}>Enable Grid Layout</FormLabel>
<Switch
size="sm"
isChecked={styles.display === 'grid'}
onChange={(e) => updateStyle('display', e.target.checked ? 'grid' : 'block')}
sx={{
'span[data-checked]': {
bg: primaryColor,
borderColor: primaryColor,
}
}}
/>
</FormControl>
{styles.display === 'grid' && (
<>
<Divider />
{/* Quick Templates */}
<FormControl>
<FormLabel fontSize="xs" fontWeight="bold">Quick Templates</FormLabel>
<VStack spacing={2}>
<Button
size="xs"
width="100%"
variant="outline"
onClick={() => updateStyle('gridTemplateColumns', '1fr')}
justifyContent="flex-start"
>
<HStack spacing={2}>
<FiSmartphone />
<Text>Single Column</Text>
</HStack>
</Button>
<Button
size="xs"
width="100%"
variant="outline"
onClick={() => updateStyle('gridTemplateColumns', '1fr 1fr')}
justifyContent="flex-start"
>
<HStack spacing={2}>
<FaColumns />
<Text>Two Equal (50% / 50%)</Text>
</HStack>
</Button>
<Button
size="xs"
width="100%"
variant="outline"
onClick={() => updateStyle('gridTemplateColumns', '2fr 1fr')}
justifyContent="flex-start"
>
<HStack spacing={2}>
<FiBarChart2 />
<Text>Left Larger (66% / 33%)</Text>
</HStack>
</Button>
<Button
size="xs"
width="100%"
variant="outline"
onClick={() => updateStyle('gridTemplateColumns', '1fr 2fr')}
justifyContent="flex-start"
>
<HStack spacing={2}>
<FiBarChart2 style={{ transform: 'scaleX(-1)' }} />
<Text>Right Larger (33% / 66%)</Text>
</HStack>
</Button>
<Button
size="xs"
width="100%"
variant="outline"
onClick={() => updateStyle('gridTemplateColumns', '1fr 1fr 1fr')}
justifyContent="flex-start"
>
<HStack spacing={2}>
<FiGrid />
<Text>Three Equal (33% / 33% / 33%)</Text>
</HStack>
</Button>
<Button
size="xs"
width="100%"
variant="outline"
onClick={() => updateStyle('gridTemplateColumns', '2fr 1fr 1fr')}
justifyContent="flex-start"
>
<HStack spacing={2}>
<FaRegNewspaper />
<Text>Featured + Two (50% / 25% / 25%)</Text>
</HStack>
</Button>
<Button
size="xs"
width="100%"
variant="outline"
onClick={() => updateStyle('gridTemplateColumns', 'repeat(4, 1fr)')}
justifyContent="flex-start"
>
<HStack spacing={2}>
<FaRegSquare />
<Text>Four Equal (25% each)</Text>
</HStack>
</Button>
<Button
size="xs"
width="100%"
variant="outline"
onClick={() => updateStyle('gridTemplateColumns', '3fr 1fr')}
justifyContent="flex-start"
>
<HStack spacing={2}>
<FiSidebar />
<Text>Main + Sidebar (75% / 25%)</Text>
</HStack>
</Button>
</VStack>
</FormControl>
<Divider />
{/* Custom Columns */}
<FormControl>
<FormLabel fontSize="xs">Grid Template Columns</FormLabel>
<Input
size="sm"
value={styles.gridTemplateColumns}
onChange={(e) => updateStyle('gridTemplateColumns', e.target.value)}
placeholder="e.g. 1fr 2fr or 300px 1fr"
fontFamily="monospace"
fontSize="xs"
/>
<Text fontSize="10px" color="gray.500" mt={1}>
Examples: 1fr 1fr | 2fr 1fr | 200px 1fr | repeat(3, 1fr)
</Text>
</FormControl>
{/* Grid Template Rows */}
<FormControl>
<FormLabel fontSize="xs">Grid Template Rows</FormLabel>
<Input
size="sm"
value={styles.gridTemplateRows}
onChange={(e) => updateStyle('gridTemplateRows', e.target.value)}
placeholder="auto or 200px 1fr"
fontFamily="monospace"
fontSize="xs"
/>
</FormControl>
{/* Column Gap */}
<FormControl>
<FormLabel fontSize="xs">Column Gap (px)</FormLabel>
<HStack spacing={2}>
<Slider
value={styles.gridColumnGap}
min={0}
max={100}
step={4}
onChange={(val) => updateStyle('gridColumnGap', val)}
flex={1}
>
<SliderTrack>
<SliderFilledTrack bg="purple.500" />
</SliderTrack>
<SliderThumb />
</Slider>
<Text fontSize="xs" minW="40px">{styles.gridColumnGap}px</Text>
</HStack>
</FormControl>
{/* Row Gap */}
<FormControl>
<FormLabel fontSize="xs">Row Gap (px)</FormLabel>
<HStack spacing={2}>
<Slider
value={styles.gridRowGap}
min={0}
max={100}
step={4}
onChange={(val) => updateStyle('gridRowGap', val)}
flex={1}
>
<SliderTrack>
<SliderFilledTrack bg="purple.500" />
</SliderTrack>
<SliderThumb />
</Slider>
<Text fontSize="xs" minW="40px">{styles.gridRowGap}px</Text>
</HStack>
</FormControl>
<Divider />
{/* Grid Auto Flow */}
<FormControl>
<FormLabel fontSize="xs">Auto Flow</FormLabel>
<Select
size="sm"
value={styles.gridAutoFlow}
onChange={(e) => updateStyle('gridAutoFlow', e.target.value)}
>
<option value="row">Row (horizontal)</option>
<option value="column">Column (vertical)</option>
<option value="row dense">Row Dense</option>
<option value="column dense">Column Dense</option>
</Select>
</FormControl>
{/* Align Items */}
<FormControl>
<FormLabel fontSize="xs">Align Items (vertical)</FormLabel>
<Select
size="sm"
value={styles.alignItems}
onChange={(e) => updateStyle('alignItems', e.target.value)}
>
<option value="stretch">Stretch</option>
<option value="start">Start</option>
<option value="center">Center</option>
<option value="end">End</option>
<option value="baseline">Baseline</option>
</Select>
</FormControl>
{/* Justify Items */}
<FormControl>
<FormLabel fontSize="xs">Justify Items (horizontal)</FormLabel>
<Select
size="sm"
value={styles.justifyItems}
onChange={(e) => updateStyle('justifyItems', e.target.value)}
>
<option value="stretch">Stretch</option>
<option value="start">Start</option>
<option value="center">Center</option>
<option value="end">End</option>
</Select>
</FormControl>
</>
)}
</VStack>
</TabPanel>
{/* Advanced Tab */}
<TabPanel>
<VStack align="stretch" spacing={4}>
<Text fontWeight="bold" fontSize="sm" textTransform="uppercase" color="gray.500">
Layout
</Text>
{/* Display */}
<FormControl>
<FormLabel fontSize="xs">Display</FormLabel>
<Select
size="sm"
value={styles.display}
onChange={(e) => updateStyle('display', e.target.value)}
>
<option value="block">Block</option>
<option value="inline-block">Inline Block</option>
<option value="flex">Flex</option>
<option value="grid">Grid</option>
<option value="none">None</option>
</Select>
</FormControl>
{/* Width */}
<FormControl>
<FormLabel fontSize="xs">Width</FormLabel>
<Input
size="sm"
value={styles.width}
onChange={(e) => updateStyle('width', e.target.value)}
placeholder="auto, 100%, 500px"
/>
</FormControl>
{/* Height */}
<FormControl>
<FormLabel fontSize="xs">Height</FormLabel>
<Input
size="sm"
value={styles.height}
onChange={(e) => updateStyle('height', e.target.value)}
placeholder="auto, 100%, 500px"
/>
</FormControl>
</VStack>
</TabPanel>
</TabPanels>
</Tabs>
</Box>
);
};
export default VisualStylePanel;
@@ -0,0 +1,271 @@
import React, { useState, useRef, useEffect } from 'react';
import { Box, IconButton, Flex, Heading, Link as ChakraLink } from '@chakra-ui/react';
import { FiChevronLeft, FiChevronRight, FiArrowRight } from 'react-icons/fi';
import '../../styles/sparta-styles.css';
interface Article {
id: string;
title: string;
slug: string;
image: string;
categories: string[];
date: string;
duration?: string;
isVideo?: boolean;
unlimited?: boolean;
}
interface SpartaHorizontalSliderProps {
title: string;
titleLink?: string;
articles: Article[];
itemsPerView?: {
mobile: number;
tablet: number;
desktop: number;
};
gap?: number;
showControls?: boolean;
enableDrag?: boolean;
showUnlimitedBadge?: boolean;
showCategories?: boolean;
showDuration?: boolean;
}
const SpartaHorizontalSlider: React.FC<SpartaHorizontalSliderProps> = ({
title,
titleLink = '#',
articles,
itemsPerView = { mobile: 1, tablet: 2, desktop: 3 },
gap = 16,
showControls = true,
enableDrag = true,
showUnlimitedBadge = true,
showCategories = true,
showDuration = true,
}) => {
const [currentIndex, setCurrentIndex] = useState(0);
const [isDragging, setIsDragging] = useState(false);
const [startX, setStartX] = useState(0);
const [scrollLeft, setScrollLeft] = useState(0);
const trackRef = useRef<HTMLDivElement>(null);
// Calculate how many items are visible based on viewport
const getItemsPerView = () => {
if (typeof window === 'undefined') return itemsPerView.desktop;
const width = window.innerWidth;
if (width < 768) return itemsPerView.mobile;
if (width < 1024) return itemsPerView.tablet;
return itemsPerView.desktop;
};
const [visibleItems, setVisibleItems] = useState(getItemsPerView());
useEffect(() => {
const handleResize = () => {
setVisibleItems(getItemsPerView());
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
const maxIndex = Math.max(0, articles.length - visibleItems);
const canGoNext = currentIndex < maxIndex;
const canGoPrev = currentIndex > 0;
const handleNext = () => {
if (canGoNext) {
setCurrentIndex(prev => Math.min(prev + 1, maxIndex));
}
};
const handlePrev = () => {
if (canGoPrev) {
setCurrentIndex(prev => Math.max(prev - 1, 0));
}
};
// Drag functionality
const handleMouseDown = (e: React.MouseEvent) => {
if (!enableDrag || !trackRef.current) return;
setIsDragging(true);
setStartX(e.pageX - trackRef.current.offsetLeft);
setScrollLeft(trackRef.current.scrollLeft);
};
const handleMouseUp = () => {
setIsDragging(false);
};
const handleMouseMove = (e: React.MouseEvent) => {
if (!isDragging || !trackRef.current) return;
e.preventDefault();
const x = e.pageX - trackRef.current.offsetLeft;
const walk = (x - startX) * 2;
trackRef.current.scrollLeft = scrollLeft - walk;
};
// Calculate transform based on current index
const slideWidth = trackRef.current?.children[0]?.clientWidth || 0;
const transformValue = -(currentIndex * (slideWidth + gap));
return (
<Box className="sparta-slider-container sparta-container sparta-section">
{/* Header with title and controls */}
<Flex className="sparta-slider-header" justifyContent="space-between" alignItems="center" mb={4}>
<Heading className="sparta-slider-title" as="h2">
<ChakraLink href={titleLink} display="inline-flex" alignItems="center" gap={2}>
{title}
<Box as={FiArrowRight} />
</ChakraLink>
</Heading>
{showControls && (
<Flex className="sparta-slider-controls" gap={2}>
<IconButton
aria-label="Previous"
icon={<FiChevronLeft />}
onClick={handlePrev}
isDisabled={!canGoPrev}
className="sparta-slider-button"
variant="ghost"
/>
<IconButton
aria-label="Next"
icon={<FiChevronRight />}
onClick={handleNext}
isDisabled={!canGoNext}
className="sparta-slider-button"
variant="ghost"
/>
</Flex>
)}
</Flex>
{/* Slider viewport */}
<Box className="sparta-slider-viewport" overflow="hidden">
<Flex
ref={trackRef}
className="sparta-slider-track"
gap={`${gap}px`}
transition="transform 0.3s cubic-bezier(0.4, 0, 0.6, 1)"
transform={`translate3d(${transformValue}px, 0, 0)`}
cursor={enableDrag ? (isDragging ? 'grabbing' : 'grab') : 'default'}
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseUp}
>
{articles.map((article) => (
<Box
key={article.id}
className="sparta-slider-slide"
flexShrink={0}
data-dragging={isDragging}
>
<ChakraLink
href={`/articles/${article.slug}`}
className="sparta-article-card"
textDecoration="none"
_hover={{ textDecoration: 'none' }}
>
{/* Article Image */}
<Box className="sparta-article-image" position="relative">
<img
src={article.image}
alt={article.title}
loading="lazy"
draggable={false}
/>
{/* Meta info overlay */}
{(showUnlimitedBadge && article.unlimited) && (
<Box className="sparta-article-meta">
<Box className="sparta-article-badge">
UNLIMITED
</Box>
</Box>
)}
{/* Video duration */}
{showDuration && article.duration && (
<Box
position="absolute"
bottom="8px"
right="8px"
padding="4px 8px"
background="rgba(0, 0, 0, 0.8)"
borderRadius="4px"
fontSize="0.75rem"
fontWeight="500"
>
{article.duration}
</Box>
)}
</Box>
{/* Article Details */}
<Box className="sparta-article-details">
{showCategories && article.categories.length > 0 && (
<Flex className="sparta-article-categories">
{article.categories.map((cat, idx) => (
<React.Fragment key={idx}>
<span>{cat}</span>
{idx < article.categories.length - 1 && (
<Box className="sparta-hero-separator" />
)}
</React.Fragment>
))}
</Flex>
)}
<Heading className="sparta-article-title" as="h3" size="sm">
{article.title}
</Heading>
<Box className="sparta-article-date" mt="auto">
{new Date(article.date).toLocaleDateString('cs-CZ', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</Box>
</Box>
</ChakraLink>
</Box>
))}
</Flex>
</Box>
</Box>
);
};
export default SpartaHorizontalSlider;
// Example usage:
/*
<SpartaHorizontalSlider
title="Videa"
titleLink="/sparta-tv"
articles={[
{
id: '1',
title: 'HIGHLIGHTS: Sparta - Slavia',
slug: 'highlights-sparta-slavia',
image: 'https://example.com/image.jpg',
categories: ['Match content', 'Highlights'],
date: '2025-10-05',
duration: '4:32',
isVideo: true,
unlimited: true,
},
// ... more articles
]}
itemsPerView={{ mobile: 1, tablet: 2, desktop: 3 }}
showUnlimitedBadge={true}
showCategories={true}
showDuration={true}
/>
*/
@@ -0,0 +1,191 @@
import React from 'react';
import { Alert, AlertIcon, Box, Link, Spinner, Text, VStack } from '@chakra-ui/react';
import ContactMap from '../home/ContactMap';
import { getPublicSettings } from '../../services/settings';
interface EventLocationMapProps {
location: string;
title?: string;
latitude?: number | null;
longitude?: number | null;
}
type GeocodeResult = {
lat: number;
lon: number;
displayName: string;
};
const NOMINATIM_BASE_URL = process.env.REACT_APP_NOMINATIM_URL || 'https://nominatim.openstreetmap.org';
const NOMINATIM_EMAIL = process.env.REACT_APP_NOMINATIM_EMAIL;
const geocodeCache = new Map<string, GeocodeResult>();
async function geocodeLocation(query: string, signal: AbortSignal): Promise<GeocodeResult> {
const cacheKey = query.trim().toLowerCase();
if (geocodeCache.has(cacheKey)) {
return geocodeCache.get(cacheKey)!;
}
const params = new URLSearchParams({
format: 'jsonv2',
limit: '1',
q: query,
'accept-language': 'cs',
});
if (NOMINATIM_EMAIL) {
params.append('email', NOMINATIM_EMAIL);
}
const endpoint = `${NOMINATIM_BASE_URL}/search?${params.toString()}`;
const response = await fetch(endpoint, {
headers: { Accept: 'application/json' },
signal,
});
if (!response.ok) {
throw new Error('Nepodařilo se načíst mapová data.');
}
const json = await response.json();
if (!Array.isArray(json) || json.length === 0) {
throw new Error('Poloha nebyla nalezena.');
}
const first = json[0];
const lat = Number(first.lat);
const lon = Number(first.lon);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
throw new Error('Neplatné souřadnice.');
}
const result: GeocodeResult = {
lat,
lon,
displayName: String(first.display_name || query),
};
geocodeCache.set(cacheKey, result);
return result;
}
const EventLocationMap: React.FC<EventLocationMapProps> = ({ location, title, latitude, longitude }) => {
const [loading, setLoading] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const [coords, setCoords] = React.useState<GeocodeResult | null>(null);
const [settings, setSettings] = React.useState<any>(null);
// Load settings for club colors
React.useEffect(() => {
getPublicSettings()
.then(setSettings)
.catch(() => {});
}, []);
React.useEffect(() => {
const trimmed = (location || '').trim();
if (!trimmed) {
setCoords(null);
setError(null);
return;
}
// If coordinates are provided, use them directly
if (latitude != null && longitude != null && Number.isFinite(latitude) && Number.isFinite(longitude)) {
setCoords({
lat: latitude,
lon: longitude,
displayName: trimmed,
});
setLoading(false);
setError(null);
return;
}
// Otherwise, geocode the location
let active = true;
const controller = new AbortController();
setLoading(true);
setError(null);
geocodeLocation(trimmed, controller.signal)
.then((result) => {
if (!active) return;
setCoords(result);
})
.catch((err: any) => {
if (!active) return;
setCoords(null);
setError(err?.message || 'Mapu se nepodařilo načíst.');
})
.finally(() => {
if (active) setLoading(false);
});
return () => {
active = false;
controller.abort();
};
}, [location, latitude, longitude]);
if (!location?.trim()) {
return null;
}
const openStreetMapUrl = `https://www.openstreetmap.org/search?query=${encodeURIComponent(location.trim())}`;
return (
<VStack align="stretch" spacing={3} mt={4} data-testid="event-location-map">
<Text fontWeight="semibold" fontSize="lg">Mapa místa</Text>
{loading && (
<HStackWithSpinner />
)}
{!loading && error && (
<Alert status="error" borderRadius="md">
<AlertIcon />
<Box>
<Text mb={1}>{error}</Text>
<Link href={openStreetMapUrl} isExternal color="blue.400">
Otevřít v OpenStreetMap
</Link>
</Box>
</Alert>
)}
{!loading && !error && coords && (
<Box borderWidth="1px" borderRadius="lg" overflow="hidden" borderColor="border.subtle">
<ContactMap
latitude={coords.lat}
longitude={coords.lon}
zoom={15}
address={coords.displayName}
clubName={title}
height={320}
mapStyle={settings?.map_style || 'default'}
clubPrimaryColor={settings?.primary_color}
clubSecondaryColor={settings?.accent_color}
/>
</Box>
)}
<Text fontSize="sm" color="gray.500">
Přesnost určena pomocí otevřených mapových dat.{' '}
<Link href={openStreetMapUrl} isExternal color="blue.400">
Zobrazit v OpenStreetMap
</Link>
</Text>
</VStack>
);
};
const HStackWithSpinner: React.FC = () => (
<Box display="flex" alignItems="center" gap={2} color="gray.500">
<Spinner size="sm" />
<Text>Načítám mapu</Text>
</Box>
);
export default EventLocationMap;
+168
View File
@@ -0,0 +1,168 @@
import { useState, useEffect } from 'react';
import {
Input,
InputGroup,
InputLeftElement,
VStack,
Box,
Text,
Spinner,
Icon,
useToast,
Image,
Flex,
Badge,
} from '@chakra-ui/react';
import { FaSearch, FaFutbol, FaFutbol as FaFutsal } from 'react-icons/fa';
import { useFacrApi } from '../../hooks/useFacrApi';
export const ClubSearch = () => {
const [searchQuery, setSearchQuery] = useState('');
const [debouncedQuery, setDebouncedQuery] = useState('');
const { searchClubs, searchResults, searchLoading, searchError } = useFacrApi();
const toast = useToast();
// Debounce search input
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedQuery(searchQuery);
}, 500);
return () => {
clearTimeout(handler);
};
}, [searchQuery]);
// Trigger search when debounced query changes
useEffect(() => {
if (debouncedQuery) {
searchClubs(debouncedQuery).catch(() => {
toast({
title: 'Error',
description: 'Failed to search for clubs',
status: 'error',
duration: 5000,
isClosable: true,
});
});
}
}, [debouncedQuery, searchClubs, toast]);
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearchQuery(e.target.value);
};
return (
<Box width="100%" maxW="800px" mx="auto" p={4}>
<InputGroup size="lg" mb={6}>
<InputLeftElement pointerEvents="none">
<Icon as={FaSearch} color="gray.400" />
</InputLeftElement>
<Input
type="text"
placeholder="Search for a club..."
value={searchQuery}
onChange={handleSearchChange}
bg="white"
borderColor="gray.200"
_hover={{ borderColor: 'gray.300' }}
_focus={{
borderColor: 'blue.500',
boxShadow: '0 0 0 1px #3182ce',
}}
/>
</InputGroup>
{searchLoading && (
<Flex justify="center" my={8}>
<Spinner size="xl" color="blue.500" />
</Flex>
)}
{searchError && (
<Text color="red.500" textAlign="center" my={4}>
Error: {searchError.message}
</Text>
)}
{!searchLoading && searchResults.length > 0 && (
<VStack spacing={4} align="stretch">
<Text fontSize="lg" fontWeight="bold" mb={2}>
Search Results:
</Text>
{searchResults.map((club) => (
<Box
key={`${club.club_id}-${club.name}`}
p={4}
borderWidth="1px"
borderRadius="lg"
bg="white"
_hover={{
shadow: 'md',
transform: 'translateY(-2px)',
transition: 'all 0.2s',
}}
>
<Flex align="center">
{club.logo_url ? (
<Image
src={club.logo_url}
alt={`${club.name} logo`}
boxSize="50px"
objectFit="contain"
mr={4}
/>
) : (
<Box
boxSize="50px"
bg="gray.100"
display="flex"
alignItems="center"
justifyContent="center"
borderRadius="md"
mr={4}
>
<Icon
as={club.club_type === 'football' ? FaFutbol : FaFutsal}
color="gray.400"
boxSize={6}
/>
</Box>
)}
<Box flex={1}>
<Text fontWeight="bold" fontSize="lg">
{club.name}
</Text>
<Flex mt={1} alignItems="center">
<Badge
colorScheme={club.club_type === 'football' ? 'blue' : 'green'}
mr={2}
>
{club.club_type === 'football' ? 'Football' : 'Futsal'}
</Badge>
<Text color="gray.600" fontSize="sm">
{club.category}
</Text>
</Flex>
{club.address && (
<Text color="gray.600" fontSize="sm" mt={1}>
{club.address}
</Text>
)}
</Box>
</Flex>
</Box>
))}
</VStack>
)}
{!searchLoading && searchQuery && searchResults.length === 0 && (
<Text textAlign="center" color="gray.500" my={8}>
No clubs found matching "{searchQuery}"
</Text>
)}
</Box>
);
};
export default ClubSearch;
@@ -0,0 +1,184 @@
import React from 'react';
import {
Modal,
ModalOverlay,
ModalContent,
ModalBody,
ModalCloseButton,
Image,
Box,
HStack,
Button,
Text,
useToast,
IconButton,
VStack,
} from '@chakra-ui/react';
import { Download, ExternalLink } from 'lucide-react';
interface PhotoModalProps {
isOpen: boolean;
onClose: () => void;
photoUrl: string;
pageUrl: string;
albumTitle?: string;
}
const PhotoModal: React.FC<PhotoModalProps> = ({
isOpen,
onClose,
photoUrl,
pageUrl,
albumTitle,
}) => {
const toast = useToast();
const getProxyUrl = (url: string) => {
const apiUrl = process.env.REACT_APP_API_URL || 'http://localhost:8080';
return `${apiUrl}/api/v1/gallery/proxy-image?url=${encodeURIComponent(url)}`;
};
const handleDownload = async () => {
try {
const response = await fetch(getProxyUrl(photoUrl));
if (!response.ok) {
throw new Error('Failed to fetch image');
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `fotka-${Date.now()}.jpg`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
toast({
title: 'Stahování zahájeno',
description: 'Fotka se stahuje',
status: 'success',
duration: 2000,
isClosable: true,
});
} catch (error) {
console.error('Failed to download image:', error);
toast({
title: 'Chyba',
description: 'Nepodařilo se stáhnout obrázek',
status: 'error',
duration: 2000,
isClosable: true,
});
}
};
return (
<Modal isOpen={isOpen} onClose={onClose} size="6xl" isCentered>
<ModalOverlay bg="blackAlpha.800" backdropFilter="blur(10px)" />
<ModalContent bg="transparent" boxShadow="none" maxW="90vw">
<ModalCloseButton
color="white"
bg="blackAlpha.600"
_hover={{ bg: 'blackAlpha.800' }}
size="lg"
top={2}
right={2}
zIndex={2}
/>
<ModalBody p={0}>
<VStack spacing={4} align="stretch">
{/* Image */}
<Box
position="relative"
borderRadius="lg"
overflow="hidden"
maxH="80vh"
display="flex"
alignItems="center"
justifyContent="center"
>
<Image
src={photoUrl}
alt={albumTitle || 'Fotka'}
maxH="80vh"
maxW="100%"
objectFit="contain"
loading="lazy"
/>
</Box>
{/* Controls */}
<Box
bg="bg.elevated"
borderWidth="1px"
borderColor="border.subtle"
borderRadius="lg"
p={4}
boxShadow="xl"
>
<VStack spacing={3} align="stretch">
{albumTitle && (
<Text fontSize="md" fontWeight="600" color="gray.700">
{albumTitle}
</Text>
)}
<HStack spacing={2} justify="space-between" flexWrap="wrap">
<HStack spacing={2}>
<Button
leftIcon={<Download size={18} />}
onClick={handleDownload}
colorScheme="green"
size="sm"
>
Stáhnout
</Button>
<Button
as="a"
href={pageUrl}
target="_blank"
rel="noopener noreferrer"
leftIcon={<ExternalLink size={18} />}
colorScheme="purple"
size="sm"
>
Zobrazit originál
</Button>
</HStack>
</HStack>
{/* Zonerama Copyright */}
<Box
pt={2}
borderTopWidth="1px"
borderColor="gray.200"
>
<HStack spacing={2} fontSize="xs" color="gray.500">
<Text>
© Fotografie z{' '}
<Text
as="a"
href="https://zonerama.com"
target="_blank"
rel="noopener noreferrer"
color="blue.500"
fontWeight="600"
_hover={{ textDecoration: 'underline' }}
>
Zonerama
</Text>
</Text>
</HStack>
</Box>
</VStack>
</Box>
</VStack>
</ModalBody>
</ModalContent>
</Modal>
);
};
export default PhotoModal;
@@ -0,0 +1,123 @@
import React from 'react';
import { Box, Image, Heading, Text, VStack, HStack, Badge, Skeleton, useColorModeValue, Button } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { getArticles, Article } from '../../services/articles';
import HorizontalScroller from '../ui/HorizontalScroller';
import { Link as RouterLink } from 'react-router-dom';
import { useClubTheme } from '../../contexts/ClubThemeContext';
import { assetUrl } from '../../utils/url';
import { Eye, Clock } from 'lucide-react';
const Card: React.FC<{ a: Article }> = ({ a }) => {
const cardBg = useColorModeValue('white', 'gray.800');
const border = useColorModeValue('gray.200', 'whiteAlpha.300');
const theme = useClubTheme();
const link = a.slug ? `/news/${a.slug}` : `/articles/${a.id}`;
const categoryBadgeColor = useColorModeValue('gray.100', 'whiteAlpha.200');
const categoryName = (a as any)?.category?.name || '';
return (
<Box
as={RouterLink}
to={link}
minW={{ base: '85%', md: '60%', lg: '33%' }}
scrollSnapAlign="start"
bg={cardBg}
borderRadius="xl"
overflow="hidden"
boxShadow="lg"
borderWidth="1px"
borderColor={border}
_hover={{ transform: 'translateY(-4px)', boxShadow: '2xl' }}
transition="all 0.3s cubic-bezier(0.4, 0, 0.2, 1)"
position="relative"
>
<Box position="relative" overflow="hidden">
<Image
src={assetUrl(a.image_url) || '/stadium-placeholder.jpg'}
alt={a.title}
w="100%"
h={{ base: '200px', md: '240px' }}
objectFit="cover"
transition="transform 0.3s ease"
_groupHover={{ transform: 'scale(1.05)' }}
/>
{categoryName && (
<Badge
position="absolute"
top={3}
left={3}
colorScheme="blue"
fontSize="xs"
px={3}
py={1}
borderRadius="full"
textTransform="uppercase"
fontWeight="bold"
>
{categoryName}
</Badge>
)}
</Box>
<VStack align="stretch" spacing={3} p={5}>
<Heading size="md" noOfLines={2} lineHeight="1.3">{a.title}</Heading>
{a.content && (
<Text fontSize="sm" color="gray.600" noOfLines={3} lineHeight="1.5">
{a.content.replace(/<[^>]*>/g, '').trim()}
</Text>
)}
<HStack spacing={3} pt={2} borderTopWidth="1px" borderColor={border} flexWrap="wrap">
{(a.read_time || a.estimated_read_minutes) && (
<HStack spacing={1}>
<Clock size={14} color="gray" />
<Text fontSize="xs" color="gray.500">
{a.read_time || a.estimated_read_minutes} min
</Text>
</HStack>
)}
{a.view_count !== undefined && a.view_count > 0 && (
<HStack spacing={1}>
<Eye size={14} color="gray" />
<Text fontSize="xs" color="gray.500">
{a.view_count}
</Text>
</HStack>
)}
{a.published_at && (
<Text fontSize="xs" color="gray.500">
{new Date(a.published_at).toLocaleDateString('cs-CZ')}
</Text>
)}
</HStack>
</VStack>
</Box>
);
};
const BlogCardsScroller: React.FC = () => {
const theme = useClubTheme();
const { data, isLoading } = useQuery({
queryKey: ['articles', { page: 1, page_size: 12, published: true }],
queryFn: () => getArticles({ page: 1, page_size: 12, published: true }),
});
const list: Article[] = data?.data || [];
return (
<Box>
<HorizontalScroller
title="Novinky"
rightAction={<Button as={RouterLink} to="/blog" variant="link" color="brand.primary">Více</Button>}
>
{isLoading && Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} minW={{ base: '85%', md: '60%', lg: '33%' }} h={{ base: '260px', md: '300px' }} borderRadius="xl" />
))}
{!isLoading && list.map((a) => (
<Card key={a.id} a={a} />
))}
</HorizontalScroller>
</Box>
);
};
export default BlogCardsScroller;
+103
View File
@@ -0,0 +1,103 @@
import { Box, Heading, SimpleGrid, Image, Text, VStack, HStack, Button, Skeleton, Badge, useColorModeValue } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { getArticles, Article } from '../../services/articles';
import { Link as RouterLink } from 'react-router-dom';
import { assetUrl } from '../../utils/url';
const BlogCard: React.FC<{ article: Article }> = ({ article }) => {
const link = article.slug ? `/news/${article.slug}` : `/articles/${article.id}`;
const cardBg = useColorModeValue('white', 'gray.800');
const border = useColorModeValue('gray.200', 'whiteAlpha.300');
const categoryName = (article as any)?.category?.name || '';
return (
<VStack
as={RouterLink}
to={link}
align="stretch"
spacing={0}
borderWidth="1px"
borderRadius="xl"
bg={cardBg}
overflow="hidden"
boxShadow="lg"
borderColor={border}
_hover={{ boxShadow: '2xl', transform: 'translateY(-4px)' }}
transition="all 0.3s cubic-bezier(0.4, 0, 0.2, 1)"
>
<Box position="relative" overflow="hidden">
<Image
src={assetUrl(article.image_url) || '/logo192.png'}
alt={article.title}
objectFit="cover"
w="100%"
h="200px"
transition="transform 0.3s ease"
_groupHover={{ transform: 'scale(1.05)' }}
/>
{categoryName && (
<Badge
position="absolute"
top={3}
left={3}
colorScheme="blue"
fontSize="xs"
px={3}
py={1}
borderRadius="full"
textTransform="uppercase"
fontWeight="bold"
>
{categoryName}
</Badge>
)}
</Box>
<VStack align="stretch" spacing={3} p={5}>
<Heading size="md" noOfLines={2} lineHeight="1.3">{article.title}</Heading>
<Text noOfLines={3} color="gray.600" fontSize="sm" lineHeight="1.5">
{article.content?.replace(/<[^>]*>/g, '').slice(0, 160)}
</Text>
<HStack spacing={2} pt={2} borderTopWidth="1px" borderColor={border}>
{article.estimated_read_minutes && (
<Text fontSize="xs" color="gray.500">
{article.estimated_read_minutes} min čtení
</Text>
)}
{article.published_at && (
<Text fontSize="xs" color="gray.500">
{new Date(article.published_at).toLocaleDateString('cs-CZ')}
</Text>
)}
</HStack>
</VStack>
</VStack>
);
};
const BlogGrid: React.FC = () => {
const { data, isLoading } = useQuery({
queryKey: ['articles', { page: 1, page_size: 10, published: true }],
queryFn: () => getArticles({ page: 1, page_size: 10, published: true }),
});
const articles = data?.data || [];
return (
<Box>
<HStack justify="space-between" mb={4}>
<Heading size="lg">Aktuality</Heading>
<Button as={RouterLink} to="/blog" size="sm" variant="link">Zobrazit všechny</Button>
</HStack>
<SimpleGrid columns={{ base: 1, sm: 2, md: 3 }} spacing={6}>
{isLoading && Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} height="240px" />
))}
{!isLoading && articles.map((a) => (
<BlogCard key={a.id} article={a} />
))}
</SimpleGrid>
</Box>
);
};
export default BlogGrid;
+302
View File
@@ -0,0 +1,302 @@
import React, { useRef, useState, useCallback } from 'react';
import { Box, Image, Heading, Text, VStack, HStack, Skeleton, Button, IconButton, Flex, useBreakpointValue, Container } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { getArticles, getFeaturedArticles, Article } from '../../services/articles';
import { Link as RouterLink } from 'react-router-dom';
import { assetUrl } from '../../utils/url';
import { ChevronLeftIcon, ChevronRightIcon } from '@chakra-ui/icons';
import { useClubTheme } from '../../contexts/ClubThemeContext';
import { motion, AnimatePresence } from 'framer-motion';
import { wrap } from 'popmotion';
const MotionBox = motion(Box);
const MotionImage = motion(Image);
const variants = {
enter: (direction: number) => ({
x: direction > 0 ? 1000 : -1000,
opacity: 0
}),
center: {
zIndex: 1,
x: 0,
opacity: 1
},
exit: (direction: number) => ({
zIndex: 0,
x: direction < 0 ? 1000 : -1000,
opacity: 0
})
};
const swipeConfidenceThreshold = 10000;
const swipePower = (offset: number, velocity: number) => {
return Math.abs(offset) * velocity;
};
const HeroSlide: React.FC<{ article: Article }> = ({ article }) => {
const theme = useClubTheme();
const excerpt = (article.content || '').replace(/<[^>]*>/g, '').slice(0, 200) + '...';
const link = article.slug ? `/news/${article.slug}` : `/articles/${article.id}`;
return (
<Box
position="relative"
w="100%"
h={{ base: '500px', md: '600px' }}
overflow="hidden"
borderRadius={{ base: 'none', md: 'xl' }}
boxShadow="lg"
>
<MotionImage
src={assetUrl(article.image_url) || '/stadium-placeholder.jpg'}
alt={article.title}
w="100%"
h="100%"
objectFit="cover"
initial={{ opacity: 0.7 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5 }}
/>
<Box
position="absolute"
bottom={0}
left={0}
right={0}
p={{ base: 6, md: 10 }}
bgGradient="linear(to-t, blackAlpha.900, blackAlpha.700, transparent)"
color="white"
>
<Container maxW="7xl" px={{ base: 4, md: 6 }}>
{/* Top-left BLOG link badge */}
<HStack spacing={3} mb={4}>
<Button
as={RouterLink}
to="/blog"
size="sm"
px={3}
height="28px"
borderRadius="full"
bg={theme.primary}
color="white"
_hover={{ bg: theme.accent }}
>
BLOG
</Button>
<Text fontSize={{ base: 'xs', md: 'sm' }} opacity={0.85}></Text>
<Text fontSize={{ base: 'xs', md: 'sm' }} opacity={0.85}>Klubové aktuality</Text>
</HStack>
<Box maxW={{ base: '100%', md: '70%', lg: '55%' }}>
<Text
fontSize={{ base: 'sm', md: 'md' }}
fontWeight="bold"
color={theme.accent}
textTransform="uppercase"
letterSpacing="0.1em"
mb={2}
>
Nejnovější aktualita
</Text>
<Heading
as="h2"
size={{ base: 'xl', md: '2xl', lg: '3xl' }}
mb={4}
lineHeight="1.2"
textShadow="0 2px 4px rgba(0,0,0,0.5)"
>
{article.title}
</Heading>
<Text
fontSize={{ base: 'sm', md: 'md' }}
noOfLines={3}
mb={6}
textShadow="0 1px 2px rgba(0,0,0,0.5)"
>
{excerpt}
</Text>
<HStack spacing={4}>
<Button
as={RouterLink}
to={link}
size="lg"
bg={theme.primary}
color="white"
rightIcon={<ChevronRightIcon />}
_hover={{
bg: theme.accent,
transform: 'translateY(-2px)',
boxShadow: 'lg',
}}
>
Číst více
</Button>
<Button
as={RouterLink}
to="/blog"
size="lg"
variant="outline"
borderColor="whiteAlpha.700"
color="white"
_hover={{ bg: 'whiteAlpha.200' }}
>
Všechny články
</Button>
</HStack>
</Box>
</Container>
</Box>
</Box>
);
};
const BlogSwiper: React.FC = () => {
const [page, setPage] = useState(0);
const [[slideIndex, direction], setSlideIndex] = useState([0, 0]);
const { data: featuredData, isLoading: loadingFeatured } = useQuery({
queryKey: ['featured-articles', { page: 1, page_size: 5 }],
queryFn: () => getFeaturedArticles({ page: 1, page_size: 5 }),
});
// Fallback to latest published if no featured are available
const { data: latestData } = useQuery({
queryKey: ['latest-articles', { page: 1, page_size: 5, published: true }],
queryFn: () => getArticles({ page: 1, page_size: 5, published: true }),
enabled: Boolean(!loadingFeatured && !(featuredData?.data?.length)),
});
const articles = (featuredData?.data?.length ? featuredData.data : (latestData?.data || []));
const articleIndex = wrap(0, articles.length, slideIndex);
const paginate = useCallback(
(newDirection: number) => {
setSlideIndex([slideIndex + newDirection, newDirection]);
},
[slideIndex]
);
// Auto-advance slides
React.useEffect(() => {
if (articles.length <= 1) return;
const timer = setInterval(() => {
paginate(1);
}, 8000);
return () => clearInterval(timer);
}, [articles.length, paginate]);
if (loadingFeatured) {
return (
<Skeleton
w="100%"
h={{ base: '500px', md: '600px' }}
borderRadius={{ base: 'none', md: 'xl' }}
/>
);
}
if (!articles.length) return null;
const currentArticle = articles[articleIndex];
if (!currentArticle) return null;
return (
<Box position="relative" w="100%" overflow="hidden">
<AnimatePresence initial={false} custom={direction}>
<MotionBox
key={slideIndex}
custom={direction}
variants={variants}
initial="enter"
animate="center"
exit="exit"
transition={{
x: { type: 'spring', stiffness: 300, damping: 30 },
opacity: { duration: 0.2 }
}}
drag="x"
dragConstraints={{ left: 0, right: 0 }}
dragElastic={1}
onDragEnd={(e, { offset, velocity }) => {
const swipe = swipePower(offset.x, velocity.x);
if (swipe < -swipeConfidenceThreshold) {
paginate(1);
} else if (swipe > swipeConfidenceThreshold) {
paginate(-1);
}
}}
position="relative"
w="100%"
h="100%"
>
<HeroSlide article={currentArticle} />
</MotionBox>
</AnimatePresence>
{articles.length > 1 && (
<>
<IconButton
aria-label="Předchozí slide"
icon={<ChevronLeftIcon />}
position="absolute"
left={4}
top="50%"
transform="translateY(-50%)"
zIndex={2}
borderRadius="full"
colorScheme="blackAlpha"
onClick={() => paginate(-1)}
size="lg"
/>
<IconButton
aria-label="Další slide"
icon={<ChevronRightIcon />}
position="absolute"
right={4}
top="50%"
transform="translateY(-50%)"
zIndex={2}
borderRadius="full"
colorScheme="blackAlpha"
onClick={() => paginate(1)}
size="lg"
/>
<Flex
position="absolute"
bottom={8}
left="50%"
transform="translateX(-50%)"
zIndex={2}
gap={2}
>
{articles.map((_, index) => (
<Box
key={index}
as="button"
px={2}
h="20px"
display="flex"
alignItems="center"
justifyContent="center"
fontSize="xs"
fontWeight="700"
color={index === articleIndex ? 'black' : 'white'}
bg={index === articleIndex ? 'white' : 'whiteAlpha.500'}
borderRadius="sm"
onClick={() => setSlideIndex([index, index > articleIndex ? 1 : -1])}
transition="all 0.3s"
_hover={{
bg: 'white',
color: 'black',
}}
>
{String(index + 1).padStart(2, '0')}
</Box>
))}
</Flex>
</>
)}
</Box>
);
};
export default BlogSwiper;
@@ -0,0 +1,85 @@
import React from 'react';
import { Box, HStack, Image, Skeleton, useBreakpointValue, Tooltip } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { getArticles, Article } from '../../services/articles';
import { Link as RouterLink } from 'react-router-dom';
import { assetUrl } from '../../utils/url';
const modulo = (n: number, m: number) => ((n % m) + m) % m;
const BlogThumbStrip: React.FC = () => {
const { data, isLoading } = useQuery({
queryKey: ['thumb-articles', { page: 1, page_size: 12, published: true }],
queryFn: () => getArticles({ page: 1, page_size: 12, published: true }),
});
const articles = data?.data?.filter(a => !!a.image_url) || [];
const visible = useBreakpointValue({ base: 2, md: 3, lg: 5 }) || 5;
const [index, setIndex] = React.useState(0);
const [paused, setPaused] = React.useState(false);
React.useEffect(() => {
if (articles.length <= visible) return;
const id = setInterval(() => {
if (!paused) setIndex((i) => i + 1);
}, 3000);
return () => clearInterval(id);
}, [articles.length, visible, paused]);
if (isLoading) {
return (
<HStack spacing={3}>
{Array.from({ length: visible }).map((_, i) => (
<Skeleton key={i} w={{ base: '50%', md: `${100/visible}%` }} h={{ base: '90px', md: '120px', lg: '140px' }} borderRadius="md" />
))}
</HStack>
);
}
if (!articles.length) return null;
const items = Array.from({ length: visible }).map((_, i) => {
const idx = modulo(index + i, articles.length);
return articles[idx];
});
return (
<Box
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
>
<HStack spacing={3}>
{items.map((a: Article) => (
<Box
key={a.id}
as={RouterLink}
to={a.slug ? `/news/${a.slug}` : `/articles/${a.id}`}
flex={`0 0 ${100/visible}%`}
position="relative"
borderRadius="md"
overflow="hidden"
_hover={{ transform: 'translateY(-2px)', boxShadow: 'lg' }}
transition="all 0.25s ease"
>
<Image
src={assetUrl(a.image_url) || '/stadium-placeholder.jpg'}
alt={a.title}
w="100%"
h={{ base: '90px', md: '120px', lg: '140px' }}
objectFit="cover"
/>
<Box position="absolute" bottom={0} left={0} right={0} h="36px" bgGradient="linear(to-t, blackAlpha.700, transparent)" />
<Tooltip label={a.title} openDelay={300}>
<Box position="absolute" bottom={1} left={2} right={2} color="white" fontSize="xs" noOfLines={1}>
{a.title}
</Box>
</Tooltip>
</Box>
))}
</HStack>
</Box>
);
};
export default BlogThumbStrip;
@@ -0,0 +1,41 @@
import { Box, Flex, Heading, Image, HStack, Button, Text } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { facrApi } from '../../services/facr/facrApi';
import { FACR_CLUB_ID, FACR_CLUB_TYPE } from '../../config/facr';
import { usePublicSettings } from '../../hooks/usePublicSettings';
const ClubHeader: React.FC = () => {
const { data: settings } = usePublicSettings();
const clubId = settings?.club_id || FACR_CLUB_ID;
const clubType = settings?.club_type || FACR_CLUB_TYPE;
const { data, isLoading, isError } = useQuery({
queryKey: ['facr-club', clubId, clubType],
queryFn: () => facrApi.getClub(clubId, clubType),
enabled: Boolean(clubId),
});
return (
<Flex align="center" justify="space-between" bg="white" borderWidth="1px" borderRadius="lg" p={4}>
<HStack spacing={4}>
<Image src={data?.logo_url || '/logo192.png'} alt={data?.name || 'Club'} boxSize="64px" objectFit="contain" />
<Box>
<Heading size="lg">{data?.name || 'Club Name'}</Heading>
<Text color="gray.600" fontSize="sm">
{data?.address || (!clubId ? 'Nastavte klub v Nastavení (Admin) nebo REACT_APP_FACR_CLUB_ID' : '')}
</Text>
</Box>
</HStack>
<HStack spacing={2}>
<Button as="a" href="https://facebook.com" target="_blank" size="sm" variant="ghost">FB</Button>
<Button as="a" href="https://instagram.com" target="_blank" size="sm" variant="ghost">IG</Button>
<Button as="a" href="https://youtube.com" target="_blank" size="sm" variant="ghost">YT</Button>
{data?.url && (
<Button as="a" href={data.url} target="_blank" size="sm" colorScheme="blue">FAČR profil</Button>
)}
</HStack>
</Flex>
);
};
export default ClubHeader;
+211
View File
@@ -0,0 +1,211 @@
import React from 'react';
import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalFooter,
ModalBody,
ModalCloseButton,
Button,
Box,
Text,
VStack,
HStack,
Badge,
Flex,
useColorModeValue,
} from '@chakra-ui/react';
import { TeamLogo } from '../common/TeamLogo';
interface ClubModalProps {
isOpen: boolean;
onClose: () => void;
club: {
team: string;
team_id?: string;
team_logo_url?: string;
rank?: string | number;
played?: string | number;
wins?: string | number;
draws?: string | number;
losses?: string | number;
score?: string;
points?: string | number;
// Additional fields from FACR
goals_scored?: string | number;
goals_conceded?: string | number;
goal_difference?: string | number;
form?: string; // Last 5 matches form (e.g., "WWDWL")
position_change?: number; // +/- change in position
} | null;
clubType?: 'football' | 'futsal';
}
const ClubModal: React.FC<ClubModalProps> = ({ isOpen, onClose, club, clubType = 'football' }) => {
if (!club) return null;
// Theme-aware colors
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'whiteAlpha.300');
const fallbackBg = useColorModeValue('gray.100', 'gray.700');
const fallbackText = useColorModeValue('gray.600', 'gray.300');
return (
<Modal isOpen={isOpen} onClose={onClose} size="lg" isCentered>
<ModalOverlay bg="blackAlpha.600" backdropFilter="blur(4px)" />
<ModalContent>
<ModalHeader>
<Flex align="center" gap={3}>
<TeamLogo
teamId={club.team_id}
teamName={club.team}
facrLogo={club.team_logo_url}
size="large"
alt={club.team}
borderRadius="full"
bg={cardBg}
borderWidth="1px"
borderColor={borderColor}
fallbackIcon={
<Box
w="48px"
h="48px"
bg={fallbackBg}
borderRadius="full"
display="flex"
alignItems="center"
justifyContent="center"
color={fallbackText}
fontSize="lg"
fontWeight="bold"
borderWidth="1px"
borderColor={borderColor}
>
{club.team.substring(0, 2).toUpperCase()}
</Box>
}
/>
<Box>
<Text fontSize="xl" fontWeight="bold">
{club.team}
</Text>
{club.rank && (
<Badge colorScheme="blue" fontSize="sm">
{club.rank}. místo
</Badge>
)}
</Box>
</Flex>
</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack spacing={4} align="stretch">
{/* Statistics */}
<Box
borderWidth="1px"
borderRadius="lg"
p={4}
bg={useColorModeValue('gray.50', 'gray.700')}
borderColor={borderColor}
>
<Text fontSize="md" fontWeight="semibold" mb={3} color={useColorModeValue('gray.700', 'gray.200')}>
Statistiky
</Text>
<VStack spacing={2} align="stretch">
<HStack justify="space-between">
<Text color={useColorModeValue('gray.600', 'gray.300')}>Odehráno zápasů:</Text>
<Text fontWeight="bold" color={useColorModeValue('gray.800', 'gray.100')}>{club.played || 0}</Text>
</HStack>
<HStack justify="space-between">
<Text color={useColorModeValue('gray.600', 'gray.300')}>Výhry:</Text>
<Text fontWeight="bold" color="green.600">
{club.wins || 0}
</Text>
</HStack>
<HStack justify="space-between">
<Text color={useColorModeValue('gray.600', 'gray.300')}>Remízy:</Text>
<Text fontWeight="bold" color={useColorModeValue('gray.600', 'gray.400')}>
{club.draws || 0}
</Text>
</HStack>
<HStack justify="space-between">
<Text color={useColorModeValue('gray.600', 'gray.300')}>Prohry:</Text>
<Text fontWeight="bold" color="red.600">
{club.losses || 0}
</Text>
</HStack>
<HStack justify="space-between">
<Text color={useColorModeValue('gray.600', 'gray.300')}>Skóre:</Text>
<Text fontWeight="bold" color={useColorModeValue('gray.800', 'gray.100')}>{club.score || '0:0'}</Text>
</HStack>
{(club.goals_scored !== undefined || club.goals_conceded !== undefined) && (
<>
<HStack justify="space-between">
<Text color={useColorModeValue('gray.600', 'gray.300')}>Vstřelené góly:</Text>
<Text fontWeight="bold" color="green.500">{club.goals_scored || 0}</Text>
</HStack>
<HStack justify="space-between">
<Text color={useColorModeValue('gray.600', 'gray.300')}>Obdržené góly:</Text>
<Text fontWeight="bold" color="red.500">{club.goals_conceded || 0}</Text>
</HStack>
</>
)}
{club.goal_difference !== undefined && (
<HStack justify="space-between">
<Text color={useColorModeValue('gray.600', 'gray.300')}>Skóre rozdíl:</Text>
<Text fontWeight="bold" color={Number(club.goal_difference) >= 0 ? 'green.600' : 'red.600'}>
{Number(club.goal_difference) > 0 ? '+' : ''}{club.goal_difference}
</Text>
</HStack>
)}
<HStack justify="space-between" pt={2} borderTopWidth="1px" borderColor={borderColor}>
<Text color={useColorModeValue('gray.700', 'gray.200')} fontWeight="semibold">Body:</Text>
<Badge colorScheme="blue" fontSize="lg" px={3} py={1}>
{club.points || 0}
</Badge>
</HStack>
</VStack>
</Box>
{/* Form (Last 5 matches) */}
{club.form && (
<Box
borderWidth="1px"
borderRadius="lg"
p={4}
bg={useColorModeValue('gray.50', 'gray.700')}
borderColor={borderColor}
>
<Text fontSize="md" fontWeight="semibold" mb={3} color={useColorModeValue('gray.700', 'gray.200')}>
Forma (posledních 5 zápasů)
</Text>
<HStack spacing={2} justify="center">
{club.form.split('').map((result, idx) => (
<Badge
key={idx}
colorScheme={result === 'W' ? 'green' : result === 'D' ? 'yellow' : 'red'}
fontSize="md"
px={3}
py={1}
borderRadius="md"
>
{result === 'W' ? 'V' : result === 'D' ? 'R' : 'P'}
</Badge>
))}
</HStack>
</Box>
)}
</VStack>
</ModalBody>
<ModalFooter>
<Button colorScheme="gray" onClick={onClose}>
Zavřít
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
};
export default ClubModal;
@@ -0,0 +1,151 @@
import { Box, Tabs, TabList, TabPanels, Tab, TabPanel, VStack, HStack, Image, Text, Skeleton, Badge } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import React from 'react';
import { facrApi } from '../../services/facr/facrApi';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import { getCompetitionAliasesPublic, CompetitionAlias } from '../../services/competitionAliases';
import { TeamLogo } from '../common/TeamLogo';
import { sortCategoriesWithOrder } from '../../utils/categorySort';
import '../../styles/logos.css';
const Row: React.FC<{ d: string; h: string; hid?: string; hl?: string; a: string; aid?: string; al?: string; s?: string; clubName?: string }> = ({ d, h, hid, hl, a, aid, al, s, clubName }) => (
<HStack justify="space-between" borderRadius="lg" p={3} bg="white" boxShadow="sm">
<Text w="140px" fontSize="sm" color="gray.600">{d}</Text>
<HStack flex={1} justify="flex-end" spacing={4}>
<HStack minW="40%" justify="flex-end" spacing={2}>
<Text noOfLines={1} textAlign="right" flex={1}>{h}</Text>
<Box className="logo-container" w="28px" h="28px">
<TeamLogo
teamId={hid}
teamName={h}
facrLogo={hl}
size="custom"
boxSize="28px"
/>
</Box>
</HStack>
<HStack minW="60px" justify="center" spacing={2}>
<Text fontWeight="bold" textAlign="center">{s || '-:-'}</Text>
{(() => {
if (!s || !clubName) return null;
const m = s.match(/^(\d+)\s*[:\-]\s*(\d+)$/);
if (!m) return null;
const hG = parseInt(m[1], 10), aG = parseInt(m[2], 10);
const norm = (x: string) => String(x||'').normalize('NFD').replace(/[\u0300-\u036f]/g,'').replace(/\s+/g,' ').trim().toLowerCase();
const strip = (x: string) => norm(x).replace(/\b(mestsky|m\.?f\.?k\.?|mfk|tj|sk|sokol|fotbalovy|fotbalový|fotbalovy\s+klub|fotbalovy\s+klub)\b/g,'').replace(/\s+/g,' ').trim();
const ourHome = (() => { const A = strip(h); const B = strip(clubName); return A && B && (A===B || A.endsWith(B) || B.endsWith(A)); })();
const ourAway = (() => { const A = strip(a); const B = strip(clubName); return A && B && (A===B || A.endsWith(B) || B.endsWith(A)); })();
if (!ourHome && !ourAway) return null;
if (hG === aG) return <Badge colorScheme="blue" variant="subtle">Remíza</Badge>;
const our = ourHome ? hG : aG; const opp = ourHome ? aG : hG;
return our > opp ? <Badge colorScheme="green" variant="subtle">Výhra</Badge> : <Badge colorScheme="red" variant="subtle">Prohra</Badge>;
})()}
</HStack>
<HStack minW="40%" spacing={2}>
<Box className="logo-container" w="28px" h="28px">
<TeamLogo
teamId={aid}
teamName={a}
facrLogo={al}
size="custom"
boxSize="28px"
/>
</Box>
<Text noOfLines={1} flex={1}>{a}</Text>
</HStack>
</HStack>
</HStack>
);
const CompetitionMatches: React.FC = () => {
const { data: settings } = usePublicSettings();
const clubId = settings?.club_id;
const clubType = settings?.club_type || 'football';
const { data, isLoading } = useQuery({
queryKey: ['facr-club', clubId, clubType],
queryFn: () => facrApi.getClub(clubId!, clubType as any),
enabled: !!clubId,
});
// Load competition aliases
const [aliases, setAliases] = React.useState<Record<string, { alias: string; original_name?: string; display_order?: number }>>({});
React.useEffect(() => {
let mounted = true;
(async () => {
try {
const list: CompetitionAlias[] = await getCompetitionAliasesPublic();
if (!mounted) return;
const map: Record<string, { alias: string; original_name?: string; display_order?: number }> = {};
(list || []).forEach((a) => { if (a?.code && a?.alias) map[a.code] = { alias: a.alias, original_name: a.original_name, display_order: a.display_order }; });
setAliases(map);
} catch {}
})();
return () => { mounted = false; };
}, []);
// Precompute sorted competitions safely (must be before any early returns to keep hooks order stable)
const competitions = data?.competitions ?? [];
const sortedCompetitions = React.useMemo(() => {
const arr = Array.isArray(competitions) ? competitions : [];
return sortCategoriesWithOrder(
arr.map(c => ({
...c,
name: aliases[c.code]?.alias || aliases[c.id]?.alias || c.name,
alias: aliases[c.code]?.alias || aliases[c.id]?.alias,
display_order: (aliases[c.code]?.display_order) ?? (aliases[c.id]?.display_order),
}))
);
}, [competitions, aliases]);
if (isLoading) return <Skeleton height="200px" />;
if (!clubId) {
return (
<Box p={4} bg="yellow.50" borderRadius="md" borderWidth="1px" borderColor="yellow.200">
<Text color="gray.700">
Pro zobrazení zápasů je potřeba nastavit klub v administraci (Nastavení Základní údaje).
</Text>
</Box>
);
}
if (!data || !data.competitions || data.competitions.length === 0) {
return (
<Box p={4} bg="gray.50" borderRadius="md" borderWidth="1px" borderColor="gray.200">
<Text color="gray.600">
Žádné soutěže ani zápasy nejsou k dispozici pro vybraný klub.
</Text>
</Box>
);
}
// Sort competitions by age (Muži first, then U19, U17, etc.) and respect custom order (computed above)
return (
<Box>
<Tabs variant="soft-rounded" colorScheme="blue" isFitted>
<TabList>
{sortedCompetitions.map((c) => {
const label = c.alias || c.name;
return <Tab key={c.id}>{label}</Tab>;
})}
</TabList>
<TabPanels>
{sortedCompetitions.map((c) => (
<TabPanel key={c.id} px={0}>
<VStack align="stretch" spacing={3}>
{(c.matches || []).slice(0, 6).map((m, idx) => (
<Row key={m.match_id || idx} d={m.date_time} h={m.home} hid={m.home_id} hl={m.home_logo_url} a={m.away} aid={m.away_id} al={m.away_logo_url} s={m.score} clubName={data.name} />
))}
{(c.matches || []).length === 0 && (
<Text color="gray.500">Žádné zápasy k dispozici.</Text>
)}
</VStack>
</TabPanel>
))}
</TabPanels>
</Tabs>
</Box>
);
};
export default CompetitionMatches;
+328
View File
@@ -0,0 +1,328 @@
import React, { useEffect, useRef } from 'react';
import { Box } from '@chakra-ui/react';
// Dynamically load Leaflet
let L: any = null;
interface ContactMapProps {
latitude: number;
longitude: number;
zoom?: number;
address?: string;
clubName?: string;
mapStyle?: string;
height?: number;
clubPrimaryColor?: string;
clubSecondaryColor?: string;
}
// Available map styles
export const MAP_STYLES = {
// Clean & Minimal
'positron': {
name: 'Positron (Light)',
url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',
attribution: '© OpenStreetMap © CartoDB',
description: 'Clean light map, perfect for overlays'
},
'positron-no-labels': {
name: 'Positron No Labels',
url: 'https://{s}.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}{r}.png',
attribution: '© OpenStreetMap © CartoDB',
description: 'Minimal light map without labels'
},
// Dark Themes
'dark': {
name: 'Dark Matter',
url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
attribution: '© OpenStreetMap © CartoDB',
description: 'Dark theme, great for night mode'
},
'dark-no-labels': {
name: 'Dark No Labels',
url: 'https://{s}.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png',
attribution: '© OpenStreetMap © CartoDB',
description: 'Dark map without labels'
},
// Black & White
'toner': {
name: 'Toner (B&W)',
url: 'https://tiles.stadiamaps.com/tiles/stamen_toner/{z}/{x}/{y}{r}.png',
attribution: '© Stamen Design © OpenStreetMap',
description: 'High contrast black and white'
},
'toner-lite': {
name: 'Toner Lite (B&W)',
url: 'https://tiles.stadiamaps.com/tiles/stamen_toner_lite/{z}/{x}/{y}{r}.png',
attribution: '© Stamen Design © OpenStreetMap',
description: 'Subtle black and white'
},
// Colorful Options
'voyager': {
name: 'Voyager',
url: 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png',
attribution: '© OpenStreetMap © CartoDB',
description: 'Balanced colors, good readability'
},
'terrain': {
name: 'Terrain',
url: 'https://tiles.stadiamaps.com/tiles/stamen_terrain/{z}/{x}/{y}{r}.jpg',
attribution: '© Stamen Design © OpenStreetMap',
description: 'Natural terrain visualization'
},
'watercolor': {
name: 'Watercolor',
url: 'https://tiles.stadiamaps.com/tiles/stamen_watercolor/{z}/{x}/{y}.jpg',
attribution: '© Stamen Design © OpenStreetMap',
description: 'Artistic watercolor style'
},
// Default
'default': {
name: 'OpenStreetMap',
url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
attribution: '© OpenStreetMap contributors',
description: 'Standard OpenStreetMap'
},
// Satellite
'satellite': {
name: 'Satellite',
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
attribution: '© Esri',
description: 'Satellite imagery'
},
};
const ContactMap: React.FC<ContactMapProps> = ({
latitude,
longitude,
zoom = 15,
address,
clubName,
mapStyle = 'default',
height = 400,
clubPrimaryColor,
clubSecondaryColor,
}) => {
const mapRef = useRef<HTMLDivElement>(null);
const mapInstanceRef = useRef<any>(null);
const [isLoaded, setIsLoaded] = React.useState(false);
const [loadError, setLoadError] = React.useState<string | null>(null);
useEffect(() => {
// Load Leaflet CSS and JS dynamically
const loadLeaflet = async () => {
try {
// Check if already loaded
if ((window as any).L) {
L = (window as any).L;
setIsLoaded(true);
return;
}
// Load CSS
if (!document.getElementById('leaflet-css')) {
const link = document.createElement('link');
link.id = 'leaflet-css';
link.rel = 'stylesheet';
link.href = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css';
link.integrity = 'sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=';
link.crossOrigin = '';
document.head.appendChild(link);
}
// Load JS
if (!document.getElementById('leaflet-js')) {
const script = document.createElement('script');
script.id = 'leaflet-js';
script.src = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js';
script.integrity = 'sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=';
script.crossOrigin = '';
script.onload = () => {
L = (window as any).L;
setIsLoaded(true);
};
script.onerror = () => {
setLoadError('Failed to load map library');
};
document.head.appendChild(script);
}
} catch (error) {
setLoadError('Error loading map');
}
};
loadLeaflet();
}, []);
useEffect(() => {
if (!isLoaded || !L || !mapRef.current || mapInstanceRef.current) return;
try {
// Initialize map
const map = L.map(mapRef.current, {
center: [latitude, longitude],
zoom: zoom,
scrollWheelZoom: false, // Disable scroll zoom for better UX
});
mapInstanceRef.current = map;
// Get tile layer URL based on style
let tileUrl = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png';
let attribution = '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors';
// Use predefined styles or custom URL
if (mapStyle && MAP_STYLES[mapStyle as keyof typeof MAP_STYLES]) {
const style = MAP_STYLES[mapStyle as keyof typeof MAP_STYLES];
tileUrl = style.url;
attribution = style.attribution;
} else if (mapStyle && mapStyle.startsWith('http')) {
// Custom tile URL
tileUrl = mapStyle;
}
// Add tile layer
const tileLayer = L.tileLayer(tileUrl, {
attribution: attribution,
maxZoom: 19,
}).addTo(map);
// Apply club color overlay if provided
if (clubPrimaryColor && clubPrimaryColor !== '') {
const colorFilter = createColorFilter(clubPrimaryColor);
if (colorFilter) {
const pane = map.createPane('colorOverlay');
pane.style.zIndex = '400';
pane.style.pointerEvents = 'none';
pane.style.mixBlendMode = 'multiply';
pane.style.backgroundColor = colorFilter;
pane.style.opacity = '0.15';
}
}
// Create custom marker icon with club colors
const markerColor = clubPrimaryColor || '#3388ff';
const customIcon = createCustomMarkerIcon(markerColor, L);
// Add marker
const marker = L.marker([latitude, longitude], { icon: customIcon }).addTo(map);
// Add popup if address is provided
if (clubName || address) {
let popupContent = '';
if (clubName) popupContent += `<b>${clubName}</b><br>`;
if (address) popupContent += address;
marker.bindPopup(popupContent);
}
// Enable scroll zoom on click
map.on('click', () => {
map.scrollWheelZoom.enable();
});
// Disable scroll zoom on mouseout
map.on('mouseout', () => {
map.scrollWheelZoom.disable();
});
} catch (error) {
console.error('Error initializing map:', error);
setLoadError('Failed to initialize map');
}
// Cleanup
return () => {
if (mapInstanceRef.current) {
mapInstanceRef.current.remove();
mapInstanceRef.current = null;
}
};
}, [isLoaded, latitude, longitude, zoom, address, clubName, mapStyle, clubPrimaryColor, clubSecondaryColor]);
// Helper function to create color filter
function createColorFilter(color: string): string | null {
try {
// Validate and normalize color
const tempDiv = document.createElement('div');
tempDiv.style.color = color;
document.body.appendChild(tempDiv);
const computedColor = window.getComputedStyle(tempDiv).color;
document.body.removeChild(tempDiv);
return computedColor;
} catch {
return null;
}
}
// Helper function to create custom marker with club colors
function createCustomMarkerIcon(color: string, leaflet: any) {
// Create SVG marker with custom color
const svgIcon = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 36" width="36" height="54">
<defs>
<filter id="shadow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur in="SourceAlpha" stdDeviation="2"/>
<feOffset dx="0" dy="2" result="offsetblur"/>
<feComponentTransfer>
<feFuncA type="linear" slope="0.3"/>
</feComponentTransfer>
<feMerge>
<feMergeNode/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<path fill="${color}" stroke="#fff" stroke-width="1.5" filter="url(#shadow)"
d="M12 0C7.03 0 3 4.03 3 9c0 7.5 9 18 9 18s9-10.5 9-18c0-4.97-4.03-9-9-9z"/>
<circle cx="12" cy="9" r="3" fill="#fff"/>
</svg>
`;
const iconUrl = 'data:image/svg+xml;base64,' + btoa(svgIcon);
return leaflet.icon({
iconUrl: iconUrl,
iconSize: [36, 54],
iconAnchor: [18, 54],
popupAnchor: [0, -54],
});
}
if (loadError) {
return (
<Box
ref={mapRef}
w="100%"
h={`${height}px`}
bg="gray.100"
display="flex"
alignItems="center"
justifyContent="center"
borderRadius="md"
>
{loadError}
</Box>
);
}
return (
<Box
ref={mapRef}
w="100%"
h={`${height}px`}
borderRadius="md"
overflow="hidden"
boxShadow="md"
/>
);
};
export default ContactMap;
@@ -0,0 +1,334 @@
import React, { useEffect, useState } from 'react';
import {
Box,
Container,
Heading,
Text,
SimpleGrid,
VStack,
HStack,
Avatar,
Badge,
Link,
Icon,
useColorModeValue,
Divider,
Accordion,
AccordionItem,
AccordionButton,
AccordionPanel,
AccordionIcon,
} from '@chakra-ui/react';
import { FiMail, FiPhone, FiMapPin } from 'react-icons/fi';
import { getPublicContacts, GroupedContacts } from '../../services/contactInfo';
import { getPublicSettings } from '../../services/settings';
import ContactMap from './ContactMap';
import { getImageUrl } from '../../utils/imageUtils';
const ContactsSection: React.FC = () => {
const [contactsData, setContactsData] = useState<GroupedContacts | null>(null);
const [settings, setSettings] = useState<any>(null);
const [loading, setLoading] = useState(true);
const bgColor = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
try {
const [contacts, settingsData] = await Promise.all([
getPublicContacts(),
getPublicSettings(),
]);
setContactsData(contacts);
setSettings(settingsData);
} catch (error) {
console.error('Failed to load contacts:', error);
} finally {
setLoading(false);
}
};
if (loading || !contactsData) {
return null;
}
// Check if there's any data to display
const hasContacts = Object.keys(contactsData.categories).length > 0 || contactsData.uncategorized.length > 0;
const hasLocation = settings?.location_latitude && settings?.location_longitude;
const hasContactInfo = settings?.contact_address || settings?.contact_phone || settings?.contact_email;
if (!hasContacts && !hasLocation && !hasContactInfo) {
return null; // Don't render if no data
}
return (
<Box py={16} bg={useColorModeValue('gray.50', 'gray.900')}>
<Container maxW="container.xl">
<VStack spacing={8} align="stretch">
<Box textAlign="center">
<Heading size="xl" mb={4}>Kontakt</Heading>
<Text fontSize="lg" color="gray.600">
Spojte se s námi
</Text>
</Box>
{/* Map and Address Section */}
{(hasLocation || hasContactInfo) && (
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={8}>
{/* Map - always show if coordinates are set */}
{hasLocation && (
<Box>
<ContactMap
latitude={settings.location_latitude}
longitude={settings.location_longitude}
zoom={settings.map_zoom_level || 15}
address={settings.contact_address}
clubName={settings.club_name || settings.site_title}
mapStyle={settings.map_style || 'default'}
clubPrimaryColor={settings.primary_color}
clubSecondaryColor={settings.accent_color}
/>
</Box>
)}
{/* Contact Information */}
{hasContactInfo && (
<Box
bg={bgColor}
p={6}
borderRadius="lg"
boxShadow="md"
border="1px"
borderColor={borderColor}
>
<VStack align="stretch" spacing={4}>
<Heading size="md">Naše adresa</Heading>
{settings.contact_address && (
<HStack align="start">
<Icon as={FiMapPin} boxSize={5} color="blue.500" mt={1} />
<VStack align="start" spacing={0}>
<Text fontWeight="bold">Adresa</Text>
<Text>{settings.contact_address}</Text>
{settings.contact_city && (
<Text>
{settings.contact_zip && `${settings.contact_zip} `}
{settings.contact_city}
</Text>
)}
{settings.contact_country && <Text>{settings.contact_country}</Text>}
</VStack>
</HStack>
)}
{settings.contact_phone && (
<HStack align="start">
<Icon as={FiPhone} boxSize={5} color="blue.500" mt={1} />
<VStack align="start" spacing={0}>
<Text fontWeight="bold">Telefon</Text>
<Link href={`tel:${settings.contact_phone}`} color="blue.500">
{settings.contact_phone}
</Link>
</VStack>
</HStack>
)}
{settings.contact_email && (
<HStack align="start">
<Icon as={FiMail} boxSize={5} color="blue.500" mt={1} />
<VStack align="start" spacing={0}>
<Text fontWeight="bold">Email</Text>
<Link href={`mailto:${settings.contact_email}`} color="blue.500">
{settings.contact_email}
</Link>
</VStack>
</HStack>
)}
</VStack>
</Box>
)}
</SimpleGrid>
)}
{/* Contacts by Category */}
{hasContacts && (
<Box>
<Divider my={8} />
<Accordion allowMultiple defaultIndex={[0]}>
{Object.entries(contactsData.categories).map(([categoryName, contacts]) => (
<AccordionItem key={categoryName} border="none" mb={4}>
<AccordionButton
bg={bgColor}
borderRadius="lg"
p={4}
_hover={{ bg: useColorModeValue('gray.100', 'gray.700') }}
boxShadow="sm"
>
<Box flex="1" textAlign="left">
<Heading size="md">{categoryName}</Heading>
<Text fontSize="sm" color="gray.600">
{contacts.length} {contacts.length === 1 ? 'kontakt' : 'kontaktů'}
</Text>
</Box>
<AccordionIcon />
</AccordionButton>
<AccordionPanel pb={4} pt={4}>
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={6}>
{contacts.map((contact) => (
<Box
key={contact.id}
bg={bgColor}
p={6}
borderRadius="lg"
boxShadow="md"
border="1px"
borderColor={borderColor}
transition="transform 0.2s"
_hover={{ transform: 'translateY(-4px)', boxShadow: 'lg' }}
>
<VStack spacing={4} align="start">
{contact.image_url && (
<Avatar
src={getImageUrl(contact.image_url)}
name={contact.name}
size="xl"
alignSelf="center"
/>
)}
<Box textAlign={contact.image_url ? 'center' : 'left'} w="100%">
<Heading size="sm" mb={1}>
{contact.name}
</Heading>
{contact.position && (
<Badge colorScheme="blue" mb={2}>
{contact.position}
</Badge>
)}
</Box>
{contact.description && (
<Text fontSize="sm" color="gray.600">
{contact.description}
</Text>
)}
<VStack align="start" spacing={2} w="100%">
{contact.email && (
<HStack spacing={2}>
<Icon as={FiMail} color="blue.500" />
<Link href={`mailto:${contact.email}`} fontSize="sm" color="blue.500">
{contact.email}
</Link>
</HStack>
)}
{contact.phone && (
<HStack spacing={2}>
<Icon as={FiPhone} color="blue.500" />
<Link href={`tel:${contact.phone}`} fontSize="sm" color="blue.500">
{contact.phone}
</Link>
</HStack>
)}
</VStack>
</VStack>
</Box>
))}
</SimpleGrid>
</AccordionPanel>
</AccordionItem>
))}
{/* Uncategorized contacts */}
{contactsData.uncategorized.length > 0 && (
<AccordionItem border="none" mb={4}>
<AccordionButton
bg={bgColor}
borderRadius="lg"
p={4}
_hover={{ bg: useColorModeValue('gray.100', 'gray.700') }}
boxShadow="sm"
>
<Box flex="1" textAlign="left">
<Heading size="md">Ostatní kontakty</Heading>
<Text fontSize="sm" color="gray.600">
{contactsData.uncategorized.length} {contactsData.uncategorized.length === 1 ? 'kontakt' : 'kontaktů'}
</Text>
</Box>
<AccordionIcon />
</AccordionButton>
<AccordionPanel pb={4} pt={4}>
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={6}>
{contactsData.uncategorized.map((contact) => (
<Box
key={contact.id}
bg={bgColor}
p={6}
borderRadius="lg"
boxShadow="md"
border="1px"
borderColor={borderColor}
transition="transform 0.2s"
_hover={{ transform: 'translateY(-4px)', boxShadow: 'lg' }}
>
<VStack spacing={4} align="start">
{contact.image_url && (
<Avatar
src={getImageUrl(contact.image_url)}
name={contact.name}
size="xl"
alignSelf="center"
/>
)}
<Box textAlign={contact.image_url ? 'center' : 'left'} w="100%">
<Heading size="sm" mb={1}>
{contact.name}
</Heading>
{contact.position && (
<Badge colorScheme="blue" mb={2}>
{contact.position}
</Badge>
)}
</Box>
{contact.description && (
<Text fontSize="sm" color="gray.600">
{contact.description}
</Text>
)}
<VStack align="start" spacing={2} w="100%">
{contact.email && (
<HStack spacing={2}>
<Icon as={FiMail} color="blue.500" />
<Link href={`mailto:${contact.email}`} fontSize="sm" color="blue.500">
{contact.email}
</Link>
</HStack>
)}
{contact.phone && (
<HStack spacing={2}>
<Icon as={FiPhone} color="blue.500" />
<Link href={`tel:${contact.phone}`} fontSize="sm" color="blue.500">
{contact.phone}
</Link>
</HStack>
)}
</VStack>
</VStack>
</Box>
))}
</SimpleGrid>
</AccordionPanel>
</AccordionItem>
)}
</Accordion>
</Box>
)}
</VStack>
</Container>
</Box>
);
};
export default ContactsSection;
@@ -0,0 +1,92 @@
import { Box, Grid, GridItem, Heading, Image, Text, VStack, HStack, Button, Skeleton, Badge } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { getArticles, Article } from '../../services/articles';
import { Link as RouterLink } from 'react-router-dom';
import { assetUrl } from '../../utils/url';
import { useClubTheme } from '../../contexts/ClubThemeContext';
import { Eye, Clock } from 'lucide-react';
const FeaturedBlog: React.FC = () => {
const { data, isLoading } = useQuery({
queryKey: ['articles', { page: 1, page_size: 3, published: true }],
queryFn: () => getArticles({ page: 1, page_size: 3, published: true }),
});
const theme = useClubTheme();
const articles = data?.data || [];
if (isLoading) return <Skeleton height="320px" />;
const [main, side1, side2] = [articles[0], articles[1], articles[2]];
return (
<Box>
<HStack justify="space-between" mb={3}>
<Heading size="lg">Aktuality</Heading>
<Button as={RouterLink} to="/blog" size="sm" variant="link">Všechny články</Button>
</HStack>
<Grid templateColumns={{ base: '1fr', md: '2fr 1fr' }} gap={4}>
<GridItem>
{main && (
<Box as={RouterLink} to={main.slug ? `/news/${main.slug}` : `/articles/${main.id}`} position="relative" overflow="hidden" borderRadius="xl">
<Image src={assetUrl(main.image_url) || '/logo192.png'} alt={main.title} w="100%" h={{ base: '220px', md: '320px' }} objectFit="cover" />
<Box position="absolute" inset={0} bgGradient="linear(to-t, rgba(0,0,0,0.7), rgba(0,0,0,0.1))" />
{/* Stats badges */}
{((main.read_time || main.estimated_read_minutes) || (main.view_count && main.view_count > 0)) && (
<HStack position="absolute" top={3} right={3} spacing={2}>
{(main.read_time || main.estimated_read_minutes) && (
<Badge display="flex" alignItems="center" gap={1} bg="rgba(0,0,0,0.7)" color="white" fontSize="xs" px={2} py={1}>
<Clock size={12} />
{main.read_time || main.estimated_read_minutes} min
</Badge>
)}
{main.view_count && main.view_count > 0 && (
<Badge display="flex" alignItems="center" gap={1} bg="rgba(0,0,0,0.7)" color="white" fontSize="xs" px={2} py={1}>
<Eye size={12} />
{main.view_count}
</Badge>
)}
</HStack>
)}
<VStack align="stretch" spacing={2} position="absolute" bottom={0} p={4} color="white">
<Text fontSize="xs" bg={theme.secondary} color="black" px={2} py={0.5} borderRadius="md" w="fit-content">Novinka</Text>
<Heading size="md">{main.title}</Heading>
</VStack>
</Box>
)}
</GridItem>
<GridItem>
<VStack spacing={4} align="stretch">
{[side1, side2].filter(Boolean).map((a) => (
<HStack key={(a as Article).id} align="stretch" spacing={3} as={RouterLink} to={(a as Article).slug ? `/news/${(a as Article).slug}` : `/articles/${(a as Article).id}`}>
<Image src={assetUrl((a as Article).image_url) || '/logo192.png'} alt={(a as Article).title} w="40%" h="120px" objectFit="cover" borderRadius="lg" />
<VStack align="stretch" spacing={2} flex={1}>
<HStack spacing={2} flexWrap="wrap">
{((a as Article).read_time || (a as Article).estimated_read_minutes) && (
<HStack spacing={1}>
<Clock size={12} color="gray" />
<Text fontSize="xs" color="gray.500">
{(a as Article).read_time || (a as Article).estimated_read_minutes} min
</Text>
</HStack>
)}
{(a as Article).view_count && (a as Article).view_count! > 0 && (
<HStack spacing={1}>
<Eye size={12} color="gray" />
<Text fontSize="xs" color="gray.500">
{(a as Article).view_count}
</Text>
</HStack>
)}
</HStack>
<Heading size="sm" noOfLines={3}>{(a as Article).title}</Heading>
</VStack>
</HStack>
))}
</VStack>
</GridItem>
</Grid>
</Box>
);
};
export default FeaturedBlog;
@@ -0,0 +1,287 @@
import React, { useEffect, useState } from 'react';
import { Link as RouterLink } from 'react-router-dom';
import {
Box,
Heading,
SimpleGrid,
Image,
Text,
VStack,
HStack,
Button,
Skeleton,
Badge,
useColorModeValue,
} from '@chakra-ui/react';
import { Calendar, Image as ImageIcon, ExternalLink, ArrowRight } from 'lucide-react';
interface Album {
id: string;
title: string;
url: string;
date: string;
photos_count: number;
views_count?: number;
photos: Array<{
id: string;
page_url: string;
image_1500: string;
}>;
}
const resolveBackendUrl = (path: string) => {
try {
if (/^https?:\/\//i.test(path)) return path;
if (path.startsWith('/cache') || path.startsWith('/uploads') || path.startsWith('/api/')) {
const base = process.env.REACT_APP_API_BASE_URL || process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1';
const b = new URL(base);
const abs = new URL(path, `${b.protocol}//${b.host}`);
return abs.toString();
}
return path;
} catch {
return path;
}
};
const GallerySection: React.FC = () => {
const [albums, setAlbums] = useState<Album[]>([]);
const [loading, setLoading] = useState(true);
// Dark mode colors
const cardBg = useColorModeValue('white', 'gray.800');
const headingColor = useColorModeValue('gray.800', 'gray.100');
const textColor = useColorModeValue('gray.600', 'gray.300');
const infoBg = useColorModeValue('blue.50', 'blue.900');
const infoBorder = useColorModeValue('blue.200', 'blue.700');
const infoText = useColorModeValue('blue.700', 'blue.200');
useEffect(() => {
const fetchAlbums = async () => {
setLoading(true);
try {
// Load from both sources and combine
const [profileRes, albumsRes] = await Promise.allSettled([
fetch(resolveBackendUrl('/cache/prefetch/zonerama_profile.json'), { cache: 'no-cache' }),
fetch(resolveBackendUrl('/cache/prefetch/zonerama_albums.json'), { cache: 'no-cache' })
]);
let combinedAlbums: Album[] = [];
// Get profile albums (newest/main source)
if (profileRes.status === 'fulfilled' && profileRes.value.ok) {
const profileData = await profileRes.value.json();
combinedAlbums = [...(profileData.albums || [])];
}
// Get blog-related albums (additional source)
if (albumsRes.status === 'fulfilled' && albumsRes.value.ok) {
const albumsData = await albumsRes.value.json();
const blogAlbums = Array.isArray(albumsData) ? albumsData : [];
// Filter out albums with empty/invalid data and avoid duplicates
const validBlogAlbums = blogAlbums.filter((album: any) =>
album.id &&
album.title &&
!combinedAlbums.some(existing => existing.id === album.id)
);
combinedAlbums = [...combinedAlbums, ...validBlogAlbums];
}
// Sort by date (newest first)
combinedAlbums.sort((a, b) => {
const parseDate = (dateStr: string) => {
if (!dateStr) return new Date(0);
const parts = dateStr.split(/[.\s]+/).filter(Boolean);
if (parts.length === 3) {
const [day, month, year] = parts;
return new Date(`${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`);
}
return new Date(dateStr);
};
return parseDate(b.date).getTime() - parseDate(a.date).getTime();
});
// Get the 3 most recent albums
const recentAlbums = combinedAlbums.slice(0, 3);
setAlbums(recentAlbums);
} catch (err) {
console.error('Error loading albums:', err);
} finally {
setLoading(false);
}
};
fetchAlbums();
}, []);
if (loading) {
return (
<Box py={12}>
<VStack spacing={6} align="stretch">
<Heading size="xl">Galerie</Heading>
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={6}>
{[1, 2, 3].map((i) => (
<Skeleton key={i} height="300px" borderRadius="lg" />
))}
</SimpleGrid>
</VStack>
</Box>
);
}
if (albums.length === 0) {
return null;
}
return (
<Box py={12}>
<VStack spacing={6} align="stretch">
{/* Header */}
<HStack justify="space-between" align="center" flexWrap="wrap">
<VStack align="start" spacing={1}>
<Heading size="xl" color={headingColor}>
Fotogalerie
</Heading>
<Text color={textColor} fontSize="sm">
Nejnovější alba z našich akcí
</Text>
</VStack>
<Button
as={RouterLink}
to="/galerie"
rightIcon={<ArrowRight size={18} />}
colorScheme="blue"
variant="outline"
size="md"
>
Zobrazit vše
</Button>
</HStack>
{/* Zonerama Attribution */}
<Box
bg={infoBg}
borderWidth="1px"
borderColor={infoBorder}
borderRadius="md"
px={4}
py={2}
>
<Text fontSize="xs" color={infoText}>
📸 Všechny fotografie jsou z platformy{' '}
<Text
as="a"
href="https://zonerama.com"
target="_blank"
rel="noopener noreferrer"
fontWeight="600"
color="blue.600"
_hover={{ textDecoration: 'underline' }}
>
Zonerama
</Text>
</Text>
</Box>
{/* Albums Grid */}
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={6}>
{albums.map((album) => {
const coverPhoto = album.photos && album.photos.length > 0
? album.photos[0]
: null;
return (
<Box
key={album.id}
as={RouterLink}
to={`/galerie/album/${album.id}`}
bg={cardBg}
borderRadius="lg"
overflow="hidden"
boxShadow="md"
transition="all 0.3s"
borderWidth="1px"
borderColor={useColorModeValue('gray.200', 'gray.700')}
_hover={{
transform: 'translateY(-8px)',
boxShadow: '2xl',
borderColor: useColorModeValue('gray.300', 'gray.600'),
}}
cursor="pointer"
>
{/* Cover Image */}
{coverPhoto ? (
<Image
src={coverPhoto.image_1500}
alt={album.title}
w="100%"
h="200px"
objectFit="cover"
loading="lazy"
/>
) : (
<Box
w="100%"
h="200px"
bg="gray.200"
display="flex"
alignItems="center"
justifyContent="center"
>
<ImageIcon size={48} color="gray" />
</Box>
)}
{/* Album Info */}
<VStack align="stretch" p={4} spacing={2}>
<Heading size="sm" color={headingColor} noOfLines={2} minH="40px">
{album.title}
</Heading>
<VStack spacing={2} fontSize="xs" color={textColor} align="stretch">
{album.date && (
<HStack spacing={1}>
<Calendar size={14} />
<Text>{album.date}</Text>
</HStack>
)}
<HStack spacing={1}>
<ImageIcon size={14} />
<Text>{album.photos_count} foto</Text>
</HStack>
</VStack>
{album.views_count !== undefined && album.views_count > 0 && (
<Badge colorScheme="purple" fontSize="2xs" alignSelf="flex-start">
{album.views_count} zhlédnutí
</Badge>
)}
</VStack>
</Box>
);
})}
</SimpleGrid>
{/* Bottom CTA */}
<Box textAlign="center" pt={4}>
<Button
as={RouterLink}
to="/galerie"
rightIcon={<ArrowRight size={18} />}
colorScheme="blue"
size="lg"
>
Zobrazit všechna alba
</Button>
</Box>
</VStack>
</Box>
);
};
export default GallerySection;
@@ -0,0 +1,199 @@
import React from 'react';
import { Box, Flex, HStack, Image, Text, Container, useColorModeValue } from '@chakra-ui/react';
import { Link as RouterLink } from 'react-router-dom';
interface HeaderVariantsProps {
variant: 'unified' | 'edge' | 'minimal' | 'modern';
clubName?: string;
clubLogo?: string;
clubId?: string;
}
const HeaderVariants: React.FC<HeaderVariantsProps> = ({
variant = 'unified',
clubName,
clubLogo,
clubId,
}) => {
const displayLogo = clubId
? `http://logoapi.sportcreative.eu/logos/${clubId}?format=svg`
: clubLogo || '/images/club-logo.png';
// Unified variant - classic header
if (variant === 'unified') {
return (
<Box
bg={useColorModeValue('white', 'gray.800')}
borderBottom="1px"
borderColor={useColorModeValue('gray.200', 'gray.700')}
py={4}
>
<Container maxW="7xl">
<Flex align="center" justify="space-between">
<HStack as={RouterLink} to="/" spacing={4}>
{displayLogo && (
<Image
src={displayLogo}
alt={clubName || 'Club'}
boxSize="48px"
objectFit="contain"
borderRadius="full"
borderWidth="2px"
borderColor="brand.primary"
style={{
padding: displayLogo.includes('logoapi.sportcreative.eu') ? '4px' : '0px',
boxSizing: 'border-box'
}}
/>
)}
<Box>
<Text fontSize="2xl" fontWeight="bold" color={useColorModeValue('gray.800', 'white')}>
{clubName || 'MyClub'}
</Text>
<Text fontSize="xs" color="gray.500">Official Website</Text>
</Box>
</HStack>
</Flex>
</Container>
</Box>
);
}
// Edge variant - modern with gradient
if (variant === 'edge') {
return (
<Box
bgGradient="linear(to-r, brand.primary, brand.secondary)"
position="relative"
overflow="hidden"
>
<Box
position="absolute"
top={0}
left={0}
right={0}
bottom={0}
bg="blackAlpha.300"
backdropFilter="blur(8px)"
/>
<Container maxW="7xl" position="relative" py={6}>
<Flex align="center" justify="center" direction="column">
{displayLogo && (
<Image
src={displayLogo}
alt={clubName || 'Club'}
boxSize="80px"
objectFit="contain"
mb={3}
filter="drop-shadow(0 4px 6px rgba(0,0,0,0.3))"
style={{
padding: displayLogo.includes('logoapi.sportcreative.eu') ? '8px' : '0px',
boxSizing: 'border-box'
}}
/>
)}
<Text fontSize="3xl" fontWeight="bold" color="white" textShadow="0 2px 4px rgba(0,0,0,0.3)">
{clubName || 'Football Club'}
</Text>
<Text fontSize="sm" color="whiteAlpha.900" mt={1}>
Official Website
</Text>
</Flex>
</Container>
</Box>
);
}
// Minimal variant - clean and simple
if (variant === 'minimal') {
return (
<Box bg={useColorModeValue('gray.50', 'gray.900')} py={3}>
<Container maxW="7xl">
<Flex align="center" justify="center">
<HStack as={RouterLink} to="/" spacing={3}>
{displayLogo && (
<Image
src={displayLogo}
alt={clubName || 'Club'}
boxSize="36px"
objectFit="contain"
style={{
padding: displayLogo.includes('logoapi.sportcreative.eu') ? '3px' : '0px',
boxSizing: 'border-box'
}}
/>
)}
<Text fontSize="lg" fontWeight="600" color={useColorModeValue('gray.700', 'gray.200')}>
{clubName || 'FC'}
</Text>
</HStack>
</Flex>
</Container>
</Box>
);
}
// Modern variant - bold with accent
if (variant === 'modern') {
return (
<Box
bg={useColorModeValue('white', 'gray.800')}
borderBottom="4px"
borderColor="brand.primary"
boxShadow="sm"
>
<Container maxW="7xl" py={5}>
<Flex align="center" justify="space-between">
<HStack as={RouterLink} to="/" spacing={4}>
{displayLogo && (
<Box
position="relative"
_before={{
content: '""',
position: 'absolute',
top: '-4px',
left: '-4px',
right: '-4px',
bottom: '-4px',
bg: 'brand.primary',
opacity: 0.1,
borderRadius: 'full',
}}
>
<Image
src={displayLogo}
alt={clubName || 'Club'}
boxSize="56px"
objectFit="contain"
borderRadius="full"
style={{
padding: displayLogo.includes('logoapi.sportcreative.eu') ? '6px' : '0px',
boxSizing: 'border-box'
}}
/>
</Box>
)}
<Box>
<Text
fontSize="2xl"
fontWeight="900"
color="brand.primary"
letterSpacing="tight"
>
{clubName || 'FOOTBALL CLUB'}
</Text>
<Text fontSize="xs" color="gray.500" fontWeight="600" letterSpacing="wider" textTransform="uppercase">
Official Website
</Text>
</Box>
</HStack>
</Flex>
</Container>
</Box>
);
}
return null;
};
export default HeaderVariants;
@@ -0,0 +1,106 @@
import React from 'react';
import { Box, Container, Grid, GridItem, VStack, HStack, Image, Heading, Text, Icon, Button } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import BlogSwiper from './BlogSwiper';
import { getArticles, Article } from '../../services/articles';
import { Link as RouterLink } from 'react-router-dom';
import { useClubTheme } from '../../contexts/ClubThemeContext';
import UpcomingSwitch from './UpcomingSwitch';
import { ChevronRightIcon } from '@chakra-ui/icons';
const RailItem: React.FC<{ a: Article }>=({ a })=>{
return (
<HStack
as={RouterLink}
to={`/articles/${a.id}`}
spacing={3}
align="center"
px={3}
py={2}
borderRadius="md"
_hover={{ bg: 'whiteAlpha.200' }}
transition="background 0.2s"
>
<Box position="relative" flexShrink={0}>
<Image src={a.image_url || '/stadium-placeholder.jpg'} alt={a.title} boxSize="64px" objectFit="cover" borderRadius="md" />
</Box>
<VStack spacing={0} align="start" minW={0}>
<Text fontSize="xs" color="whiteAlpha.700">{new Date(a.created_at || '').toLocaleDateString()}</Text>
<Text fontWeight={600} noOfLines={2} color="white">{a.title}</Text>
</VStack>
<ChevronRightIcon color="whiteAlpha.700" ml="auto" />
</HStack>
);
}
const HeroWithRail: React.FC = () => {
const theme = useClubTheme();
const { data, isLoading } = useQuery({
queryKey: ['hero-rail-articles', { page: 1, page_size: 8, published: true }],
queryFn: () => getArticles({ page: 1, page_size: 8, published: true }),
});
const articles = data?.data || [];
return (
<Box position="relative">
{/* Hero */}
<Grid templateColumns={{ base: '1fr', lg: '2fr 1fr' }} gap={6}>
<GridItem>
<BlogSwiper />
</GridItem>
{/* Right rail */}
<GridItem display={{ base: 'none', lg: 'block' }}>
<Box
h={{ base: 'auto', lg: '600px' }}
bg="blackAlpha.600"
borderRadius="xl"
overflow="hidden"
backdropFilter="auto"
backdropBlur="8px"
border="1px solid"
borderColor="whiteAlpha.200"
>
<VStack align="stretch" spacing={0} h="100%">
<HStack justify="space-between" px={4} py={3} borderBottom="1px solid" borderColor="whiteAlpha.200">
<Text fontWeight="700" color="white">Novinky</Text>
<Button as={RouterLink} to="/blog" size="sm" variant="ghost" color="whiteAlpha.800" _hover={{ bg: 'whiteAlpha.200' }}>Vše</Button>
</HStack>
<VStack spacing={1} align="stretch" px={2} py={2} overflowY="auto">
{isLoading && Array.from({length:5}).map((_,i)=> (
<Box key={i} h="72px" borderRadius="md" bg="whiteAlpha.200" />
))}
{!isLoading && articles.map((a) => (
<RailItem key={a.id} a={a} />
))}
</VStack>
</VStack>
</Box>
</GridItem>
</Grid>
{/* Glasmorphic upcoming panel */}
<Box
position="absolute"
left="50%"
bottom={{ base: 2, md: 4 }}
transform="translateX(-50%)"
w={{ base: '95%', md: '80%' }}
bg="whiteAlpha.200"
backdropFilter="auto"
backdropBlur="10px"
border="1px solid"
borderColor="whiteAlpha.300"
borderRadius="xl"
px={{ base: 3, md: 6 }}
py={{ base: 3, md: 4 }}
boxShadow="lg"
>
<UpcomingSwitch />
</Box>
</Box>
);
};
export default HeroWithRail;
@@ -0,0 +1,227 @@
import { Box, Tabs, TabList, TabPanels, Tab, TabPanel, Table, Thead, Tbody, Tr, Th, Td, Text, Badge, Heading, Flex, Tooltip } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { facrApi } from '../../services/facr/facrApi';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import { useClubTheme } from '../../contexts/ClubThemeContext';
import { useState } from 'react';
import ClubModal from './ClubModal';
import { TeamLogo } from '../common/TeamLogo';
const LeagueTablePro: React.FC = () => {
const { data: settings } = usePublicSettings();
const clubId = settings?.club_id;
const clubType = settings?.club_type || 'football';
const theme = useClubTheme();
const { data } = useQuery({
queryKey: ['facr-table', clubId, clubType],
queryFn: () => facrApi.getClubTable(clubId!, clubType as any),
enabled: !!clubId,
});
const [selectedClub, setSelectedClub] = useState<any>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const handleClubClick = (row: any) => {
// Transform row data to match ClubModal interface
const clubData = {
team: row.team || row.team_name || '-',
team_id: row.team_id || '',
team_logo_url: row.team_logo_url,
rank: row.rank,
played: row.played,
wins: row.wins,
draws: row.draws,
losses: row.losses,
score: row.score,
points: row.points,
};
setSelectedClub(clubData);
setIsModalOpen(true);
};
if (!data) return null;
return (
<Box borderRadius="lg" overflow="hidden" bg="white" boxShadow="sm" borderWidth="1px" borderColor="gray.100">
<Box bg="primary.600" px={4} py={2} borderBottomWidth="1px" borderColor="primary.700">
<Heading size="md" color="white">Tabulka</Heading>
</Box>
<Tabs variant="enclosed" colorScheme="gray" size="sm">
<TabList px={2} pt={2} overflowX="auto" overflowY="hidden" css={{
'&::-webkit-scrollbar': { height: '4px' },
'&::-webkit-scrollbar-track': { background: 'transparent' },
'&::-webkit-scrollbar-thumb': { background: 'gray.300', borderRadius: '4px' },
}}>
{data.competitions?.map((c) => (
<Tab
key={c.id}
_selected={{
color: 'primary.600',
borderBottom: '2px solid',
borderColor: 'primary.600',
fontWeight: 'bold'
}}
_focus={{ boxShadow: 'none' }}
fontSize="sm"
px={3}
py={3}
>
{c.name}
</Tab>
))}
</TabList>
<TabPanels>
{data.competitions?.map((c) => (
<TabPanel key={c.id} px={0}>
<Box maxH="420px" overflowY="auto">
<Table size="sm" variant="simple">
<Thead position="sticky" top={0} zIndex={1} bg="gray.50">
<Tr borderBottomWidth="1px" borderColor="gray.200">
<Th width="8" px={2} textAlign="center" color="gray.600" fontWeight="bold" fontSize="xs" textTransform="uppercase">#</Th>
<Th px={3} color="gray.600" fontWeight="bold" fontSize="xs" textTransform="uppercase">Tým</Th>
<Th width="8" px={1} textAlign="center" color="gray.600" fontWeight="bold" fontSize="xs" textTransform="uppercase">Z</Th>
<Th width="8" px={1} textAlign="center" color="gray.600" fontWeight="bold" fontSize="xs" textTransform="uppercase">V</Th>
<Th width="8" px={1} textAlign="center" color="gray.600" fontWeight="bold" fontSize="xs" textTransform="uppercase">R</Th>
<Th width="8" px={1} textAlign="center" color="gray.600" fontWeight="bold" fontSize="xs" textTransform="uppercase">P</Th>
<Th width="16" px={1} textAlign="center" color="gray.600" fontWeight="bold" fontSize="xs" textTransform="uppercase">Skóre</Th>
<Th width="14" px={1} textAlign="center" color="gray.600" fontWeight="bold" fontSize="xs" textTransform="uppercase">Body</Th>
</Tr>
</Thead>
<Tbody>
{c.table?.overall?.map((row, idx) => {
const isHighlighted = row.team_id === clubId;
return (
<Tr
key={`${row.team_id}-${idx}`}
bg={isHighlighted ? 'primary.50' : idx % 2 === 0 ? 'white' : 'gray.50'}
borderLeft={isHighlighted ? '3px solid' : '3px solid transparent'}
borderLeftColor={isHighlighted ? 'primary.500' : 'transparent'}
_hover={{ bg: isHighlighted ? 'primary.100' : 'gray.100', cursor: 'pointer' }}
onClick={() => handleClubClick(row)}
>
<Td px={2} textAlign="center" color={isHighlighted ? 'primary.700' : 'gray.700'} fontWeight={isHighlighted ? 'bold' : 'normal'}>
<Flex align="center" justify="center">
{row.rank}
{idx < 3 && (
<Box as="span" ml={1} color={idx === 0 ? 'yellow.400' : idx === 1 ? 'gray.400' : 'yellow.700'}>
{idx === 0 ? '🥇' : idx === 1 ? '🥈' : '🥉'}
</Box>
)}
</Flex>
</Td>
<Td px={3} py={2}>
<Flex align="center" minW="180px">
<TeamLogo
teamId={row.team_id}
teamName={row.team}
facrLogo={row.team_logo_url}
size="small"
alt={row.team}
mr={2}
fallbackIcon={
<Box
w="20px"
h="20px"
bg="gray.200"
borderRadius="md"
display="flex"
alignItems="center"
justifyContent="center"
color="gray.400"
fontSize="xs"
fontWeight="bold"
>
{row.team.substring(0, 2).toUpperCase()}
</Box>
}
/>
<Text
as="span"
fontWeight={isHighlighted ? 'bold' : 'normal'}
color={isHighlighted ? 'primary.700' : 'gray.800'}
isTruncated
>
{row.team}
</Text>
</Flex>
</Td>
<Td isNumeric px={1} textAlign="center" color={isHighlighted ? 'primary.700' : 'gray.700'} fontWeight={isHighlighted ? 'bold' : 'normal'}>
{row.played}
</Td>
<Td isNumeric px={1} textAlign="center" color={isHighlighted ? 'primary.700' : 'gray.700'} fontWeight={isHighlighted ? 'bold' : 'normal'}>
{row.wins}
</Td>
<Td isNumeric px={1} textAlign="center" color={isHighlighted ? 'primary.700' : 'gray.700'} fontWeight={isHighlighted ? 'bold' : 'normal'}>
{row.draws}
</Td>
<Td isNumeric px={1} textAlign="center" color={isHighlighted ? 'primary.700' : 'gray.700'} fontWeight={isHighlighted ? 'bold' : 'normal'}>
{row.losses}
</Td>
<Td isNumeric px={1} textAlign="center" fontFamily="mono" color={isHighlighted ? 'primary.700' : 'gray.700'} fontWeight={isHighlighted ? 'bold' : 'normal'}>
{row.score}
</Td>
<Td
isNumeric
px={1}
textAlign="center"
fontWeight="bold"
color={isHighlighted ? 'white' : 'gray.800'}
bg={isHighlighted ? 'primary.500' : 'gray.100'}
borderLeftWidth="1px"
borderLeftColor="white"
>
{row.points}
</Td>
</Tr>
);
})}
</Tbody>
</Table>
</Box>
{/* League Info Footer */}
<Box px={4} py={3} borderTopWidth="1px" borderColor="gray.100" bg="gray.50">
<Flex justify="space-between" fontSize="xs" color="gray.600">
<Box>
<Text as="span" mr={4} display="inline-flex" alignItems="center">
<Box as="span" display="inline-block" w="8px" h="8px" bg="green.500" borderRadius="full" mr={1} />
Postup
</Text>
<Text as="span" mr={4} display="inline-flex" alignItems="center">
<Box as="span" display="inline-block" w="8px" h="8px" bg="blue.500" borderRadius="full" mr={1} />
Evropské poháry
</Text>
<Text as="span" display="inline-flex" alignItems="center">
<Box as="span" display="inline-block" w="8px" h="8px" bg="red.500" borderRadius="full" mr={1} />
Sestup
</Text>
</Box>
<Box>
<Text as="span" display="inline-flex" alignItems="center">
<Box as="span" fontWeight="bold" mr={1}>Aktualizováno:</Box>
{new Date().toLocaleDateString('cs-CZ', {
day: '2-digit',
month: 'long',
year: 'numeric'
})}
</Text>
</Box>
</Flex>
</Box>
</TabPanel>
))}
</TabPanels>
</Tabs>
<ClubModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
club={selectedClub}
clubType={clubType as 'football' | 'futsal'}
/>
</Box>
);
};
export default LeagueTablePro;
+193
View File
@@ -0,0 +1,193 @@
import React, { useMemo } from 'react';
import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalCloseButton,
ModalBody,
ModalFooter,
Button,
HStack,
VStack,
Image,
Text,
Badge,
Link,
Divider,
} from '@chakra-ui/react';
import { useCountdown } from '../../hooks/useCountdown';
import { assetUrl } from '../../utils/url';
export type FacrMatchLike = {
id?: string | number;
date?: string; // yyyy-mm-dd
time?: string; // HH:MM
date_time?: string; // alternative combined format (dd.MM.yyyy HH:mm)
home?: string;
away?: string;
home_logo_url?: string;
away_logo_url?: string;
competition?: string;
competitionName?: string;
venue?: string;
score?: string | null;
facr_link?: string | null;
report_url?: string | null;
};
interface MatchModalProps {
isOpen: boolean;
match: FacrMatchLike | null;
onClose: () => void;
onTeamClick?: (teamName: string, teamLogoUrl?: string) => void;
}
const formatWhen = (m: FacrMatchLike | null) => {
if (!m) return '';
try {
if (m.date && m.time) {
const d = new Date(`${m.date}T${(m.time || '00:00')}:00`);
if (!isNaN(d.getTime())) return d.toLocaleString();
}
if (m.date_time) {
// Try to parse dd.MM.yyyy HH:mm quickly by reordering
const dt = String(m.date_time);
const [dPart, tPart] = dt.split(' ');
const [dd, MM, yyyy] = (dPart || '').split('.');
if (dd && MM && yyyy) {
const iso = `${yyyy}-${MM.padStart(2, '0')}-${dd.padStart(2, '0')}T${(tPart || '00:00')}:00`;
const d = new Date(iso);
if (!isNaN(d.getTime())) return d.toLocaleString();
}
return m.date_time;
}
} catch {}
return '';
};
export const MatchModal: React.FC<MatchModalProps> = ({ isOpen, match, onClose, onTeamClick }) => {
const kickoffIso = useMemo(() => {
if (!match) return null;
if (match.date && match.time) return `${match.date}T${(match.time || '00:00')}:00`;
if (match.date_time) {
const dt = String(match.date_time);
const [dPart, tPart] = dt.split(' ');
const [dd, MM, yyyy] = (dPart || '').split('.');
if (dd && MM && yyyy) return `${yyyy}-${MM.padStart(2, '0')}-${dd.padStart(2, '0')}T${(tPart || '00:00')}:00`;
return match.date_time; // fallback
}
return null;
}, [match]);
const { countdownString, isActive, timeRemaining } = useCountdown(kickoffIso, 1000);
const facrLink = match?.facr_link || match?.report_url || null;
const when = formatWhen(match);
// Determine if match has started (countdown finished) but no score yet
const matchStarted = kickoffIso ? new Date(kickoffIso).getTime() <= Date.now() : false;
const hasScore = match?.score && match.score.trim() !== '';
return (
<Modal isOpen={isOpen} onClose={onClose} isCentered size={{ base: 'md', md: 'lg' }}>
<ModalOverlay />
<ModalContent>
<ModalHeader>
{match?.home || 'Domácí'} vs {match?.away || 'Hosté'}
</ModalHeader>
<ModalCloseButton />
<ModalBody>
{match && (
<VStack align="stretch" spacing={4}>
<HStack justify="space-between" align="center">
<VStack
align="center"
spacing={2}
flex={1}
minW={0}
cursor={onTeamClick ? 'pointer' : 'default'}
onClick={() => onTeamClick && onTeamClick(match.home || '', match.home_logo_url)}
_hover={onTeamClick ? { opacity: 0.8, transform: 'scale(1.05)' } : {}}
transition="all 0.2s"
role={onTeamClick ? 'button' : undefined}
tabIndex={onTeamClick ? 0 : undefined}
>
<Image src={assetUrl(match.home_logo_url) || '/logo192.png'} alt={match.home || 'Domácí'} boxSize="56px" objectFit="contain" />
<Text fontWeight="semibold" noOfLines={1} textAlign="center">{match.home || 'Domácí'}</Text>
</VStack>
<VStack spacing={1} minW="120px">
{hasScore ? (
<>
<Text fontSize="2xl" fontWeight="bold">{match.score}</Text>
<Text fontSize="sm" color="gray.600">Skončeno</Text>
</>
) : matchStarted ? (
<>
<Text fontSize="2xl" fontWeight="bold">:</Text>
<Text fontSize="sm" color="green.600">Probíhá</Text>
</>
) : (
<>
<Text fontSize="lg" color="gray.600">Začátek za</Text>
<Text fontSize="2xl" fontWeight="bold">{countdownString || '—'}</Text>
</>
)}
{(match.competition || match.competitionName) && (
<Badge colorScheme="blue" variant="subtle">{match.competition || match.competitionName}</Badge>
)}
</VStack>
<VStack
align="center"
spacing={2}
flex={1}
minW={0}
cursor={onTeamClick ? 'pointer' : 'default'}
onClick={() => onTeamClick && onTeamClick(match.away || '', match.away_logo_url)}
_hover={onTeamClick ? { opacity: 0.8, transform: 'scale(1.05)' } : {}}
transition="all 0.2s"
role={onTeamClick ? 'button' : undefined}
tabIndex={onTeamClick ? 0 : undefined}
>
<Image src={assetUrl(match.away_logo_url) || '/logo192.png'} alt={match.away || 'Hosté'} boxSize="56px" objectFit="contain" />
<Text fontWeight="semibold" noOfLines={1} textAlign="center">{match.away || 'Hosté'}</Text>
</VStack>
</HStack>
<Divider />
<VStack align="stretch" spacing={1} color="gray.700">
{when && <Text><strong>Kdy:</strong> {when}</Text>}
{match.venue && <Text><strong>Kde:</strong> {match.venue}</Text>}
</VStack>
</VStack>
)}
</ModalBody>
<ModalFooter>
{facrLink && (
<Button
colorScheme="blue"
mr={3}
onClick={(e) => {
e.preventDefault();
// Open in background tab without switching focus
const link = document.createElement('a');
link.href = facrLink;
link.target = '_blank';
link.rel = 'noopener noreferrer';
link.style.display = 'none';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}}
>
Detail na FAČR
</Button>
)}
<Button onClick={onClose}>Zavřít</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
};
export default MatchModal;
@@ -0,0 +1,118 @@
import { Box, Heading, Tabs, TabList, TabPanels, Tab, TabPanel, VStack, HStack, Image, Text, Link, Skeleton, Badge } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { facrApi } from '../../services/facr/facrApi';
import { FACR_CLUB_ID, FACR_CLUB_TYPE } from '../../config/facr';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import { TeamLogo } from '../common/TeamLogo';
import '../../styles/logos.css';
const MatchRow: React.FC<{
date: string;
home: { name: string; logo?: string; id?: string };
away: { name: string; logo?: string; id?: string };
score?: string;
clubName?: string;
}> = ({ date, home, away, score, clubName }) => (
<HStack justify="space-between" borderWidth="1px" borderRadius="md" p={3} bg="white">
<Text w="140px" fontSize="sm" color="gray.600">{date}</Text>
<HStack flex={1} justify="flex-end">
<HStack minW="40%" justify="flex-end" spacing={2}>
<Text noOfLines={1} textAlign="right" flex={1}>{home.name}</Text>
<Box className="logo-container" w="28px" h="28px">
<TeamLogo
teamId={home.id}
teamName={home.name}
facrLogo={home.logo}
size="custom"
boxSize="28px"
/>
</Box>
</HStack>
<HStack w="auto" minW="60px" justify="center" spacing={2}>
<Text fontWeight="bold" textAlign="center">{score || '-:-'}</Text>
{(() => {
const sent = (() => {
if (!score || !clubName) return null;
const m = score.match(/^(\d+)\s*[:\-]\s*(\d+)$/);
if (!m) return null;
const h = parseInt(m[1], 10), a = parseInt(m[2], 10);
const norm = (s: string) => String(s||'').normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/\s+/g,' ').trim().toLowerCase();
const strip = (s: string) => norm(s).replace(/\b(mestsky|m\.?f\.?k\.?|mfk|tj|sk|sokol|fotbalovy|fotbalový|fotbalovy\s+klub|fotbalovy\s+klub)\b/g,'').replace(/\s+/g,' ').trim();
const ourIsHome = (() => { const aName = strip(home.name); const bName = strip(clubName); return aName && bName && (aName===bName || aName.endsWith(bName) || bName.endsWith(aName)); })();
const ourIsAway = (() => { const aName = strip(away.name); const bName = strip(clubName); return aName && bName && (aName===bName || aName.endsWith(bName) || bName.endsWith(aName)); })();
if (!ourIsHome && !ourIsAway) return null;
if (h === a) return { label: 'Remíza', color: 'blue' } as const;
const our = ourIsHome ? h : a; const opp = ourIsHome ? a : h;
return our > opp ? ({ label: 'Výhra', color: 'green' } as const) : ({ label: 'Prohra', color: 'red' } as const);
})();
return sent ? <Badge colorScheme={sent.color} variant="subtle">{sent.label}</Badge> : null;
})()}
</HStack>
<HStack minW="40%" spacing={2}>
<Box className="logo-container" w="28px" h="28px">
<TeamLogo
teamId={away.id}
teamName={away.name}
facrLogo={away.logo}
size="custom"
boxSize="28px"
/>
</Box>
<Text noOfLines={1} flex={1}>{away.name}</Text>
</HStack>
</HStack>
</HStack>
);
const MatchesSection: React.FC = () => {
const { data: settings } = usePublicSettings();
const clubId = settings?.club_id || FACR_CLUB_ID;
const clubType = settings?.club_type || FACR_CLUB_TYPE;
const { data, isLoading, isError } = useQuery({
queryKey: ['facr-club', clubId, clubType],
queryFn: () => facrApi.getClub(clubId, clubType),
enabled: Boolean(clubId),
});
return (
<Box>
<Heading size="lg" mb={4}>Zápasy</Heading>
{!clubId && (
<Text color="orange.500" mb={4}>Nastavte klub v Nastavení (Admin) nebo REACT_APP_FACR_CLUB_ID pro načtení zápasů z FAČR.</Text>
)}
{isLoading && <Skeleton height="200px" />}
{!isLoading && data && (
<Tabs variant="enclosed-colored" isFitted>
<TabList>
{data.competitions?.map((c) => (
<Tab key={c.id}>{c.name}</Tab>
))}
</TabList>
<TabPanels>
{data.competitions?.map((c) => (
<TabPanel key={c.id} px={0}>
<VStack align="stretch" spacing={3}>
{(c.matches || []).slice(0, 5).map((m, idx) => (
<MatchRow
key={m.match_id || idx}
date={m.date_time}
home={{ name: m.home, logo: m.home_logo_url, id: m.home_id }}
away={{ name: m.away, logo: m.away_logo_url, id: m.away_id }}
score={m.score}
clubName={data.name}
/>
))}
{(c.matches || []).length === 0 && (
<Text color="gray.500">Žádné zápasy k dispozici.</Text>
)}
</VStack>
</TabPanel>
))}
</TabPanels>
</Tabs>
)}
</Box>
);
};
export default MatchesSection;
@@ -0,0 +1,77 @@
import { Box, SimpleGrid, Heading, Text, useColorModeValue, HStack, Button, Link, Badge } from '@chakra-ui/react';
import { useEffect, useState } from 'react';
import { getClothing, ClothingItem } from '../../services/clothing';
import { Link as RouterLink } from 'react-router-dom';
const MerchSection: React.FC = () => {
const [items, setItems] = useState<ClothingItem[]>([]);
const [loading, setLoading] = useState(true);
const cardBg = useColorModeValue('white', 'gray.800');
useEffect(() => {
const fetchItems = async () => {
try {
const data = await getClothing();
// Show only 5 newest items on homepage
setItems(data.slice(0, 5));
} catch (e) {
console.error('Failed to fetch clothing items:', e);
} finally {
setLoading(false);
}
};
fetchItems();
}, []);
if (loading || items.length === 0) return null;
return (
<Box>
<HStack justify="space-between" mb={3}>
<Heading as="h3" size="md">Oblečení týmu</Heading>
<Link as={RouterLink} to="/obleceni">
<Button size="sm" variant="outline" colorScheme="blue">Zobrazit vše</Button>
</Link>
</HStack>
<SimpleGrid columns={{ base: 2, md: 3, lg: 5 }} spacing={4}>
{items.map((it) => (
<a
key={it.id}
href={it.url || '/obleceni'}
target={it.url ? "_blank" : undefined}
rel={it.url ? "noreferrer noopener" : undefined}
>
<Box
bg={cardBg}
borderRadius="xl"
overflow="hidden"
boxShadow="sm"
borderWidth="1px"
transition="all 0.2s"
_hover={{ transform: 'translateY(-4px)', boxShadow: 'md' }}
>
<Box
aria-hidden
height={{ base: 140, md: 180 }}
bgSize="cover"
bgPos="center"
style={{ backgroundImage: `url(${it.image_url})` }}
/>
<Box p={3} borderTopWidth="1px">
<Text noOfLines={1} fontWeight="semibold" fontSize="sm">{it.title}</Text>
{it.price && it.price > 0 && (
<Badge colorScheme="blue" mt={1} fontSize="xs">
{it.price} {it.currency || 'Kč'}
</Badge>
)}
</Box>
</Box>
</a>
))}
</SimpleGrid>
</Box>
);
};
export default MerchSection;
@@ -0,0 +1,168 @@
import { Box, Grid, GridItem, Heading, Image, Button, HStack, Text, VStack, Badge } from '@chakra-ui/react';
import React, { useEffect, useState } from 'react';
import { Link as RouterLink } from 'react-router-dom';
import { Calendar, Image as ImageIcon } from 'lucide-react';
interface Album {
id: string;
title: string;
url: string;
date: string;
photos_count: number;
views_count?: number;
photos: Array<{
id: string;
page_url: string;
image_1500: string;
}>;
}
// Resolve backend-relative URLs against API origin
const resolveBackendUrl = (path: string) => {
try {
if (/^https?:\/\//i.test(path)) return path;
if (path.startsWith('/cache') || path.startsWith('/uploads') || path.startsWith('/api/')) {
const base = (process.env.REACT_APP_API_BASE_URL || process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1');
const b = new URL(base);
const abs = new URL(path, `${b.protocol}//${b.host}`);
return abs.toString();
}
return path;
} catch { return path; }
};
const PhotosSection: React.FC<{ zoneramaUrl?: string | null }> = ({ zoneramaUrl }) => {
const [albums, setAlbums] = useState<Album[]>([]);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
let active = true;
(async () => {
try {
const apiUrl = process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1';
const response = await fetch(`${apiUrl}/gallery/albums`);
if (response.ok) {
const data = await response.json();
if (active) {
// Get 5 most recent albums
setAlbums((data.albums || []).slice(0, 5));
}
}
} catch {
if (active) setAlbums([]);
} finally {
if (active) setLoaded(true);
}
})();
return () => { active = false };
}, []);
const showSetupHint = loaded && albums.length === 0 && !zoneramaUrl;
return (
<Box>
<HStack justify="space-between" mb={3}>
<Heading size="lg">Fotogalerie</Heading>
<Button as={RouterLink} to="/galerie" size="sm" variant="outline">
Zobrazit vše
</Button>
</HStack>
{showSetupHint && (
<Box bg="yellow.50" borderWidth="1px" borderColor="yellow.200" color="yellow.800" p={3} borderRadius="md" mb={3}>
<Text>Žádné fotky nejsou k dispozici. Zadejte prosím odkaz na Zonerama v nastavení (Sociální sítě Fotogalerie) a my ji budeme automaticky načítat.</Text>
</Box>
)}
{/* Zonerama Attribution */}
{albums.length > 0 && (
<Box bg="blue.50" borderWidth="1px" borderColor="blue.200" color="blue.800" p={2} borderRadius="md" mb={3} fontSize="xs">
<Text>
📸 Fotografie z{' '}
<Text
as="a"
href="https://zonerama.com"
target="_blank"
rel="noopener noreferrer"
fontWeight="600"
color="blue.600"
_hover={{ textDecoration: 'underline' }}
>
Zonerama
</Text>
</Text>
</Box>
)}
<Grid templateColumns={{ base: '1fr', md: 'repeat(2, 1fr)', lg: 'repeat(3, 1fr)' }} gap={4}>
{albums.map((album) => {
const coverPhoto = album.photos && album.photos.length > 0 ? album.photos[0] : null;
return (
<GridItem key={album.id}>
<Box
as={RouterLink}
to={`/galerie/album/${album.id}`}
bg="white"
borderRadius="md"
overflow="hidden"
boxShadow="sm"
transition="all 0.2s"
_hover={{
transform: 'translateY(-2px)',
boxShadow: 'md',
}}
cursor="pointer"
display="block"
>
{/* Cover Image */}
{coverPhoto ? (
<Image
src={resolveBackendUrl(coverPhoto.image_1500)}
alt={album.title}
h="180px"
w="100%"
objectFit="cover"
/>
) : (
<Box
h="180px"
w="100%"
bg="gray.200"
display="flex"
alignItems="center"
justifyContent="center"
>
<ImageIcon size={32} color="gray" />
</Box>
)}
{/* Album Info */}
<VStack align="stretch" p={3} spacing={2}>
<Heading size="sm" noOfLines={2} color="gray.800">
{album.title}
</Heading>
<HStack spacing={3} fontSize="xs" color="gray.600">
{album.date && (
<HStack spacing={1}>
<Calendar size={14} />
<Text>{album.date}</Text>
</HStack>
)}
<HStack spacing={1}>
<ImageIcon size={14} />
<Text>{album.photos_count} foto</Text>
</HStack>
</HStack>
</VStack>
</Box>
</GridItem>
);
})}
</Grid>
</Box>
);
};
export default PhotosSection;
@@ -0,0 +1,87 @@
import React from 'react';
import {
Box,
VStack,
Heading,
Text,
Spinner,
Alert,
AlertIcon,
useColorModeValue,
} from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { getPolls, getPoll } from '../../services/polls';
import PollCard from '../polls/PollCard';
interface PollsWidgetProps {
featuredOnly?: boolean;
maxPolls?: number;
title?: string;
}
const PollsWidget: React.FC<PollsWidgetProps> = ({
featuredOnly = true,
maxPolls = 1,
title = 'Hlasování',
}) => {
const bgSection = useColorModeValue('gray.50', 'gray.900');
// Fetch polls list
const { data: polls, isLoading } = useQuery({
queryKey: ['polls', { featured: featuredOnly }],
queryFn: () => getPolls(featuredOnly ? { featured: true } : undefined),
staleTime: 2 * 60 * 1000, // 2 minutes
});
// Get full poll data for each featured poll
const pollsToDisplay = polls?.slice(0, maxPolls) || [];
const { data: pollsData, isLoading: isLoadingPolls } = useQuery({
queryKey: ['polls-details', pollsToDisplay.map((p) => p.id)],
queryFn: async () => {
const promises = pollsToDisplay.map((poll) => getPoll(poll.id));
return await Promise.all(promises);
},
enabled: pollsToDisplay.length > 0,
});
if (isLoading || isLoadingPolls) {
return (
<Box bg={bgSection} py={12} px={4}>
<VStack spacing={4}>
<Spinner size="lg" />
<Text>Načítání ankety...</Text>
</VStack>
</Box>
);
}
if (!pollsData || pollsData.length === 0) {
return null; // Don't show widget if no polls
}
return (
<Box bg={bgSection} py={12} px={4}>
<VStack spacing={8} maxW="4xl" mx="auto">
<Heading size="lg" textAlign="center">
{title}
</Heading>
<VStack spacing={6} w="full">
{pollsData.map((pollResponse) => (
<Box key={pollResponse.poll.id} w="full" maxW="600px">
<PollCard
poll={pollResponse.poll}
hasVoted={pollResponse.has_voted}
isActive={pollResponse.is_active}
canShowResults={pollResponse.can_show_results}
/>
</Box>
))}
</VStack>
</VStack>
</Box>
);
};
export default PollsWidget;
@@ -0,0 +1,121 @@
import React, { useMemo } from 'react';
import { usePublicSettings } from '../../hooks/usePublicSettings';
// Normalizes various social URL formats to a proper https URL
const normalizeSocialUrl = (network: 'facebook' | 'instagram' | 'youtube', raw?: string | null): string | null => {
let v = String(raw || '').trim();
if (!v) return null;
// Replace whitespace
v = v.replace(/\s+/g, '');
// Accept handle like @club
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 only a username without slashes
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 looks like domain without scheme
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;
};
const SocialEmbeds: React.FC<{ variant?: 'unified' | 'magazine' | 'pro' | 'edge' }>
= ({ variant = 'unified' }) => {
const { data: settings } = usePublicSettings();
const facebookHref = useMemo(() => {
const raw = (settings as any)?.facebook_url
|| (settings as any)?.facebook
|| (settings as any)?.facebookPage
|| (settings as any)?.facebook_page
|| (settings as any)?.facebookPageUrl
|| (settings as any)?.facebook_page_url;
return normalizeSocialUrl('facebook', raw);
}, [settings]);
const instagramHref = useMemo(() => {
const raw = (settings as any)?.instagram_url
|| (settings as any)?.instagram
|| (settings as any)?.instagramProfile
|| (settings as any)?.instagram_profile
|| (settings as any)?.ig
|| (settings as any)?.ig_url;
return normalizeSocialUrl('instagram', raw);
}, [settings]);
const youtubeHref = useMemo(() => {
const raw = (settings as any)?.youtube_url
|| (settings as any)?.youtube
|| (settings as any)?.yt
|| (settings as any)?.youtube_channel;
return normalizeSocialUrl('youtube', raw);
}, [settings]);
if (!instagramHref && !youtubeHref) return null;
const outerStyle: React.CSSProperties = {
margin: variant === 'pro' ? '16px 0' : '8px 0',
};
const gridStyle: React.CSSProperties = {
display: 'grid',
gridTemplateColumns: '1fr',
gap: 12,
};
const colStyle: React.CSSProperties = {
background: '#fff',
borderRadius: 10,
border: '1px solid var(--light-gray)',
padding: 8,
};
// Instagram profile embed is unofficial; try /embed fallback, else show CTA tile
const instagramEmbedSrc = instagramHref ? `${instagramHref.replace(/\/$/, '')}/embed` : null;
return (
<div className={`social-embeds ${variant}`} style={outerStyle}>
<div className="section-head" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 8 }}>
<h3 style={{ margin: 0 }}>Sledujte nás</h3>
<div className="links" style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{facebookHref && (<a className="btn" href={facebookHref} target="_blank" rel="noreferrer noopener">Facebook</a>)}
{instagramHref && (<a className="btn" href={instagramHref} target="_blank" rel="noreferrer noopener">Instagram</a>)}
{youtubeHref && (<a className="btn" href={youtubeHref} target="_blank" rel="noreferrer noopener">YouTube</a>)}
</div>
</div>
<div className="grid" style={gridStyle}>
{instagramHref && (
<div className="col" style={colStyle}>
{instagramEmbedSrc ? (
<iframe
title="Instagram"
src={instagramEmbedSrc}
width="100%"
height={360}
style={{ border: 0, width: '100%' }}
frameBorder={0}
scrolling="no"
/>
) : (
<div style={{ padding: 16 }}>
<p>Sledujte nás na Instagramu.</p>
<a className="btn" href={instagramHref} target="_blank" rel="noreferrer noopener">Otevřít Instagram</a>
</div>
)}
</div>
)}
</div>
</div>
);
};
export default SocialEmbeds;
@@ -0,0 +1,266 @@
import React, { useEffect, useState } from 'react';
import { Box, Heading, Tabs, TabList, TabPanels, Tab, TabPanel, Table, Thead, Tbody, Tr, Th, Td, Skeleton, Text, Badge, HStack, useColorModeValue } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { facrApi } from '../../services/facr/facrApi';
import { FACR_CLUB_ID, FACR_CLUB_TYPE } from '../../config/facr';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import ClubModal from './ClubModal';
import { TeamLogo } from '../common/TeamLogo';
const TableSection: React.FC = () => {
const { data: settings } = usePublicSettings();
const clubId = settings?.club_id || FACR_CLUB_ID;
const clubType = settings?.club_type || FACR_CLUB_TYPE;
// movement map: compKey -> teamKey -> delta (prevRank - currentRank)
const [movementMap, setMovementMap] = useState<Record<string, Record<string, number>>>({});
const [selectedClub, setSelectedClub] = useState<any>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const handleClubClick = (row: any) => {
// Transform row data to match ClubModal interface
const clubData = {
team: row.team || row.team_name || '-',
team_id: row.team_id || '',
team_logo_url: row.team_logo_url,
rank: row.rank,
played: row.played,
wins: row.wins,
draws: row.draws,
losses: row.losses,
score: row.score,
points: row.points,
};
setSelectedClub(clubData);
setIsModalOpen(true);
};
// Theme-aware movement colors (softer in dark mode)
const upColor = useColorModeValue('green.400', 'green.300');
const downColor = useColorModeValue('red.400', 'red.300');
const sameColor = useColorModeValue('gray.300', 'gray.600');
// Badge/background colors to avoid white-on-white
const badgeBg = useColorModeValue('gray.100', 'gray.700');
const badgeText = useColorModeValue('gray.800', 'whiteAlpha.900');
const rankTopBg = useColorModeValue('green.100', 'green.600');
const rankTopText = useColorModeValue('green.800', 'white');
const pointsBg = useColorModeValue('blue.600', 'blue.400');
const pointsText = 'white';
const { data, isLoading, isError, error } = useQuery({
queryKey: ['facr-table', clubId, clubType],
queryFn: () => facrApi.getClubTable(clubId, clubType),
enabled: Boolean(clubId),
staleTime: 1000 * 60 * 3, // 3 minutes
retry: 2,
retryDelay: attempt => Math.min(1000 * 2 ** attempt, 8000),
});
// After data loads, compare with previous snapshot stored in localStorage to compute movement
useEffect(() => {
try {
if (!data?.competitions?.length) return;
const storageKey = `facr_table_prev_${clubId || 'unknown'}_${clubType || 'football'}`;
const prevRaw = localStorage.getItem(storageKey);
const prev = prevRaw ? JSON.parse(prevRaw) : null;
const map: Record<string, Record<string, number>> = {};
data.competitions.forEach((c: any) => {
const compKey = String(c.id ?? c.code ?? c.name ?? 'comp');
const prevComp = prev?.competitions?.find((pc: any) => String(pc.id ?? pc.code ?? pc.name) === compKey);
const prevRanks: Record<string, number> = {};
(prevComp?.table?.overall || []).forEach((r: any, i: number) => {
const teamKey = String(r.team_id ?? r.team ?? r.team_name ?? i).toLowerCase();
const rank = Number(r.rank ?? (i + 1));
prevRanks[teamKey] = rank;
});
const compMov: Record<string, number> = {};
(c.table?.overall || []).forEach((r: any, i: number) => {
const teamKeyRaw = String(r.team_id ?? r.team ?? r.team_name ?? i);
const teamKey = teamKeyRaw.toLowerCase();
const currentRank = Number(r.rank ?? (i + 1));
const prevRank = prevRanks[teamKey];
if (typeof prevRank === 'number') {
compMov[teamKeyRaw] = prevRank - currentRank; // positive => moved up
}
});
map[compKey] = compMov;
});
setMovementMap(map);
// Save current snapshot for next comparison (trim to essentials)
const snapshot = {
competitions: (data.competitions || []).map((c: any) => ({
id: c.id,
code: c.code,
name: c.name,
table: { overall: (c.table?.overall || []).map((r: any, i: number) => ({
team_id: r.team_id,
team: r.team,
team_name: r.team_name,
rank: Number(r.rank ?? (i + 1)),
})) },
})),
};
localStorage.setItem(storageKey, JSON.stringify(snapshot));
} catch {}
}, [data, clubId, clubType]);
return (
<Box>
<Heading size="lg" mb={4}>Tabulka soutěží</Heading>
{!clubId && (
<Text color="orange.500" mb={4}>Nastavte klub v Nastavení (Admin) nebo REACT_APP_FACR_CLUB_ID pro načtení tabulek z FAČR.</Text>
)}
{isLoading && <Skeleton height="200px" />}
{isError && (
<Text color="red.500" mb={4}>
Nepodařilo se načíst tabulky z FAČR. Zkuste to prosím znovu později.
{process.env.NODE_ENV !== 'production' && error instanceof Error ? ` (${error.message})` : ''}
</Text>
)}
{/* Legend for movement */}
{!isLoading && !isError && (
<HStack spacing={4} mb={2} color="gray.600" fontSize="sm">
<HStack spacing={2}>
<Box w="10px" h="10px" borderRadius="2px" bg={upColor} />
<Text>Lepší pozice</Text>
</HStack>
<HStack spacing={2}>
<Box w="10px" h="10px" borderRadius="2px" bg={sameColor} />
<Text>Beze změny</Text>
</HStack>
<HStack spacing={2}>
<Box w="10px" h="10px" borderRadius="2px" bg={downColor} />
<Text>Horší pozice</Text>
</HStack>
</HStack>
)}
{!isLoading && !isError && data && data.competitions?.length > 0 && (
<Tabs variant="enclosed" colorScheme="blue" isFitted>
<TabList bg={useColorModeValue('white', 'gray.800')} borderRadius="md" borderWidth="1px" borderColor={useColorModeValue('gray.200', 'gray.700')}>
{data.competitions?.map((c) => (
<Tab
key={c.id}
_selected={{ bg: useColorModeValue('blue.50', 'blue.900'), color: useColorModeValue('blue.700', 'blue.200'), borderColor: useColorModeValue('blue.200', 'blue.600') }}
color={useColorModeValue('gray.800', 'gray.200')}
>
{c.name}
</Tab>
))}
</TabList>
<TabPanels>
{data.competitions?.map((c) => (
<TabPanel key={c.id} px={0}>
<Box maxH="420px" overflowY="auto" borderWidth="1px" borderRadius="md" bg={useColorModeValue('white', 'gray.800')} color={useColorModeValue('gray.800', 'gray.100')} borderColor={useColorModeValue('gray.200', 'gray.700')}>
<Table size="sm" variant="striped" colorScheme="gray">
<Thead position="sticky" top={0} zIndex={1} bg="brand.primary">
<Tr>
<Th color="text.onPrimary">#</Th>
<Th color="text.onPrimary">Tým</Th>
<Th isNumeric color="text.onPrimary">Z</Th>
<Th isNumeric color="text.onPrimary">V</Th>
<Th isNumeric color="text.onPrimary">R</Th>
<Th isNumeric color="text.onPrimary">P</Th>
<Th isNumeric color="text.onPrimary">Skóre</Th>
<Th isNumeric color="text.onPrimary">Body</Th>
</Tr>
</Thead>
<Tbody>
{c.table?.overall?.map((row, idx) => {
const compKey = String(c.id ?? c.code ?? c.name ?? 'comp');
const teamKeyRaw = String((row as any).team_id ?? (row as any).team ?? (row as any).team_name ?? idx);
const deltaStored = movementMap?.[compKey]?.[teamKeyRaw];
const movement: 'up' | 'same' | 'down' = typeof deltaStored === 'number' ? (deltaStored > 0 ? 'up' : (deltaStored < 0 ? 'down' : 'same')) : 'same';
const deltaVal = typeof deltaStored === 'number' ? deltaStored : 0;
const borderCol = movement === 'up' ? upColor : movement === 'down' ? downColor : sameColor;
const ourClubId = settings?.club_id;
const ourClubName = (settings?.club_name || '').toLowerCase();
const isOurClub = (ourClubId && row.team_id === ourClubId) || (!!ourClubName && String(row.team || '').toLowerCase() === ourClubName);
return (
<Tr
key={`${row.team_id}-${idx}`}
_hover={{ bg: useColorModeValue('gray.50', 'gray.700'), cursor: 'pointer' }}
bg={idx % 2 === 0 ? useColorModeValue('white', 'gray.800') : useColorModeValue('gray.50', 'gray.750')}
sx={{ borderLeftWidth: '4px', borderLeftStyle: 'solid', borderLeftColor: borderCol }}
onClick={() => handleClubClick(row)}
>
<Td>
<Badge
variant="subtle"
bg={idx <= 2 ? rankTopBg : badgeBg}
color={idx <= 2 ? rankTopText : badgeText}
borderWidth="1px"
borderColor={useColorModeValue('gray.200', 'whiteAlpha.300')}
>
{row.rank}
</Badge>
</Td>
<Td>
<HStack spacing={2} align="center">
<TeamLogo
teamId={row.team_id}
teamName={row.team}
facrLogo={row.team_logo_url}
size="small"
alt={row.team}
borderRadius="full"
bg="white"
borderWidth="1px"
borderColor={useColorModeValue('gray.200', 'whiteAlpha.300')}
/>
<Text as="span" color={isOurClub ? 'brand.primary' : useColorModeValue('gray.800', 'gray.100')} fontWeight={isOurClub ? 'bold' : 'normal'}>
{row.team}
</Text>
<Text as="span" fontSize="xs" color={movement === 'up' ? 'green.500' : movement === 'down' ? 'red.500' : 'gray.500'}>
{movement === 'up' ? '▲' : movement === 'down' ? '▼' : '•'}
</Text>
</HStack>
{deltaVal !== 0 && (
<Badge
ml={2}
variant="subtle"
bg={movement === 'up' ? 'green.100' : movement === 'down' ? 'red.100' : badgeBg}
color={movement === 'up' ? 'green.700' : movement === 'down' ? 'red.700' : badgeText}
borderWidth="1px"
borderColor={useColorModeValue('green.200', movement === 'down' ? 'red.300' : 'whiteAlpha.300')}
>
{movement === 'up' ? `+${deltaVal}` : `${deltaVal}`}
</Badge>
)}
</Td>
<Td isNumeric>{row.played}</Td>
<Td isNumeric>{row.wins}</Td>
<Td isNumeric>{row.draws}</Td>
<Td isNumeric>{row.losses}</Td>
<Td isNumeric>{row.score}</Td>
<Td isNumeric>
<Badge variant="solid" bg={pointsBg} color={pointsText}>{row.points}</Badge>
</Td>
</Tr>
);
})}
</Tbody>
</Table>
</Box>
</TabPanel>
))}
</TabPanels>
</Tabs>
)}
{!isLoading && !isError && data && (!data.competitions || data.competitions.length === 0) && (
<Text color="gray.500">Pro tento klub nejsou dostupné tabulky.</Text>
)}
<ClubModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
club={selectedClub}
clubType={clubType as 'football' | 'futsal'}
/>
</Box>
);
};
export default TableSection;
@@ -0,0 +1,26 @@
import { Box, Heading, HStack, VStack, Image, Text, useColorModeValue } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { getPlayers, Player } from '../../services/players';
const TeamScroller: React.FC = () => {
const { data } = useQuery({ queryKey: ['players'], queryFn: getPlayers });
const players = (data || []).filter(p => p.is_active);
if (!players.length) return null;
return (
<Box>
<Heading size="lg" mb={4} textAlign="center">Náš tým</Heading>
<HStack spacing={6} overflowX="auto" py={2} className="hide-scrollbar">
{players.map((p: Player) => (
<VStack key={p.id} minW="160px" spacing={2} bg={useColorModeValue('white', 'gray.800')} borderRadius="xl" p={4} boxShadow="sm" borderWidth="1px" borderColor={useColorModeValue('gray.200', 'gray.700')}>
<Image src={p.image_url || '/logo192.png'} alt={p.first_name + ' ' + p.last_name} w="140px" h="140px" objectFit="cover" borderRadius="lg" />
<Text fontWeight="bold" textAlign="center">{p.first_name} {p.last_name}</Text>
<Text fontSize="sm" color={useColorModeValue('gray.600', 'gray.400')}>{p.position}</Text>
</VStack>
))}
</HStack>
</Box>
);
};
export default TeamScroller;
@@ -0,0 +1,72 @@
import React from 'react';
import ContactMap from './ContactMap';
import VectorMap from './VectorMap';
interface UnifiedMapProps {
latitude: number;
longitude: number;
zoom?: number;
address?: string;
clubName?: string;
mapStyle?: string;
height?: number;
clubPrimaryColor?: string;
clubSecondaryColor?: string;
useVectorMaps?: boolean;
}
/**
* Unified Map Component
*
* Automatically chooses between raster (Leaflet) and vector (MapLibre GL) maps
* based on the useVectorMaps prop or environment configuration.
*
* Usage:
* <UnifiedMap
* latitude={50.0755}
* longitude={14.4378}
* useVectorMaps={true} // or from settings
* />
*/
const UnifiedMap: React.FC<UnifiedMapProps> = ({
useVectorMaps = false,
...props
}) => {
// Map style conversion: raster styles to vector equivalents
const getVectorStyle = (rasterStyle?: string): 'positron' | 'dark-matter' | 'osm-bright' | 'klokantech-basic' => {
const styleMap: Record<string, any> = {
'default': 'osm-bright',
'positron': 'positron',
'positron-no-labels': 'positron',
'dark': 'dark-matter',
'dark-no-labels': 'dark-matter',
'dark-matter': 'dark-matter',
'toner': 'klokantech-basic',
'toner-lite': 'klokantech-basic',
'voyager': 'osm-bright',
'osm-bright': 'osm-bright',
'klokantech-basic': 'klokantech-basic',
};
return styleMap[rasterStyle || 'default'] || 'positron';
};
if (useVectorMaps) {
// Use vector maps (MapLibre GL JS)
return (
<VectorMap
{...props}
mapStyle={getVectorStyle(props.mapStyle)}
/>
);
} else {
// Use raster maps (Leaflet)
return (
<ContactMap
{...props}
/>
);
}
};
export default UnifiedMap;
@@ -0,0 +1,70 @@
import { Box, Flex, Heading, Text, HStack, Image, Button } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import { facrApi } from '../../services/facr/facrApi';
import { useClubTheme } from '../../contexts/ClubThemeContext';
function formatCountdown(dt: string) {
const target = new Date(dt).getTime();
const diff = target - Date.now();
if (isNaN(target) || diff <= 0) return '';
const d = Math.floor(diff / (1000 * 60 * 60 * 24));
const h = Math.floor((diff / (1000 * 60 * 60)) % 24);
const m = Math.floor((diff / (1000 * 60)) % 60);
return `${String(d).padStart(2,'0')}d ${String(h).padStart(2,'0')}h ${String(m).padStart(2,'0')}m`;
}
const UpcomingBanner: React.FC = () => {
const { data: settings } = usePublicSettings();
const clubId = settings?.club_id;
const clubType = settings?.club_type || 'football';
const theme = useClubTheme();
const { data } = useQuery({
queryKey: ['facr-club', clubId, clubType],
queryFn: () => facrApi.getClub(clubId!, clubType as any),
enabled: !!clubId,
});
const allMatches = (data?.competitions || []).flatMap(c => c.matches || []);
const upcoming = allMatches
.map(m => ({ m, t: new Date(m.date_time).getTime() }))
.filter(x => !isNaN(x.t) && x.t > Date.now())
.sort((a, b) => a.t - b.t)[0]?.m;
if (!upcoming) return null;
return (
<Box bg={theme.primary} color="white" borderRadius="xl" p={{ base: 4, md: 6 }} shadow="md">
<Text fontSize="sm" opacity={0.9} fontWeight="600">Nadcházející zápas</Text>
<Flex align="center" justify="space-between" gap={4} mt={2} direction={{ base: 'column', md: 'row' }}>
<HStack spacing={4} flex={1} justify="center">
<HStack>
<Image src={upcoming.home_logo_url} alt={upcoming.home} boxSize={{ base: '36px', md: '48px' }} objectFit="contain" />
<Text fontWeight="600">{upcoming.home}</Text>
</HStack>
<Heading size="md">vs</Heading>
<HStack>
<Image src={upcoming.away_logo_url} alt={upcoming.away} boxSize={{ base: '36px', md: '48px' }} objectFit="contain" />
<Text fontWeight="600">{upcoming.away}</Text>
</HStack>
</HStack>
<HStack spacing={6}>
<Box textAlign="center">
<Text fontSize="xs" opacity={0.8}>KICKOFF</Text>
<Heading size="sm">{new Date(upcoming.date_time).toLocaleString()}</Heading>
</Box>
<Box textAlign="center">
<Text fontSize="xs" opacity={0.8}>ZAČÍNÁ ZA</Text>
<Heading size="sm">{formatCountdown(upcoming.date_time)}</Heading>
</Box>
</HStack>
{upcoming.report_url && (
<Button as="a" href={upcoming.report_url} target="_blank" colorScheme="red" variant="solid">Detail</Button>
)}
</Flex>
</Box>
);
};
export default UpcomingBanner;
@@ -0,0 +1,105 @@
import React from 'react';
import { Box, HStack, VStack, Text, Heading, Tabs, TabList, TabPanels, Tab, TabPanel, Image, Button } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import { facrApi } from '../../services/facr/facrApi';
import { useClubTheme } from '../../contexts/ClubThemeContext';
function formatCountdown(dt?: string) {
if (!dt) return '';
const target = new Date(dt).getTime();
const diff = target - Date.now();
if (isNaN(target) || diff <= 0) return '';
const d = Math.floor(diff / (1000 * 60 * 60 * 24));
const h = Math.floor((diff / (1000 * 60 * 60)) % 24);
const m = Math.floor((diff / (1000 * 60)) % 60);
return `${String(d).padStart(2,'0')}d ${String(h).padStart(2,'0')}h ${String(m).padStart(2,'0')}m`;
}
const UpcomingSwitch: React.FC = () => {
const { data: settings } = usePublicSettings();
const clubId = settings?.club_id;
const clubType = (settings?.club_type || 'football') as any;
const theme = useClubTheme();
const { data } = useQuery({
queryKey: ['facr-club', clubId, clubType],
queryFn: () => facrApi.getClub(clubId!, clubType),
enabled: !!clubId,
});
const comps = data?.competitions || [];
if (!comps.length) return null;
return (
<Tabs variant="unstyled" colorScheme="whiteAlpha">
<TabList gap={2} flexWrap={{ base: 'wrap', md: 'nowrap' }}>
{comps.map((c) => (
<Tab
key={c.id}
px={3}
py={2}
borderRadius="full"
bg="whiteAlpha.200"
color="white"
_selected={{ bg: 'white', color: 'black' }}
>
{c.name}
</Tab>
))}
</TabList>
<TabPanels>
{comps.map((c) => {
const upcoming = (c.matches || [])
.map((m) => ({ m, t: new Date(m.date_time).getTime() }))
.filter((x) => !isNaN(x.t) && x.t > Date.now())
.sort((a, b) => a.t - b.t)[0]?.m;
if (!upcoming) {
return (
<TabPanel key={c.id} px={0}>
<Box py={6} textAlign="center" color="whiteAlpha.800">Žádný nadcházející zápas.</Box>
</TabPanel>
);
}
return (
<TabPanel key={c.id} px={0}>
<HStack spacing={6} align="center" justify="space-between" flexWrap={{ base: 'wrap', md: 'nowrap' }}>
<HStack spacing={4} flex={1} minW={0} justify="center">
<HStack minW={0}>
<Image src={upcoming.home_logo_url} alt={upcoming.home} boxSize={{ base: '32px', md: '44px' }} objectFit="contain" />
<Text fontWeight={700} color="white" noOfLines={1}>{upcoming.home}</Text>
</HStack>
<Heading size="sm" color="white">vs</Heading>
<HStack minW={0}>
<Image src={upcoming.away_logo_url} alt={upcoming.away} boxSize={{ base: '32px', md: '44px' }} objectFit="contain" />
<Text fontWeight={700} color="white" noOfLines={1}>{upcoming.away}</Text>
</HStack>
</HStack>
<HStack spacing={6}>
<Box textAlign="center" color="white">
<Text fontSize="xs" opacity={0.85}>KICKOFF</Text>
<Heading size="sm">{new Date(upcoming.date_time).toLocaleString()}</Heading>
</Box>
<Box textAlign="center" color="white">
<Text fontSize="xs" opacity={0.85}>ZAČÍNÁ ZA</Text>
<Heading size="sm">{formatCountdown(upcoming.date_time)}</Heading>
</Box>
</HStack>
{upcoming.report_url && (
<Button as="a" href={upcoming.report_url} target="_blank" bg={theme.primary} color="white" _hover={{ bg: theme.accent }}>
Detail zápasu
</Button>
)}
</HStack>
</TabPanel>
);
})}
</TabPanels>
</Tabs>
);
};
export default UpcomingSwitch;
+323
View File
@@ -0,0 +1,323 @@
import React, { useEffect, useRef, useState } from 'react';
import { Box } from '@chakra-ui/react';
import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
interface VectorMapProps {
latitude: number;
longitude: number;
zoom?: number;
address?: string;
clubName?: string;
mapStyle?: 'positron' | 'dark-matter' | 'osm-bright' | 'klokantech-basic';
height?: number;
clubPrimaryColor?: string;
clubSecondaryColor?: string;
customStyleUrl?: string;
}
// OpenMapTiles free demo server (for development/testing)
// For production, use your own tile server or a commercial provider
const MAPTILER_API_KEY = process.env.REACT_APP_MAPTILER_KEY || 'get_your_own_OpIi9ZULNHzrESv6T2vL';
// Vector tile style definitions
export const VECTOR_STYLES = {
'positron': {
name: 'Positron (Light)',
description: 'Clean light style, perfect for data visualization',
getStyleUrl: (apiKey: string) => `https://api.maptiler.com/maps/positron/style.json?key=${apiKey}`,
},
'dark-matter': {
name: 'Dark Matter',
description: 'Sleek dark theme for modern interfaces',
getStyleUrl: (apiKey: string) => `https://api.maptiler.com/maps/darkmatter/style.json?key=${apiKey}`,
},
'osm-bright': {
name: 'OSM Bright',
description: 'Colorful OpenStreetMap style',
getStyleUrl: (apiKey: string) => `https://api.maptiler.com/maps/bright/style.json?key=${apiKey}`,
},
'klokantech-basic': {
name: 'Basic',
description: 'Simple and clean base map',
getStyleUrl: (apiKey: string) => `https://api.maptiler.com/maps/basic/style.json?key=${apiKey}`,
},
};
// Custom Positron-like style with club colors (self-hosted tiles not required)
const createCustomPositronStyle = (primaryColor?: string, secondaryColor?: string): any => {
const mainColor = primaryColor || '#e11d48';
const accentColor = secondaryColor || '#3b82f6';
return {
version: 8,
name: 'Custom Positron',
sources: {
'openmaptiles': {
type: 'vector',
url: `https://api.maptiler.com/tiles/v3/tiles.json?key=${MAPTILER_API_KEY}`,
},
},
glyphs: 'https://api.maptiler.com/fonts/{fontstack}/{range}.pbf?key=' + MAPTILER_API_KEY,
layers: [
// Background
{
id: 'background',
type: 'background',
paint: { 'background-color': '#f8f8f8' },
},
// Water
{
id: 'water',
type: 'fill',
source: 'openmaptiles',
'source-layer': 'water',
paint: { 'fill-color': '#e3e8ed' },
},
// Parks
{
id: 'park',
type: 'fill',
source: 'openmaptiles',
'source-layer': 'park',
paint: { 'fill-color': '#e8f5e8' },
},
// Buildings
{
id: 'building',
type: 'fill',
source: 'openmaptiles',
'source-layer': 'building',
paint: {
'fill-color': '#ececec',
'fill-opacity': 0.6,
},
},
// Roads - major
{
id: 'road-major',
type: 'line',
source: 'openmaptiles',
'source-layer': 'transportation',
filter: ['in', 'class', 'motorway', 'trunk', 'primary'],
paint: {
'line-color': '#ffffff',
'line-width': {
base: 1.4,
stops: [[6, 0.5], [20, 30]],
},
},
},
// Roads - minor
{
id: 'road-minor',
type: 'line',
source: 'openmaptiles',
'source-layer': 'transportation',
filter: ['in', 'class', 'secondary', 'tertiary', 'minor'],
paint: {
'line-color': '#ffffff',
'line-width': {
base: 1.4,
stops: [[6, 0.25], [20, 20]],
},
},
},
// Place labels
{
id: 'place-label',
type: 'symbol',
source: 'openmaptiles',
'source-layer': 'place',
layout: {
'text-field': '{name}',
'text-font': ['Noto Sans Regular'],
'text-size': {
base: 1.2,
stops: [[7, 11], [15, 14]],
},
},
paint: {
'text-color': '#666666',
'text-halo-color': '#ffffff',
'text-halo-width': 1.5,
},
},
],
};
};
const VectorMap: React.FC<VectorMapProps> = ({
latitude,
longitude,
zoom = 15,
address,
clubName,
mapStyle = 'positron',
height = 400,
clubPrimaryColor,
clubSecondaryColor,
customStyleUrl,
}) => {
const mapContainer = useRef<HTMLDivElement>(null);
const map = useRef<maplibregl.Map | null>(null);
const marker = useRef<maplibregl.Marker | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!mapContainer.current || map.current) return;
try {
// Determine style URL
let styleUrl: string | any;
if (customStyleUrl) {
styleUrl = customStyleUrl;
} else if (mapStyle === 'positron' && clubPrimaryColor) {
// Use custom style with club colors
styleUrl = createCustomPositronStyle(clubPrimaryColor, clubSecondaryColor);
} else {
// Use predefined style
styleUrl = VECTOR_STYLES[mapStyle]?.getStyleUrl(MAPTILER_API_KEY) ||
VECTOR_STYLES.positron.getStyleUrl(MAPTILER_API_KEY);
}
// Initialize map
map.current = new maplibregl.Map({
container: mapContainer.current,
style: styleUrl,
center: [longitude, latitude],
zoom: zoom,
});
// Add navigation controls
map.current.addControl(new maplibregl.NavigationControl(), 'top-right');
// Create custom marker with club color
const markerColor = clubPrimaryColor || '#e11d48';
// Create marker element
const el = document.createElement('div');
el.style.width = '36px';
el.style.height = '54px';
el.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 36" width="36" height="54">
<defs>
<filter id="marker-shadow-${Date.now()}" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur in="SourceAlpha" stdDeviation="2"/>
<feOffset dx="0" dy="2" result="offsetblur"/>
<feComponentTransfer>
<feFuncA type="linear" slope="0.3"/>
</feComponentTransfer>
<feMerge>
<feMergeNode/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<path fill="${markerColor}" stroke="#fff" stroke-width="1.5"
filter="url(#marker-shadow-${Date.now()})"
d="M12 0C7.03 0 3 4.03 3 9c0 7.5 9 18 9 18s9-10.5 9-18c0-4.97-4.03-9-9-9z"/>
<circle cx="12" cy="9" r="3" fill="#fff"/>
</svg>
`;
// Add marker to map
marker.current = new maplibregl.Marker({ element: el })
.setLngLat([longitude, latitude])
.addTo(map.current);
// Add popup if there's content
if (clubName || address) {
let popupContent = '';
if (clubName) popupContent += `<strong>${clubName}</strong><br>`;
if (address) popupContent += address;
const popup = new maplibregl.Popup({ offset: 25 })
.setHTML(popupContent);
marker.current.setPopup(popup);
}
// Handle map load event for additional customization
map.current.on('load', () => {
if (!map.current) return;
// Apply club color tint to water features if primary color is set
if (clubPrimaryColor && map.current.getLayer('water')) {
map.current.setPaintProperty('water', 'fill-color',
adjustColorBrightness(clubPrimaryColor, 0.9));
}
});
} catch (err: any) {
console.error('Error initializing map:', err);
setError(err?.message || 'Failed to load map');
}
// Cleanup
return () => {
if (marker.current) {
marker.current.remove();
marker.current = null;
}
if (map.current) {
map.current.remove();
map.current = null;
}
};
}, [latitude, longitude, zoom, mapStyle, clubPrimaryColor, clubSecondaryColor, customStyleUrl]);
// Update marker and center when coordinates change
useEffect(() => {
if (!map.current || !marker.current) return;
const newCenter: [number, number] = [longitude, latitude];
marker.current.setLngLat(newCenter);
map.current.setCenter(newCenter);
}, [latitude, longitude]);
// Helper function to adjust color brightness
function adjustColorBrightness(color: string, factor: number): string {
try {
// Simple RGB adjustment
const hex = color.replace('#', '');
const r = Math.min(255, Math.floor(parseInt(hex.substring(0, 2), 16) * factor));
const g = Math.min(255, Math.floor(parseInt(hex.substring(2, 4), 16) * factor));
const b = Math.min(255, Math.floor(parseInt(hex.substring(4, 6), 16) * factor));
return `rgb(${r}, ${g}, ${b})`;
} catch {
return color;
}
}
if (error) {
return (
<Box
w="100%"
h={`${height}px`}
bg="gray.100"
display="flex"
alignItems="center"
justifyContent="center"
borderRadius="md"
p={4}
>
{error}
</Box>
);
}
return (
<Box
ref={mapContainer}
w="100%"
h={`${height}px`}
borderRadius="md"
overflow="hidden"
boxShadow="md"
/>
);
};
export default VectorMap;
@@ -0,0 +1,337 @@
import { Box, AspectRatio, Text, useColorModeValue, SimpleGrid, Heading, HStack, Badge, Button, Link, Modal, ModalOverlay, ModalContent, ModalBody, ModalCloseButton, useDisclosure, Icon, VStack } from '@chakra-ui/react';
import { Link as RouterLink } from 'react-router-dom';
import { FaYoutube, FaPlay } from 'react-icons/fa';
import HorizontalScroller from '../ui/HorizontalScroller';
import { useClubTheme } from '../../contexts/ClubThemeContext';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import { getCachedYouTube, YouTubeVideo } from '../../services/youtube';
import { useEffect, useMemo, useState } from 'react';
type Props = {
// optional manual override
videos?: string[];
};
type RenderItem = {
key: string;
title: string;
embedUrl: string;
thumbnail?: string;
date?: string; // YYYY-MM-DD
videoId?: string;
};
const toEmbed = (idOrUrl: string): string => {
// If a full URL is passed, try to extract the id; otherwise assume it's already an id
// supports https://www.youtube.com/watch?v=ID or youtu.be/ID
try {
if (idOrUrl.includes('youtube.com') || idOrUrl.includes('youtu.be')) {
const u = new URL(idOrUrl);
if (u.hostname.includes('youtu.be')) {
const id = u.pathname.replace('/', '');
return `https://www.youtube.com/embed/${id}`;
}
const id = u.searchParams.get('v');
if (id) return `https://www.youtube.com/embed/${id}`;
}
} catch {}
// otherwise treat as id
return `https://www.youtube.com/embed/${idOrUrl}`;
};
const VideosSection: React.FC<Props> = ({ videos }) => {
const cardBg = useColorModeValue('white', 'gray.800');
const theme = useClubTheme();
const { data: settings } = usePublicSettings();
const [yt, setYt] = useState<YouTubeVideo[]>([]);
const { isOpen, onOpen, onClose } = useDisclosure();
const [selectedVideo, setSelectedVideo] = useState<RenderItem | null>(null);
// If admin explicitly disabled, respect it. Otherwise default to ON when there are manual videos configured
// or when a YouTube URL is present for auto mode.
const hasManualConfigured = Boolean((settings as any)?.videos_items?.length || (settings as any)?.videos?.length);
const hasAutoConfigured = Boolean((settings as any)?.youtube_url || (settings as any)?.social_youtube);
// Default enablement: if not explicitly set, enable when manual items exist or when a YouTube URL is configured (auto mode).
// This avoids flicker caused by toggling visibility while data is loading.
const enabled = (typeof (settings as any)?.videos_module_enabled === 'boolean')
? Boolean((settings as any)?.videos_module_enabled)
: (hasManualConfigured || ((settings?.videos_source || 'auto') === 'auto' && hasAutoConfigured));
const style = settings?.videos_style || 'slider';
const source = settings?.videos_source || 'auto';
// Default to 6 items on homepage unless overridden by settings (max 12)
const limit = Math.max(1, Math.min(12, settings?.videos_limit ?? 6));
const youtubeUrl = (settings as any)?.youtube_url || (settings as any)?.social_youtube || null;
useEffect(() => {
let canceled = false;
const run = async () => {
if (source !== 'auto') return;
const payload = await getCachedYouTube();
if (!payload) return;
// Sort by published_date descending (safety; service should already do this)
const vids = (payload.videos || []).slice().sort((a, b) => (Date.parse(b.published_date || '') || 0) - (Date.parse(a.published_date || '') || 0));
if (!canceled) setYt(vids);
};
run();
return () => { canceled = true; };
}, [source]);
const extractVideoId = (embedUrl: string): string | undefined => {
if (embedUrl?.includes('/embed/')) {
return embedUrl.split('/embed/')[1]?.split('?')[0];
}
return undefined;
};
const items: RenderItem[] = useMemo(() => {
if (source === 'auto') {
return (yt || []).slice(0, limit).map(v => ({
key: v.video_id,
title: v.title,
embedUrl: toEmbed(v.video_id),
thumbnail: v.thumbnail_url,
date: v.published_date,
videoId: v.video_id,
}));
}
// manual fallback from settings or prop
const manual = (settings?.videos_items || []).map((it, i) => {
const embedUrl = toEmbed(it.url);
return {
key: `${i}-${it.url}`,
title: it.title || `Video ${i+1}`,
embedUrl,
thumbnail: it.thumbnail_url,
date: it.uploaded_at,
videoId: extractVideoId(embedUrl),
};
});
const legacy = (videos || settings?.videos || []).map((url, i) => {
const embedUrl = toEmbed(url as any);
return {
key: `${i}-${url}`,
title: `Video ${i+1}`,
embedUrl,
videoId: extractVideoId(embedUrl),
};
});
return (manual.length ? manual : legacy).slice(0, limit);
}, [source, yt, settings?.videos_items, settings?.videos, videos, limit]);
if (!enabled || items.length === 0) return null;
const handlePlayClick = (it: RenderItem) => {
setSelectedVideo(it);
onOpen();
};
const Card: React.FC<{ it: RenderItem; idx: number }> = ({ it, idx }) => {
const thumb = it.thumbnail || (it.videoId ? `https://i.ytimg.com/vi/${it.videoId}/hqdefault.jpg` : undefined);
const borderColor = useColorModeValue('gray.200', 'gray.600');
const placeholderBg = useColorModeValue('gray.100', 'gray.700');
const placeholderIcon = useColorModeValue('gray.400', 'gray.500');
const videoPrimaryColor = theme.primary;
return (
<Box
bg={cardBg}
borderRadius="xl"
overflow="hidden"
boxShadow="sm"
borderWidth="2px"
borderColor={borderColor}
transition="all 0.3s"
position="relative"
sx={{
'&:hover': {
transform: 'translateY(-8px)',
boxShadow: '0 20px 40px rgba(0,0,0,0.15)',
borderColor: 'brand.primary',
},
'&:hover .play-overlay': {
opacity: 1,
},
'&:hover .play-overlay > div': {
transform: 'scale(1.05)',
},
}}
>
<AspectRatio ratio={16 / 9}>
<Box position="relative" cursor="pointer" onClick={() => handlePlayClick(it)}>
{/* Thumbnail */}
{thumb ? (
<Box
as="img"
src={thumb}
alt={it.title}
width="100%"
height="100%"
style={{ objectFit: 'cover' }}
/>
) : (
<Box bg={placeholderBg} display="flex" alignItems="center" justifyContent="center">
<Icon as={FaPlay} boxSize={12} color={placeholderIcon} />
</Box>
)}
{/* Play overlay */}
<Box
className="play-overlay"
position="absolute"
inset={0}
display="flex"
alignItems="center"
justifyContent="center"
bg="blackAlpha.700"
opacity={0}
transition="opacity 0.3s ease"
pointerEvents="none"
>
<Box
bg="white"
color="brand.primary"
borderRadius="full"
px={8}
py={4}
fontWeight="bold"
display="flex"
alignItems="center"
gap={2}
transform="scale(0.9)"
transition="transform 0.3s cubic-bezier(0.4, 0, 0.2, 1)"
boxShadow="0 12px 32px rgba(0,0,0,0.4)"
>
<Icon as={FaPlay} boxSize={5} />
<Text fontSize="lg">Přehrát</Text>
</Box>
</Box>
</Box>
</AspectRatio>
<Box p={4} borderTopWidth="2px" borderTopColor={videoPrimaryColor}>
<VStack align="start" spacing={2}>
<Text fontWeight="bold" fontSize="md" color={videoPrimaryColor} noOfLines={2}>
{it.title}
</Text>
<HStack justify="space-between" width="100%">
{it.date && (
<Badge colorScheme="gray" fontSize="0.7rem">
{new Date(it.date).toLocaleDateString('cs-CZ')}
</Badge>
)}
{it.videoId && (
<Link href={`https://www.youtube.com/watch?v=${it.videoId}`} isExternal onClick={(e) => e.stopPropagation()}>
<Button
size="xs"
variant="ghost"
colorScheme="red"
leftIcon={<Icon as={FaYoutube} />}
>
YouTube
</Button>
</Link>
)}
</HStack>
</VStack>
</Box>
</Box>
);
};
if (style === 'slider') {
return (
<Box>
<Box className="section-head" style={{ marginTop: 8, marginBottom: 16 }}>
<HStack spacing={3}>
<Heading as="h3" size="lg" fontWeight="700">Videa</Heading>
</HStack>
<Link as={RouterLink} to="/videa">
<Button
size="md"
variant="solid"
bg={theme.primary}
color="white"
rightIcon={<Box as="span"></Box>}
_hover={{ opacity: 0.9, transform: 'translateX(4px)' }}
transition="all 0.2s"
>
Více videí
</Button>
</Link>
</Box>
<HorizontalScroller draggable>
{items.map((it, idx) => (
<Box
key={it.key}
minW={{ base: '85%', md: '60%', lg: '33%' }}
display="flex"
flexDirection="column"
>
<Card it={it} idx={idx} />
</Box>
))}
</HorizontalScroller>
{/* Video Modal */}
<Modal isOpen={isOpen} onClose={onClose} size="6xl" isCentered>
<ModalOverlay bg="blackAlpha.800" />
<ModalContent bg="transparent" boxShadow="none" maxW="90vw">
<ModalCloseButton color="white" size="lg" bg="blackAlpha.600" _hover={{ bg: 'blackAlpha.700' }} borderRadius="full" zIndex={2} />
<ModalBody p={0}>
{selectedVideo && (
<AspectRatio ratio={16 / 9} maxH="90vh">
<iframe
src={`${selectedVideo.embedUrl}?autoplay=1&vq=hd1080&rel=0&modestbranding=1&playsinline=1`}
title={selectedVideo.title}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
referrerPolicy="strict-origin-when-cross-origin"
style={{ borderRadius: '8px' }}
/>
</AspectRatio>
)}
</ModalBody>
</ModalContent>
</Modal>
</Box>
);
}
const cols = style === 'grid3' ? { base: 1, md: 3 } : { base: 1, md: 2, lg: 3 };
return (
<Box>
<Box className="section-head">
<Heading as="h3" size="md">Videa</Heading>
<Link as={RouterLink} to="/videa">
<Button size="sm" variant="outline" colorScheme="blue">Více videí</Button>
</Link>
</Box>
<SimpleGrid columns={cols} spacing={4}>
{items.map((it, idx) => (
<Card key={it.key} it={it} idx={idx} />
))}
</SimpleGrid>
{/* Video Modal */}
<Modal isOpen={isOpen} onClose={onClose} size="6xl" isCentered>
<ModalOverlay bg="blackAlpha.800" />
<ModalContent bg="transparent" boxShadow="none" maxW="90vw">
<ModalCloseButton color="white" size="lg" bg="blackAlpha.600" _hover={{ bg: 'blackAlpha.700' }} borderRadius="full" zIndex={2} />
<ModalBody p={0}>
{selectedVideo && (
<AspectRatio ratio={16 / 9} maxH="90vh">
<iframe
src={`${selectedVideo.embedUrl}?autoplay=1&vq=hd1080&rel=0&modestbranding=1&playsinline=1`}
title={selectedVideo.title}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
referrerPolicy="strict-origin-when-cross-origin"
style={{ borderRadius: '8px' }}
/>
</AspectRatio>
)}
</ModalBody>
</ModalContent>
</Modal>
</Box>
);
};
export default VideosSection;
@@ -0,0 +1,3 @@
// Deprecated: use `src/layouts/AdminLayout` instead.
// This file re-exports the new AdminLayout to avoid code duplication.
export { default } from '../../layouts/AdminLayout';
+304
View File
@@ -0,0 +1,304 @@
import { Box, Container, HStack, Link, Text, Stack, Wrap, WrapItem, Button, Image, VStack, IconButton, SimpleGrid, Heading } from '@chakra-ui/react';
import React, { useEffect, useState } from 'react';
import { FiArrowUpRight, FiMail } from 'react-icons/fi';
import { FaFacebook, FaInstagram, FaYoutube } from 'react-icons/fa';
import { trackNavigation } from '../../utils/umami';
import { useClubTheme } from '../../contexts/ClubThemeContext';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import { assetUrl } from '../../utils/url';
const resolveBackendUrl = (path: string) => {
try {
if (/^https?:\/\//i.test(path)) return path;
if (path.startsWith('/cache') || path.startsWith('/uploads')) {
const base = process.env.REACT_APP_API_BASE_URL || process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1';
const u = new URL(base);
u.pathname = path;
return u.toString();
}
return path;
} catch {
return path;
}
};
interface Sponsor {
id: number | string;
name: string;
logo_url?: string;
website_url?: string;
is_active?: boolean;
}
const Footer: React.FC = () => {
const currentYear = new Date().getFullYear();
const [clubName, setClubName] = useState<string>('Fotbal Club');
const [shopUrl, setShopUrl] = useState<string | null>(null);
const [sponsors, setSponsors] = useState<Sponsor[]>([]);
const theme = useClubTheme();
const { data: settings } = usePublicSettings();
useEffect(() => {
let cancelled = false;
(async () => {
try {
const res = await fetch(resolveBackendUrl('/cache/prefetch/facr_club_info.json'), { cache: 'no-cache' });
if (!res.ok) return;
const json = await res.json();
if (cancelled) return;
if (json?.name) setClubName(String(json.name));
} catch {}
try {
const res = await fetch(resolveBackendUrl('/cache/prefetch/settings.json'), { cache: 'no-cache' });
if (res?.ok) {
const s = await res.json();
if (!cancelled && s) {
setShopUrl(s?.shop_url || s?.eshop_url || null);
}
}
} catch {}
// Fetch sponsors
try {
const apiUrl = process.env.REACT_APP_API_BASE_URL || process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1';
const sponsorsRes = await fetch(`${apiUrl}/public/sponsors`);
if (sponsorsRes.ok) {
const data = await sponsorsRes.json();
if (!cancelled && Array.isArray(data)) {
// Filter active sponsors only
const activeSponsors = data.filter((s: Sponsor) => s.is_active !== false);
setSponsors(activeSponsors);
}
}
} catch {}
})();
return () => { cancelled = true; };
}, []);
return (
<>
{/* Navigation Footer */}
<Box bg="gray.800" color="white" mt={12} py={8} borderTop="1px" borderColor="whiteAlpha.200">
<Container maxW="container.xl">
<Stack direction={{ base: 'column', lg: 'row' }} spacing={6} justify="space-between" align={{ base: 'flex-start', lg: 'center' }} w="100%">
{/* Brand */}
<HStack spacing={3} align="center">
<Text fontWeight="700" fontSize="lg">{clubName}</Text>
</HStack>
{/* Navigation links */}
<Wrap spacing={4} shouldWrapChildren>
<WrapItem><Link href="/blog" color="whiteAlpha.900" fontWeight="600" _hover={{ color: 'white', textDecoration: 'underline' }}>Články</Link></WrapItem>
<WrapItem><Link href="/kalendar" color="whiteAlpha.800" _hover={{ color: 'white', textDecoration: 'underline' }}>Zápasy</Link></WrapItem>
<WrapItem><Link href="/tabulky" color="whiteAlpha.800" _hover={{ color: 'white', textDecoration: 'underline' }}>Tabulka</Link></WrapItem>
<WrapItem><Link href="/sponzori" color="whiteAlpha.800" _hover={{ color: 'white', textDecoration: 'underline' }}>Sponzoři</Link></WrapItem>
<WrapItem><Link href="/kontakt" color="whiteAlpha.800" _hover={{ color: 'white', textDecoration: 'underline' }}>Kontakt</Link></WrapItem>
<WrapItem><Link href="/pravidla-cookies" color="whiteAlpha.800" _hover={{ color: 'white', textDecoration: 'underline' }}>Cookies</Link></WrapItem>
<WrapItem><Link href="/obchodni-podminky" color="whiteAlpha.800" _hover={{ color: 'white', textDecoration: 'underline' }}>Obchodní podmínky</Link></WrapItem>
<WrapItem><Link href="/zasady-ochrany-osobnich-udaju" color="whiteAlpha.800" _hover={{ color: 'white', textDecoration: 'underline' }}>Zásady ochrany osobních údajů</Link></WrapItem>
{shopUrl && (
<WrapItem><Link href={shopUrl} color="whiteAlpha.800" _hover={{ color: 'white', textDecoration: 'underline' }} isExternal display="inline-flex" alignItems="center" gap={1}>Eshop <FiArrowUpRight /></Link></WrapItem>
)}
</Wrap>
</Stack>
</Container>
</Box>
{/* Sponsors Section */}
{sponsors.length > 0 && (
<Box bg="gray.700" color="white" py={8} borderTop="1px" borderColor="whiteAlpha.200">
<Container maxW="container.xl">
<VStack spacing={6}>
<Heading size="md" color="whiteAlpha.900">
Naši partneři
</Heading>
<SimpleGrid
columns={{ base: 2, sm: 3, md: 4, lg: 6 }}
spacing={6}
w="full"
>
{sponsors.map((sponsor) => (
<Link
key={sponsor.id}
href={sponsor.website_url || '#'}
isExternal={!!sponsor.website_url}
target={sponsor.website_url ? '_blank' : undefined}
rel={sponsor.website_url ? 'noopener noreferrer' : undefined}
display="flex"
alignItems="center"
justifyContent="center"
p={3}
bg="whiteAlpha.100"
borderRadius="md"
_hover={{ bg: 'whiteAlpha.200', transform: 'translateY(-2px)' }}
transition="all 0.2s"
onClick={() => trackNavigation('footer', `sponsor_${sponsor.name}`)}
>
<Image
src={assetUrl(sponsor.logo_url) || '/logo192.png'}
alt={sponsor.name}
maxH="60px"
maxW="full"
objectFit="contain"
filter="brightness(0) invert(1)"
opacity={0.9}
_hover={{ opacity: 1 }}
/>
</Link>
))}
</SimpleGrid>
</VStack>
</Container>
</Box>
)}
{/* Social Media Section */}
{(settings?.facebook_url || settings?.instagram_url || settings?.youtube_url) && (
<Box bg="gray.600" color="white" py={6} borderTop="1px" borderColor="whiteAlpha.200">
<Container maxW="container.xl">
<VStack spacing={4}>
<Text fontSize="lg" fontWeight="600" color="whiteAlpha.900">
Sledujte nás
</Text>
<HStack spacing={4}>
{settings?.facebook_url && (
<IconButton
as="a"
href={settings.facebook_url}
target="_blank"
rel="noopener noreferrer"
aria-label="Facebook"
icon={<FaFacebook />}
size="lg"
colorScheme="facebook"
variant="ghost"
color="white"
_hover={{
bg: 'whiteAlpha.200',
transform: 'translateY(-2px)',
color: '#1877F2'
}}
transition="all 0.2s"
onClick={() => trackNavigation('footer', 'social_facebook')}
/>
)}
{settings?.instagram_url && (
<IconButton
as="a"
href={settings.instagram_url}
target="_blank"
rel="noopener noreferrer"
aria-label="Instagram"
icon={<FaInstagram />}
size="lg"
variant="ghost"
color="white"
_hover={{
bg: 'whiteAlpha.200',
transform: 'translateY(-2px)',
color: '#E4405F'
}}
transition="all 0.2s"
onClick={() => trackNavigation('footer', 'social_instagram')}
/>
)}
{settings?.youtube_url && (
<IconButton
as="a"
href={settings.youtube_url}
target="_blank"
rel="noopener noreferrer"
aria-label="YouTube"
icon={<FaYoutube />}
size="lg"
variant="ghost"
color="white"
_hover={{
bg: 'whiteAlpha.200',
transform: 'translateY(-2px)',
color: '#FF0000'
}}
transition="all 0.2s"
onClick={() => trackNavigation('footer', 'social_youtube')}
/>
)}
</HStack>
</VStack>
</Container>
</Box>
)}
{/* Copyright Bar */}
<Box bg="gray.900" color="whiteAlpha.900" py={4}>
<Container maxW="container.xl">
<Text fontSize="sm" textAlign="center">
© {currentYear} {clubName}. Všechna práva vyhrazena.
</Text>
</Container>
</Box>
{/* MyClub Watermark - Clean White Branding */}
<Box bg="white" borderTop="1px" borderColor="gray.200" py={6}>
<Container maxW="container.xl">
<Stack
direction={{ base: 'column', md: 'row' }}
spacing={6}
justify="space-between"
align="center"
>
{/* Left: MyClub Logo & Text */}
<HStack spacing={4} align="center">
<Image
src="https://myclub.sportcreative.eu/logo.svg"
alt="MyClub"
h={{ base: '32px', md: '40px' }}
w="auto"
fallbackSrc="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 200 50'%3E%3Ctext x='10' y='35' font-family='Arial' font-size='24' font-weight='bold' fill='%23000'%3EMyClub%3C/text%3E%3C/svg%3E"
/>
<VStack align="start" spacing={0}>
<Text fontSize={{ base: 'sm', md: 'md' }} fontWeight="600" color="gray.800">
Stránku provozuje MyClub
</Text>
<Text fontSize={{ base: 'xs', md: 'sm' }} color="gray.600">
Profesionální webové stránky pro sportovní kluby
</Text>
</VStack>
</HStack>
{/* Right: CTA Buttons */}
<HStack spacing={3}>
<Button
as="a"
href="https://myclub.sportcreative.eu/kontakt"
target="_blank"
rel="noopener noreferrer"
size={{ base: 'sm', md: 'md' }}
colorScheme="blue"
variant="solid"
leftIcon={<FiMail />}
_hover={{ transform: 'translateY(-2px)', boxShadow: 'lg' }}
transition="all 0.2s"
>
Objednat
</Button>
<Button
as="a"
href="https://myclub.sportcreative.eu"
target="_blank"
rel="noopener noreferrer"
size={{ base: 'sm', md: 'md' }}
variant="outline"
colorScheme="gray"
rightIcon={<FiArrowUpRight />}
_hover={{ bg: 'gray.50' }}
>
Více info
</Button>
</HStack>
</Stack>
</Container>
</Box>
</>
);
};
export default Footer;
@@ -0,0 +1,59 @@
import { Box, Container, IconButton } from '@chakra-ui/react';
import { ReactNode, useEffect, useState } from 'react';
import { FiChevronUp } from 'react-icons/fi';
import Navbar from '../Navbar';
import Footer from './Footer';
interface MainLayoutProps {
children: ReactNode;
}
export const MainLayout: React.FC<MainLayoutProps> = ({ children }) => {
const [showTop, setShowTop] = useState(false);
useEffect(() => {
const onScroll = () => {
try {
setShowTop(window.scrollY > 400);
} catch {}
};
window.addEventListener('scroll', onScroll, { passive: true } as any);
onScroll();
return () => window.removeEventListener('scroll', onScroll as any);
}, []);
const scrollToTop = () => {
try {
window.scrollTo({ top: 0, behavior: 'smooth' });
} catch {
window.scrollTo(0, 0);
}
};
return (
<Box minH="100vh" bg="bg.app" overflowX="hidden">
<Box id="top" position="absolute" top={0} left={0} />
<Navbar />
<Container maxW="container.xl" py={8}>
{children}
</Container>
<Footer />
{showTop && (
<IconButton
aria-label="Zpět nahoru"
icon={<FiChevronUp />}
position="fixed"
right={{ base: 4, md: 6 }}
bottom={{ base: 4, md: 6 }}
zIndex={1000}
colorScheme="blue"
onClick={scrollToTop}
borderRadius="full"
shadow="md"
/>
)}
</Box>
);
};
export default MainLayout;
@@ -0,0 +1,39 @@
import { Box, Container, Heading, Text, VStack } from '@chakra-ui/react';
import NewsletterSubscribe from './NewsletterSubscribe';
type NewsletterSectionProps = {
title?: string;
description?: string;
bgColor?: string;
py?: number | string;
};
export default function NewsletterSection({
title = 'Přihlaste se k odběru novinek',
description = 'Nenechte si ujít žádné novinky, zápasy a akce našeho klubu. Odebírejte náš newsletter a buďte v obraze.',
bgColor = 'gray.50',
py = 16,
}: NewsletterSectionProps) {
return (
<Box as="section" bg={bgColor} py={py}>
<Container maxW="container.lg">
<VStack spacing={6} align="center" textAlign="center" maxW="3xl" mx="auto">
<Heading as="h2" size="xl">
{title}
</Heading>
{description && (
<Text fontSize="lg" color="gray.600" maxW="2xl">
{description}
</Text>
)}
<Box w="100%" maxW="md" mt={4}>
<NewsletterSubscribe />
</Box>
<Text fontSize="sm" color="gray.500" mt={2}>
Vaši e-mailovou adresu budeme používat pouze pro zasílání novinek. Můžete se kdykoli odhlásit.
</Text>
</VStack>
</Container>
</Box>
);
}
@@ -0,0 +1,127 @@
import { useState } from 'react';
import {
Box,
Button,
Flex,
FormControl,
FormErrorMessage,
Input,
Text,
useToast,
VStack,
useColorModeValue
} from '@chakra-ui/react';
import { useForm } from 'react-hook-form';
import { subscribeToNewsletter } from '../../services/public';
import { trackNewsletterSubscribe, trackFormSubmit } from '../../utils/umami';
type FormData = {
email: string;
};
export default function NewsletterSubscribe() {
const [isLoading, setIsLoading] = useState(false);
const toast = useToast();
const {
register,
handleSubmit,
reset,
formState: { errors },
} = useForm<FormData>();
const onSubmit = async (data: FormData) => {
setIsLoading(true);
try {
await subscribeToNewsletter(data.email);
// Track successful newsletter subscription
trackNewsletterSubscribe(window.location.pathname);
trackFormSubmit('Newsletter Subscribe', true);
toast({
title: 'Přihlášení k odběru proběhlo úspěšně',
description: 'Děkujeme za přihlášení k odběru našeho newsletteru!',
status: 'success',
duration: 5000,
isClosable: true,
});
reset();
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Nastala chyba při přihlašování k odběru';
// Track failed subscription
trackFormSubmit('Newsletter Subscribe', false);
toast({
title: 'Chyba',
description: errorMessage,
status: 'error',
duration: 5000,
isClosable: true,
});
} finally {
setIsLoading(false);
}
};
const cardBg = useColorModeValue('white', 'transparent');
const cardBorder = useColorModeValue('gray.200', 'gray.600');
const headingColor = useColorModeValue('gray.800', 'white');
const textColor = useColorModeValue('gray.600', 'gray.300');
const disclaimerColor = useColorModeValue('gray.500', 'gray.400');
return (
<Box w="100%" maxW="xl" mx="auto" p={4} bg={cardBg} borderRadius="md" boxShadow="sm" borderWidth="1px" borderColor={cardBorder}>
<VStack spacing={3} align="stretch">
<Text fontSize="xl" fontWeight="bold" textAlign="center" color={headingColor}>
Přihlaste se k odběru novinek
</Text>
<Text textAlign="center" color={textColor} mb={2}>
Budeme vás informovat o novinkách, zápasech a akcích našeho klubu.
</Text>
<form onSubmit={handleSubmit(onSubmit)}>
<VStack spacing={3}>
<FormControl isInvalid={!!errors.email}>
<Input
id="email"
type="email"
placeholder="Váš e-mail"
{...register('email', {
required: 'E-mail je povinný',
pattern: {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
message: 'Neplatná e-mailová adresa',
},
})}
size="md"
disabled={isLoading}
/>
<FormErrorMessage>
{errors.email && errors.email.message}
</FormErrorMessage>
</FormControl>
<Button
type="submit"
colorScheme="blue"
size="md"
width="100%"
isLoading={isLoading}
loadingText="Odesílám..."
data-umami-event="Newsletter Submit"
data-umami-event-location={window.location.pathname}
>
Odeslat
</Button>
</VStack>
</form>
<Text fontSize="xs" color={disclaimerColor} textAlign="center" mt={2}>
Odesláním formuláře souhlasíte se zpracováním osobních údajů.
Vaši e-mailovou adresu budeme používat pouze pro zasílání novinek.
</Text>
</VStack>
</Box>
);
}
@@ -0,0 +1,118 @@
import React from 'react';
import {
Box,
VStack,
Heading,
Spinner,
Text,
useColorModeValue,
} from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { getPolls, getPoll } from '../../services/polls';
import PollCard from './PollCard';
interface EmbeddedPollProps {
articleId?: number;
eventId?: number;
videoUrl?: string;
title?: string;
showTitle?: boolean;
}
/**
* EmbeddedPoll component - displays polls related to specific content
* Use in article pages, event pages, or video pages
*/
const EmbeddedPoll: React.FC<EmbeddedPollProps> = ({
articleId,
eventId,
videoUrl,
title = 'Hlasování',
showTitle = true,
}) => {
const bgSection = useColorModeValue('gray.50', 'gray.900');
// Build query params based on what's provided
const queryParams: any = {};
if (articleId) queryParams.article_id = articleId;
if (eventId) queryParams.event_id = eventId;
if (videoUrl) queryParams.video_url = videoUrl;
// Fetch polls related to this content
const { data: polls, isLoading } = useQuery({
queryKey: ['embedded-polls', queryParams],
queryFn: () => getPolls(queryParams),
enabled: !!(articleId || eventId || videoUrl), // Only fetch if at least one param is provided
staleTime: 2 * 60 * 1000,
});
// Get full poll data for each
const pollsToDisplay = polls?.slice(0, 3) || []; // Max 3 polls per content
const { data: pollsData, isLoading: isLoadingPolls } = useQuery({
queryKey: ['embedded-polls-details', pollsToDisplay.map((p) => p.id)],
queryFn: async () => {
const promises = pollsToDisplay.map((poll) => getPoll(poll.id));
return await Promise.all(promises);
},
enabled: pollsToDisplay.length > 0,
});
// Don't render anything if no content identifier provided
if (!articleId && !eventId && !videoUrl) {
return null;
}
// Don't render if loading initially
if (isLoading) {
return (
<Box py={4}>
<VStack spacing={2}>
<Spinner size="sm" />
<Text fontSize="sm" color="gray.500">
Načítání hlasování...
</Text>
</VStack>
</Box>
);
}
// Don't render if no polls found
if (!polls || polls.length === 0 || !pollsData || pollsData.length === 0) {
return null;
}
return (
<Box bg={bgSection} py={8} px={4} borderRadius="xl" my={8}>
<VStack spacing={6} maxW="3xl" mx="auto">
{showTitle && (
<Heading size="md" textAlign="center">
{title}
</Heading>
)}
<VStack spacing={4} w="full">
{isLoadingPolls ? (
<VStack py={8}>
<Spinner />
<Text>Načítání...</Text>
</VStack>
) : (
pollsData.map((pollResponse) => (
<Box key={pollResponse.poll.id} w="full">
<PollCard
poll={pollResponse.poll}
hasVoted={pollResponse.has_voted}
isActive={pollResponse.is_active}
canShowResults={pollResponse.can_show_results}
/>
</Box>
))
)}
</VStack>
</VStack>
</Box>
);
};
export default EmbeddedPoll;
+370
View File
@@ -0,0 +1,370 @@
import React, { useState } from 'react';
import {
Box,
VStack,
HStack,
Text,
Button,
Radio,
RadioGroup,
Checkbox,
CheckboxGroup,
Progress,
Badge,
useToast,
Image,
Heading,
useColorModeValue,
} from '@chakra-ui/react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { CheckIcon } from '@chakra-ui/icons';
import {
Poll,
PollOption,
votePoll,
getPollResults,
generateSessionToken,
} from '../../services/polls';
interface PollCardProps {
poll: Poll;
hasVoted: boolean;
isActive: boolean;
canShowResults: boolean;
onVoteSuccess?: () => void;
}
const PollCard: React.FC<PollCardProps> = ({
poll,
hasVoted: initialHasVoted,
isActive,
canShowResults: initialCanShowResults,
onVoteSuccess,
}) => {
const toast = useToast();
const queryClient = useQueryClient();
const [selectedOptions, setSelectedOptions] = useState<number[]>([]);
const [hasVoted, setHasVoted] = useState(initialHasVoted);
const [canShowResults, setCanShowResults] = useState(initialCanShowResults);
const [results, setResults] = useState<any[]>([]);
const [showingResults, setShowingResults] = useState(initialCanShowResults);
const bgCard = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.600');
const hoverBg = useColorModeValue('gray.50', 'gray.700');
// Vote mutation
const voteMutation = useMutation({
mutationFn: () => {
const sessionToken = generateSessionToken();
return votePoll(poll.id, {
option_ids: selectedOptions,
session_token: sessionToken,
});
},
onSuccess: async () => {
setHasVoted(true);
setCanShowResults(true);
setShowingResults(true);
// Fetch results
try {
const resultsData = await getPollResults(poll.id);
setResults(resultsData.results);
} catch (error) {
console.error('Failed to fetch results:', error);
}
queryClient.invalidateQueries({ queryKey: ['polls'] });
queryClient.invalidateQueries({ queryKey: ['poll', poll.id] });
toast({
title: 'Hlas zaznamenán!',
description: 'Děkujeme za vaši účast v anketě.',
status: 'success',
duration: 3000,
});
if (onVoteSuccess) {
onVoteSuccess();
}
},
onError: (error: any) => {
toast({
title: 'Chyba',
description: error.response?.data?.error || 'Nepodařilo se zaznamenat váš hlas',
status: 'error',
duration: 5000,
});
},
});
const handleVote = () => {
if (selectedOptions.length === 0) {
toast({
title: 'Vyberte možnost',
description: 'Před hlasováním vyberte alespoň jednu možnost.',
status: 'warning',
duration: 3000,
});
return;
}
if (poll.allow_multiple && selectedOptions.length > poll.max_choices) {
toast({
title: 'Příliš mnoho voleb',
description: `Můžete vybrat maximálně ${poll.max_choices} možností.`,
status: 'warning',
duration: 3000,
});
return;
}
voteMutation.mutate();
};
const handleSingleChoice = (value: string) => {
setSelectedOptions([parseInt(value)]);
};
const handleMultipleChoice = (values: (string | number)[]) => {
const numValues = values.map((v) => (typeof v === 'string' ? parseInt(v) : v));
if (numValues.length <= poll.max_choices) {
setSelectedOptions(numValues);
}
};
const loadResults = async () => {
try {
const resultsData = await getPollResults(poll.id);
setResults(resultsData.results);
setShowingResults(true);
} catch (error: any) {
toast({
title: 'Chyba',
description: 'Nepodařilo se načíst výsledky',
status: 'error',
duration: 3000,
});
}
};
const calculatePercentage = (voteCount: number) => {
if (poll.total_votes === 0) return 0;
return (voteCount / poll.total_votes) * 100;
};
// Show results if available
if (showingResults && canShowResults) {
const displayResults = results.length > 0 ? results : poll.options.map(opt => ({
option_id: opt.id,
text: opt.text,
vote_count: opt.vote_count,
percentage: calculatePercentage(opt.vote_count),
image_url: opt.image_url,
}));
return (
<Box
bg={bgCard}
borderWidth="1px"
borderColor={borderColor}
borderRadius="xl"
p={6}
boxShadow="md"
>
<VStack spacing={4} align="stretch">
{poll.image_url && (
<Image
src={poll.image_url}
alt={poll.title}
borderRadius="lg"
maxH="200px"
objectFit="cover"
/>
)}
<HStack justify="space-between" align="start">
<Heading size="md">{poll.title}</Heading>
{hasVoted && (
<Badge colorScheme="green" fontSize="sm">
<HStack spacing={1}>
<CheckIcon boxSize={3} />
<Text>Hlasováno</Text>
</HStack>
</Badge>
)}
</HStack>
{poll.description && (
<Text fontSize="sm" color="gray.600">
{poll.description}
</Text>
)}
<VStack spacing={3} align="stretch">
<Text fontWeight="bold" fontSize="sm" color="gray.500">
Výsledky ({poll.total_votes} hlasů)
</Text>
{displayResults.map((result) => (
<Box key={result.option_id}>
<HStack justify="space-between" mb={1}>
<Text fontWeight="medium">{result.text}</Text>
<Text fontSize="sm" color="gray.500">
{result.vote_count} ({result.percentage.toFixed(1)}%)
</Text>
</HStack>
<Progress
value={result.percentage}
colorScheme="blue"
borderRadius="full"
size="sm"
/>
</Box>
))}
</VStack>
</VStack>
</Box>
);
}
// Show voting form
return (
<Box
bg={bgCard}
borderWidth="1px"
borderColor={borderColor}
borderRadius="xl"
p={6}
boxShadow="md"
>
<VStack spacing={4} align="stretch">
{poll.image_url && (
<Image
src={poll.image_url}
alt={poll.title}
borderRadius="lg"
maxH="200px"
objectFit="cover"
/>
)}
<Heading size="md">{poll.title}</Heading>
{poll.description && (
<Text fontSize="sm" color="gray.600">
{poll.description}
</Text>
)}
{!isActive && (
<Badge colorScheme="orange">Anketa je momentálně uzavřena</Badge>
)}
{isActive && (
<>
{poll.allow_multiple ? (
<CheckboxGroup
value={selectedOptions.map(String)}
onChange={handleMultipleChoice}
>
<VStack spacing={2} align="stretch">
<Text fontSize="sm" color="gray.500">
Vyberte {poll.max_choices} možností
</Text>
{poll.options.map((option) => (
<Box
key={option.id}
p={3}
borderWidth="1px"
borderRadius="md"
_hover={{ bg: hoverBg }}
cursor="pointer"
>
<Checkbox value={String(option.id)}>
<VStack align="start" spacing={1}>
<Text>{option.text}</Text>
{option.description && (
<Text fontSize="xs" color="gray.500">
{option.description}
</Text>
)}
</VStack>
</Checkbox>
</Box>
))}
</VStack>
</CheckboxGroup>
) : (
<RadioGroup
value={selectedOptions[0]?.toString() || ''}
onChange={handleSingleChoice}
>
<VStack spacing={2} align="stretch">
{poll.options.map((option) => (
<Box
key={option.id}
p={3}
borderWidth="1px"
borderRadius="md"
_hover={{ bg: hoverBg }}
cursor="pointer"
>
<Radio value={String(option.id)}>
<VStack align="start" spacing={1}>
<Text>{option.text}</Text>
{option.description && (
<Text fontSize="xs" color="gray.500">
{option.description}
</Text>
)}
{option.player && (
<HStack spacing={2}>
{option.player.image_url && (
<Image
src={option.player.image_url}
alt={`${option.player.first_name} ${option.player.last_name}`}
boxSize="24px"
borderRadius="full"
/>
)}
<Text fontSize="xs" color="gray.500">
#{option.player.jersey_number} {option.player.first_name}{' '}
{option.player.last_name}
</Text>
</HStack>
)}
</VStack>
</Radio>
</Box>
))}
</VStack>
</RadioGroup>
)}
<Button
colorScheme="blue"
onClick={handleVote}
isLoading={voteMutation.isPending}
isDisabled={!isActive || selectedOptions.length === 0}
>
Hlasovat
</Button>
</>
)}
{canShowResults && !showingResults && (
<Button variant="outline" onClick={loadResults} size="sm">
Zobrazit výsledky
</Button>
)}
<Text fontSize="xs" color="gray.500" textAlign="center">
Celkem hlasů: {poll.total_votes}
</Text>
</VStack>
</Box>
);
};
export default PollCard;
@@ -0,0 +1,16 @@
import React from 'react';
import { Box } from '@chakra-ui/react';
import { ScoreboardState } from '@/services/scoreboard';
import ScoreboardPreview from './ScoreboardPreview';
// Full display component intended for public overlay usage.
// For now it reuses ScoreboardPreview visuals; can diverge later for larger sizing.
const ScoreboardDisplay: React.FC<{ state: ScoreboardState }> = ({ state }) => {
return (
<Box>
<ScoreboardPreview state={state} />
</Box>
);
};
export default ScoreboardDisplay;
@@ -0,0 +1,173 @@
import React from 'react';
import { Box, HStack, Text, Image } from '@chakra-ui/react';
import { ScoreboardState } from '@/services/scoreboard';
export const ScoreboardPreview: React.FC<{ state: ScoreboardState }> = ({ state }) => {
const theme = state.theme || 'pill';
switch (theme) {
case 'pill':
return (
<HStack spacing={2} px={1.5} py={1} borderRadius="full" bg="white" borderWidth="1px" borderColor="gray.200" boxShadow="sm" width="max-content">
<SegmentTeam colorA={state.primaryColor} left>
{state.homeLogo ? <Image src={state.homeLogo} alt="home" boxSize="16px" objectFit="contain" /> : null}
<Text textTransform="uppercase" fontSize="sm" lineHeight={1}>{state.homeShort || deriveShortLocal(state.homeName)}</Text>
</SegmentTeam>
<SegmentScore>{state.homeScore} {state.awayScore}</SegmentScore>
<SegmentTeam colorA={state.secondaryColor} right>
<Text textTransform="uppercase" fontSize="sm" lineHeight={1}>{state.awayShort || deriveShortLocal(state.awayName)}</Text>
{state.awayLogo ? <Image src={state.awayLogo} alt="away" boxSize="16px" objectFit="contain" /> : null}
</SegmentTeam>
</HStack>
);
case 'classic':
case 'var1':
return (
<HStack spacing={3} bgGradient="linear(to-b, #c8d4dc, #a8b8c4)" px={5} py={3} borderRadius="lg" boxShadow="md" width="max-content">
<Box bg="white" color="black" fontWeight="bold" px={3} py={1} borderRadius="md" fontSize="lg">{formatTimer(state.halfLength)}</Box>
<Box bg={state.primaryColor || '#34495e'} color="white" px={4} py={2} borderRadius="md" fontWeight="bold">{state.homeShort || deriveShortLocal(state.homeName)}</Box>
<Text fontWeight="bold" color="black">{state.homeScore}-{state.awayScore}</Text>
<Box bg={state.secondaryColor || '#2c3e50'} color="white" px={4} py={2} borderRadius="md" fontWeight="bold">{state.awayShort || deriveShortLocal(state.awayName)}</Box>
</HStack>
);
case 'var2':
return (
<HStack spacing={0} borderRadius="md" overflow="hidden" boxShadow="md" width="max-content">
<Box bgGradient="linear(135deg, #4a5568, #2d3748)" color="white" px={3} py={2} fontWeight="bold">{formatTimer(state.halfLength)}</Box>
<Box bgGradient="linear(135deg, #2c5282, #2a4365)" color="white" px={4} py={2} fontWeight="bold">{state.homeShort || deriveShortLocal(state.homeName)}</Box>
<Box bgGradient="linear(135deg, #2c5282, #2a4365)" color="white" px={3} py={2} fontWeight="bold">{state.homeScore}-{state.awayScore}</Box>
<Box bgGradient="linear(135deg, #2c5282, #2a4365)" color="white" px={4} py={2} fontWeight="bold">{state.awayShort || deriveShortLocal(state.awayName)}</Box>
</HStack>
);
case 'var3':
return (
<Box textAlign="center" fontFamily="Poppins, Arial, sans-serif">
<HStack spacing={0} justify="center">
<Box w="102px" h="38px" bg="#F6F6F6" lineHeight="41px" position="relative">
<Box position="absolute" left="-8px" top={0} w="6px" h="38px" bg={state.primaryColor || '#ea2212'} />
<Text>{state.homeShort || deriveShortLocal(state.homeName)}</Text>
</Box>
<Box w="102px" h="38px" bg="#F6F6F6" lineHeight="41px" zIndex={2} boxShadow="0 3px 10px rgba(0,0,0,0.7)">
<Text fontWeight="bold">{state.homeScore}-{state.awayScore}</Text>
</Box>
<Box w="102px" h="38px" bg="#F6F6F6" lineHeight="41px" position="relative">
<Box position="absolute" right="-8px" top={0} w="6px" h="38px" bg={state.secondaryColor || '#ea2212'} />
<Text>{state.awayShort || deriveShortLocal(state.awayName)}</Text>
</Box>
</HStack>
<Box mt={2} w="306px" mx="auto" bg="#F6F6F6">
<Text>{formatTimer(state.halfLength)}</Text>
</Box>
</Box>
);
case 'var4':
return (
<Box w="340px" borderWidth="1px" borderRadius="xl" boxShadow="xl" p={4} bg="white" color="gray.900">
<HStack>
<Text fontWeight="bold">{state.homeName}</Text>
<Text ml="auto" fontWeight="extrabold">{state.homeScore}</Text>
</HStack>
<Box textAlign="center" fontWeight="extrabold" py={1}>VS</Box>
<HStack>
<Text fontWeight="bold">{state.awayName}</Text>
<Text ml="auto" fontWeight="extrabold">{state.awayScore}</Text>
</HStack>
<HStack justify="flex-end" fontSize="sm" opacity={0.8} pt={2}>
<Text>{formatTimer(state.halfLength)}</Text>
</HStack>
</Box>
);
default:
return (
<HStack spacing={3} bg="gray.900" color="white" px={4} py={3} borderRadius="lg" boxShadow="lg" width="max-content">
<Text fontWeight="bold">{state.homeName}</Text>
<Text fontWeight="black">{state.homeScore} : {state.awayScore}</Text>
<Text fontWeight="bold">{state.awayName}</Text>
</HStack>
);
}
};
// Small presentational helpers for the pill theme
const SegmentTeam: React.FC<{ colorA?: string; left?: boolean; right?: boolean; children: React.ReactNode }> = ({ colorA = '#1e3a8a', left, right, children }) => {
return (
<HStack
px={2}
py={0.5}
borderRadius="full"
bgGradient={`linear(to-r, ${colorA}, ${shadeColor(colorA, 20)})`}
color="white"
spacing={1.5}
position="relative"
_before={left ? { content: '""', position: 'absolute', left: '-10px', top: 0, bottom: 0, width: '14px', bgGradient: `linear(to-r, ${colorA}, ${shadeColor(colorA, 20)})`, borderTopLeftRadius: '999px', borderBottomLeftRadius: '999px' } : undefined}
_after={right ? { content: '""', position: 'absolute', right: '-10px', top: 0, bottom: 0, width: '14px', bgGradient: `linear(to-r, ${colorA}, ${shadeColor(colorA, 20)})`, borderTopRightRadius: '999px', borderBottomRightRadius: '999px' } : undefined}
minW="46px"
>
{children}
</HStack>
);
};
const SegmentScore: React.FC<{ children: React.ReactNode }> = ({ children }) => {
return (
<Box px={2} py={0.5} borderRadius="md" bg="gray.50" borderWidth="1px" borderColor="gray.200" fontWeight="800" minW="58px" textAlign="center" fontSize="sm">
{children}
</Box>
);
};
function formatTimer(halfLength: number): string {
// Simple static mm:ss display using half length as baseline; real timer would come from backend
const min = Math.max(0, Math.min(halfLength, 99));
return `${String(min).padStart(2, '0')}:00`;
}
function deriveShortLocal(name?: string) {
if (!name) return '---';
const s = String(name).trim().toUpperCase();
if (!s) return '---';
const map: Record<string, string> = {
'Á':'A','Ä':'A','Å':'A','Â':'A','À':'A',
'Č':'C','Ć':'C','Ç':'C',
'Ď':'D',
'É':'E','Ě':'E','È':'E','Ë':'E','Ê':'E',
'Í':'I','Ì':'I','Ï':'I','Î':'I',
'Ň':'N','Ń':'N',
'Ó':'O','Ö':'O','Ô':'O','Ò':'O',
'Ř':'R',
'Š':'S','Ś':'S',
'Ť':'T',
'Ú':'U','Ů':'U','Ù':'U','Ü':'U','Û':'U',
'Ý':'Y',
'Ž':'Z',
};
let out = '';
for (const ch of s) {
const c = map[ch] || ch;
if (c >= 'A' && c <= 'Z') {
out += c;
if (out.length === 3) break;
}
}
while (out.length < 3) out += '-';
return out;
}
function shadeColor(hex: string, percent: number) {
// Simple hex shade function
try {
const n = hex.replace('#','');
const num = parseInt(n.length === 3 ? n.split('').map((c)=>c+c).join('') : n, 16);
let r = (num >> 16) & 0xff;
let g = (num >> 8) & 0xff;
let b = num & 0xff;
r = Math.min(255, Math.max(0, Math.round(r + (percent/100)*255)));
g = Math.min(255, Math.max(0, Math.round(g + (percent/100)*255)));
b = Math.min(255, Math.max(0, Math.round(b + (percent/100)*255)));
return `#${[r,g,b].map(v=>v.toString(16).padStart(2,'0')).join('')}`;
} catch {
return hex;
}
}
export default ScoreboardPreview;
+128
View File
@@ -0,0 +1,128 @@
import { Helmet } from 'react-helmet-async';
import { useEffect, useState } from 'react';
import api from '../../services/api';
import { assetUrl } from '../../utils/url';
import { usePublicSettings } from '../../hooks/usePublicSettings';
interface SEOData {
site_title?: string;
site_description?: string;
meta_keywords?: string;
default_og_image_url?: string;
twitter_handle?: string;
canonical_base_url?: string;
additional_meta?: string; // raw HTML strings not injected for safety here
enable_indexing?: boolean;
}
export default function DefaultSEO() {
const [seo, setSeo] = useState<SEOData | null>(null);
const [social, setSocial] = useState<{ facebook?: string; instagram?: string; youtube?: string } | null>(null);
const { data: publicSettings } = usePublicSettings();
useEffect(() => {
let mounted = true;
api.get<SEOData>('/seo')
.then(res => { if (mounted) setSeo(res.data as any); })
.catch(() => {});
// Socials can come from settings (hook). Set when available
// We still keep axios SEO fetch above for dedicated SEO fields
return () => { mounted = false; };
}, []);
// Keep socials in sync when settings load
useEffect(() => {
if (!publicSettings) return;
setSocial({
facebook: publicSettings.facebook_url,
instagram: publicSettings.instagram_url,
youtube: publicSettings.youtube_url,
});
}, [publicSettings]);
const fallbackClubName = publicSettings?.club_name;
const title = (seo?.site_title && seo.site_title.trim()) || (fallbackClubName && fallbackClubName.trim()) || 'MyClub';
const desc = (seo?.site_description && seo.site_description.trim()) || (fallbackClubName ? `Official ${fallbackClubName} Website` : 'Official MyClub Website');
const keywords = seo?.meta_keywords || '';
const rawOg = seo?.default_og_image_url || publicSettings?.club_logo_url || '/logo512.png';
const ogImg = assetUrl(rawOg) || rawOg;
const twitter = seo?.twitter_handle || '';
const origin = seo?.canonical_base_url || (typeof window !== 'undefined' ? window.location.origin : '');
const sameAs: string[] = [];
if (twitter) {
const handle = twitter.startsWith('@') ? twitter.slice(1) : twitter;
sameAs.push(`https://twitter.com/${handle}`);
}
if (social?.facebook) sameAs.push(social.facebook);
if (social?.instagram) sameAs.push(social.instagram);
if (social?.youtube) sameAs.push(social.youtube);
// robots
const robots = seo?.enable_indexing === false ? 'noindex, nofollow' : 'index, follow';
// Ensure document title updates as soon as we have a computed title
useEffect(() => {
if (typeof document !== 'undefined' && title) {
document.title = title;
}
}, [title]);
return (
<Helmet defaultTitle={title} titleTemplate={`%s | ${fallbackClubName || title}`}>
<title>{title}</title>
<meta name="description" content={desc} />
{keywords && <meta name="keywords" content={keywords} />}
<meta name="robots" content={robots} />
{/* Favicon and app icons based on club logo */}
{publicSettings?.club_logo_url && (
<>
<link rel="icon" href={assetUrl(publicSettings.club_logo_url)} />
<link rel="shortcut icon" href={assetUrl(publicSettings.club_logo_url)} />
<link rel="apple-touch-icon" href={assetUrl(publicSettings.club_logo_url)} />
</>
)}
{/* Open Graph */}
<meta property="og:type" content="website" />
<meta property="og:title" content={title} />
<meta property="og:description" content={desc} />
<meta property="og:image" content={ogImg} />
{/* Twitter */}
<meta name="twitter:card" content="summary_large_image" />
{twitter && <meta name="twitter:site" content={twitter} />}
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={desc} />
<meta name="twitter:image" content={ogImg} />
{/* Canonical (best-effort: base only; pages can override) */}
{seo?.canonical_base_url && <link rel="canonical" href={seo.canonical_base_url} />}
{/* JSON-LD: WebSite + SearchAction */}
{origin && (
<script type="application/ld+json">
{JSON.stringify({
'@context': 'https://schema.org',
'@type': 'WebSite',
url: origin,
name: title,
potentialAction: {
'@type': 'SearchAction',
target: `${origin}/blog?q={search_term_string}`,
'query-input': 'required name=search_term_string'
}
})}
</script>
)}
{/* JSON-LD: Organization */}
{origin && (
<script type="application/ld+json">
{JSON.stringify({
'@context': 'https://schema.org',
'@type': 'Organization',
url: origin,
name: title,
logo: ogImg,
sameAs: sameAs.length ? sameAs : undefined,
})}
</script>
)}
</Helmet>
);
}
@@ -0,0 +1,269 @@
import React, { useEffect, useRef, useState } from 'react';
import { Box, HStack, IconButton, Heading, useColorModeValue } from '@chakra-ui/react';
import { ChevronLeftIcon, ChevronRightIcon } from '@chakra-ui/icons';
import { useClubTheme } from '../../contexts/ClubThemeContext';
interface HorizontalScrollerProps {
title?: string;
rightAction?: React.ReactNode;
children: React.ReactNode;
// Enhancements (all optional; defaults keep legacy behavior)
draggable?: boolean; // enable mouse/touch drag scrolling
autoScroll?: boolean; // enable continuous auto-scroll
autoSpeed?: number; // pixels per frame (~60fps). default 1.2
rewindLoop?: boolean; // if true, jump to start when reaching end (instead of stopping)
pauseOnHover?: boolean; // pause auto-scroll on hover
infiniteScroll?: boolean; // duplicate children for seamless infinite loop
}
const SCROLL_AMOUNT = 0.7; // 70% of viewport width per click
const HorizontalScroller: React.FC<HorizontalScrollerProps> = ({ title, rightAction, children, draggable = false, autoScroll = false, autoSpeed = 1.2, rewindLoop = true, pauseOnHover = true, infiniteScroll = false }) => {
const containerRef = useRef<HTMLDivElement>(null);
const theme = useClubTheme();
const cardBg = useColorModeValue('white', 'gray.800');
const [isHovering, setIsHovering] = useState(false);
const isPointerDownRef = useRef(false);
const startXRef = useRef(0);
const startScrollLeftRef = useRef(0);
const hasDraggedRef = useRef(false);
const rafIdRef = useRef<number | null>(null);
const scrollBy = (dir: 1 | -1) => {
const el = containerRef.current;
if (!el) return;
// Use the container width rather than window width for more accurate scrolling
const amount = Math.floor(el.clientWidth * SCROLL_AMOUNT) * dir;
el.scrollBy({ left: amount, behavior: 'smooth' });
};
// Mouse/touch drag handlers
const onPointerDown = (clientX: number) => {
const el = containerRef.current;
if (!el) return;
isPointerDownRef.current = true;
hasDraggedRef.current = false;
startXRef.current = clientX;
startScrollLeftRef.current = el.scrollLeft;
// while dragging, disable smooth scroll behavior and selection
el.style.scrollSnapType = 'none';
el.style.cursor = 'grabbing';
(document.body as any).style.userSelect = 'none';
};
const onPointerMove = (clientX: number) => {
const el = containerRef.current;
if (!el || !isPointerDownRef.current) return;
const dx = clientX - startXRef.current;
if (Math.abs(dx) > 3) hasDraggedRef.current = true;
el.scrollLeft = startScrollLeftRef.current - dx;
};
const onPointerUp = () => {
const el = containerRef.current;
isPointerDownRef.current = false;
if (el) {
el.style.cursor = 'grab';
}
(document.body as any).style.userSelect = '';
if (el) {
// restore snap a moment later for a natural feel
setTimeout(() => { if (el) el.style.scrollSnapType = 'x mandatory'; }, 100);
}
};
// Auto-scroll implementation with infinite scroll support
useEffect(() => {
const el = containerRef.current;
if (!autoScroll || !el) return;
// For infinite scroll, set initial position to middle of duplicated content
if (infiniteScroll) {
const halfWidth = el.scrollWidth / 2;
el.scrollLeft = halfWidth;
}
let running = true;
const step = () => {
if (!running || !el) return;
const scrollLeft = el.scrollLeft;
const scrollWidth = el.scrollWidth;
const clientWidth = el.clientWidth;
const shouldPause = (pauseOnHover && isHovering) || isPointerDownRef.current;
if (!shouldPause) {
if (infiniteScroll) {
// Seamless infinite loop by resetting when reaching halfway
const halfWidth = scrollWidth / 2;
if (scrollLeft >= halfWidth) {
el.scrollLeft = 0;
} else {
el.scrollLeft += autoSpeed;
}
} else {
// Original rewind behavior
const atEnd = scrollLeft + clientWidth >= scrollWidth - 2;
if (atEnd && rewindLoop) {
el.scrollLeft = 0;
} else if (!atEnd) {
el.scrollLeft += autoSpeed;
}
}
}
rafIdRef.current = requestAnimationFrame(step);
};
// Start the animation loop
rafIdRef.current = requestAnimationFrame(step);
return () => {
running = false;
if (rafIdRef.current !== null) {
cancelAnimationFrame(rafIdRef.current);
rafIdRef.current = null;
}
};
}, [autoScroll, autoSpeed, pauseOnHover, isHovering, rewindLoop, infiniteScroll]);
return (
<Box position="relative">
{(title || rightAction) && (
<HStack justify="space-between" mb={3}>
{title && (
<Heading size="lg" letterSpacing="0.04em" style={{ textTransform: 'uppercase' }}>
{title}
</Heading>
)}
{rightAction}
</HStack>
)}
{/* gradient masks for fancy edges */}
<Box
pointerEvents="none"
position="absolute"
left={0}
top={0}
bottom={0}
w={{ base: 16, md: 24 }}
bgGradient={useColorModeValue(
'linear(to-r, white, rgba(255,255,255,0.9), rgba(255,255,255,0.4), transparent)',
'linear(to-r, gray.900, rgba(17,25,40,0.9), rgba(17,25,40,0.4), transparent)'
)}
zIndex={1}
/>
<Box
pointerEvents="none"
position="absolute"
right={0}
top={0}
bottom={0}
w={{ base: 16, md: 24 }}
bgGradient={useColorModeValue(
'linear(to-l, white, rgba(255,255,255,0.9), rgba(255,255,255,0.4), transparent)',
'linear(to-l, gray.900, rgba(17,25,40,0.9), rgba(17,25,40,0.4), transparent)'
)}
zIndex={1}
/>
{/* scroll area */}
<HStack
ref={containerRef}
spacing={4}
overflowX="auto"
py={2}
px={1}
cursor={draggable ? 'grab' : 'default'}
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => { setIsHovering(false); if (draggable) onPointerUp(); }}
onMouseDown={(e) => { if (!draggable) return; e.preventDefault(); onPointerDown(e.clientX); }}
onMouseMove={(e) => { if (!draggable) return; onPointerMove(e.clientX); }}
onMouseUp={() => { if (!draggable) return; onPointerUp(); }}
onTouchStart={(e) => { if (!draggable) return; if (e.touches[0]) onPointerDown(e.touches[0].clientX); }}
onTouchMove={(e) => { if (!draggable) return; if (e.touches[0]) onPointerMove(e.touches[0].clientX); }}
onTouchEnd={() => { if (!draggable) return; onPointerUp(); }}
css={{
scrollSnapType: infiniteScroll ? 'none' : 'x proximity',
scrollBehavior: 'smooth',
WebkitOverflowScrolling: 'touch',
'&::-webkit-scrollbar': {
height: '6px',
},
'&::-webkit-scrollbar-track': {
background: 'transparent',
},
'&::-webkit-scrollbar-thumb': {
background: useColorModeValue('rgba(0,0,0,0.15)', 'rgba(255,255,255,0.15)'),
borderRadius: '3px',
},
'&::-webkit-scrollbar-thumb:hover': {
background: useColorModeValue('rgba(0,0,0,0.25)', 'rgba(255,255,255,0.25)'),
},
}}
>
{children}
{infiniteScroll && children}
</HStack>
{/* navigation buttons - must be above gradient masks */}
<IconButton
aria-label="scroll left"
icon={<ChevronLeftIcon boxSize={6} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
scrollBy(-1);
}}
onMouseDown={(e) => { e.preventDefault(); e.stopPropagation(); }}
onTouchStart={(e) => { e.preventDefault(); e.stopPropagation(); }}
position="absolute"
top="50%"
transform="translateY(-50%)"
left={{ base: 1, md: 2 }}
size="lg"
colorScheme="blackAlpha"
bg={useColorModeValue('rgba(255,255,255,0.95)', 'rgba(45,55,72,0.95)')}
color={useColorModeValue('gray.800', 'white')}
boxShadow="xl"
_hover={{ bg: useColorModeValue('white', 'gray.600'), transform: 'translateY(-50%) scale(1.15)', boxShadow: '2xl' }}
_active={{ transform: 'translateY(-50%) scale(0.95)' }}
transition="all 0.2s"
zIndex={30}
borderRadius="full"
pointerEvents="auto"
/>
<IconButton
aria-label="scroll right"
icon={<ChevronRightIcon boxSize={6} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
scrollBy(1);
}}
onMouseDown={(e) => { e.preventDefault(); e.stopPropagation(); }}
onTouchStart={(e) => { e.preventDefault(); e.stopPropagation(); }}
position="absolute"
top="50%"
transform="translateY(-50%)"
right={{ base: 1, md: 2 }}
size="lg"
colorScheme="blackAlpha"
bg={useColorModeValue('rgba(255,255,255,0.95)', 'rgba(45,55,72,0.95)')}
color={useColorModeValue('gray.800', 'white')}
boxShadow="xl"
_hover={{ bg: useColorModeValue('white', 'gray.600'), transform: 'translateY(-50%) scale(1.15)', boxShadow: '2xl' }}
_active={{ transform: 'translateY(-50%) scale(0.95)' }}
transition="all 0.2s"
zIndex={30}
borderRadius="full"
pointerEvents="auto"
/>
{/* bottom accent line */}
<Box mt={2} h="2px" bg={theme.primary} borderRadius="full" />
</Box>
);
};
export default HorizontalScroller;
+41
View File
@@ -0,0 +1,41 @@
import React from 'react';
export interface CardProps extends React.HTMLAttributes<HTMLDivElement> {}
export interface CardHeaderProps extends React.HTMLAttributes<HTMLDivElement> {}
export interface CardTitleProps extends React.HTMLAttributes<HTMLHeadingElement> {}
export interface CardContentProps extends React.HTMLAttributes<HTMLDivElement> {}
export const Card: React.FC<CardProps> = ({ className = '', children, ...props }) => {
return (
<div
className={`rounded-xl border bg-white text-slate-950 shadow ${className}`}
{...props}
>
{children}
</div>
);
};
export const CardHeader: React.FC<CardHeaderProps> = ({ className = '', children, ...props }) => {
return (
<div className={`flex flex-col space-y-1.5 p-6 ${className}`} {...props}>
{children}
</div>
);
};
export const CardTitle: React.FC<CardTitleProps> = ({ className = '', children, ...props }) => {
return (
<h3 className={`text-2xl font-semibold leading-none tracking-tight ${className}`} {...props}>
{children}
</h3>
);
};
export const CardContent: React.FC<CardContentProps> = ({ className = '', children, ...props }) => {
return (
<div className={`p-6 pt-0 ${className}`} {...props}>
{children}
</div>
);
};
@@ -0,0 +1,143 @@
import { Box, Text, VStack, HStack, Image, Skeleton, Link as ChakraLink, Icon } from '@chakra-ui/react';
import { FaNewspaper, FaUser, FaCalendarAlt } from 'react-icons/fa';
import { Link as RouterLink } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { api } from '../../services/api';
import { Widget } from './Widget';
import { format, parseISO } from 'date-fns';
import { cs } from 'date-fns/locale';
import { Article } from '../../types';
export const ArticlesWidget = () => {
const { data: articles = [], isLoading, error } = useQuery<Article[]>({
queryKey: ['recentArticles'],
queryFn: async () => {
try {
const { data } = await api.get('/articles', {
params: {
limit: 3,
include: 'author',
sort: '-createdAt',
published: true
}
});
return data.data || [];
} catch (err) {
console.error('Error fetching articles:', err);
return [];
}
},
staleTime: 5 * 60 * 1000, // 5 minutes
});
if (isLoading) {
return (
<Widget title="Poslední články" icon={FaNewspaper}>
<VStack spacing={4} align="stretch">
{[1, 2, 3].map((i) => (
<Box key={i}>
<Skeleton height="120px" mb={2} borderRadius="md" />
<Skeleton height="20px" mb={2} width="80%" />
<Skeleton height="16px" width="60%" />
</Box>
))}
</VStack>
</Widget>
);
}
if (error || !articles.length) {
return (
<Widget title="Poslední články" icon={FaNewspaper}>
<VStack p={4} spacing={4}>
<Box p={4} bg="gray.50" borderRadius="md" textAlign="center" w="full">
<Icon as={FaNewspaper} boxSize={6} color="gray.400" mb={2} />
<Text color="gray.500">Žádné články nebyly nalezeny</Text>
</Box>
</VStack>
</Widget>
);
}
return (
<Widget title="Poslední články" icon={FaNewspaper}>
<VStack spacing={3} align="stretch" divider={<Box borderBottomWidth="1px" borderColor="gray.100" />}>
{articles.map((article) => (
<ChakraLink
key={article.id}
as={RouterLink}
to={`/clanky/${article.slug}`}
_hover={{ textDecoration: 'none' }}
display="block"
>
<Box _hover={{ bg: 'gray.50' }} borderRadius="md" p={2}>
<HStack align="flex-start" spacing={3}>
<Box
flexShrink={0}
width="60px"
height="60px"
bg="gray.100"
borderRadius="md"
overflow="hidden"
position="relative"
>
{article.imageUrl ? (
<Image
src={article.imageUrl}
alt={article.title}
width="100%"
height="100%"
objectFit="cover"
/>
) : (
<Box
width="100%"
height="100%"
display="flex"
alignItems="center"
justifyContent="center"
bg="gray.200"
>
<Icon as={FaNewspaper} color="gray.400" boxSize={5} />
</Box>
)}
</Box>
<Box flex={1} minW={0}>
<Text fontWeight="medium" fontSize="sm" noOfLines={2} mb={1}>
{article.title}
</Text>
<HStack spacing={3} fontSize="xs" color="gray.500">
<HStack spacing={1}>
<Icon as={FaUser} boxSize={3} />
<Text>{article.author.name}</Text>
</HStack>
<HStack spacing={1}>
<Icon as={FaCalendarAlt} boxSize={3} />
<Text>
{format(parseISO(article.createdAt), 'd. M. yyyy', {
locale: cs,
})}
</Text>
</HStack>
</HStack>
</Box>
</HStack>
</Box>
</ChakraLink>
))}
<Box textAlign="right" mt={2}>
<ChakraLink
as={RouterLink}
to="/admin/clanky"
color="blue.500"
fontWeight="medium"
fontSize="sm"
_hover={{ textDecoration: 'underline' }}
>
Zobrazit všechny články
</ChakraLink>
</Box>
</VStack>
</Widget>
);
};
@@ -0,0 +1,306 @@
import { Box, Text, VStack, HStack, Badge, Icon, Spinner, Alert, AlertIcon, Image, Input, Button, useToast } from '@chakra-ui/react';
import React, { useState } from 'react';
import { useLocation } from 'react-router-dom';
import { FaCalendarAlt, FaFutbol, FaExclamationTriangle, FaMapMarkerAlt } from 'react-icons/fa';
import { useQuery } from '@tanstack/react-query';
import { api } from '../../services/api';
import { useSettings } from '@/hooks/useSettings';
import { Widget } from './Widget';
import { format, parse, isToday, isTomorrow, isAfter } from 'date-fns';
import { cs } from 'date-fns/locale';
import { Match } from '../../types';
import { fetchTeamLogoOverrides } from '@/services/adminMatches';
import { assetUrl } from '@/utils/url';
import { TeamLogo } from '../common/TeamLogo';
import '../../styles/logos.css';
const FACR_DATE_FMT = 'dd.MM.yyyy HH:mm';
const formatMatchDate = (dateString: string) => {
try {
// Parse FACR date format from cache JSON (e.g., "17.08.2025 15:00")
const date = parse(dateString, FACR_DATE_FMT, new Date());
if (isToday(date)) {
return format(date, 'HH:mm');
} else if (isTomorrow(date)) {
return format(date, "HH:mm '(zítra)'");
} else {
return format(date, 'dd.MM. HH:mm');
}
} catch (error) {
console.error('Error formatting date:', error, dateString);
return dateString;
}
};
export const MatchesWidget = () => {
const toast = useToast();
const [email, setEmail] = useState<string>('');
const [prefWeekly, setPrefWeekly] = useState<boolean>(true);
const [prefMatches, setPrefMatches] = useState<boolean>(true);
const [subscribing, setSubscribing] = useState<boolean>(false);
const location = useLocation();
const isAdminRoute = String(location.pathname || '').startsWith('/admin');
const resolveUrl = (path: string) => {
try {
if (/^https?:\/\//i.test(path)) return path;
const apiUrl = process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1';
const origin = new URL(apiUrl).origin;
return origin + path;
} catch {
return path;
}
};
const subscribe = async () => {
if (!email) {
toast({ title: 'Zadejte email', status: 'warning' });
return;
}
setSubscribing(true);
try {
const res = await fetch(resolveUrl('/api/v1/newsletter/subscribe'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, preferences: { weekly: prefWeekly, matches: prefMatches } })
});
if (!res.ok) {
const jt = await res.json().catch(() => ({} as any));
throw new Error(jt?.error || `HTTP ${res.status}`);
}
toast({ title: 'Přihlášeno k odběru', status: 'success' });
setEmail('');
} catch (e: any) {
toast({ title: 'Chyba přihlášení', description: e?.message || String(e), status: 'error' });
} finally {
setSubscribing(false);
}
};
const { settings } = useSettings();
const { data: overrides = {} } = useQuery({
queryKey: ['teamLogoOverrides'],
queryFn: fetchTeamLogoOverrides,
staleTime: 5 * 60 * 1000,
});
const getLogo = (teamName?: string, original?: string) => {
const byName = (overrides as any)?.by_name || {} as Record<string, string>;
const norm = (s: string) => String(s || '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/\s+/g, ' ')
.trim()
.toLowerCase();
const stripPrefixes = (s: string) => {
let x = norm(s);
x = x.replace(/\b(mestsky|m\.?f\.?k\.?|mfk|tj|sk|sokol|fotbalovy|fotbalový|fotbalovy\s+klub|fotbalovy\s+klub)\b/g, '');
return x.replace(/\s+/g, ' ').trim();
};
const byNameNorm: Record<string, string> = Object.keys(byName || {}).reduce((acc: Record<string, string>, k) => { acc[norm(k)] = byName[k]; return acc; }, {});
const strippedPairs = Object.keys(byName || {}).map((k) => ({ key: stripPrefixes(k), url: byName[k] }));
const pick = (name?: string, orig?: string) => {
if (!name) return orig;
const exact = byName[name];
let candidate = exact || byNameNorm[norm(name)];
if (!candidate) {
const s = stripPrefixes(name);
for (const { key, url } of strippedPairs) { if (key && (s.endsWith(key) || key.endsWith(s))) { candidate = url; break; } }
}
const chosen = candidate || orig;
if (typeof chosen === 'string' && chosen.startsWith('/')) {
const apiUrl = process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1';
const origin = new URL(apiUrl).origin;
return origin + chosen;
}
return chosen || (assetUrl('/dist/img/logo-club-empty.svg') as string);
};
return pick(teamName, original);
};
const { data: matches = [], isLoading, error } = useQuery<Match[]>({
queryKey: ['upcomingMatchesCache'],
queryFn: async () => {
// Build absolute origin from API URL env (which may include /api/v1)
const apiUrl = process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1';
const origin = new URL(apiUrl).origin;
const url = `${origin}/cache/prefetch/facr_club_info.json`;
const res = await fetch(url, { headers: { 'Cache-Control': 'no-cache' } });
if (!res.ok) throw new Error(`Failed to load cache: ${res.status}`);
const json = await res.json();
// Flatten competitions -> matches, enrich with competition name
const comps = Array.isArray(json?.competitions) ? json.competitions : [];
const items: any[] = comps.flatMap((c: any) =>
(Array.isArray(c.matches) ? c.matches : []).map((m: any) => ({ ...m, competitionName: c.name, competition_id: c.id }))
);
// Parse, filter for future, sort ascending, take next 5
const now = new Date();
const upcoming = items
.map((m) => ({
...m,
__dt: parse(String(m.date_time || m.date), FACR_DATE_FMT, new Date()),
}))
.filter((m) => isAfter(m.__dt, now))
.sort((a, b) => a.__dt.getTime() - b.__dt.getTime())
.slice(0, 5)
.map((m) => ({
id: m.match_id,
date_time: m.date_time || m.date,
competitionName: m.competitionName,
home: m.home || m.home_team,
away: m.away || m.away_team,
score: m.score,
venue: m.venue,
home_logo_url: getLogo(m.home || m.home_team, m.home_logo_url),
away_logo_url: getLogo(m.away || m.away_team, m.away_logo_url),
})) as Match[];
return upcoming;
},
});
if (isLoading) {
return (
<Widget title="Nadcházející zápasy">
<VStack p={4}>
<Spinner size="md" />
<Text>Načítám zápasy...</Text>
</VStack>
</Widget>
);
}
if (error) {
return (
<Widget title="Nadcházející zápasy">
<Alert status="error" variant="left-accent">
<AlertIcon />
Nepodařilo se načíst zápasy. Zkuste to prosím později.
</Alert>
</Widget>
);
}
if (!matches || matches.length === 0) {
return (
<Widget title="Nadcházející zápasy">
<VStack p={4} spacing={4}>
<Icon as={FaCalendarAlt} boxSize={8} color="gray.400" />
<Text color="gray.500" textAlign="center">
Žádné nadcházející zápasy nebyly nalezeny.
</Text>
</VStack>
</Widget>
);
}
return (
<Widget title="Nadcházející zápasy">
<VStack spacing={{ base: 2, md: 3 }} align="stretch" divider={<Box borderBottomWidth="1px" borderColor="gray.200" />}>
{matches.map((match) => (
<Box
key={match.id}
p={{ base: 3, md: 4 }}
bg="gray.50"
_hover={{ bg: 'gray.100' }}
borderRadius="lg"
transition="background-color 0.2s"
shadow="sm"
>
<HStack justify="space-between" mb={1} spacing={2} flexWrap="wrap">
<Text
fontSize={{ base: 'xs', sm: 'sm' }}
color="gray.700"
fontWeight="medium"
whiteSpace="nowrap"
>
{formatMatchDate(match.date_time)}
</Text>
<Badge
colorScheme="blue"
variant="subtle"
fontSize="xs"
bg="blue.50"
color="blue.700"
>
{match.competitionName}
</Badge>
</HStack>
<HStack justify="space-between" align="center">
<HStack flex={1} minW={0} spacing={2}>
<Box flexShrink={0} className="match-widget-logo">
<TeamLogo
teamId={(match as any).home_id}
teamName={match.home}
facrLogo={match.home_logo_url}
size="small"
fallbackIcon={<Icon as={FaFutbol} color="gray.400" boxSize={{ base: 4, md: 5 }} />}
/>
</Box>
<Text
fontSize={{ base: 'xs', sm: 'sm' }}
fontWeight="medium"
isTruncated
color="gray.800"
>
{match.home}
</Text>
</HStack>
<Text
fontSize={{ base: 'xs', sm: 'sm' }}
fontWeight="bold"
minW={{ base: '32px', sm: '40px' }}
textAlign="center"
color="gray.900"
flexShrink={0}
>
{match.score || 'vs'}
</Text>
<HStack flex={1} justify="flex-end" spacing={2} minW={0}>
<Text
fontSize={{ base: 'xs', sm: 'sm' }}
fontWeight="medium"
isTruncated
textAlign="right"
color="gray.800"
>
{match.away}
</Text>
<Box flexShrink={0} className="match-widget-logo">
<TeamLogo
teamId={(match as any).away_id}
teamName={match.away}
facrLogo={match.away_logo_url}
size="small"
fallbackIcon={<Icon as={FaFutbol} color="gray.400" boxSize={{ base: 4, md: 5 }} />}
/>
</Box>
</HStack>
</HStack>
{match.venue && (
<HStack mt={2} spacing={2} color="gray.500" fontSize="sm">
<Icon as={FaMapMarkerAlt} boxSize={3} />
<Text isTruncated>{match.venue}</Text>
</HStack>
)}
</Box>
))}
{/* Fan subscription form (hidden on admin pages) */}
{!isAdminRoute && (
<VStack p={3} spacing={2} align="stretch">
<Text fontSize="sm" color="gray.600">Chcete dostávat novinky o zápasech emailem?</Text>
<HStack>
<Input type="email" placeholder="váš@email.cz" value={email} onChange={(e) => setEmail(e.target.value)} />
<Button colorScheme="red" onClick={subscribe} isLoading={subscribing}>Odebírat</Button>
</HStack>
</VStack>
)}
</VStack>
</Widget>
);
};
@@ -0,0 +1,143 @@
import { Box, Text, VStack, HStack, Image, Skeleton, Link, SimpleGrid, Tooltip, Icon } from '@chakra-ui/react';
import { FaHandshake, FaExternalLinkAlt } from 'react-icons/fa';
import { Link as RouterLink } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { api } from '../../services/api';
import { Widget } from './Widget';
import { Sponsor } from '../../types';
import { assetUrl } from '../../utils/url';
export const SponsorsWidget = () => {
const { data: sponsors = [], isLoading, error } = useQuery<Sponsor[]>({
queryKey: ['sponsors'],
queryFn: async () => {
try {
const { data } = await api.get('/sponsors');
const raw = Array.isArray(data) ? data : (data?.data || []);
// Normalize fields and resolve logo URL against backend origin
return (raw || []).map((s: any) => ({
...s,
logoUrl: assetUrl(s.logo_url) || s.logo_url,
websiteUrl: s.website_url,
}));
} catch (err) {
console.error('Error fetching sponsors:', err);
return [];
}
},
staleTime: 15 * 60 * 1000, // 15 minutes
});
if (isLoading) {
return (
<Widget title="Partneři klubu" icon={FaHandshake}>
<SimpleGrid columns={2} spacing={4}>
{[1, 2, 3, 4].map((i) => (
<Skeleton key={i} height="80px" borderRadius="md" />
))}
</SimpleGrid>
</Widget>
);
}
if (error || !sponsors.length) {
return (
<Widget title="Partneři klubu" icon={FaHandshake}>
<VStack p={4} spacing={4}>
<Box p={4} bg="gray.50" borderRadius="md" textAlign="center" w="full">
<Icon as={FaHandshake} boxSize={6} color="gray.400" mb={2} />
<Text color="gray.500">Žádní partneři k zobrazení</Text>
<Text fontSize="sm" color="gray.400" mt={2}>
Buďte první, kdo nás podpoří
</Text>
</Box>
</VStack>
</Widget>
);
}
return (
<Widget title="Partneři klubu" icon={FaHandshake}>
<SimpleGrid columns={2} spacing={3}>
{sponsors.map((sponsor) => (
<Tooltip key={sponsor.id} label={sponsor.websiteUrl ? `Navštívit ${sponsor.name}` : sponsor.name} placement="top">
<Link
href={sponsor.websiteUrl || '#'}
isExternal={!!sponsor.websiteUrl}
_hover={{ textDecoration: 'none' }}
display="block"
>
<Box
p={3}
borderWidth="1px"
borderColor="gray.200"
borderRadius="md"
height="100%"
display="flex"
alignItems="center"
justifyContent="center"
bg="white"
_hover={{ shadow: 'sm', borderColor: 'blue.200' }}
transition="all 0.2s"
position="relative"
overflow="hidden"
>
{sponsor.websiteUrl && (
<Box
position="absolute"
top={1}
right={1}
color="blue.400"
fontSize="xs"
opacity={0.7}
>
<FaExternalLinkAlt />
</Box>
)}
<Image
src={sponsor.logoUrl || assetUrl((sponsor as any).logo_url) || '/images/sponsors/placeholder.png'}
alt={sponsor.name}
maxH="50px"
maxW="100px"
objectFit="contain"
fallback={
<Box
width="100px"
height="50px"
display="flex"
alignItems="center"
justifyContent="center"
bg="gray.50"
borderRadius="md"
p={2}
textAlign="center"
>
<Text fontSize="xs" color="gray.500" noOfLines={2}>
{sponsor.name}
</Text>
</Box>
}
/>
</Box>
</Link>
</Tooltip>
))}
</SimpleGrid>
<Box mt={3} textAlign="center">
<Link
as={RouterLink}
to="/partneri"
fontSize="sm"
color="blue.500"
_hover={{ textDecoration: 'underline' }}
display="inline-flex"
alignItems="center"
gap={1}
>
Zobrazit všechny partnery <FaExternalLinkAlt size={10} />
</Link>
</Box>
</Widget>
);
};
@@ -0,0 +1,146 @@
import { Box, Text, VStack, HStack, Avatar, Skeleton, Icon, Alert, AlertIcon } from '@chakra-ui/react';
import { FaUsers, FaFutbol } from 'react-icons/fa';
import { useQuery } from '@tanstack/react-query';
import { Widget } from './Widget';
import { getFacrTablesCache } from '../../services/facr/cache';
import { getPublicSettings, PublicSettings } from '../../services/settings';
export const TeamsWidget = () => {
// Load settings for primary club name (for exclusion)
const { data: settings, isLoading: settingsLoading } = useQuery<PublicSettings>({
queryKey: ['public-settings'],
queryFn: getPublicSettings as any,
staleTime: 5 * 60 * 1000,
});
// Load FACR tables cache
const { data: facrTables, isLoading, error } = useQuery<any>({
queryKey: ['facr-tables-cache'],
queryFn: getFacrTablesCache,
staleTime: 5 * 60 * 1000,
});
const normalize = (s?: string) => {
let out = String(s || '');
// Normalize diacritics and case
out = out
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase();
// Unify various dash characters to a simple hyphen
out = out.replace(/[\u2012\u2013\u2014\u2015\u2212]/g, '-');
// Remove legal suffixes like ", z.s." / ", z. s." / " z.s." / "o.s." at end
out = out.replace(/[,\s]*(z\.?\s*s\.?|o\.?\s*s\.?)\s*$/g, '');
// Remove organization phrases/prefixes anywhere (keep core locality/name)
const orgPhrases = [
'fotbalovy klub',
'sportovni klub',
'telovychovna jednota',
'skolni sportovni klub',
'fotbal',
'futsal',
];
for (const phrase of orgPhrases) {
const re = new RegExp(`(^|\\b)${phrase}(\\b|$)`, 'g');
out = out.replace(re, ' ');
}
// Remove common short prefixes (tokens) like FC, FK, MFK, TJ, SK, SFC, AFK at word boundaries
out = out.replace(/\b(1\.)?\s*(sfc|afc|fc|fk|mfk|tj|sk|afk)\b\.?/g, ' ');
// Remove punctuation except hyphen
out = out.replace(/[\.,!;:()\[\]{}]/g, ' ');
// Collapse multiple spaces and trim
out = out.replace(/\s+/g, ' ').trim();
return out;
};
const primaryKey = normalize((settings as any)?.club_name);
const teams = (() => {
const map: Record<string, { id: string; name: string; logoUrl?: string }> = {};
const comps = Array.isArray(facrTables?.competitions) ? facrTables.competitions : [];
for (const c of comps) {
const rows = Array.isArray(c?.table?.overall) ? c.table.overall : [];
for (const r of rows) {
const name = (r?.team || '').trim();
if (!name) continue;
const key = normalize(name);
if (key && key === primaryKey) continue; // exclude primary club from listing
if (!map[key]) {
map[key] = { id: key, name, logoUrl: r?.team_logo_url };
}
}
}
return Object.values(map).slice(0, 8); // show up to 8
})();
// If application settings are still loading, show skeletons
if (settingsLoading || isLoading) {
return (
<Widget title="Týmy klubu" icon={FaUsers}>
<VStack spacing={3} align="stretch">
{[1, 2, 3].map((i) => (
<HStack key={i} p={2} spacing={3}>
<Skeleton boxSize="40px" borderRadius="md" />
<Box flex={1}>
<Skeleton height="16px" width="80%" mb={1} />
<Skeleton height="12px" width="60%" />
</Box>
</HStack>
))}
</VStack>
</Widget>
);
}
if (error) {
return (
<Widget title="Týmy klubu" icon={FaUsers}>
<Alert status="info" variant="left-accent">
<AlertIcon />
Nepodařilo se načíst seznam týmů z cache.
</Alert>
</Widget>
);
}
if (teams.length === 0) {
return (
<Widget title="Týmy klubu" icon={FaUsers}>
<VStack p={4} spacing={4}>
<Box p={4} bg="gray.50" borderRadius="md" textAlign="center" w="full">
<Icon as={FaFutbol} boxSize={6} color="gray.400" mb={2} />
<Text color="gray.500">Žádné týmy nebyly nalezeny</Text>
</Box>
</VStack>
</Widget>
);
}
return (
<Widget title="Týmy klubu" icon={FaUsers}>
<VStack spacing={3} align="stretch" divider={<Box borderBottomWidth="1px" borderColor="gray.100" />}>
{teams.map((team) => (
<Box
key={team.id}
p={3}
_hover={{ bg: 'gray.50', cursor: 'pointer' }}
borderRadius="md"
>
<HStack spacing={3}>
<Avatar
name={team.name}
src={team.logoUrl}
size="sm"
bg="blue.100"
color="blue.700"
icon={<FaFutbol />}
/>
<Box flex={1} minW={0}>
<Text fontWeight="medium" isTruncated>{team.name}</Text>
</Box>
</HStack>
</Box>
))}
</VStack>
</Widget>
);
};
@@ -0,0 +1,264 @@
import { useQuery } from '@tanstack/react-query';
import { format } from 'date-fns';
import { cs } from 'date-fns/locale';
import {
Box,
Text,
VStack,
HStack,
Skeleton,
Stat,
StatLabel,
StatNumber,
StatHelpText,
useColorModeValue,
Icon,
} from '@chakra-ui/react';
import { FaChartLine, FaArrowUp, FaArrowDown } from 'react-icons/fa';
import { Line } from 'react-chartjs-2';
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Filler,
} from 'chart.js';
import { api } from '@/services/api';
import { Widget } from './Widget';
// Register Chart.js components
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Filler
);
interface ChartDataset {
label: string;
data: number[];
borderColor: string;
backgroundColor: string;
tension: number;
fill: boolean;
}
interface VisitorStats {
totalVisitors: number;
changePercentage: number;
chartData: {
labels: string[];
datasets: ChartDataset[];
};
}
export const VisitorsWidget = () => {
const borderColor = useColorModeValue('gray.200', 'gray.600');
const bgColor = useColorModeValue('white', 'gray.700');
const textColor = useColorModeValue('gray.600', 'gray.300');
const { data: stats, isLoading, error } = useQuery<VisitorStats>({
queryKey: ['analytics', 'visitors'],
queryFn: async () => {
try {
const { data } = await api.get('/analytics/visitors', {
params: {
days: 30,
groupBy: 'day'
}
});
return data;
} catch (err) {
console.error('Error fetching visitor stats:', err);
// Return mock data in case of error
return generateMockData();
}
},
staleTime: 15 * 60 * 1000, // 15 minutes
});
const changePercentage = stats?.changePercentage ?? 0;
const isPositiveChange = changePercentage >= 0;
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false,
},
tooltip: {
mode: 'index' as const,
intersect: false,
backgroundColor: useColorModeValue('white', 'gray.800'),
titleColor: textColor,
bodyColor: textColor,
borderColor: borderColor,
borderWidth: 1,
padding: 12,
boxShadow: 'lg',
callbacks: {
label: (context: { parsed: { y: number } }) => {
return `${context.parsed.y} návštěv`;
},
},
},
},
scales: {
x: {
grid: {
display: false,
},
ticks: {
color: textColor,
maxRotation: 0,
autoSkip: true,
maxTicksLimit: 7,
},
border: {
display: false,
},
},
y: {
beginAtZero: true,
grid: {
borderDash: [3, 3],
color: borderColor,
},
ticks: {
color: textColor,
precision: 0,
},
border: {
display: false,
},
},
},
elements: {
point: {
radius: 0,
hoverRadius: 6,
hoverBorderWidth: 2,
},
line: {
borderWidth: 2,
tension: 0.3,
},
},
};
// Generate mock data for the chart
const generateMockData = (): VisitorStats => {
const labels = Array.from({ length: 30 }, (_, i) => {
const date = new Date();
date.setDate(date.getDate() - (29 - i));
return format(date, 'd. M.', { locale: cs });
});
const baseData = Array.from({ length: 30 }, (_, i) => {
const base = 50 + Math.floor(Math.random() * 50);
const dayOfWeek = new Date().getDay();
const dayFactor = dayOfWeek >= 5 ? 0.7 : 1.0;
return Math.floor(base * dayFactor);
});
return {
totalVisitors: baseData.reduce((sum, val) => sum + val, 0),
changePercentage: 12.5,
chartData: {
labels,
datasets: [
{
label: 'Návštěvníci',
data: baseData,
borderColor: 'rgba(66, 153, 225, 1)',
backgroundColor: 'rgba(66, 153, 225, 0.5)',
tension: 0.3,
fill: true,
},
],
},
};
};
if (isLoading) {
return (
<Widget title="Návštěvnost" icon={FaChartLine}>
<VStack spacing={4}>
<Skeleton height="200px" width="100%" borderRadius="md" />
<HStack width="100%" justify="space-between">
<Skeleton height="60px" flex={1} mr={2} />
<Skeleton height="60px" flex={1} />
</HStack>
</VStack>
</Widget>
);
}
// Use mock data if stats is not available
const displayStats = stats || generateMockData();
return (
<Widget title="Návštěvnost" icon={FaChartLine}>
<VStack spacing={4} align="stretch">
<Stat>
<HStack justify="space-between">
<Box>
<StatLabel color={textColor}>Celkem návštěv</StatLabel>
<StatNumber fontSize="2xl">
{new Intl.NumberFormat('cs-CZ').format(displayStats.totalVisitors)}
</StatNumber>
</Box>
<Box
bg={isPositiveChange ? 'green.50' : 'red.50'}
_dark={{
bg: isPositiveChange ? 'green.900' : 'red.900',
color: isPositiveChange ? 'green.200' : 'red.200',
}}
color={isPositiveChange ? 'green.600' : 'red.600'}
px={3}
py={1.5}
borderRadius="full"
display="flex"
alignItems="center"
>
{isPositiveChange ? (
<Icon as={FaArrowUp} mr={1} />
) : (
<Icon as={FaArrowDown} mr={1} />
)}
<Text fontSize="sm" fontWeight="medium">
{Math.abs(changePercentage)}%
</Text>
</Box>
</HStack>
<StatHelpText color={textColor}>Za posledních 30 dní</StatHelpText>
</Stat>
<Box height="200px" mt={4}>
<Line data={displayStats.chartData} options={chartOptions} />
</Box>
{!stats && (
<Box width="100%" textAlign="center" mt={2}>
<Text fontSize="xs" color="gray.500">
<Icon as={FaChartLine} mr={1} />
Zobrazují se ukázková data
</Text>
</Box>
)}
</VStack>
</Widget>
);
};
@@ -0,0 +1,30 @@
import { Box, BoxProps, Heading, Icon, useColorModeValue } from '@chakra-ui/react';
import { ReactNode } from 'react';
interface WidgetProps extends BoxProps {
title: string;
icon?: any;
children: ReactNode;
}
export const Widget = ({ title, icon, children, ...rest }: WidgetProps) => {
return (
<Box
bg={useColorModeValue('white', 'gray.800')}
p={4}
borderRadius="lg"
boxShadow="sm"
borderWidth="1px"
borderColor={useColorModeValue('gray.200', 'gray.700')}
_hover={{ boxShadow: 'md' }}
transition="all 0.2s"
{...rest}
>
<Heading size="md" mb={4} display="flex" alignItems="center">
{icon && <Icon as={icon} mr={2} />}
{title}
</Heading>
{children}
</Box>
);
};