This commit is contained in:
Tomas Dvorak
2025-10-28 22:38:27 +01:00
parent 3d621e2187
commit 823fabee02
106 changed files with 9011 additions and 3930 deletions
+56 -31
View File
@@ -1,4 +1,4 @@
import { Box, Button, Flex, Text, Link } from '@chakra-ui/react';
import { Box, Button, Flex, Text, Link, Checkbox } from '@chakra-ui/react';
import { useEffect, useState } from 'react';
const STORAGE_KEY = 'cookie_consent';
@@ -41,6 +41,18 @@ const CookieBanner: React.FC = () => {
}
}, []);
// Allow opening the preferences from anywhere (e.g. Cookie Policy page)
useEffect(() => {
const openHandler = (_e: Event) => {
setManaging(true);
setVisible(true);
};
window.addEventListener('cookie-consent-open', openHandler);
return () => {
window.removeEventListener('cookie-consent-open', openHandler);
};
}, []);
const saveAndClose = (c: Consent) => {
const payload = { ...c, timestamp: new Date().toISOString() };
localStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
@@ -62,46 +74,59 @@ const CookieBanner: React.FC = () => {
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}>
<Box
role="dialog"
aria-live="polite"
position="fixed"
bottom={{ base: 4, md: 6 }}
left="50%"
transform="translateX(-50%)"
bg="blackAlpha.800"
color="gray.100"
zIndex={1000}
px={{ base: 4, md: 6 }}
py={{ base: 4, md: 5 }}
borderRadius="xl"
boxShadow="xl"
borderWidth="1px"
borderColor="whiteAlpha.300"
w={{ base: 'calc(100% - 2rem)', sm: 'calc(100% - 3rem)', md: 'auto' }}
maxW="3xl"
style={{ backdropFilter: 'blur(6px)' }}
>
<Flex align="start" justify="space-between" gap={6} wrap="wrap">
<Box maxW={{ base: '100%', md: '70%' }}>
<Box maxW={{ base: '100%', md: '75%' }}>
<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.
<span role="img" aria-label="cookie">🍪</span>{' '}
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">
<Box mt={3} bg="gray.800" borderRadius="lg" p={4} borderWidth="1px" 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 />
<Checkbox isChecked isDisabled>
<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 }))}
/>
</Checkbox>
<Checkbox
isChecked={!!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 }))}
/>
</Checkbox>
<Checkbox
isChecked={!!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 }))}
/>
</Checkbox>
<Checkbox
isChecked={!!consent.marketing}
onChange={(e) => setConsent((c) => ({ ...c, marketing: e.target.checked }))}
>
<Text fontSize="sm">Marketingové cookies</Text>
</label>
</Checkbox>
<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>
@@ -111,8 +136,8 @@ const CookieBanner: React.FC = () => {
)}
</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" onClick={() => setManaging((v) => !v)} variant="ghost">Nastavit</Button>
<Button size="sm" onClick={rejectNonEssential} variant="outline" colorScheme="gray">Odmítnout nepovinné</Button>
<Button size="sm" colorScheme="blue" onClick={acceptAll}>Přijmout vše</Button>
</Flex>
</Flex>
+63 -39
View File
@@ -52,6 +52,7 @@ import { getCachedYouTube } from '../services/youtube';
import { getZoneramaManifestWithFallbacks } from '../services/zonerama';
import { getMyNewsletterToken } from '../services/public/newsletter';
import { API_URL } from '../services/api';
import { assetUrl } from '../utils/url';
type NavLink = { label: string; to?: string; items?: { label: string; to: string }[]; external?: boolean };
@@ -164,15 +165,15 @@ const MobileMenu = ({ isOpen, onClose, isAdmin, isAuthenticated, menuBg, divider
)}
<Button as={RouterLink} to="/kalendar" variant="ghost" justifyContent="flex-start">Kalendář</Button>
<Button as={RouterLink} to="/zapasy" variant="ghost" justifyContent="flex-start">Zápasy</Button>
{hasActivities !== false && (
{hasActivities === true && (
<Button as={RouterLink} to="/aktivity" variant="ghost" justifyContent="flex-start">Aktivity</Button>
)}
{hasPlayers !== false && (
{hasPlayers === true && (
<Button as={RouterLink} to="/hraci" variant="ghost" justifyContent="flex-start">Hráči</Button>
)}
{hasTables ? (
{hasTables === true && (
<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 || '/' };
@@ -191,7 +192,7 @@ const MobileMenu = ({ isOpen, onClose, isAdmin, isAuthenticated, menuBg, divider
</Button>
);
})}
{hasArticles !== false && (
{hasArticles === true && (
<>
<Button as={RouterLink} to="/blog" variant="ghost" justifyContent="flex-start" fontWeight="bold">Články</Button>
{Array.isArray(categories) && categories.length > 0 && (
@@ -210,11 +211,11 @@ const MobileMenu = ({ isOpen, onClose, isAdmin, isAuthenticated, menuBg, divider
)}
</>
)}
{hasVideos !== false && (
{hasVideos === true && (
<Button as={RouterLink} to="/videa" variant="ghost" justifyContent="flex-start">Videa</Button>
)}
<Button as={RouterLink} to="/hledat" variant="ghost" justifyContent="flex-start">Hledat</Button>
{hasGallery !== false && (
{hasGallery === true && (
<Button as={RouterLink} to="/galerie" variant="ghost" justifyContent="flex-start">{galleryLabel || 'Fotogalerie'}</Button>
)}
{settings?.shop_url && (
@@ -258,6 +259,7 @@ const Navbar: React.FC<{ fullWidth?: boolean }> = ({ fullWidth = false }) => {
const { isOpen, onOpen, onClose } = useDisclosure();
const { isOpen: isSearchOpen, onOpen: onSearchOpen, onClose: onSearchClose } = useDisclosure();
const isAdmin = user?.role === 'admin';
const accountPath = isAdmin ? '/admin/nastaveni' : '/semiadmin';
const { data: settings } = usePublicSettings();
const theme = useClubTheme();
const location = useLocation();
@@ -324,16 +326,9 @@ const Navbar: React.FC<{ fullWidth?: boolean }> = ({ fullWidth = false }) => {
// Set favicon/logo in head for fan pages (SPA)
useEffect(() => {
try {
let url = settings?.club_logo_url || theme.logoUrl || '/dist/img/logo-club-empty.svg';
if (!url) return;
// Normalize relative upload paths to API origin so favicon resolves on all pages
try {
const apiOrigin = new URL(API_URL, typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000').origin;
if (/^\/.+/.test(url) && !/^https?:\/\//i.test(url)) {
// If starts with /uploads or any absolute path, prefix API origin
url = apiOrigin + url;
}
} catch {}
const raw = settings?.club_logo_url || theme.logoUrl || '/dist/img/logo-club-empty.svg';
if (!raw) return;
const url = assetUrl(raw) || raw;
const setIcon = (rel: string) => {
let link = document.querySelector<HTMLLinkElement>(`link[rel="${rel}"]`);
@@ -544,11 +539,29 @@ const Navbar: React.FC<{ fullWidth?: boolean }> = ({ fullWidth = false }) => {
}));
}, [navCategories]);
// Filter dynamic navigation items based on available data (only show when data exists)
const filteredDynamicNavItems = useMemo(() => {
const filterItem = (item: NavigationItem): NavigationItem | null => {
const url = item.url || '';
if (url.startsWith('/aktivity') && hasActivities !== true) return null;
if (url.startsWith('/hraci') && hasPlayers !== true) return null;
if (url.startsWith('/blog') && hasArticles !== true) return null;
if (url.startsWith('/videa') && hasVideos !== true) return null;
if (url.startsWith('/galerie') && hasGallery !== true) return null;
if (item.type === 'dropdown' && Array.isArray(item.children)) {
const children = item.children.map(filterItem).filter(Boolean) as NavigationItem[];
return { ...item, children };
}
return item;
};
return dynamicNavItems.map(filterItem).filter(Boolean) as NavigationItem[];
}, [dynamicNavItems, hasActivities, hasPlayers, hasArticles, hasVideos, hasGallery]);
// Use dynamic navigation if available, otherwise fallback to hardcoded
let NAV_LINKS: NavLink[] = useMemo(() => {
if (!navLoading && dynamicNavItems.length > 0) {
if (!navLoading && filteredDynamicNavItems.length > 0) {
// Use dynamic navigation from API
const navLinks = dynamicNavItems.map(convertToNavLink);
const navLinks = filteredDynamicNavItems.map(convertToNavLink);
// Inject categories into "Články" or "Blog" navigation item if it exists
if (categoryItems.length > 0) {
@@ -566,8 +579,19 @@ const Navbar: React.FC<{ fullWidth?: boolean }> = ({ fullWidth = false }) => {
};
}
}
return navLinks;
// Ensure we only show sections when there is data
const filtered = navLinks.filter((link) => {
const to = link.to || '';
if (to.startsWith('/aktivity')) return hasActivities === true;
if (to.startsWith('/hraci')) return hasPlayers === true;
if (to.startsWith('/blog')) return hasArticles === true;
if (to.startsWith('/videa')) return hasVideos === true;
if (to.startsWith('/galerie')) return hasGallery === true;
return true;
});
return filtered;
}
// Fallback to hardcoded navigation
@@ -607,40 +631,40 @@ const Navbar: React.FC<{ fullWidth?: boolean }> = ({ fullWidth = false }) => {
links = links.filter((n) => n.label !== 'Tabulky');
}
// Hide Aktivity when there are no activities
if (hasActivities === false) {
// Hide Aktivity unless there are activities
if (hasActivities !== true) {
links = links.filter((n) => n.label !== 'Aktivity');
}
// Hide Hráči when there are no players
if (hasPlayers === false) {
// Hide Hráči unless there are players
if (hasPlayers !== true) {
links = links.filter((n) => n.label !== 'Hráči');
}
// Hide Články when there are no articles
if (hasArticles === false) {
// Hide Články unless there are articles
if (hasArticles !== true) {
links = links.filter((n) => n.label !== 'Články');
}
// Hide Videa when there are no videos
if (hasVideos === false) {
// Hide Videa unless there are videos
if (hasVideos !== true) {
links = links.filter((n) => n.label !== 'Videa');
}
// Hide Fotogalerie when there is no gallery content
if (hasGallery === false) {
links = links.filter((n) => n.label === galleryLabel).length === 0 ? links : links.filter((n) => n.label !== galleryLabel);
// Hide Fotogalerie unless there is gallery content
if (hasGallery !== true) {
links = links.filter((n) => n.to !== '/galerie');
}
return links;
}, [dynamicNavItems, navLoading, settings, categoryItems, hasTables, hasActivities, hasPlayers, hasArticles, hasVideos, hasGallery, galleryLabel]);
}, [filteredDynamicNavItems, navLoading, settings, categoryItems, hasTables, hasActivities, hasPlayers, hasArticles, hasVideos, hasGallery, galleryLabel]);
return (
<Box position="sticky" top={0} zIndex={1000}>
{/* Top bar with socials and quick external links */}
{(settings?.facebook_url || settings?.instagram_url || settings?.youtube_url || settings?.shop_url) && (
<Box bg={topBarBg} borderBottomWidth="1px" borderColor="border.subtle" py={1}>
<Container maxW={containerMaxW}>
<Container maxW={containerMaxW} px={fullWidth ? 0 : undefined}>
<Flex align="center" justify="space-between" gap={2}>
<HStack spacing={2}>
{settings?.shop_url && (
@@ -674,15 +698,15 @@ const Navbar: React.FC<{ fullWidth?: boolean }> = ({ fullWidth = false }) => {
boxShadow={scrolled ? 'sm' : 'none'}
transition="box-shadow 0.2s ease, background-color 0.2s ease, backdrop-filter 0.2s ease"
>
<MobileMenu isOpen={isOpen} onClose={onClose} isAdmin={isAdmin} isAuthenticated={isAuthenticated} menuBg={menuBg} dividerColor={dividerColor} settings={settings} categories={navCategories} galleryHref={galleryHref} galleryLabel={galleryLabel} hasTables={hasTables} hasActivities={hasActivities} hasPlayers={hasPlayers} hasArticles={hasArticles} hasVideos={hasVideos} hasGallery={hasGallery} dynamicNavItems={dynamicNavItems} navLoading={navLoading} />
<Container maxW={containerMaxW}>
<MobileMenu isOpen={isOpen} onClose={onClose} isAdmin={isAdmin} isAuthenticated={isAuthenticated} menuBg={menuBg} dividerColor={dividerColor} settings={settings} categories={navCategories} galleryHref={galleryHref} galleryLabel={galleryLabel} hasTables={hasTables} hasActivities={hasActivities} hasPlayers={hasPlayers} hasArticles={hasArticles} hasVideos={hasVideos} hasGallery={hasGallery} dynamicNavItems={filteredDynamicNavItems} navLoading={navLoading} />
<Container maxW={containerMaxW} px={fullWidth ? 0 : undefined}>
<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}
src={assetUrl(settings?.club_logo_url || theme.logoUrl) || settings?.club_logo_url || theme.logoUrl}
alt={settings?.club_name || theme.name || 'Logo'}
boxSize={{ base: '36px', md: '40px' }}
objectFit="contain"
@@ -826,9 +850,9 @@ const Navbar: React.FC<{ fullWidth?: boolean }> = ({ fullWidth = false }) => {
<Avatar size="sm" name={user?.name || 'Uživatel'} />
</MenuButton>
<MenuList>
<MenuItem as={RouterLink} to="/admin/nastaveni">Můj účet</MenuItem>
<MenuItem as={RouterLink} to={accountPath}>Můj účet</MenuItem>
<MenuItem onClick={openMyNewsletterPrefs}>Emailové preference</MenuItem>
<MenuItem as={RouterLink} to="/profil/nastaveni">Nastavení stránky</MenuItem>
{isAdmin && <MenuItem as={RouterLink} to="/admin/nastaveni">Nastavení stránky</MenuItem>}
{isAdmin && <MenuItem as={RouterLink} to="/admin">Administrace</MenuItem>}
<MenuItem onClick={logout}>Odhlásit se</MenuItem>
</MenuList>
+26 -3
View File
@@ -1,6 +1,6 @@
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 { useEffect, useRef, useCallback, useState, useMemo } from 'react';
import {
FaTachometerAlt,
FaUsers,
@@ -30,13 +30,15 @@ import {
FaTshirt,
FaBullhorn,
FaUserShield,
FaFileAlt
FaFileAlt,
FaLink
} 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';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import { assetUrl } from '../../utils/url';
interface NavItemProps {
icon: any;
@@ -146,6 +148,7 @@ const getIconForPageType = (pageType?: string): any => {
settings: FaPalette,
files: FaFolder,
docs: FaBook,
shortlinks: FaLink,
};
return iconMap[pageType || ''] || FaFileAlt;
};
@@ -175,6 +178,9 @@ const AdminSidebar = ({
// Dynamic navigation state
const [navItems, setNavItems] = useState<NavigationItem[]>([]);
const [navLoading, setNavLoading] = useState(true);
const hasShortlinks = useMemo(() => {
return navItems.some(it => (it.page_type === 'shortlinks') || (it.url === '/admin/shortlinks'));
}, [navItems]);
// Restore scroll on mount
useEffect(() => {
@@ -287,7 +293,7 @@ const AdminSidebar = ({
<Box px={3} mb={8}>
<Flex align="center" gap={3} mb={2}>
<Image
src={publicSettings?.club_logo_url || '/dist/img/logo-club-empty.svg'}
src={assetUrl(publicSettings?.club_logo_url) || publicSettings?.club_logo_url || '/dist/img/logo-club-empty.svg'}
alt="Club Logo"
boxSize="48px"
objectFit="contain"
@@ -365,6 +371,16 @@ const AdminSidebar = ({
>
MyUIbrix Editor
</NavItem>
{/* Ensure Shortlinks is present even if not configured in dynamic nav */}
{!hasShortlinks && (
<NavItem
icon={FaLink}
to="/admin/shortlinks"
onClick={onClose}
>
Zkrácené odkazy
</NavItem>
)}
</>
) : (
// Fallback to hardcoded navigation
@@ -592,6 +608,13 @@ const AdminSidebar = ({
>
Nastavení
</NavItem>
<NavItem
icon={FaLink}
to="/admin/shortlinks"
onClick={onClose}
>
Zkrácené odkazy
</NavItem>
<NavItem
icon={FaFolder}
to="/admin/soubory"
+2 -2
View File
@@ -44,7 +44,7 @@ interface PollLinkerProps {
const PollLinker: React.FC<PollLinkerProps> = ({ articleId, eventId, onPollsChanged }) => {
const toast = useToast();
const queryClient = useQueryClient();
const [isExpanded, setIsExpanded] = useState(false);
const [isExpanded, setIsExpanded] = useState(true);
const [selectedPollId, setSelectedPollId] = useState<string>('');
const [showCreateForm, setShowCreateForm] = useState(false);
@@ -363,7 +363,7 @@ const PollLinker: React.FC<PollLinkerProps> = ({ articleId, eventId, onPollsChan
<Divider />
<Tabs size="sm" variant="enclosed">
<Tabs size="sm" variant="enclosed" defaultIndex={1}>
<TabList>
<Tab>Propojit existující</Tab>
<Tab>Vytvořit novou</Tab>
@@ -76,6 +76,8 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
const quillRef = useRef<ReactQuill | null>(null);
const toolbarRef = useRef<HTMLDivElement | null>(null);
const onChangeRef = useRef(onChange);
const selectedImageIdRef = useRef<string | null>(null);
const selectImageByIdRef = useRef<(id: string) => void>(() => {});
const [isMounted, setIsMounted] = useState(false);
// Ensure component is mounted before rendering Quill
@@ -192,6 +194,54 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
},
}), [toolbarConfig, onImageUpload, handleImageUpload]);
// Localize Quill toolbar tooltips/labels to Czech
useEffect(() => {
if (!isMounted) return;
const editor = quillRef.current?.getEditor();
if (!editor) return;
const container = editor.root?.parentElement; // .ql-container
const toolbarEl = container?.previousElementSibling as HTMLElement | null; // .ql-toolbar
if (!toolbarEl) return;
const setTitle = (selector: string, title: string) => {
toolbarEl.querySelectorAll(selector).forEach((el) => {
(el as HTMLElement).setAttribute('title', title);
(el as HTMLElement).setAttribute('aria-label', title);
});
};
// Basic formatting
setTitle('button.ql-bold', 'Tučné');
setTitle('button.ql-italic', 'Kurzíva');
setTitle('button.ql-underline', 'Podtržení');
setTitle('button.ql-strike', 'Přeškrtnutí');
setTitle('button.ql-link', 'Vložit odkaz');
setTitle('button.ql-image', 'Vložit obrázek');
setTitle('button.ql-blockquote', 'Citace');
setTitle('button.ql-clean', 'Vyčistit formátování');
// Lists
setTitle('button.ql-list[value="ordered"]', 'Číslovaný seznam');
setTitle('button.ql-list[value="bullet"]', 'Odrážkový seznam');
// Alignment
setTitle('button.ql-align', 'Zarovnání');
setTitle('button.ql-align[value=""]', 'Zarovnat vlevo');
setTitle('button.ql-align[value="center"]', 'Zarovnat na střed');
setTitle('button.ql-align[value="right"]', 'Zarovnat vpravo');
setTitle('button.ql-align[value="justify"]', 'Do bloku');
// Colors and background
setTitle('.ql-color .ql-picker-label', 'Barva textu');
setTitle('.ql-background .ql-picker-label', 'Barva pozadí');
// Headers
setTitle('.ql-header .ql-picker-label', 'Nadpis');
setTitle('.ql-header .ql-picker-item[data-value="1"]', 'Nadpis 1');
setTitle('.ql-header .ql-picker-item[data-value="2"]', 'Nadpis 2');
setTitle('.ql-header .ql-picker-item[data-value="3"]', 'Nadpis 3');
}, [isMounted, toolbar]);
// Get cropped blob
const getCroppedBlob = (image: HTMLImageElement, cropPixels: { x: number; y: number; width: number; height: number }): Promise<Blob> => {
const canvas = document.createElement('canvas');
@@ -368,18 +418,40 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
const editorRect = editor.root.getBoundingClientRect();
const scrollTop = editor.root.scrollTop;
const scrollLeft = editor.root.scrollLeft;
const sizeLabel = document.createElement('div');
sizeLabel.style.cssText = `
position: absolute;
top: -26px;
right: 0;
background: rgba(26,32,44,0.9);
color: #fff;
font-size: 11px;
line-height: 1;
padding: 4px 6px;
border-radius: 4px;
pointer-events: none;
box-shadow: 0 2px 6px rgba(0,0,0,0.3);
`;
const updateSizeLabel = (w: number) => {
try {
const edW = editor.root.clientWidth || w || 1;
const pct = Math.max(1, Math.min(100, Math.round((w / edW) * 100)));
sizeLabel.textContent = `${Math.round(w)} px (${pct}%)`;
} catch {
sizeLabel.textContent = `${Math.round(w)} px`;
}
};
// Create edge handles (right, bottom, left, top)
const handles = [
{ position: 'right', cursor: 'ew-resize', width: '8px', height: '60%' },
{ position: 'bottom', cursor: 'ns-resize', width: '60%', height: '8px' },
{ position: 'left', cursor: 'ew-resize', width: '8px', height: '60%' },
{ position: 'top', cursor: 'ns-resize', width: '60%', height: '8px' },
// Corner handles
{ position: 'bottom-right', cursor: 'nwse-resize', width: '16px', height: '16px', isCorner: true },
{ position: 'bottom-left', cursor: 'nesw-resize', width: '16px', height: '16px', isCorner: true },
{ position: 'top-right', cursor: 'nesw-resize', width: '16px', height: '16px', isCorner: true },
{ position: 'top-left', cursor: 'nwse-resize', width: '16px', height: '16px', isCorner: true },
{ position: 'right', cursor: 'ew-resize', width: '12px', height: '60%' },
{ position: 'bottom', cursor: 'ns-resize', width: '60%', height: '12px' },
{ position: 'left', cursor: 'ew-resize', width: '12px', height: '60%' },
{ position: 'top', cursor: 'ns-resize', width: '60%', height: '12px' },
{ position: 'bottom-right', cursor: 'nwse-resize', width: '20px', height: '20px', isCorner: true },
{ position: 'bottom-left', cursor: 'nesw-resize', width: '20px', height: '20px', isCorner: true },
{ position: 'top-right', cursor: 'nesw-resize', width: '20px', height: '20px', isCorner: true },
{ position: 'top-left', cursor: 'nwse-resize', width: '20px', height: '20px', isCorner: true },
];
const updateHandlePositions = () => {
@@ -467,7 +539,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
});
}
handle.addEventListener('mousedown', (e) => {
handle.addEventListener('pointerdown', (e: PointerEvent) => {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
@@ -477,26 +549,19 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
startWidth = img.offsetWidth;
const startHeight = img.offsetHeight;
const aspectRatio = startWidth / startHeight;
const onMouseMove = (e: MouseEvent) => {
const onPointerMove = (ev: PointerEvent) => {
if (!isResizing) return;
const deltaX = e.clientX - startX;
const deltaY = e.clientY - startY;
const deltaX = ev.clientX - startX;
const deltaY = ev.clientY - startY;
let newWidth = startWidth;
// Calculate new width based on handle position
if (position.includes('right')) {
newWidth = startWidth + deltaX;
} else if (position.includes('left')) {
newWidth = startWidth - deltaX;
} else if (position.includes('bottom') || position.includes('top')) {
// For vertical handles, maintain aspect ratio
newWidth = startWidth + (deltaY * aspectRatio);
}
// Constrain width
newWidth = Math.max(50, Math.min(newWidth, editor.root.clientWidth - 40));
img.style.width = `${newWidth}px`;
img.style.maxWidth = '100%';
img.style.height = 'auto';
@@ -508,25 +573,28 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
setWidthPercent(Math.max(1, Math.min(100, Math.round((newWidth / editorWidth) * 100))));
} catch {}
updateHandlePositions();
updateSizeLabel(newWidth);
};
const onMouseUp: (ev: MouseEvent) => void = () => {
const onPointerUp = () => {
isResizing = false;
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
document.removeEventListener('pointermove', onPointerMove);
document.removeEventListener('pointerup', onPointerUp);
onChangeRef.current(editor.root.innerHTML);
const id = selectedImageIdRef.current;
setTimeout(() => { if (id) { try { selectImageByIdRef.current?.(id); } catch {} } }, 30);
};
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
document.addEventListener('pointermove', onPointerMove);
document.addEventListener('pointerup', onPointerUp);
});
container.appendChild(handle);
});
updateHandlePositions();
updateSizeLabel(img.offsetWidth || img.width || 0);
editor.root.style.position = 'relative';
editor.root.appendChild(container);
container.appendChild(sizeLabel);
resizeHandle = container;
return container;
@@ -547,6 +615,13 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
}
selectedImage = img;
// Ensure image has a persistent ID for reselection after content updates
let id = img.getAttribute('data-img-id') || '';
if (!id) {
id = 'img-' + Date.now() + '-' + Math.random().toString(36).slice(2);
try { img.setAttribute('data-img-id', id); } catch {}
}
selectedImageIdRef.current = id;
img.style.outline = '3px solid #3182ce';
img.style.cursor = 'move';
img.style.boxShadow = '0 4px 12px rgba(49, 130, 206, 0.3)';
@@ -622,6 +697,16 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
setShowImageToolbar(true);
};
// Expose reselection helper bound to current effect scope
selectImageByIdRef.current = (id: string) => {
const ed = quillRef.current?.getEditor();
if (!ed) return;
const node = ed.root.querySelector(`img[data-img-id="${id}"]`) as HTMLImageElement | null;
if (node) {
selectImage(node);
}
};
const deselectImage = () => {
if (selectedImage) {
selectedImage.style.outline = '';
@@ -965,6 +1050,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
// Force overlay reposition
try { editor.root.dispatchEvent(new Event('scroll')); } catch {}
}
reselectAfterContentUpdate();
toast({ title: 'Filtry aplikovány', status: 'success', duration: 2000 });
} catch (e: any) {
@@ -998,10 +1084,20 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
// Force overlay reposition
try { editor.root.dispatchEvent(new Event('scroll')); } catch {}
}
reselectAfterContentUpdate();
toast({ title: `Obrázek zarovnán ${alignment === 'left' ? 'vlevo' : alignment === 'center' ? 'na střed' : 'vpravo'}`, status: 'success', duration: 1500 });
}
}, [selectedImageElement, toast]);
// Reselect helper after content updates (e.g., when value change triggers rerender)
const reselectAfterContentUpdate = useCallback(() => {
const id = selectedImageIdRef.current;
if (!id) return;
setTimeout(() => {
try { selectImageByIdRef.current?.(id); } catch {}
}, 30);
}, []);
const applyWidthPx = useCallback((px: number, opts?: { silent?: boolean }) => {
if (!selectedImageElement) return;
const editor = quillRef.current?.getEditor();
@@ -1017,6 +1113,8 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
onChangeRef.current(editor.root.innerHTML);
try { editor.root.dispatchEvent(new Event('scroll')); } catch {}
}
// Keep selection active for subsequent operations (e.g., 50% → 75%)
reselectAfterContentUpdate();
if (!opts?.silent) {
toast({ title: 'Šířka nastavena', description: `${finalWidth}px`, status: 'success', duration: 1500 });
}
@@ -1036,6 +1134,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
onChangeRef.current(editor.root.innerHTML);
try { editor.root.dispatchEvent(new Event('scroll')); } catch {}
}
reselectAfterContentUpdate();
toast({ title: 'Šířka resetována', status: 'info', duration: 1200 });
}, [selectedImageElement, toast]);
+55 -7
View File
@@ -3,6 +3,34 @@ import { Image, ImageProps, Skeleton } from '@chakra-ui/react';
import { getTeamLogo } from '../../utils/sportLogosAPI';
import { getLogoStyle, getLogoClassName } from '../../utils/logoUtils';
import '../../styles/logos.css';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import { assetUrl } from '../../utils/url';
// Lightweight cached overrides loader
let __teamOverridesCache: { ts: number; data: { by_id?: Record<string, { name?: string; logo_url?: string }>; by_name?: Record<string, string> } } | null = null;
const loadTeamOverrides = async (): Promise<{ by_id?: Record<string, { name?: string; logo_url?: string }>; by_name?: Record<string, string> }> => {
const now = Date.now();
if (__teamOverridesCache && now - __teamOverridesCache.ts < 60_000) {
return __teamOverridesCache.data || {};
}
try {
const res = await fetch(`/api/v1/public/team-logo-overrides?t=${now}`, { cache: 'no-cache' });
if (res.ok) {
const json = await res.json();
__teamOverridesCache = { ts: now, data: json || {} };
return json || {};
}
} catch {}
try {
const res2 = await fetch('/cache/prefetch/team_logo_overrides.json', { cache: 'no-cache' });
if (res2.ok) {
const json = await res2.json();
__teamOverridesCache = { ts: now, data: json || {} };
return json || {};
}
} catch {}
__teamOverridesCache = { ts: now, data: {} };
return {};
};
interface TeamLogoProps extends Omit<ImageProps, 'src'> {
teamId?: string;
@@ -32,6 +60,7 @@ export const TeamLogo: React.FC<TeamLogoProps> = ({
const [logoUrl, setLogoUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const { data: publicSettings } = usePublicSettings();
useEffect(() => {
let mounted = true;
@@ -40,11 +69,30 @@ export const TeamLogo: React.FC<TeamLogoProps> = ({
try {
setLoading(true);
setError(false);
const url = await getTeamLogo(teamId, teamName, facrLogo);
if (mounted) {
setLogoUrl(url);
// Load admin overrides (cached)
let overrides: { by_id?: Record<string, { name?: string; logo_url?: string }> } = {};
try { overrides = await loadTeamOverrides(); } catch {}
// Prefer local club logo for own team when IDs match
if (
teamId && publicSettings?.club_id && String(teamId) === String(publicSettings.club_id) && publicSettings?.club_logo_url
) {
if (mounted) {
setLogoUrl(assetUrl(publicSettings.club_logo_url) || publicSettings.club_logo_url);
}
} else if (teamId && overrides?.by_id?.[teamId]?.logo_url) {
const v = overrides.by_id[teamId]!.logo_url as string;
if (mounted) {
if (typeof v === 'string' && v.startsWith('/')) {
setLogoUrl(assetUrl(v) || v);
} else {
setLogoUrl(v);
}
}
} else {
const url = await getTeamLogo(teamId, teamName, facrLogo);
if (mounted) {
setLogoUrl(url);
}
}
} catch (e) {
console.error('Failed to fetch logo:', e);
@@ -65,7 +113,7 @@ export const TeamLogo: React.FC<TeamLogoProps> = ({
return () => {
mounted = false;
};
}, [teamId, teamName, facrLogo]);
}, [teamId, teamName, facrLogo, publicSettings?.club_id, publicSettings?.club_logo_url]);
// Size mapping
const sizeMap = {
@@ -101,7 +149,7 @@ export const TeamLogo: React.FC<TeamLogoProps> = ({
return (
<Image
src={logoUrl || '/logo192.png'}
src={(assetUrl(logoUrl || undefined) || logoUrl || '/logo192.png')}
alt={alt || teamName || 'Team logo'}
{...sizeProps}
{...imageProps}
@@ -50,6 +50,7 @@ const ThumbnailPreview: React.FC<ThumbnailPreviewProps> = ({
borderRadius={borderRadius}
borderWidth="1px"
borderColor={borderColor}
fallbackSrc="/dist/img/logo-club-empty.svg"
loading="lazy"
/>
</Box>
@@ -70,6 +71,7 @@ const ThumbnailPreview: React.FC<ThumbnailPreviewProps> = ({
maxH="400px"
objectFit="contain"
borderRadius="md"
fallbackSrc="/dist/img/logo-club-empty.svg"
loading="lazy"
/>
</PopoverBody>
@@ -5,6 +5,7 @@ import { usePublicSettings } from '../../hooks/usePublicSettings';
import { useClubTheme } from '../../contexts/ClubThemeContext';
import { getNavigationItems, NavigationItem, seedDefaultNavigation } from '../../services/navigation';
import { getCategories, Category } from '../../services/public';
import { assetUrl } from '../../utils/url';
// Minimal NavLink type used to render items
type NavLink = { label: string; to?: string; items?: { label: string; to: string }[]; external?: boolean };
@@ -121,7 +122,7 @@ const SpartaNavbar: React.FC = () => {
return links;
}, [navLoading, dynamicNavItems, settings?.show_about_in_nav, settings?.shop_url, settings?.gallery_label, categoryItems]);
const logoUrl = settings?.club_logo_url || theme.logoUrl || '/dist/img/logo-club-empty.svg';
const logoUrl = (assetUrl(settings?.club_logo_url || theme.logoUrl) || settings?.club_logo_url || theme.logoUrl) || '/dist/img/logo-club-empty.svg';
const clubName = settings?.club_name || theme.name || 'Klub';
return (
@@ -133,7 +133,17 @@ const EventLocationMap: React.FC<EventLocationMapProps> = ({ location, title, la
return null;
}
const openStreetMapUrl = `https://www.openstreetmap.org/search?query=${encodeURIComponent(location.trim())}`;
const encodedQuery = encodeURIComponent(location.trim());
// Build external map URLs prefer coordinates when available, otherwise fallback to address search
const openStreetMapUrl = coords
? `https://www.openstreetmap.org/?mlat=${coords.lat}&mlon=${coords.lon}#map=17/${coords.lat}/${coords.lon}`
: `https://www.openstreetmap.org/search?query=${encodedQuery}`;
const googleMapsUrl = coords
? `https://www.google.com/maps/search/?api=1&query=${coords.lat},${coords.lon}`
: `https://www.google.com/maps/search/?api=1&query=${encodedQuery}`;
const mapyCzUrl = coords
? `https://mapy.cz/zakladni?x=${coords.lon}&y=${coords.lat}&z=17`
: `https://mapy.cz/zakladni?q=${encodedQuery}`;
return (
<VStack align="stretch" spacing={3} mt={4} data-testid="event-location-map">
@@ -148,9 +158,13 @@ const EventLocationMap: React.FC<EventLocationMapProps> = ({ location, title, la
<AlertIcon />
<Box>
<Text mb={1}>{error}</Text>
<Link href={openStreetMapUrl} isExternal color="blue.400">
Otevřít v OpenStreetMap
</Link>
<Text>
<Link href={openStreetMapUrl} isExternal color="blue.400">Otevřít v OpenStreetMap</Link>
{' · '}
<Link href={googleMapsUrl} isExternal color="blue.400">Otevřít v Google Maps</Link>
{' · '}
<Link href={mapyCzUrl} isExternal color="blue.400">Otevřít v Mapy.cz</Link>
</Text>
</Box>
</Alert>
)}
@@ -172,10 +186,12 @@ const EventLocationMap: React.FC<EventLocationMapProps> = ({ location, title, la
)}
<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>
Přesnost určena pomocí otevřených mapových dat. Zobrazit v{' '}
<Link href={openStreetMapUrl} isExternal color="blue.400">OpenStreetMap</Link>
{' · '}
<Link href={googleMapsUrl} isExternal color="blue.400">Google Maps</Link>
{' · '}
<Link href={mapyCzUrl} isExternal color="blue.400">Mapy.cz</Link>
</Text>
</VStack>
);
+11 -2
View File
@@ -1,8 +1,9 @@
import { Box, Flex, Heading, Image, HStack, Button, Text } from '@chakra-ui/react';
import { Box, Flex, Heading, 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';
import { TeamLogo } from '../common/TeamLogo';
const ClubHeader: React.FC = () => {
const { data: settings } = usePublicSettings();
@@ -18,7 +19,15 @@ const ClubHeader: React.FC = () => {
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" />
<TeamLogo
teamId={clubId}
teamName={data?.name}
facrLogo={data?.logo_url || undefined}
size="custom"
boxSize="64px"
alt={data?.name || 'Club'}
borderRadius="full"
/>
<Box>
<Heading size="lg">{data?.name || 'Club Name'}</Heading>
<Text color="gray.600" fontSize="sm">
@@ -0,0 +1,44 @@
import React from 'react';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import { useClubTheme } from '../../contexts/ClubThemeContext';
import { assetUrl } from '../../utils/url';
export type ClubHeroTopbarVariant = 'brand' | 'minimal' | 'badge';
const cls = (...parts: Array<string | false | null | undefined>) => parts.filter(Boolean).join(' ');
const ClubHeroTopbar: React.FC<{ variant?: ClubHeroTopbarVariant; fullBleed?: boolean }>= ({ variant = 'brand', fullBleed = false }) => {
const { data: settings } = usePublicSettings();
const theme = useClubTheme();
const title = settings?.club_name || theme.name || 'Fotbalový klub';
const tagline = 'Oficiální web klubu';
const logo = assetUrl(settings?.club_logo_url || theme.logoUrl) || settings?.club_logo_url || theme.logoUrl || '/dist/img/logo-club-empty.svg';
const shopUrl = settings?.shop_url || undefined;
const calendarUrl = '/kalendar';
return (
<div className={cls('club-hero-topbar', fullBleed && 'full-bleed',
variant === 'brand' && 'club-hero-topbar--brand',
variant === 'minimal' && 'club-hero-topbar--minimal',
variant === 'badge' && 'club-hero-topbar--badge'
)}>
<div className="club-hero-topbar__logo">
{/* eslint-disable-next-line jsx-a11y/alt-text */}
<img src={logo} alt={title} style={{ width: 36, height: 36, objectFit: 'contain' }} />
</div>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<div className="club-hero-topbar__title">{title}</div>
<div className="club-hero-topbar__tagline">{tagline}</div>
</div>
<div className="club-hero-topbar__spacer" />
<div className="club-hero-topbar__actions">
<a href={calendarUrl} className="sparta-button-tertiary">Kalendář</a>
{shopUrl && (
<a href={shopUrl} target="_blank" rel="noreferrer" className="sparta-button-primary">Fanshop</a>
)}
</div>
</div>
);
};
export default ClubHeroTopbar;
@@ -139,11 +139,19 @@ const CompetitionMatches: React.FC = () => {
return (
<Box>
<Tabs variant="soft-rounded" colorScheme="blue" isFitted>
<TabList>
<Tabs variant="soft-rounded" colorScheme="blue" 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' },
}}>
{sortedCompetitions.map((c) => {
const label = c.alias || c.name;
return <Tab key={c.id}>{label}</Tab>;
return (
<Tab key={c.id} flex="0 0 auto" px={3} py={2} fontSize="sm">
<Text as="span" noOfLines={1} maxW="220px" title={label}>{label}</Text>
</Tab>
);
})}
</TabList>
<TabPanels>
@@ -1,6 +1,7 @@
import React from 'react';
import { Box, Flex, HStack, Image, Text, Container, useColorModeValue } from '@chakra-ui/react';
import { Link as RouterLink } from 'react-router-dom';
import { assetUrl } from '../../utils/url';
interface HeaderVariantsProps {
variant: 'unified' | 'edge' | 'minimal' | 'modern';
@@ -15,9 +16,9 @@ const HeaderVariants: React.FC<HeaderVariantsProps> = ({
clubLogo,
clubId,
}) => {
const displayLogo = clubId
const displayLogo = (assetUrl(clubLogo) || clubLogo) || (clubId
? `http://logoapi.sportcreative.eu/logos/${clubId}?format=svg`
: clubLogo || '/images/club-logo.png';
: '/images/club-logo.png');
// Unified variant - classic header
if (variant === 'unified') {
@@ -99,10 +99,16 @@ const MatchesSection: React.FC = () => {
)}
{isLoading && <Skeleton height="200px" />}
{!isLoading && data && (
<Tabs variant="enclosed-colored" isFitted>
<TabList>
<Tabs variant="enclosed-colored" 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}>{c.name}</Tab>
<Tab key={c.id} flex="0 0 auto" px={3} py={2} fontSize="sm">
<Text as="span" noOfLines={1} maxW="220px" title={c.name}>{c.name}</Text>
</Tab>
))}
</TabList>
<TabPanels>
+30 -5
View File
@@ -44,6 +44,13 @@ const TableSection: React.FC = () => {
const rankTopText = useColorModeValue('green.800', 'white');
const pointsBg = useColorModeValue('blue.600', 'blue.400');
const pointsText = 'white';
// TabList theming constants (hoisted to avoid hooks in loops/conditions)
const tabListBg = useColorModeValue('white', 'gray.800');
const tabListBorder = useColorModeValue('gray.200', 'gray.700');
const tabSelectedBg = useColorModeValue('blue.50', 'blue.900');
const tabSelectedColor = useColorModeValue('blue.700', 'blue.200');
const tabSelectedBorderColor = useColorModeValue('blue.200', 'blue.600');
const tabColor = useColorModeValue('gray.800', 'gray.200');
const { data, isLoading, isError, error } = useQuery({
queryKey: ['facr-table', clubId, clubType],
queryFn: () => facrApi.getClubTable(clubId, clubType),
@@ -135,15 +142,33 @@ const TableSection: React.FC = () => {
</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')}>
<Tabs variant="enclosed" colorScheme="blue" size="sm">
<TabList
bg={tabListBg}
borderRadius="md"
borderWidth="1px"
borderColor={tabListBorder}
px={2}
pt={2}
overflowX="auto"
overflowY="hidden"
css={{
'&::-webkit-scrollbar': { height: '4px' },
'&::-webkit-scrollbar-track': { background: 'transparent' },
'&::-webkit-scrollbar-thumb': { background: 'var(--chakra-colors-gray-300)', borderRadius: '4px' },
}}
>
{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')}
_selected={{ bg: tabSelectedBg, color: tabSelectedColor, borderColor: tabSelectedBorderColor }}
color={tabColor}
flex="0 0 auto"
px={3}
py={2}
fontSize="sm"
>
{c.name}
<Text as="span" noOfLines={1} maxW="240px" title={c.name}>{c.name}</Text>
</Tab>
))}
</TabList>
@@ -1,6 +1,7 @@
import { Box, Heading, HStack, VStack, Image, Text, useColorModeValue } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { getPlayers, Player } from '../../services/players';
import { assetUrl } from '../../utils/url';
const TeamScroller: React.FC = () => {
const { data } = useQuery({ queryKey: ['players'], queryFn: getPlayers });
@@ -13,7 +14,7 @@ const TeamScroller: React.FC = () => {
<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" />
<Image src={assetUrl(p.image_url) || '/logo192.png'} alt={p.first_name + ' ' + p.last_name} w="140px" h="140px" objectFit="cover" borderRadius="lg" fallbackSrc="/dist/img/logo-club-empty.svg" />
<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>
+14 -7
View File
@@ -16,6 +16,9 @@ export const MainLayout: React.FC<MainLayoutProps> = ({ children, headerInsideCo
const [showTop, setShowTop] = useState(false);
const { getStyles, getVariant } = useAllPageElementConfigs('homepage');
const headerVariant = getVariant('header', 'unified');
const sponsorsVariant = getVariant('sponsors', 'grid');
const footerVariant = getVariant('footer', 'standard');
const headerIsInside = headerInsideContainer && headerVariant !== 'fullwidth';
useEffect(() => {
const onScroll = () => {
@@ -39,10 +42,10 @@ export const MainLayout: React.FC<MainLayoutProps> = ({ children, headerInsideCo
return (
<Box minH="100vh" bg="bg.app" overflowX="hidden">
<Box id="top" position="absolute" top={0} left={0} />
{headerInsideContainer ? (
{headerIsInside ? (
<>
<Container maxW="container.xl" py={8}>
<Box as="header" data-element="header" style={{ ...getStyles('header') }}>
<Box as="header" data-element="header" data-variant={headerVariant} style={{ ...getStyles('header') }}>
{headerVariant === 'sparta_navbar' ? (
<SpartaNavbar />
) : (
@@ -51,14 +54,16 @@ export const MainLayout: React.FC<MainLayoutProps> = ({ children, headerInsideCo
</Box>
{children}
</Container>
<SponsorsSection />
<Box as="footer" data-element="footer" style={{ ...getStyles('footer') }}>
<Box data-element="sponsors" data-variant={sponsorsVariant} style={{ ...getStyles('sponsors') }}>
<SponsorsSection />
</Box>
<Box as="footer" data-element="footer" data-variant={footerVariant} style={{ ...getStyles('footer') }}>
<Footer />
</Box>
</>
) : (
<>
<Box as="header" data-element="header" style={{ ...getStyles('header') }}>
<Box as="header" data-element="header" data-variant={headerVariant} style={{ ...getStyles('header') }}>
{headerVariant === 'sparta_navbar' ? (
<SpartaNavbar />
) : (
@@ -69,8 +74,10 @@ export const MainLayout: React.FC<MainLayoutProps> = ({ children, headerInsideCo
{children}
</Container>
{/* Global sponsors section across front-facing pages */}
<SponsorsSection />
<Box as="footer" data-element="footer" style={{ ...getStyles('footer') }}>
<Box data-element="sponsors" data-variant={sponsorsVariant} style={{ ...getStyles('sponsors') }}>
<SponsorsSection />
</Box>
<Box as="footer" data-element="footer" data-variant={footerVariant} style={{ ...getStyles('footer') }}>
<Footer />
</Box>
</>
@@ -0,0 +1,33 @@
import React from 'react';
import { assetUrl } from '../../utils/url';
export type ActivityItem = {
id: number | string;
title: string;
image_url?: string | null;
start_time: string;
location?: string | null;
};
const ActivitiesList: React.FC<{
items: ActivityItem[];
}> = ({ items }) => {
const list = Array.isArray(items) ? items.slice(0, 4) : [];
return (
<div className="blog-list">
{list.map((e) => (
<a key={e.id} href={`/aktivita/${e.id}`} className="card" style={{ textDecoration: 'none', color: 'inherit' }}>
<div className="thumb" style={{ backgroundImage: `url(${assetUrl(e.image_url) || '/images/news/placeholder.jpg'})` }} />
<div>
<h4>{e.title}</h4>
<div style={{ color: 'var(--dark-gray)', fontSize: '0.9rem' }}>
{new Date(e.start_time).toLocaleDateString()} {e.location ? `${e.location}` : ''}
</div>
</div>
</a>
))}
</div>
);
};
export default ActivitiesList;
@@ -0,0 +1,127 @@
import React, { useEffect, useRef } from 'react';
import { TeamLogo } from '../common/TeamLogo';
import { sanitizeClubName } from '../../utils/url';
export type SliderMatch = {
id?: string | number;
date?: string;
time?: string;
home_id?: any;
home?: string;
home_logo_url?: string;
away_id?: any;
away?: string;
away_logo_url?: string;
venue?: string;
score?: string;
};
export type CompetitionBucket = { name: string; matches: SliderMatch[] };
const MatchesSlider: React.FC<{
title?: string;
comps: CompetitionBucket[];
activeIndex: number;
onActiveChange: (idx: number) => void;
onMatchClick?: (m: SliderMatch, compName?: string) => void;
elementProps?: any;
}> = ({ title = 'Zápasy', comps, activeIndex, onActiveChange, onMatchClick, elementProps }) => {
const trackRef = useRef<HTMLDivElement | null>(null);
const current = comps[Math.max(0, Math.min(activeIndex, comps.length - 1))];
// Auto-center closest match by current time when comp/tab changes
useEffect(() => {
try {
const el = trackRef.current;
if (!el) return;
const items = Array.isArray(current?.matches) ? current!.matches : [];
const now = Date.now();
let best = 0;
let bestDiff = Number.POSITIVE_INFINITY;
items.forEach((m, idx) => {
const iso = `${m.date || ''}T${(m.time || '00:00')}:00`;
const t = new Date(iso).getTime();
if (!isNaN(t)) {
const d = Math.abs(t - now);
if (d < bestDiff) { bestDiff = d; best = idx; }
}
});
const child = el.children?.[best] as HTMLElement | undefined;
if (!child) return;
const run = () => {
const targetLeft = child.offsetLeft - (el.clientWidth - child.clientWidth) / 2;
el.scrollTo({ left: Math.max(0, targetLeft), behavior: 'smooth' });
};
if (typeof requestAnimationFrame !== 'undefined') requestAnimationFrame(run); else setTimeout(run, 0);
} catch {}
}, [activeIndex, JSON.stringify(current?.matches)]);
return (
<section className="matches-slider" {...(elementProps || {})}>
<div className="section-head" style={{ marginTop: 16, marginBottom: 16 }}>
<h3>{title}</h3>
<a href="/kalendar" className="see-all">Všechny zápasy</a>
</div>
<div className="matches-grid">
<div className="matches-track" ref={trackRef}>
{(current?.matches || []).map((m, idx) => (
<div
key={m.id || idx}
className="match-card"
onClick={(e) => { e.preventDefault(); onMatchClick?.(m, current?.name); }}
style={{ cursor: onMatchClick ? 'pointer' as const : 'default' as const }}
>
<div className="match-meta">
<span>{(m.venue || '').split(',')[0] || ''}</span>
<span></span>
<span>{m.date ? new Date(`${m.date}T${(m.time || '00:00')}:00`).toLocaleDateString() : (m.time || '')}</span>
</div>
<div className="teams">
<div className="team">
<TeamLogo
teamId={m.home_id}
teamName={m.home}
facrLogo={m.home_logo_url}
size="custom"
alt={m.home}
borderRadius="full"
/>
<div className="name">{sanitizeClubName(m.home || '')}</div>
</div>
<div className="score">
{m.score ? (
<>
<span className="home">{String(m.score).split(':')[0]}</span>
<span className="sep">:</span>
<span className="away">{String(m.score).split(':')[1]}</span>
</>
) : (
<span className="time">{m.time}</span>
)}
</div>
<div className="team">
<TeamLogo
teamId={m.away_id}
teamName={m.away}
facrLogo={m.away_logo_url}
size="custom"
alt={m.away}
borderRadius="full"
/>
<div className="name">{sanitizeClubName(m.away || '')}</div>
</div>
</div>
</div>
))}
</div>
<div className="matches-tabs">
{comps.map((c, i) => (
<button key={`${c.name}-${i}`} className={i === activeIndex ? 'active' : ''} onClick={() => onActiveChange(i)}>{c.name}</button>
))}
</div>
</div>
</section>
);
};
export default MatchesSlider;
+48
View File
@@ -0,0 +1,48 @@
import React from 'react';
import { assetUrl } from '../../utils/url';
export type NewsListItem = {
id: number | string;
title: string;
excerpt?: string;
image?: string;
slug?: string;
};
const NewsList: React.FC<{
items: NewsListItem[];
emptyText?: string;
seeAllHref?: string;
seeAllLabel?: string;
}> = ({ items, emptyText = 'Zatím nejsou k dispozici žádné aktuality.', seeAllHref, seeAllLabel = 'Zobrazit všechny aktuality' }) => {
return (
<>
<div className="blog-list">
{items && items.length > 0 ? (
items.slice(0, 4).map((n) => (
<a key={n.id} href={`/news/${n.slug || n.id}`} className="card" style={{ textDecoration: 'none', color: 'inherit' }}>
<div className="thumb" style={{ backgroundImage: `url(${assetUrl(n.image) || '/images/news/placeholder.jpg'})` }} />
<div>
<h4>{n.title}</h4>
{n.excerpt && (
<div style={{ color: 'var(--dark-gray)', fontSize: '0.9rem' }}>{n.excerpt}</div>
)}
</div>
</a>
))
) : (
<div style={{ padding: '24px', textAlign: 'center', color: 'var(--dark-gray)', background: 'var(--bg-soft)', borderRadius: '12px' }}>
<p>{emptyText}</p>
</div>
)}
</div>
{seeAllHref && items && items.length > 0 && (
<div style={{ marginTop: 12 }}>
<a className="btn" href={seeAllHref}>{seeAllLabel}</a>
</div>
)}
</>
);
};
export default NewsList;
@@ -0,0 +1,92 @@
import React from 'react';
import { FiChevronLeft, FiChevronRight } from 'react-icons/fi';
import { TeamLogo } from '../common/TeamLogo';
import { sanitizeClubName } from '../../utils/url';
export type NextMatchData = {
competition?: string;
home_id?: any;
home?: string;
home_logo_url?: string;
away_id?: any;
away?: string;
away_logo_url?: string;
};
const NextMatch: React.FC<{
data: NextMatchData | null;
competitionName?: string;
countdown?: string;
onPrev?: () => void;
onNext?: () => void;
onOpen?: () => void;
elementProps?: any;
}> = ({ data, competitionName, countdown, onPrev, onNext, onOpen, elementProps }) => {
const show = data;
return (
<section
className="next-match"
{...(elementProps as any)}
onClick={(e) => { e.stopPropagation(); onOpen?.(); }}
style={{ cursor: onOpen ? 'pointer' : 'default', position: 'relative', ...(elementProps?.style || {}) }}
>
{onPrev && (
<button
aria-label="Předchozí soutěž"
onClick={(e) => { e.stopPropagation(); onPrev?.(); }}
className="nav prev"
style={{ background: 'transparent', border: 'none', color: 'var(--text-on-primary)' }}
>
<FiChevronLeft size={24} />
</button>
)}
<div className="team">
<TeamLogo
className="logo"
teamId={show?.home_id}
teamName={show?.home}
facrLogo={show?.home_logo_url}
size="custom"
alt="Domácí"
borderRadius="full"
/>
<div>{sanitizeClubName(show?.home || '')}</div>
</div>
<div className="countdown">
{competitionName && (
<div style={{ fontSize: '0.8rem', opacity: 0.85, marginBottom: 4 }}>{competitionName}</div>
)}
{countdown || '—'}
<div style={{ fontSize: '0.8rem', opacity: 0.85 }}>Začátek zápasu</div>
</div>
<div className="team">
<TeamLogo
className="logo"
teamId={show?.away_id}
teamName={show?.away}
facrLogo={show?.away_logo_url}
size="custom"
alt="Hosté"
borderRadius="full"
/>
<div>{sanitizeClubName(show?.away || '')}</div>
</div>
{onNext && (
<button
aria-label="Další soutěž"
onClick={(e) => { e.stopPropagation(); onNext?.(); }}
className="nav next"
style={{ background: 'transparent', border: 'none', color: 'var(--text-on-primary)' }}
>
<FiChevronRight size={24} />
</button>
)}
</section>
);
};
export default NextMatch;
@@ -0,0 +1,80 @@
import React from 'react';
export interface StandingRow {
position?: number;
pos?: number;
rank?: number;
team?: any;
team_id?: string | number;
team_logo_url?: string;
club?: string;
points?: number | string;
pts?: number | string;
played?: number | string;
matches?: number | string;
wins?: number | string;
win?: number | string;
draws?: number | string;
draw?: number | string;
losses?: number | string;
loss?: number | string;
score?: string;
}
const StandingsCard: React.FC<{ rows: StandingRow[]; onRowClick?: (row: StandingRow, index: number) => void }>= ({ rows, onRowClick }) => {
const safe = Array.isArray(rows) ? rows : [];
return (
<div className="table-card">
<div className="standings-table-wrapper" style={{ overflowX: 'auto' }}>
<table className="standings-table-compact" style={{ width: '100%', borderCollapse: 'separate', borderSpacing: '0 4px' }}>
<thead>
<tr style={{ fontSize: '0.75rem', color: 'var(--dark-gray)', textTransform: 'uppercase' }}>
<th style={{ padding: '6px 8px', textAlign: 'left', fontWeight: 600 }}>#</th>
<th style={{ padding: '6px 8px', textAlign: 'left', fontWeight: 600 }}>Tým</th>
<th style={{ padding: '6px 4px', textAlign: 'center', fontWeight: 600 }}>Z</th>
<th style={{ padding: '6px 4px', textAlign: 'center', fontWeight: 600 }}>V</th>
<th style={{ padding: '6px 4px', textAlign: 'center', fontWeight: 600 }}>R</th>
<th style={{ padding: '6px 4px', textAlign: 'center', fontWeight: 600 }}>P</th>
<th style={{ padding: '6px 4px', textAlign: 'center', fontWeight: 600, display: 'none' }} className="hide-mobile">Skóre</th>
<th style={{ padding: '6px 8px', textAlign: 'center', fontWeight: 600 }}>Body</th>
</tr>
</thead>
<tbody>
{safe.slice(0, 8).map((row, idx) => (
<tr
key={idx}
onClick={() => onRowClick?.(row, idx)}
style={{
cursor: onRowClick ? 'pointer' : 'default',
background: 'var(--card-bg)',
border: '1px solid var(--card-border)',
borderRadius: '8px',
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLTableRowElement).style.boxShadow = '0 4px 12px rgba(0,0,0,0.08)';
(e.currentTarget as HTMLTableRowElement).style.borderColor = 'var(--primary)';
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLTableRowElement).style.boxShadow = 'none';
(e.currentTarget as HTMLTableRowElement).style.borderColor = 'var(--card-border)';
}}
>
<td style={{ padding: '10px 8px', fontWeight: 700, color: 'var(--secondary)' }}>{row.position ?? row.pos ?? row.rank ?? idx + 1}</td>
<td style={{ padding: '10px 8px', fontWeight: 600 }}>{(row as any).team?.name ?? (row as any).team ?? (row as any).club ?? '-'}</td>
<td style={{ padding: '10px 4px', textAlign: 'center' }}>{(row as any).played ?? (row as any).matches ?? '-'}</td>
<td style={{ padding: '10px 4px', textAlign: 'center' }}>{(row as any).wins ?? (row as any).win ?? '-'}</td>
<td style={{ padding: '10px 4px', textAlign: 'center' }}>{(row as any).draws ?? (row as any).draw ?? '-'}</td>
<td style={{ padding: '10px 4px', textAlign: 'center' }}>{(row as any).losses ?? (row as any).loss ?? '-'}</td>
<td style={{ padding: '10px 4px', textAlign: 'center', display: 'none' }} className="hide-mobile">{(row as any).score ?? '-'}</td>
<td style={{ padding: '10px 8px', textAlign: 'center', fontWeight: 800 }}>{(row as any).points ?? (row as any).pts ?? '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
};
export default StandingsCard;
+91 -25
View File
@@ -6,6 +6,7 @@ import {
Spinner,
Text,
useColorModeValue,
SimpleGrid,
} from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { getPolls, getPoll } from '../../services/polls';
@@ -17,6 +18,7 @@ interface EmbeddedPollProps {
videoUrl?: string;
title?: string;
showTitle?: boolean;
maxPolls?: number;
}
/**
@@ -29,6 +31,7 @@ const EmbeddedPoll: React.FC<EmbeddedPollProps> = ({
videoUrl,
title = 'Hlasování',
showTitle = true,
maxPolls,
}) => {
const bgSection = useColorModeValue('gray.50', 'gray.900');
@@ -46,16 +49,31 @@ const EmbeddedPoll: React.FC<EmbeddedPollProps> = ({
staleTime: 2 * 60 * 1000,
});
// Get full poll data for each
const pollsToDisplay = polls?.slice(0, 3) || []; // Max 3 polls per content
// Get full poll data for each (all linked polls)
const pollsToDisplay = polls || [];
const preSortedLimited = React.useMemo(() => {
const sorted = [...pollsToDisplay].sort((a, b) => {
const aRating = a.type === 'rating' ? 1 : 0;
const bRating = b.type === 'rating' ? 1 : 0;
if (aRating !== bRating) return bRating - aRating;
const aFeat = a.featured ? 1 : 0;
const bFeat = b.featured ? 1 : 0;
if (aFeat !== bFeat) return bFeat - aFeat;
const aDate = new Date(a.created_at).getTime();
const bDate = new Date(b.created_at).getTime();
return bDate - aDate;
});
return typeof maxPolls === 'number' ? sorted.slice(0, maxPolls) : sorted;
}, [pollsToDisplay, maxPolls]);
const { data: pollsData, isLoading: isLoadingPolls } = useQuery({
queryKey: ['embedded-polls-details', pollsToDisplay.map((p) => p.id)],
queryKey: ['embedded-polls-details', preSortedLimited.map((p) => p.id)],
queryFn: async () => {
const promises = pollsToDisplay.map((poll) => getPoll(poll.id));
const promises = preSortedLimited.map((poll) => getPoll(poll.id));
return await Promise.all(promises);
},
enabled: pollsToDisplay.length > 0,
enabled: preSortedLimited.length > 0,
});
// Don't render anything if no content identifier provided
@@ -84,35 +102,83 @@ const EmbeddedPoll: React.FC<EmbeddedPollProps> = ({
return (
<Box bg={bgSection} py={8} px={4} borderRadius="xl" my={8}>
<VStack spacing={6} maxW="3xl" mx="auto">
<VStack spacing={6} maxW="6xl" 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>
{isLoadingPolls ? (
<VStack py={8}>
<Spinner />
<Text>Načítání...</Text>
</VStack>
) : (
(() => {
// Sort: rating first, then featured, then newest
const sorted = [...(pollsData || [])].sort((a, b) => {
const aRating = a.poll.type === 'rating' ? 1 : 0;
const bRating = b.poll.type === 'rating' ? 1 : 0;
if (aRating !== bRating) return bRating - aRating;
const aFeat = a.poll.featured ? 1 : 0;
const bFeat = b.poll.featured ? 1 : 0;
if (aFeat !== bFeat) return bFeat - aFeat;
const aDate = new Date(a.poll.created_at).getTime();
const bDate = new Date(b.poll.created_at).getTime();
return bDate - aDate;
});
const limited = typeof maxPolls === 'number' ? sorted.slice(0, maxPolls) : sorted;
const count = limited.length;
if (count === 1) {
const pollResponse = limited[0];
return (
<Box w="full">
<PollCard
poll={pollResponse.poll}
hasVoted={pollResponse.has_voted}
isActive={pollResponse.is_active}
canShowResults={pollResponse.can_show_results}
/>
</Box>
);
}
if (count === 2) {
return (
<SimpleGrid w="full" columns={{ base: 1, md: 2 }} spacing={4}>
{limited.map((pollResponse) => (
<Box key={pollResponse.poll.id}>
<PollCard
poll={pollResponse.poll}
hasVoted={pollResponse.has_voted}
isActive={pollResponse.is_active}
canShowResults={pollResponse.can_show_results}
/>
</Box>
))}
</SimpleGrid>
);
}
return (
<SimpleGrid w="full" columns={{ base: 1, sm: 2, lg: 3 }} spacing={4}>
{limited.map((pollResponse) => (
<Box key={pollResponse.poll.id}>
<PollCard
poll={pollResponse.poll}
hasVoted={pollResponse.has_voted}
isActive={pollResponse.is_active}
canShowResults={pollResponse.can_show_results}
/>
</Box>
))}
</SimpleGrid>
);
})()
)}
</VStack>
</Box>
);
};
export default EmbeddedPoll;
+19 -6
View File
@@ -20,11 +20,12 @@ import {
FormLabel,
Link,
} from '@chakra-ui/react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
import { CheckIcon, StarIcon } from '@chakra-ui/icons';
import {
Poll,
PollOption,
PollResultsResponse,
votePoll,
getPollResults,
generateSessionToken,
@@ -87,6 +88,16 @@ const PollCard: React.FC<PollCardProps> = ({
const borderColor = useColorModeValue('gray.200', 'gray.600');
const hoverBg = useColorModeValue('gray.50', 'gray.700');
// Live results polling when results are visible and allowed
const showLiveResults = canShowResults && showingResults;
const { data: liveResultsData } = useQuery<PollResultsResponse>({
queryKey: ['poll-results', poll.id],
queryFn: () => getPollResults(poll.id),
enabled: showLiveResults,
refetchInterval: 4000,
refetchOnWindowFocus: true,
});
// Vote mutation
const voteMutation = useMutation({
mutationFn: () => {
@@ -113,6 +124,7 @@ const PollCard: React.FC<PollCardProps> = ({
queryClient.invalidateQueries({ queryKey: ['polls'] });
queryClient.invalidateQueries({ queryKey: ['poll', poll.id] });
queryClient.invalidateQueries({ queryKey: ['poll-results', poll.id] });
toast({
title: 'Hlas zaznamenán!',
@@ -227,13 +239,14 @@ const PollCard: React.FC<PollCardProps> = ({
// Show results if available
if (showingResults && canShowResults) {
const displayResults = results.length > 0 ? results : poll.options.map(opt => ({
const totalVotesToShow = liveResultsData?.total_votes ?? poll.total_votes;
const displayResults = liveResultsData?.results || (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),
percentage: totalVotesToShow ? (opt.vote_count / totalVotesToShow) * 100 : 0,
image_url: opt.image_url,
}));
})));
return (
<Box
@@ -275,7 +288,7 @@ const PollCard: React.FC<PollCardProps> = ({
<VStack spacing={3} align="stretch">
<Text fontWeight="bold" fontSize="sm" color="gray.500">
Výsledky ({poll.total_votes} hlasů)
Výsledky ({totalVotesToShow} hlasů)
</Text>
{displayResults.map((result) => (
<Box key={result.option_id}>
@@ -569,7 +582,7 @@ const PollCard: React.FC<PollCardProps> = ({
)}
<Text fontSize="xs" color="gray.500" textAlign="center">
Celkem hlasů: {poll.total_votes}
Celkem hlasů: {liveResultsData?.total_votes ?? poll.total_votes}
</Text>
</VStack>
</Box>
@@ -83,6 +83,7 @@ export const MatchesWidget = () => {
queryFn: fetchTeamLogoOverrides,
staleTime: 5 * 60 * 1000,
});
const byId: Record<string, { name?: string; logo_url?: string }> = (overrides as any)?.by_id || {};
const getLogo = (teamName?: string, original?: string) => {
const byName = (overrides as any)?.by_name || {} as Record<string, string>;
const norm = (s: string) => String(s || '')
@@ -147,12 +148,14 @@ export const MatchesWidget = () => {
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,
home: (m.home_id && byId?.[m.home_id]?.name && String(byId[m.home_id].name).trim()) ? String(byId[m.home_id].name) : (m.home || m.home_team),
away: (m.away_id && byId?.[m.away_id]?.name && String(byId[m.away_id].name).trim()) ? String(byId[m.away_id].name) : (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),
home_logo_url: (m.home_id && byId?.[m.home_id]?.logo_url) ? String(byId[m.home_id].logo_url) : getLogo(m.home || m.home_team, m.home_logo_url),
away_logo_url: (m.away_id && byId?.[m.away_id]?.logo_url) ? String(byId[m.away_id].logo_url) : getLogo(m.away || m.away_team, m.away_logo_url),
home_id: m.home_id,
away_id: m.away_id,
})) as Match[];
return upcoming;