mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 02:32:57 +00:00
de day #74
This commit is contained in:
@@ -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
|
||||
<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>
|
||||
|
||||
@@ -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}>E‑mailové 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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user