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
+13 -2
View File
@@ -1,7 +1,7 @@
import React, { useEffect, useState } from 'react';
import { ChakraProvider, extendTheme } from '@chakra-ui/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter as Router, Routes, Route, Navigate, Outlet } from 'react-router-dom';
import { BrowserRouter as Router, Routes, Route, Navigate, Outlet, useLocation } from 'react-router-dom';
import './styles/custom-scrollbar.css';
import { AuthProvider, useAuth } from './contexts/AuthContext';
import AuthPage from './pages/AuthPage';
@@ -51,6 +51,7 @@ import AnalyticsAdminPage from './pages/admin/AnalyticsAdminPage';
import FilesAdminPage from './pages/admin/FilesAdminPage';
import ContactsAdminPage from './pages/admin/ContactsAdminPage';
import NavigationAdminPage from './pages/admin/NavigationAdminPage';
import ShortlinksAdminPage from './pages/admin/ShortlinksAdminPage';
import SemiAdminPage from './pages/SemiAdminPage';
import PollsAdminPage from './pages/admin/PollsAdminPage';
// Admin pages render their own AdminLayout internally
@@ -75,6 +76,7 @@ import ForbiddenPage from './pages/ForbiddenPage';
import NotFoundPage from './pages/NotFoundPage';
import VideosPage from './pages/VideosPage';
import SearchPage from './pages/SearchPage';
import ShortRedirectPage from './pages/ShortRedirectPage';
import ClothingPage from './pages/ClothingPage';
import PollsPage from './pages/PollsPage';
import { useUmami } from './hooks/useUmami';
@@ -258,6 +260,12 @@ const FontLoader: React.FC = () => {
return null;
};
// Redirect /news -> /blog while preserving query parameters
const NewsRedirect: React.FC = () => {
const loc = useLocation();
return <Navigate to={`/blog${loc.search || ''}`} replace />;
};
const App: React.FC = () => {
// Uses shared ProtectedRoute component for auth guard
@@ -352,7 +360,9 @@ const App: React.FC = () => {
<Route path="/pravidla-cookies" element={<CookiePolicyPage />} />
<Route path="/obchodni-podminky" element={<TermsPage />} />
<Route path="/zasady-ochrany-osobnich-udaju" element={<PrivacyPolicyPage />} />
<Route path="/news" element={<Navigate to="/blog" replace />} />
{/* Short links - forward to backend origin if frontend captured it */}
<Route path="/s/:code" element={<ShortRedirectPage />} />
<Route path="/news" element={<NewsRedirect />} />
{/* Slug routes must precede id route to avoid conflicts */}
<Route path="/news/:slug" element={<ArticleDetailPage />} />
<Route path="/articles/slug/:slug" element={<ArticleDetailPage />} />
@@ -440,6 +450,7 @@ const App: React.FC = () => {
<Route path="/admin/scoreboard" element={<ScoreboardAdminPage />} />
<Route path="/admin/scoreboard/remote" element={<MobileScoreboardControlPage />} />
<Route path="/admin/analytika" element={<AnalyticsAdminPage />} />
<Route path="/admin/shortlinks" element={<ShortlinksAdminPage />} />
<Route path="/admin/soubory" element={<FilesAdminPage />} />
<Route path="/admin/kontakty" element={<ContactsAdminPage />} />
<Route path="/admin/navigace" element={<NavigationAdminPage />} />
+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;
+2 -2
View File
@@ -134,8 +134,8 @@ export const ClubThemeProvider: React.FC<{ children: React.ReactNode }>= ({ chil
textOnAccent = pickTextColor(accent!);
}
// Prefer logo from logoapi.sportcreative.eu when club ID is known
if (clubId) {
// Prefer local logo from settings; only fetch from logoapi when no explicit local logo is configured
if (!explicitLogo && clubId) {
try {
const apiLogo = await fetchLogoFromLogoAPI(String(clubId), name);
if (apiLogo) {
+18
View File
@@ -6,7 +6,9 @@ import { PageElementConfig } from '../services/pageElements';
// Elements that are actually implemented on HomePage
// Only these should be available in the editor
export const HOMEPAGE_IMPLEMENTED_ELEMENTS = [
'style-pack', // Global style pack selector
'header', // Site navigation/header
'hero-topbar', // Club bar above hero
'hero', // Hero section with news cards (grid/scroller/swiper variants)
'news', // Featured news articles
'matches', // Upcoming/recent matches
@@ -23,6 +25,14 @@ export const HOMEPAGE_IMPLEMENTED_ELEMENTS = [
];
export const DEFAULT_HOMEPAGE_ELEMENTS: PageElementConfig[] = [
{
page_type: 'homepage',
element_name: 'style-pack',
variant: 'default',
visible: true,
display_order: -1,
settings: {},
},
{
page_type: 'homepage',
element_name: 'header',
@@ -31,6 +41,14 @@ export const DEFAULT_HOMEPAGE_ELEMENTS: PageElementConfig[] = [
display_order: 0,
settings: {},
},
{
page_type: 'homepage',
element_name: 'hero-topbar',
variant: 'brand',
visible: true,
display_order: 1,
settings: {},
},
{
page_type: 'homepage',
element_name: 'hero',
+3 -2
View File
@@ -23,6 +23,7 @@ import { getCategories, CategoryItem } from '../services/categories';
import { Link as RouterLink } from 'react-router-dom';
import SponsorsSection from '../components/common/SponsorsSection';
import NewsletterCTA from '../components/common/NewsletterCTA';
import { assetUrl } from '../utils/url';
type AboutPageData = {
title: string;
@@ -155,7 +156,7 @@ const AboutPage: React.FC = () => {
<Helmet>
<title>{settings?.club_name ? `O klubu | ${settings.club_name}` : 'O klubu'}</title>
<meta name="description" content="Informace o našem klubu, soutěžích, nadcházejících zápasech a rubrikách." />
{settings?.club_logo_url && <meta property="og:image" content={settings.club_logo_url} />}
{settings?.club_logo_url && <meta property="og:image" content={assetUrl(settings.club_logo_url) || settings.club_logo_url} />}
</Helmet>
<Container maxW="container.lg" py={8}>
<Box textAlign="center" py={6}>
@@ -177,7 +178,7 @@ const AboutPage: React.FC = () => {
const seoTitle = data.seo_title || data.title;
const seoDesc = data.seo_description || data.subtitle;
const clubName = settings?.club_name || data.title;
const clubLogo = settings?.club_logo_url;
const clubLogo = settings?.club_logo_url ? (assetUrl(settings.club_logo_url) || settings.club_logo_url) : undefined;
const cleanContent = DOMPurify.sanitize(data.content);
const renderContent = () => {
+29 -2
View File
@@ -6,6 +6,7 @@ import { Box, Container, Heading, Text, VStack, HStack, Badge, Spinner, Button,
import { FiDownload, FiMapPin, FiClock } from 'react-icons/fi';
import DOMPurify from 'dompurify';
import { assetUrl } from '../utils/url';
import { trackEvent as umamiTrackEvent } from '../utils/umami';
import EventLocationMap from '../components/events/EventLocationMap';
import EmbeddedPoll from '../components/polls/EmbeddedPoll';
import FilePreview from '../components/common/FilePreview';
@@ -35,6 +36,24 @@ const ActivityDetailPage: React.FC = () => {
const typeLabel = (t?: string) => t === 'match' ? 'Zápas' : t === 'training' ? 'Trénink' : t === 'meeting' ? 'Schůzka' : 'Jiné';
const typeColor = (t?: string) => t === 'match' ? 'red' : t === 'training' ? 'blue' : t === 'meeting' ? 'green' : 'gray';
// Delegate click tracking for links inside activity description
const contentRef = React.useRef<HTMLDivElement | null>(null);
React.useEffect(() => {
const el = contentRef.current;
if (!el) return;
const handler = (e: MouseEvent) => {
const target = e.target as HTMLElement | null;
if (!target) return;
const a = (target.closest ? target.closest('a') : null) as HTMLAnchorElement | null;
if (a && a.href) {
const href = a.getAttribute('href') || a.href;
try { umamiTrackEvent('Link Click', { href, page: window.location.pathname, context: 'activity_content' }); } catch {}
}
};
el.addEventListener('click', handler);
return () => { el.removeEventListener('click', handler); };
}, [contentRef.current]);
// Extract YouTube video ID from various URL formats
const getYouTubeEmbedUrl = (url: string): string | null => {
if (!url) return null;
@@ -92,7 +111,14 @@ const ActivityDetailPage: React.FC = () => {
<VStack align="stretch" spacing={5}>
{data.image_url && (
<Box borderRadius="xl" overflow="hidden" borderWidth="1px">
<Image src={assetUrl(data.image_url) || data.image_url} alt={data.title} w="100%" maxH="420px" objectFit="cover" />
<Image
src={assetUrl(data.image_url) || data.image_url}
alt={data.title}
w="100%"
maxH="420px"
objectFit="cover"
fallbackSrc="/dist/img/logo-club-empty.svg"
/>
</Box>
)}
@@ -132,6 +158,7 @@ const ActivityDetailPage: React.FC = () => {
' a': { color: linkColor, textDecoration: 'underline', _hover: { color: linkHoverColor } },
' img': { maxWidth: '100%', borderRadius: 'md' },
}}
ref={contentRef}
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(String(data.description)) }}
/>
)}
@@ -161,7 +188,7 @@ const ActivityDetailPage: React.FC = () => {
)}
{data?.id && (
<EmbeddedPoll eventId={data.id} />
<EmbeddedPoll eventId={data.id} maxPolls={2} />
)}
{(Array.isArray(data.attachments) && data.attachments.length > 0) && (
+258 -156
View File
@@ -1,4 +1,4 @@
import { Box, Container, Heading, Image, Spinner, Stack, Text, HStack, Badge, Link, SimpleGrid, Button, AspectRatio } from '@chakra-ui/react';
import { Box, Container, Heading, Image, Spinner, Stack, Text, HStack, Badge, Link, SimpleGrid, Button, AspectRatio, useColorModeValue, Flex, VStack, Tag } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { useParams, Link as RouterLink } from 'react-router-dom';
import { getArticle, getArticleBySlug, getArticleMatchLink, trackArticleView } from '../services/articles';
@@ -13,6 +13,11 @@ import React from 'react';
import { trackEvent as umamiTrackEvent, trackMatchView as umamiTrackMatchView, trackVideoPlay as umamiTrackVideoPlay, trackArticleView as umamiTrackArticleView } from '../utils/umami';
import { assetUrl } from '../utils/url';
import { API_URL } from '../services/api';
import TeamLogo from '../components/common/TeamLogo';
import { extractPalette } from '../utils/colors';
import { getTeamLogo } from '../utils/sportLogosAPI';
import FilePreview from '../components/common/FilePreview';
import { usePublicSettings } from '../hooks/usePublicSettings';
const toText = (html?: string) => {
if (!html) return '';
@@ -29,6 +34,22 @@ const ArticleDetailPage: React.FC = () => {
enabled: Boolean(slug || id),
});
// UI colors and public settings
const { data: publicSettings } = usePublicSettings();
const cardBg = useColorModeValue('white','gray.900');
const videoBg = useColorModeValue('gray.50','gray.800');
const textMuted = useColorModeValue('gray.600','gray.400');
// Hoist all color mode values to top-level to avoid conditional hook calls
const videoTitleColor = useColorModeValue('gray.700','gray.300');
const galleryBg = useColorModeValue('blue.50','blue.900');
const galleryBorder = useColorModeValue('blue.200','blue.700');
const attachmentsBg = useColorModeValue('gray.50','gray.800');
// Derive opponent color (for right edge fade) from team logo
const [opponentColor, setOpponentColor] = React.useState<string | null>(null);
// Placeholders; moved tracking effects below to avoid using variables before declaration
// Track article view when data is loaded
@@ -40,6 +61,27 @@ const ArticleDetailPage: React.FC = () => {
}
}, [data]);
// Delegated click tracking for normal links inside content
const contentRef = React.useRef<HTMLDivElement | null>(null);
React.useEffect(() => {
const el = contentRef.current;
if (!el) return;
const handler = (e: MouseEvent) => {
let target = e.target as HTMLElement | null;
if (!target) return;
// Find nearest anchor
const anchor = (target.closest ? target.closest('a') : null) as HTMLAnchorElement | null;
if (anchor && anchor.href) {
try {
const href = anchor.getAttribute('href') || anchor.href;
umamiTrackEvent('Link Click', { href, page: window.location.pathname, context: 'article_content' });
} catch {}
}
};
el.addEventListener('click', handler);
return () => { el.removeEventListener('click', handler); };
}, [contentRef.current]);
// Fetch linked match (public)
const matchLinkQuery = useQuery({
queryKey: ['article-match-link', (data as any)?.id],
@@ -81,10 +123,10 @@ const ArticleDetailPage: React.FC = () => {
staleTime: 60_000,
});
// Fetch gallery album if article has one
// Fetch gallery album if article has one (fallback to URL when ID is missing)
const galleryAlbumQuery = useQuery({
queryKey: ['article-gallery-album', (data as any)?.gallery_album_id],
enabled: Boolean((data as any)?.gallery_album_id),
queryKey: ['article-gallery-album', (data as any)?.gallery_album_id || (data as any)?.gallery_album_url],
enabled: Boolean((data as any)?.gallery_album_id || (data as any)?.gallery_album_url),
queryFn: async () => {
const albumId = (data as any)?.gallery_album_id;
let photoIds: string[] = [];
@@ -139,6 +181,24 @@ const ArticleDetailPage: React.FC = () => {
}
}
// Fallback: load by URL via proxy endpoint
const albumUrl = (data as any)?.gallery_album_url;
if (albumUrl) {
const params = new URLSearchParams({ link: albumUrl, photo_limit: '12', rendered: 'true' });
const resp = await fetch(`${API_URL}/zonerama-album?${params.toString()}`);
if (resp.ok) {
const payload = await resp.json();
let photos: any[] = [];
if (Array.isArray(payload?.albums) && payload.albums.length > 0) {
photos = payload.albums[0]?.photos || [];
} else if (Array.isArray(payload?.photos)) {
photos = payload.photos;
}
if (photoIds.length > 0) photos = photos.filter((p: any) => photoIds.includes(p.id));
return { id: albumUrl, title: 'Album', date: '', photos } as any;
}
}
return null;
},
staleTime: 60_000,
@@ -152,15 +212,6 @@ const ArticleDetailPage: React.FC = () => {
}
}, [(data as any)?.gallery_album_id, galleryAlbumQuery.data]);
if (isLoading) return <Spinner />;
if (isError || !data) return <Text color="red.500">Článek nenalezen</Text>;
const title = (data as any).seo_title || data.title;
const description = (data as any).seo_description || toText(data.content).slice(0, 160);
const ogImageRaw = (data as any).og_image_url || data.image_url || '/logo512.png';
const ogImage = assetUrl(ogImageRaw) || ogImageRaw;
const canonical = typeof window !== 'undefined' ? window.location.href : undefined;
// Transform content to ensure /uploads URLs resolve against API origin and allow iframes
// Memoize the transformation function to prevent infinite loops
const toAbsoluteUploads = React.useCallback((html?: string) => {
@@ -177,13 +228,27 @@ const ArticleDetailPage: React.FC = () => {
}, []);
const safeContentHTML = React.useMemo(() => {
const transformed = toAbsoluteUploads(data.content);
const transformed = toAbsoluteUploads((data as any)?.content);
return DOMPurify.sanitize(transformed || '', {
USE_PROFILES: { html: true },
ADD_TAGS: ['iframe'],
ADD_ATTR: ['target', 'rel', 'allow', 'allowfullscreen'],
});
}, [data.content, toAbsoluteUploads]);
}, [(data as any)?.content, toAbsoluteUploads]);
if (isLoading) return <Spinner />;
if (isError || !data) return <Text color="red.500">Článek nenalezen</Text>;
const title = (data as any).seo_title || data.title;
const description = (data as any).seo_description || toText(data.content).slice(0, 160);
const ogImageRaw = (data as any).og_image_url || data.image_url || '/logo512.png';
const ogImage = assetUrl(ogImageRaw) || ogImageRaw;
const canonical = typeof window !== 'undefined' ? window.location.href : undefined;
const publishedAt = (data as any).published_at || (data as any).created_at;
const monthParam = (() => {
if (!publishedAt) return '';
try { const d = new Date(publishedAt); return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}`; } catch { return ''; }
})();
return (
<MainLayout>
@@ -243,201 +308,238 @@ const ArticleDetailPage: React.FC = () => {
})}
</script>
</Helmet>
<Box bg="transparent" color="inherit" py={{ base: 6, md: 8 }} mb={4}>
<Box bg="transparent" color="inherit" py={{ base: 6, md: 8 }} mb={2}>
<Container maxW="7xl">
<Heading as="h1" size={{ base: 'xl', md: '2xl' }} mb={3}>{data.title}</Heading>
<HStack spacing={4} fontSize="sm" color="gray.600">
{((data as any).read_time || (data as any).estimated_read_minutes) && (
<Heading as="h1" size={{ base: 'xl', md: '2xl' }} mb={2}>{data.title}</Heading>
<HStack spacing={2} rowGap={2} wrap="wrap" fontSize="sm" color={textMuted}>
{((data as any).read_time || (data as any).estimated_read_minutes) ? (
<HStack spacing={1}>
<Clock size={16} />
<Text>{(data as any).read_time || (data as any).estimated_read_minutes} min čtení</Text>
</HStack>
) : null}
{publishedAt && (
<Tag as={RouterLink} to={`/news?month=${monthParam}`} size="sm" variant="subtle">{new Date(publishedAt).toLocaleDateString('cs-CZ')}</Tag>
)}
{(data as any).view_count !== undefined && (data as any).view_count > 0 && (
<HStack spacing={1}>
{(data as any)?.category?.id && (
<Tag as={RouterLink} to={`/news?category_id=${(data as any).category.id}`} size="sm" variant="subtle">{(data as any).category.name || 'Kategorie'}</Tag>
)}
{(matchLinkQuery.data as any)?.external_match_id && (
<Tag as={RouterLink} to={`/news?match_id=${(matchLinkQuery.data as any).external_match_id}`} size="sm" variant="subtle">Zápas</Tag>
)}
{(data as any).view_count ? (
<HStack spacing={1} ml={{ base: 0, md: 2 }}>
<Eye size={16} />
<Text>{(data as any).view_count} zobrazení</Text>
</HStack>
)}
) : null}
</HStack>
</Container>
</Box>
<Container maxW="7xl">
<Stack spacing={6}>
{/* Featured Image - Top */}
{/* Featured Image - smaller with subtle overlay */}
{data.image_url && (
<Image src={assetUrl(data.image_url) || data.image_url} alt={data.title} borderRadius="lg" />
<Box position="relative" borderRadius="xl" overflow="hidden">
<Image src={assetUrl(data.image_url) || data.image_url} alt={data.title} w="100%" h={{ base: '220px', md: '360px' }} objectFit="cover" />
<Box position="absolute" inset={0} bg="brand.primary" opacity={0.08} pointerEvents="none" />
<Box position="absolute" inset={0} bgGradient="linear(to-b, rgba(0,0,0,0.12), rgba(0,0,0,0.02))" pointerEvents="none" />
</Box>
)}
{/* YouTube Video Section - If attached to article */}
{/* YouTube Video Section - smaller and rounded */}
{(data as any)?.youtube_video_id && (
<Box borderWidth="1px" borderRadius="lg" p={{ base: 3, md: 4 }} bg="gray.50">
<Box borderWidth="1px" borderRadius="lg" p={{ base: 3, md: 4 }} bg={videoBg}>
<Heading as="h3" size="md" mb={2}>🎬 Video k článku</Heading>
<AspectRatio ratio={16 / 9}>
<Box
as="iframe"
src={`https://www.youtube-nocookie.com/embed/${(data as any).youtube_video_id}`}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
title={(data as any).youtube_video_title || 'YouTube video'}
onLoad={() => umamiTrackEvent('Video Widget Shown', { id: (data as any).youtube_video_id, title: (data as any).youtube_video_title })}
onClick={() => umamiTrackVideoPlay((data as any).youtube_video_id, (data as any).youtube_video_title)}
/>
</AspectRatio>
{ (data as any).youtube_video_title ? (
<Text mt={2} color="gray.700">{(data as any).youtube_video_title}</Text>
) : null }
<Box maxW="3xl" mx="auto" borderRadius="lg" overflow="hidden">
<AspectRatio ratio={16 / 9}>
<Box
as="iframe"
src={`https://www.youtube-nocookie.com/embed/${(data as any).youtube_video_id}`}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
title={(data as any).youtube_video_title || 'YouTube video'}
onLoad={() => umamiTrackEvent('Video Widget Shown', { id: (data as any).youtube_video_id, title: (data as any).youtube_video_title })}
onClick={() => umamiTrackVideoPlay((data as any).youtube_video_id, (data as any).youtube_video_title)}
/>
</AspectRatio>
</Box>
{(data as any).youtube_video_title ? (
<Text mt={2} color={videoTitleColor}>{(data as any).youtube_video_title}</Text>
) : null}
</Box>
)}
{/* Match Section - After Image */}
{/* Match Section - Card with logos, score/countdown, venue/date */}
{(matchLinkQuery.data as any)?.external_match_id && (
<Box borderWidth="1px" borderRadius="lg" p={{ base: 3, md: 4 }} bg="gray.50">
<HStack justify="space-between" align="start">
<Box>
<Heading as="h3" size="md" mb={1}>Zápas k článku</Heading>
{facrMatchQuery.isLoading ? (
<Text color="gray.600">Načítám údaje o zápasu</Text>
) : facrMatchQuery.data ? (
<>
<HStack spacing={2} wrap="wrap">
{facrMatchQuery.data.competitionName && (
<Badge colorScheme="blue">{String(facrMatchQuery.data.competitionName)}</Badge>
)}
<Badge>{String(facrMatchQuery.data.date_time || facrMatchQuery.data.date || '')}</Badge>
</HStack>
<Text mt={2} fontWeight="600">
{String(facrMatchQuery.data.home || facrMatchQuery.data.home_team || '')}
{' '}
{String(facrMatchQuery.data.score || (facrMatchQuery.data.result_home!=null && facrMatchQuery.data.result_away!=null ? `${facrMatchQuery.data.result_home}:${facrMatchQuery.data.result_away}` : 'vs'))}
{' '}
{String(facrMatchQuery.data.away || facrMatchQuery.data.away_team || '')}
</Text>
{facrMatchQuery.data.venue && (
<Text color="gray.600">{String(facrMatchQuery.data.venue)}</Text>
)}
{facrMatchQuery.data.report_url && (
<Box mt={2}>
<Link href={String(facrMatchQuery.data.report_url)} isExternal color="blue.600">Protokol zápasu (fotbal.cz)</Link>
</Box>
)}
</>
) : (
<Text color="gray.600">Propojeno s FACR ID: {(matchLinkQuery.data as any)?.external_match_id}</Text>
<Box position="relative" borderWidth="1px" borderRadius="lg" p={{ base: 4, md: 5 }} bg={cardBg} overflow="hidden">
{/* Edge fades */}
<Box position="absolute" top={0} left={0} bottom={0} w={{ base: '6px', md: '12px' }} bgGradient={`linear(to-r, var(--club-primary, #0b5cff), transparent)`} pointerEvents="none" />
{opponentColor && (
<Box position="absolute" top={0} right={0} bottom={0} w={{ base: '6px', md: '12px' }} bgGradient={`linear(to-l, ${opponentColor}, transparent)`} pointerEvents="none" />
)}
<Heading as="h3" size="md" mb={3}>Zápas k článku</Heading>
{facrMatchQuery.isLoading ? (
<Text color={textMuted}>Načítám údaje o zápasu</Text>
) : facrMatchQuery.data ? (
<>
<HStack spacing={2} wrap="wrap" mb={3}>
{facrMatchQuery.data.competitionName && (
<Badge colorScheme="blue">{String(facrMatchQuery.data.competitionName)}</Badge>
)}
<Badge>{String((facrMatchQuery.data as any).date_time || (facrMatchQuery.data as any).date || '')}</Badge>
</HStack>
<Flex align="center" justify="space-between" gap={4}>
<VStack flex={1} spacing={2} minW="0">
<TeamLogo size="custom" style={{ width: 64, height: 64 }} teamId={String((facrMatchQuery.data as any).home_team_id || (facrMatchQuery.data as any).home_id || '')} teamName={String((facrMatchQuery.data as any).home || (facrMatchQuery.data as any).home_team || '')} />
<Text fontWeight="600" noOfLines={2} textAlign="center">{String((facrMatchQuery.data as any).home || (facrMatchQuery.data as any).home_team || '')}</Text>
</VStack>
<VStack minW={{ base: '100px', md: '140px' }}>
{(() => {
const dRaw = String((facrMatchQuery.data as any).date_time || (facrMatchQuery.data as any).date || '');
const d = new Date(dRaw);
const hasScore = ((facrMatchQuery.data as any).result_home != null && (facrMatchQuery.data as any).result_away != null) || Boolean((facrMatchQuery.data as any).score && (facrMatchQuery.data as any).score !== 'vs');
if (hasScore) {
const score = String((facrMatchQuery.data as any).score || `${(facrMatchQuery.data as any).result_home}:${(facrMatchQuery.data as any).result_away}`);
return (<Heading size="2xl">{score}</Heading>);
}
const now = Date.now();
const ms = d.getTime() - now;
const days = Math.max(0, Math.floor(ms / (1000*60*60*24)));
const hours = Math.max(0, Math.floor((ms % (1000*60*60*24))/(1000*60*60)));
const mins = Math.max(0, Math.floor((ms % (1000*60*60))/(1000*60)));
return (<Text fontSize="lg" fontWeight="700">Za {days} d {hours} h {mins} min</Text>);
})()}
{(facrMatchQuery.data as any).venue && <Text fontSize="sm" color={textMuted}>{String((facrMatchQuery.data as any).venue)}</Text>}
{(() => {
const dRaw = String((facrMatchQuery.data as any).date_time || (facrMatchQuery.data as any).date || '');
const d = new Date(dRaw);
return <Text fontSize="sm" color={textMuted}>{d.toLocaleDateString('cs-CZ')} {d.toLocaleTimeString('cs-CZ', { hour: '2-digit', minute: '2-digit' })}</Text>;
})()}
</VStack>
<VStack flex={1} spacing={2} minW="0">
<TeamLogo size="custom" style={{ width: 64, height: 64 }} teamId={String((facrMatchQuery.data as any).away_team_id || (facrMatchQuery.data as any).away_id || '')} teamName={String((facrMatchQuery.data as any).away || (facrMatchQuery.data as any).away_team || '')} />
<Text fontWeight="600" noOfLines={2} textAlign="center">{String((facrMatchQuery.data as any).away || (facrMatchQuery.data as any).away_team || '')}</Text>
</VStack>
</Flex>
{(facrMatchQuery.data as any).report_url && (
<Box mt={3}>
<Link href={String((facrMatchQuery.data as any).report_url)} isExternal color="blue.600">Protokol zápasu (fotbal.cz)</Link>
</Box>
)}
</Box>
</HStack>
</>
) : (
<Text color={textMuted}>Propojeno s FACR ID: {(matchLinkQuery.data as any)?.external_match_id}</Text>
)}
</Box>
)}
{/* Article Content - Main Section */}
{/* Article Content - Main Section with editor-like lists */}
<Box
className="article-content"
bg="white"
bg={useColorModeValue('white','gray.900')}
borderRadius="lg"
p={{ base: 4, md: 6 }}
ref={contentRef}
sx={{ 'ul, ol': { pl: 6, listStylePosition: 'outside' }, 'ul': { listStyleType: 'disc' }, 'ol': { listStyleType: 'decimal' }, 'li': { mb: 2 } }}
dangerouslySetInnerHTML={{ __html: safeContentHTML }}
/>
{/* Gallery Section - At the End */}
{(data as any)?.gallery_album_id && (
<Box borderWidth="1px" borderRadius="lg" p={{ base: 3, md: 4 }} bg="blue.50" borderColor="blue.200">
{/* Gallery Section - Mosaic of 5 images with grayscale + hover color */}
{((data as any)?.gallery_album_id || (data as any)?.gallery_album_url) && (
<Box borderWidth="1px" borderRadius="lg" p={{ base: 3, md: 4 }} bg={galleryBg} borderColor={galleryBorder}>
<Box mb={3}>
<HStack justify="space-between" align="center" mb={2}>
<Heading as="h3" size="md">Fotogalerie k článku</Heading>
<Button
as={RouterLink}
to={`/galerie/album/${(data as any).gallery_album_id}`}
to={(data as any).gallery_album_id ? `/galerie/album/${(data as any).gallery_album_id}` : '#'}
size="sm"
colorScheme="blue"
variant="outline"
rightIcon={<ArrowRight size={16} />}
onClick={() => umamiTrackEvent('Gallery Link Click', { album_id: (data as any).gallery_album_id })}
>
Zobrazit celé album
Zobrazit galerii
</Button>
</HStack>
{galleryAlbumQuery.isLoading ? (
<Text color="gray.600">Načítám fotografie</Text>
) : galleryAlbumQuery.data ? (
<>
<HStack spacing={2} mb={3}>
<Badge colorScheme="purple">
{galleryAlbumQuery.data.title}
</Badge>
{galleryAlbumQuery.data.date && (
<Badge>{galleryAlbumQuery.data.date}</Badge>
)}
<Badge colorScheme="blue">
{galleryAlbumQuery.data.photos?.length || 0} foto
</Badge>
</HStack>
{/* Photo Grid */}
{galleryAlbumQuery.data.photos && galleryAlbumQuery.data.photos.length > 0 && (
<SimpleGrid columns={{ base: 2, sm: 3, md: 4, lg: 6 }} spacing={2}>
{galleryAlbumQuery.data.photos.slice(0, 12).map((photo: any) => (
<Box
key={photo.id}
as={RouterLink}
to={`/galerie/album/${(data as any).gallery_album_id}`}
position="relative"
borderRadius="md"
overflow="hidden"
cursor="pointer"
transition="all 0.2s"
_hover={{ transform: 'scale(1.05)', boxShadow: 'lg' }}
onClick={() => umamiTrackEvent('Gallery Photo Click', { album_id: (data as any).gallery_album_id, photo_id: photo.id })}
>
<Image
src={photo.image_1500}
alt={`Fotka ${photo.id}`}
w="100%"
h="120px"
objectFit="cover"
/>
</Box>
{/* Custom 5-image mosaic */}
{galleryAlbumQuery.data.photos && galleryAlbumQuery.data.photos.length > 0 && (() => {
const photos = galleryAlbumQuery.data.photos.slice(0, 5);
if (photos.length < 5) {
return (
<SimpleGrid columns={{ base: 2, sm: 3 }} spacing={2}>
{photos.map((p: any) => (
<Image key={p.id} src={p.image_1500} alt={String(p.id)} w="100%" h="140px" objectFit="cover" filter="grayscale(100%)" _hover={{ filter: 'grayscale(0%)' }} />
))}
</SimpleGrid>
)}
{/* Zonerama Attribution */}
<HStack mt={3} spacing={1} fontSize="xs" color="blue.700">
<Text>📸 Fotografie z</Text>
<Link
href={(data as any).gallery_album_url || `https://zonerama.com`}
isExternal
fontWeight="600"
color="blue.600"
display="inline-flex"
alignItems="center"
gap={1}
>
Zonerama
<ExternalLink size={12} />
</Link>
</HStack>
</>
) : (
<Text color="gray.600">Album s ID: {(data as any).gallery_album_id}</Text>
)}
);
}
return (
<Box position="relative" sx={{
display: 'grid',
gridTemplateColumns: '1fr 1.2fr 1fr',
gridTemplateRows: 'repeat(2, 140px)',
gap: '8px'
}}>
<Image src={photos[0].image_1500} alt={String(photos[0].id)} sx={{ gridColumn: 1, gridRow: 1 }} objectFit="cover" w="100%" h="100%" filter="grayscale(100%)" _hover={{ filter: 'grayscale(0%)' }} borderRadius="md" />
<Image src={photos[1].image_1500} alt={String(photos[1].id)} sx={{ gridColumn: 1, gridRow: 2 }} objectFit="cover" w="100%" h="100%" filter="grayscale(100%)" _hover={{ filter: 'grayscale(0%)' }} borderRadius="md" />
<Image src={photos[2].image_1500} alt={String(photos[2].id)} sx={{ gridColumn: 2, gridRow: '1 / span 2' }} objectFit="cover" w="100%" h="100%" filter="grayscale(100%)" _hover={{ filter: 'grayscale(0%)' }} borderRadius="md" />
<Image src={photos[3].image_1500} alt={String(photos[3].id)} sx={{ gridColumn: 3, gridRow: 1 }} objectFit="cover" w="100%" h="100%" filter="grayscale(100%)" _hover={{ filter: 'grayscale(0%)' }} borderRadius="md" />
<Image src={photos[4].image_1500} alt={String(photos[4].id)} sx={{ gridColumn: 3, gridRow: 2 }} objectFit="cover" w="100%" h="100%" filter="grayscale(100%)" _hover={{ filter: 'grayscale(0%)' }} borderRadius="md" />
<Button as={RouterLink} to={(data as any).gallery_album_id ? `/galerie/album/${(data as any).gallery_album_id}` : '#'} size="sm" colorScheme="blue" position="absolute" top="50%" left="50%" transform="translate(-50%, -50%)" onClick={() => umamiTrackEvent('Gallery Link Click', { album_id: (data as any).gallery_album_id })}>Zobrazit galerii</Button>
</Box>
);
})()}
{/* Zonerama Attribution */}
<HStack mt={3} spacing={1} fontSize="xs" color="blue.700">
<Text>📸 Fotografie z</Text>
<Link
href={(data as any).gallery_album_url || `https://zonerama.com`}
isExternal
fontWeight="600"
color="blue.600"
display="inline-flex"
alignItems="center"
gap={1}
>
Zonerama
<ExternalLink size={12} />
</Link>
</HStack>
</Box>
</Box>
)}
{/* Embedded Poll - directly under content/gallery */}
{data?.id && <EmbeddedPoll articleId={(data as any).id} maxPolls={3} />}
</Stack>
</Container>
</Box>
{/* Embedded Poll - shows polls related to this article */}
{data?.id && <EmbeddedPoll articleId={data.id} />}
{/* Newsletter CTA */}
<NewsletterCTA />
{/* Sponsors Section */}
<SponsorsSection />
</MainLayout>
);
{/* Attachments - bottom above CTA */}
{Array.isArray((data as any)?.attachments) && (data as any).attachments.length > 0 && (
<Container maxW="7xl" mt={4}>
<Box borderWidth="1px" borderRadius="lg" p={{ base: 3, md: 4 }} bg={attachmentsBg}>
<Heading as="h3" size="md" mb={2}>Přílohy</Heading>
<Stack spacing={2}>
{(data as any).attachments.map((f: any, idx: number) => (
<HStack key={idx} justify="space-between">
<Text noOfLines={1}>{f.name || f.url}</Text>
<FilePreview url={assetUrl(f.url) || f.url} name={f.name || ''} mimeType={f.mime_type || ''} size={f.size} />
</HStack>
))}
</Stack>
</Box>
</Container>
)}
{/* Newsletter CTA */}
<NewsletterCTA />
{/* Sponsors Section */}
<SponsorsSection />
</MainLayout>
);
};
export default ArticleDetailPage;
+21 -4
View File
@@ -2,7 +2,7 @@ import React from 'react';
import { Box, Container, Heading, VStack, Image, Text, Skeleton, LinkBox, HStack, Select, Badge, useColorModeValue } from '@chakra-ui/react';
import { useInfiniteQuery } from '@tanstack/react-query';
import { getArticles, Article, Paginated } from '../services/articles';
import { Link as RouterLink } from 'react-router-dom';
import { Link as RouterLink, useSearchParams } from 'react-router-dom';
import { assetUrl } from '../utils/url';
import MainLayout from '../components/layout/MainLayout';
import { getCategories, CategoryItem } from '../services/categories';
@@ -92,7 +92,14 @@ const BlogTile: React.FC<{ article: Article }> = ({ article }) => {
const BlogPage: React.FC = () => {
const pageSize = 18;
const [categories, setCategories] = React.useState<CategoryItem[]>([]);
const [categoryId, setCategoryId] = React.useState<number | ''>('');
const [searchParams, setSearchParams] = useSearchParams();
const initialCategory = React.useMemo(() => {
const cid = searchParams.get('category_id');
return cid ? Number(cid) : '';
}, []);
const [categoryId, setCategoryId] = React.useState<number | ''>(initialCategory);
const month = searchParams.get('month') || '';
const matchId = searchParams.get('match_id') || '';
const borderColor = useColorModeValue('gray.200', 'gray.700');
const textColor = useColorModeValue('gray.500', 'gray.400');
@@ -111,13 +118,15 @@ const BlogPage: React.FC = () => {
hasNextPage,
fetchNextPage,
} = useInfiniteQuery<Paginated<Article>>(
['articles-public', { page_size: pageSize, published: true, category_id: categoryId || undefined }],
['articles-public', { page_size: pageSize, published: true, category_id: categoryId || undefined, month: month || undefined, match_id: matchId || undefined }],
({ pageParam = 1 }) =>
getArticles({
page: pageParam,
page_size: pageSize,
published: true,
...(categoryId ? { category_id: Number(categoryId) } : {}),
...(month ? { month } : {}),
...(matchId ? { match_id: matchId } : {}),
}),
{
getNextPageParam: (lastPage, allPages) => {
@@ -159,7 +168,15 @@ const BlogPage: React.FC = () => {
maxW={{ base: '52%', md: '320px' }}
placeholder="Všechny kategorie"
value={categoryId}
onChange={(e) => setCategoryId(e.target.value ? Number(e.target.value) : '')}
onChange={(e) => {
const val = e.target.value ? Number(e.target.value) : '';
setCategoryId(val);
const next: Record<string, string> = {};
if (val) next.category_id = String(val);
if (month) next.month = month;
if (matchId) next.match_id = matchId;
setSearchParams(next);
}}
>
{categories.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
+39 -13
View File
@@ -13,6 +13,7 @@ import { sortCategoriesWithOrder } from '../utils/categorySort';
import ClubModal from '../components/home/ClubModal';
import { assetUrl } from '../utils/url';
import { API_URL } from '../services/api';
import { TeamLogo } from '../components/common/TeamLogo';
// Weekday headers (Czech, starting Monday)
const WEEKDAYS_SHORT: string[] = ['Po', 'Út', 'St', 'Čt', 'Pá', 'So', 'Ne'];
@@ -226,9 +227,16 @@ const CalendarPage: React.FC = () => {
} catch {}
}
const byName: Record<string, string> = (overrides?.by_name || {}) as any;
const byId: Record<string, { name?: string; logo_url?: string }> = (overrides?.by_id || {}) as any;
const byNameNormalized: Record<string, string> = Object.keys(byName || {}).reduce((acc: Record<string, string>, k: string) => { acc[normalize(k)] = byName[k]; return acc; }, {});
const byNameStrippedPairs: Array<{ keyNorm: string; url: string }> = Object.keys(byName || {}).map((k: string) => ({ keyNorm: stripPrefixes(k), url: byName[k] }));
const getOverrideLogo = (teamName?: string, original?: string) => {
const getOverrideLogo = (teamName?: string, original?: string, teamId?: string) => {
// Prefer admin override by ID
if (teamId && byId?.[teamId]?.logo_url) {
const v = byId[teamId]!.logo_url as string;
if (typeof v === 'string' && v.startsWith('/')) return resolveBackendUrl(v);
return v;
}
if (!teamName) return original;
const exact = (byName || {})[teamName];
const normName = normalize(teamName);
@@ -260,17 +268,19 @@ const CalendarPage: React.FC = () => {
const isoDate = (day && month && year) ? `${year}-${month.padStart(2,'0')}-${day.padStart(2,'0')}` : new Date().toISOString().slice(0,10);
const time = (t || '00:00').slice(0,5);
const score = (m.score || m.result || (typeof m.goals_home === 'number' && typeof m.goals_away === 'number' ? `${m.goals_home}:${m.goals_away}` : '') || '').toString();
const homeName = (byId?.[m.home_id]?.name && String(byId[m.home_id].name).trim()) ? String(byId[m.home_id].name) : m.home;
const awayName = (byId?.[m.away_id]?.name && String(byId[m.away_id].name).trim()) ? String(byId[m.away_id].name) : m.away;
return {
id: m.match_id || `${cIdx}-${idx}`,
date: isoDate,
time,
home: m.home,
away: m.away,
home: homeName,
away: awayName,
home_id: m.home_id,
away_id: m.away_id,
venue: m.venue,
home_logo_url: getOverrideLogo(m.home, m.home_logo_url),
away_logo_url: getOverrideLogo(m.away, m.away_logo_url),
home_logo_url: getOverrideLogo(homeName, m.home_logo_url, m.home_id),
away_logo_url: getOverrideLogo(awayName, m.away_logo_url, m.away_id),
report_url: m.report_url,
facr_link: m.facr_link,
score: score && /\d+\s*:\s*\d+/.test(score) ? score.replace(/\s+/g,'') : undefined,
@@ -309,13 +319,13 @@ const CalendarPage: React.FC = () => {
id: m.match_id || `${cIdx}-${idx}`,
date: isoDate,
time,
home: m.home,
away: m.away,
home: (byId?.[m.home_id]?.name && String(byId[m.home_id].name).trim()) ? String(byId[m.home_id].name) : m.home,
away: (byId?.[m.away_id]?.name && String(byId[m.away_id].name).trim()) ? String(byId[m.away_id].name) : m.away,
home_id: m.home_id,
away_id: m.away_id,
venue: m.venue,
home_logo_url: getOverrideLogo(m.home, m.home_logo_url),
away_logo_url: getOverrideLogo(m.away, m.away_logo_url),
home_logo_url: getOverrideLogo(m.home, m.home_logo_url, m.home_id),
away_logo_url: getOverrideLogo(m.away, m.away_logo_url, m.away_id),
report_url: m.report_url,
score: score && /\d+\s*:\s*\d+/.test(score) ? score.replace(/\s+/g,'') : undefined,
} as MatchItem;
@@ -381,7 +391,7 @@ const CalendarPage: React.FC = () => {
})();
return {
position: Number(r.rank || idx + 1),
team_name: teamName,
team_name: (teamId && byId?.[teamId]?.name && String(byId[teamId].name).trim()) ? String(byId[teamId].name) : teamName,
team_id: teamId,
points: Number(r.points || r.pts || 0),
played: Number(r.played || r.matches || 0),
@@ -390,7 +400,7 @@ const CalendarPage: React.FC = () => {
losses: Number(r.losses || r.loss || 0),
goals_for: Number(r.goals_for ?? r.gf ?? r.goalsFor ?? r.scored ?? r.goals ?? 0),
goals_against: Number(r.goals_against ?? r.ga ?? r.goalsAgainst ?? r.conceded ?? 0),
logo_url: r.team_logo_url || undefined,
logo_url: (teamId && byId?.[teamId]?.logo_url) ? String(byId[teamId].logo_url) : (r.team_logo_url || undefined),
};
}),
}));
@@ -660,10 +670,26 @@ const CalendarPage: React.FC = () => {
<Badge colorScheme="purple">{m.__compName || c.name}</Badge>
</Flex>
<Flex align="center" gap={2} justify="center">
{m.home_logo_url && <Image src={m.home_logo_url} alt={m.home} boxSize="18px" borderRadius="full" objectFit="cover" />}
<TeamLogo
teamId={m.home_id}
teamName={m.home}
facrLogo={m.home_logo_url}
size="custom"
boxSize="18px"
alt={m.home}
borderRadius="full"
/>
<Text fontSize="sm">{m.home}</Text>
<Badge colorScheme={getSentiment(m)?.color || 'gray'}>{m.score || 'vs'}</Badge>
{m.away_logo_url && <Image src={m.away_logo_url} alt={m.away} boxSize="18px" borderRadius="full" objectFit="cover" />}
<TeamLogo
teamId={m.away_id}
teamName={m.away}
facrLogo={m.away_logo_url}
size="custom"
boxSize="18px"
alt={m.away}
borderRadius="full"
/>
<Text fontSize="sm">{m.away}</Text>
</Flex>
{href && <Link href={href} isExternal onClick={(e)=> e.stopPropagation()} display="none"/>}
+123 -322
View File
@@ -3,6 +3,8 @@ import MainLayout from '../components/layout/MainLayout';
import { FiArrowRight, FiCalendar, FiUsers, FiAward, FiChevronLeft, FiChevronRight } from 'react-icons/fi';
import '../styles/theme.css';
import '../styles/sparta-styles.css';
import '../styles/club-styles.css';
import '../styles/home-style-pack.css';
import './styles/UnifiedHome.css';
import { getPublicSettings } from '../services/settings';
import { assetUrl, sanitizeClubName } from '../utils/url';
@@ -25,6 +27,12 @@ import MatchModal from '../components/home/MatchModal';
import { useAllPageElementConfigs } from '../hooks/usePageElementConfig';
import { API_URL } from '../services/api';
import { TeamLogo } from '../components/common/TeamLogo';
import ClubHeroTopbar from '../components/home/ClubHeroTopbar';
import NewsList from '../components/pack/NewsList';
import StandingsCard from '../components/pack/StandingsCard';
import NextMatch from '../components/pack/NextMatch';
import MatchesSlider from '../components/pack/MatchesSlider';
import ActivitiesList from '../components/pack/ActivitiesList';
// Types for real API-driven data
type NewsItem = {
@@ -87,9 +95,7 @@ const HomePage: React.FC = () => {
// Index for the NEXT MATCH competition carousel
const [nextCompIdx, setNextCompIdx] = useState<number>(0);
const [nextMatchLink, setNextMatchLink] = useState<string | undefined>(undefined);
// Ref to the draggable matches track and per-competition closest index
const trackRef = useRef<HTMLDivElement | null>(null);
const [closestIndexByComp, setClosestIndexByComp] = useState<number[]>([]);
// Matches slider auto-centering handled internally by MatchesSlider component
// API-driven players and sponsors
type UiPlayer = { id:number|string; name:string; number?:number; position?:string; image?:string; slug?:string };
@@ -114,6 +120,16 @@ const HomePage: React.FC = () => {
// MyUIbrix element configuration hook for live preview
const { getVariant, isVisible, getStyles, loading: configLoading, refreshKey } = useAllPageElementConfigs('homepage');
const stylePack = getVariant('style-pack', 'default');
useEffect(() => {
try {
const cls = `style-pack-${stylePack}`;
const all = ['style-pack-default','style-pack-modern','style-pack-minimal','style-pack-sparta'];
all.forEach(c => document.body.classList.remove(c));
document.body.classList.add(cls);
} catch {}
}, [stylePack]);
const heroFallbackArticles = useMemo(() => featured.map((item, index) => ({
id: typeof item.id === 'number' ? item.id : index,
@@ -360,22 +376,6 @@ const HomePage: React.FC = () => {
});
setFacrCompetitions(comps);
// Compute closest match index per competition to current time
const nowTs = Date.now();
const closestIdx: number[] = comps.map((c: { matches: any[] }) => {
let bestIdx = -1;
let bestDiff = Number.POSITIVE_INFINITY;
(c.matches || []).forEach((m: any, idx: number) => {
const ts = new Date(`${m.date}T${(m.time || '00:00')}:00`).getTime();
if (!isNaN(ts)) {
const diff = Math.abs(ts - nowTs);
if (diff < bestDiff) { bestDiff = diff; bestIdx = idx; }
}
});
return bestIdx;
});
setClosestIndexByComp(closestIdx);
// Next match FACR link
const first = filteredMatches?.[0];
setNextMatchLink((first && (first.facr_link || first.report_url)) || comps?.[0]?.matches_link || facrClubJSON?.url);
@@ -528,24 +528,7 @@ const HomePage: React.FC = () => {
};
}, []);
// Auto-scroll matches track to the closest match for current tab
useEffect(() => {
const el = trackRef.current;
if (!el) return;
const idx = (closestIndexByComp[matchesTab] ?? 0);
const child = el.children?.[idx] 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' });
};
// Wait for layout
if (typeof requestAnimationFrame !== 'undefined') {
requestAnimationFrame(run);
} else {
setTimeout(run, 0);
}
}, [facrCompetitions, matchesTab, closestIndexByComp]);
// Removed: legacy auto-scroll. Handled by MatchesSlider.
// MyUIbrix events are handled by useAllPageElementConfigs hook
// It automatically updates getVariant() and isVisible() when changes occur in edit mode
@@ -1340,26 +1323,38 @@ const HomePage: React.FC = () => {
return (
<MainLayout headerInsideContainer>
<div className="container" data-element="container" style={{ ...getStyles('container') }}>
{/* Header: logo + club name */}
<div className="home-header">
<TeamLogo
teamId={settings?.club_id}
teamName={clubName}
facrLogo={assetUrl(clubLogo) || undefined}
size="custom"
alt="Klub"
borderRadius="full"
style={{ width: 56, height: 56 }}
/>
<div>
<h1 style={{ margin: 0 }}>{clubName}</h1>
<div className="subtitle" style={{ fontSize: '0.95rem' }}>Oficiální web klubu</div>
<div data-element="style-pack" data-variant={stylePack} style={{ display: 'none' }} />
{/* Above-hero club bar (MyUIbrix managed) */}
{isVisible('hero-topbar', true) && (
<section data-element="hero-topbar" data-variant={getVariant('hero-topbar', 'brand')} style={{ ...getStyles('hero-topbar') }}>
<ClubHeroTopbar
variant={(getVariant('hero-topbar', 'brand') as any) as 'brand' | 'minimal' | 'badge'}
fullBleed={getVariant('header', 'unified') === 'fullwidth'}
/>
</section>
)}
{/* Header: logo + club name (legacy). Hidden when hero-topbar is visible */}
{!isVisible('hero-topbar', true) && (
<div className="home-header">
<TeamLogo
teamId={settings?.club_id}
teamName={clubName}
facrLogo={assetUrl(clubLogo) || undefined}
size="custom"
alt="Klub"
borderRadius="full"
style={{ width: 56, height: 56 }}
/>
<div>
<h1 style={{ margin: 0 }}>{clubName}</h1>
<div className="subtitle" style={{ fontSize: '0.95rem' }}>Oficiální web klubu</div>
</div>
</div>
</div>
)}
{/* Hero section: variant controlled by MyUIbrix (getVariant) or fallback to settings.hero_style */}
{getVariant('hero', heroStyle) === 'grid' && isVisible('hero', true) && (
<section key={`hero-grid-${refreshKey}`} data-element="hero" className="hero-grid" style={{ position: 'relative', ...getStyles('hero') }}>
<section key={`hero-grid-${refreshKey}`} data-element="hero" data-variant={getVariant('hero', heroStyle)} className="hero-grid" style={{ position: 'relative', ...getStyles('hero') }}>
{featured[0] ? (
<a href={`/news/${featured[0].slug || featured[0].id}`} className="hero-card big" style={{ textDecoration: 'none' }}>
<div className="bg" style={{ backgroundImage: `url(${assetUrl(featured[0].image) || '/images/news/placeholder.jpg'})` }} />
@@ -1401,7 +1396,7 @@ const HomePage: React.FC = () => {
)}
{/* Banner: homepage_middle */}
{(banners || []).some(b => b.placement === 'homepage_middle') && isVisible('banner', true) && (
<section data-element="banner" className="banner banner-middle" style={{ margin: '24px 0', textAlign: 'center', ...getStyles('banner') }}>
<section data-element="banner" data-variant={getVariant('banner', 'top')} className="banner banner-middle" style={{ margin: '24px 0', textAlign: 'center', ...getStyles('banner') }}>
{(banners || []).filter(b => b.placement === 'homepage_middle').map((b) => (
<a key={b.id} href={b.url || '#'} target={b.url ? '_blank' : undefined} rel={b.url ? 'noopener noreferrer' : undefined} style={{ display: 'inline-block', margin: 8 }}>
{/* eslint-disable-next-line jsx-a11y/alt-text */}
@@ -1415,7 +1410,7 @@ const HomePage: React.FC = () => {
{/* Sidebar banners (homepage_sidebar) */}
{(banners || []).some(b => b.placement === 'homepage_sidebar') && (
<section data-element="sidebar" className="banner banner-sidebar" style={{ margin: '24px 0', ...getStyles('sidebar') }}>
<section data-element="sidebar" data-variant={getVariant('sidebar', 'right')} className="banner banner-sidebar" style={{ margin: '24px 0', ...getStyles('sidebar') }}>
{/* Simple responsive behavior: stack on mobile, sticky right rail on desktop */}
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<div style={{ width: 320, maxWidth: '100%', position: 'sticky' as const, top: 96 }}>
@@ -1430,12 +1425,12 @@ const HomePage: React.FC = () => {
</section>
)}
{getVariant('hero', heroStyle) === 'scroller' && isVisible('hero', true) && (
<section key={`hero-scroller-${refreshKey}`} data-element="hero" style={{ position: 'relative', ...getStyles('hero') }}>
<section key={`hero-scroller-${refreshKey}`} data-element="hero" data-variant={getVariant('hero', heroStyle)} style={{ position: 'relative', ...getStyles('hero') }}>
<BlogCardsScroller />
</section>
)}
{(getVariant('hero', heroStyle) === 'swiper' || getVariant('hero', heroStyle) === 'swiper_full') && isVisible('hero', true) && (
<section key={`hero-swiper-${refreshKey}`} data-element="hero" style={getVariant('hero', heroStyle) === 'swiper_full' ? { position: 'relative', marginLeft: 'calc(50% - 50vw)', marginRight: 'calc(50% - 50vw)', ...getStyles('hero') } : { position: 'relative', ...getStyles('hero') }}>
<section key={`hero-swiper-${refreshKey}`} data-element="hero" data-variant={getVariant('hero', heroStyle)} style={getVariant('hero', heroStyle) === 'swiper_full' ? { position: 'relative', marginLeft: 'calc(50% - 50vw)', marginRight: 'calc(50% - 50vw)', ...getStyles('hero') } : { position: 'relative', ...getStyles('hero') }}>
<BlogSwiper fallbackArticles={heroFallbackArticles}
/>
</section>
@@ -1457,155 +1452,54 @@ const HomePage: React.FC = () => {
setSelectedMatch({
...show,
competition: comp?.name,
competitionName: comp?.name,
});
setIsMatchModalOpen(true);
} else if (link) {
window.open(link, '_blank', 'noopener,noreferrer');
}
};
return (
<section data-element="matches" className="next-match" onClick={handleNextMatchClick} style={{ cursor: 'pointer', position: 'relative', ...getStyles('matches') }}>
<button
aria-label="Předchozí soutěž"
onClick={(e) => { e.stopPropagation(); setMatchesTab((i) => (i - 1 + facrCompetitions.length) % facrCompetitions.length); }}
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 || matches[0]?.homeTeam || clubName)}</div>
</div>
<div className="countdown">
<div style={{ fontSize: '0.8rem', opacity: 0.85, marginBottom: 4 }}>{comp?.name || 'Soutěž'}</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 || matches[0]?.awayTeam || 'Soupeř')}</div>
</div>
<button
aria-label="Další soutěž"
onClick={(e) => { e.stopPropagation(); setMatchesTab((i) => (i + 1) % facrCompetitions.length); }}
className="nav next"
style={{ background:'transparent', border:'none', color:'var(--text-on-primary)' }}
>
<FiChevronRight size={24} />
</button>
</section>
<NextMatch
data={show}
competitionName={comp?.name}
countdown={countdown}
onPrev={() => setMatchesTab((i) => (i - 1 + facrCompetitions.length) % facrCompetitions.length)}
onNext={() => setMatchesTab((i) => (i + 1) % facrCompetitions.length)}
onOpen={handleNextMatchClick}
elementProps={{
'data-element': 'matches' as any,
'data-variant': getVariant('matches', 'compact') as any,
style: { ...getStyles('matches') },
}}
/>
);
})()
) : isVisible('matches', true) ? (
<section data-element="matches" className="next-match" style={{ position: 'relative', ...getStyles('matches') }}>
<div className="team">
<img className="logo" src={assetUrl(matches[0]?.homeLogoURL) || assetUrl(clubLogo) || '/images/club-logo.png'} alt="Domácí" />
<div>{sanitizeClubName(matches[0]?.homeTeam || clubName)}</div>
</div>
<div className="countdown">
{countdown || '—'}
<div style={{ fontSize: '0.8rem', opacity: 0.85 }}>Začátek zápasu</div>
{nextMatchLink && (
<div style={{ marginTop: 6 }}>
<a href={nextMatchLink} target="_blank" rel="noopener noreferrer" style={{ color: '#fff', textDecoration: 'underline', fontSize: '0.85rem' }}>Detail na FACR</a>
</div>
)}
</div>
<div className="team">
<img className="logo" src={assetUrl(matches[0]?.awayLogoURL) || '/images/club-opponent.png'} alt="Hosté" />
<div>{sanitizeClubName(matches[0]?.awayTeam || 'Soupeř')}</div>
</div>
</section>
<NextMatch
data={{
home: matches[0]?.homeTeam || clubName,
home_logo_url: matches[0]?.homeLogoURL || clubLogo,
away: matches[0]?.awayTeam || 'Soupeř',
away_logo_url: matches[0]?.awayLogoURL,
}}
countdown={countdown}
elementProps={{ 'data-element': 'matches', 'data-variant': getVariant('matches', 'compact'), style: { position: 'relative', ...getStyles('matches') } }}
/>
) : null}
{/* Matches slider with scores by competition (moved after news+tables) */}
{facrCompetitions.length > 0 && (
<section data-element="matches-slider" className="matches-slider" style={{ position: 'relative', ...getStyles('matches-slider') }}>
<div className="section-head" style={{ marginTop: 16, marginBottom: 16 }}>
<h3>Zápasy</h3>
<a href="/kalendar" className="see-all">Všechny zápasy <FiArrowRight /></a>
</div>
<div className="matches-grid">
<div className="matches-track" ref={trackRef}>
{(facrCompetitions[matchesTab]?.matches || []).map((m:any, idx:number) => {
const handleMatchClick = (e: React.MouseEvent) => {
e.preventDefault();
setSelectedMatch({
...m,
competition: facrCompetitions[matchesTab]?.name,
competitionName: facrCompetitions[matchesTab]?.name,
});
setIsMatchModalOpen(true);
};
return (
<div key={m.id || idx} className="match-card" onClick={handleMatchClick} style={{ cursor: 'pointer' }}>
<div className="match-meta">
<span>{(m.venue || '').split(',')[0] || ''}</span>
<span></span>
<span>{new Date(`${m.date}T${(m.time||'00:00')}:00`).toLocaleDateString()}</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">
{facrCompetitions.map((c, i) => (
<button key={`${c.name}-${i}`} className={i===matchesTab ? 'active' : ''} onClick={() => setMatchesTab(i)}>{c.name}</button>
))}
</div>
</div>
</section>
<MatchesSlider
comps={facrCompetitions as any}
activeIndex={matchesTab}
onActiveChange={setMatchesTab}
onMatchClick={(m: any, compName?: string) => {
setSelectedMatch({ ...m, competition: compName, competitionName: compName });
setIsMatchModalOpen(true);
}}
elementProps={{ 'data-element': 'matches-slider', 'data-variant': getVariant('matches-slider', 'carousel'), style: { position: 'relative', ...getStyles('matches-slider') } }}
/>
)}
{/* News + Tables: split into two independent sections */}
@@ -1631,121 +1525,40 @@ const HomePage: React.FC = () => {
style={{ marginTop: 32 }}
>
{showNews && (
<section data-element="news" className="news-list" style={{ ...getStyles('news') }}>
<section data-element="news" data-variant={getVariant('news', 'grid')} className="news-list" style={{ ...getStyles('news') }}>
<div className="section-head" style={{ marginTop: 0 }}>
<h3>Další aktuality</h3>
<a href="/news" className="see-all" style={{ fontSize: '0.85rem' }}>Zobrazit vše <FiArrowRight size={14} /></a>
</div>
<div className="blog-list">
{news.length > 0 ? news.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>
<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>Zatím nejsou k dispozici žádné aktuality.</p>
</div>
)}
</div>
{news.length > 0 && (
<div style={{ marginTop: 12 }}>
<a className="btn" href="/news">Zobrazit všechny aktuality</a>
</div>
)}
<NewsList items={news as any} />
</section>
)}
{showTable && (
<div data-element="table" style={{ ...getStyles('table') }}>
<div className="table-card">
<div className="section-head" style={{ marginTop: 0, marginBottom: 12 }}>
<h3>Tabulky</h3>
<a href="/tabulky" className="see-all" style={{ fontSize: '0.85rem' }}>Zobrazit vše <FiArrowRight size={14} /></a>
</div>
<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>
{(matchingStanding?.table || matchingStanding?.rows || []).slice(0,8).map((row: any, idx: number) => {
const handleClick = () => {
const clubData = {
team: row.team?.name ?? row.team ?? row.club ?? '-',
team_id: row.team_id || '',
team_logo_url: row.team_logo_url,
rank: row.position ?? row.pos ?? row.rank ?? idx+1,
played: row.played ?? row.matches ?? '-',
wins: row.wins ?? row.win ?? '-',
draws: row.draws ?? row.draw ?? '-',
losses: row.losses ?? row.loss ?? '-',
score: row.score ?? '-',
points: row.points ?? row.pts ?? '-',
};
setSelectedClub(clubData);
setIsModalOpen(true);
};
return (
<tr
key={idx}
onClick={handleClick}
style={{
cursor: 'pointer',
background: 'var(--card-bg)',
border: '1px solid var(--card-border)',
borderRadius: '8px',
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.boxShadow = '0 4px 12px rgba(0,0,0,0.08)';
e.currentTarget.style.borderColor = 'var(--primary)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.boxShadow = 'none';
e.currentTarget.style.borderColor = 'var(--card-border)';
}}
>
<td style={{ padding: '10px 8px', fontWeight: 700, color: 'var(--primary)', fontSize: '0.9rem' }}>#{row.position ?? row.pos ?? row.rank ?? idx+1}</td>
<td style={{ padding: '10px 8px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', minWidth: 0 }}>
{row.team_logo_url && (
<TeamLogo
teamId={row.team_id}
teamName={row.team?.name ?? row.team ?? row.club}
facrLogo={row.team_logo_url}
size="custom"
alt={row.team?.name ?? row.team ?? row.club ?? '-'}
style={{ width: '24px', height: '24px', borderRadius: '50%', objectFit: 'cover', background: 'var(--bg-soft)', border: '1px solid var(--card-border)', flexShrink: 0 }}
/>
)}
<span style={{ fontWeight: 600, color: 'var(--text)', fontSize: '0.9rem', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{row.team?.name ?? row.team ?? row.club ?? '-'}</span>
</div>
</td>
<td style={{ padding: '10px 4px', textAlign: 'center', fontSize: '0.85rem', color: 'var(--text)' }}>{row.played ?? row.matches ?? '-'}</td>
<td style={{ padding: '10px 4px', textAlign: 'center', fontSize: '0.85rem', color: 'var(--text)' }}>{row.wins ?? row.win ?? '-'}</td>
<td style={{ padding: '10px 4px', textAlign: 'center', fontSize: '0.85rem', color: 'var(--text)' }}>{row.draws ?? row.draw ?? '-'}</td>
<td style={{ padding: '10px 4px', textAlign: 'center', fontSize: '0.85rem', color: 'var(--text)' }}>{row.losses ?? row.loss ?? '-'}</td>
<td style={{ padding: '10px 4px', textAlign: 'center', fontSize: '0.85rem', color: 'var(--text)', display: 'none' }} className="hide-mobile">{row.score ?? '-'}</td>
<td style={{ padding: '10px 8px', textAlign: 'center', fontWeight: 700, color: 'var(--secondary)', fontSize: '1rem' }}>{row.points ?? row.pts ?? '-'}</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div data-element="table" data-variant={getVariant('table', 'split_news')} style={{ ...getStyles('table') }}>
<div className="section-head" style={{ marginTop: 0, marginBottom: 12 }}>
<h3>Tabulky</h3>
<a href="/tabulky" className="see-all" style={{ fontSize: '0.85rem' }}>Zobrazit vše <FiArrowRight size={14} /></a>
</div>
<StandingsCard
rows={(matchingStanding?.table || matchingStanding?.rows || []) as any}
onRowClick={(row) => {
const clubData = {
team: (row as any).team?.name ?? (row as any).team ?? (row as any).club ?? '-',
team_id: (row as any).team_id || '',
team_logo_url: (row as any).team_logo_url,
rank: (row as any).position ?? (row as any).pos ?? (row as any).rank ?? 0,
played: (row as any).played ?? (row as any).matches ?? '-',
wins: (row as any).wins ?? (row as any).win ?? '-',
draws: (row as any).draws ?? (row as any).draw ?? '-',
losses: (row as any).losses ?? (row as any).loss ?? '-',
score: (row as any).score ?? '-',
points: (row as any).points ?? (row as any).pts ?? '-',
};
setSelectedClub(clubData);
setIsModalOpen(true);
}}
/>
</div>
)}
</section>
@@ -1755,32 +1568,20 @@ const HomePage: React.FC = () => {
{/* Competition tables moved into right column below */}
{upcomingEvents.length > 0 && isVisible('activities', true) && (
<section data-element="activities" style={{ marginTop: 32, marginBottom: 16, position: 'relative', ...getStyles('activities') }}>
<section data-element="activities" data-variant={getVariant('activities', 'list')} style={{ marginTop: 32, marginBottom: 16, position: 'relative', ...getStyles('activities') }}>
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
<div className="section-head" style={{ marginTop: 0 }}>
<h3>Aktivity</h3>
<a href="/aktivity" className="see-all">Zobrazit vše <FiArrowRight /></a>
</div>
<div className="blog-list">
{upcomingEvents.slice(0,4).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>
<ActivitiesList items={upcomingEvents as any} />
</div>
</section>
)}
{/* Players scroller */}
{players.length > 0 && isVisible('team', false) && (
<section data-element="team" className="players-scroller" style={{ marginTop: 32, position: 'relative', ...getStyles('team') }}>
<section data-element="team" data-variant={getVariant('team', 'grid')} className="players-scroller" style={{ marginTop: 32, position: 'relative', ...getStyles('team') }}>
<div className="section-head">
<h3>Hráči</h3>
<a href="/players" className="see-all">Zobrazit vše <FiArrowRight /></a>
@@ -1799,7 +1600,7 @@ const HomePage: React.FC = () => {
{/* Gallery */}
{isVisible('gallery', false) && (
<section data-element="gallery" style={{ marginTop: 32, marginBottom: 32, position: 'relative', ...getStyles('gallery') }}>
<section data-element="gallery" data-variant={getVariant('gallery', 'grid')} style={{ marginTop: 32, marginBottom: 32, position: 'relative', ...getStyles('gallery') }}>
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
<GallerySection zoneramaUrl={galleryUrl} />
</div>
@@ -1808,7 +1609,7 @@ const HomePage: React.FC = () => {
{/* Videos */}
{isVisible('videos', false) && (
<section data-element="videos" style={{ marginTop: 32, marginBottom: 32, position: 'relative', ...getStyles('videos') }}>
<section data-element="videos" data-variant={getVariant('videos', 'grid')} style={{ marginTop: 32, marginBottom: 32, position: 'relative', ...getStyles('videos') }}>
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
<VideosSection />
</div>
@@ -1816,7 +1617,7 @@ const HomePage: React.FC = () => {
)}
{isVisible('merch', true) && (
<section data-element="merch" style={{ marginTop: 24, marginBottom: 24, position: 'relative', ...getStyles('merch') }}>
<section data-element="merch" data-variant={getVariant('merch', 'grid')} style={{ marginTop: 24, marginBottom: 24, position: 'relative', ...getStyles('merch') }}>
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
<MerchSection />
</div>
@@ -1825,7 +1626,7 @@ const HomePage: React.FC = () => {
{/* Polls / Voting */}
{isVisible('poll', false) && (
<section data-element="poll" style={{ marginTop: 32, marginBottom: 32, position: 'relative', ...getStyles('poll') }}>
<section data-element="poll" data-variant={getVariant('poll', 'vertical')} style={{ marginTop: 32, marginBottom: 32, position: 'relative', ...getStyles('poll') }}>
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
<PollsWidget featuredOnly={true} maxPolls={1} title="Anketa" />
</div>
@@ -1834,7 +1635,7 @@ const HomePage: React.FC = () => {
{/* Banner: homepage_footer */}
{(banners || []).some(b => b.placement === 'homepage_footer') && (
<section data-element="banner" className="banner banner-footer" style={{ margin: '24px 0', textAlign: 'center', ...getStyles('banner') }}>
<section data-element="banner" data-variant={getVariant('banner', 'bottom')} className="banner banner-footer" style={{ margin: '24px 0', textAlign: 'center', ...getStyles('banner') }}>
{(banners || []).filter(b => b.placement === 'homepage_footer').map((b) => (
<a key={b.id} href={b.url || '#'} target={b.url ? '_blank' : undefined} rel={b.url ? 'noopener noreferrer' : undefined} style={{ display: 'inline-block', margin: 8 }}>
{/* eslint-disable-next-line jsx-a11y/alt-text */}
@@ -1846,7 +1647,7 @@ const HomePage: React.FC = () => {
{/* CTA (Newsletter) moved up */}
{isVisible('newsletter', false) && (
<section data-element="newsletter" className="newsletter-cta" style={{ marginTop: 24, marginBottom: 24, position: 'relative', ...getStyles('newsletter') }}>
<section data-element="newsletter" data-variant={getVariant('newsletter', 'default')} className="newsletter-cta" style={{ marginTop: 24, marginBottom: 24, position: 'relative', ...getStyles('newsletter') }}>
<div className="card" style={{ maxWidth: 960, margin: '0 auto' }}>
<NewsletterSubscribe />
</div>
+26 -5
View File
@@ -277,8 +277,9 @@ const MatchesPage: React.FC = () => {
setAliasMap(amap);
// Build override helpers
const teamLogoOverridesJSON = (teamLogoOverridesAPI && teamLogoOverridesAPI.by_name) ? teamLogoOverridesAPI : (teamLogoOverridesFile || {});
const teamLogoOverridesJSON = (teamLogoOverridesAPI && (teamLogoOverridesAPI.by_name || teamLogoOverridesAPI.by_id)) ? teamLogoOverridesAPI : (teamLogoOverridesFile || {});
const byName: Record<string, string> = (teamLogoOverridesJSON?.by_name || {}) as any;
const byId: Record<string, { name?: string; logo_url?: string }> = (teamLogoOverridesJSON?.by_id || {}) as any;
const normalize = (s: string) => String(s)
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
@@ -321,9 +322,27 @@ const MatchesPage: React.FC = () => {
};
const getOverrideLogo = (teamName?: string, teamId?: string, original?: string) => {
// Prefer explicit admin override by ID
if (teamId && byId?.[teamId]?.logo_url) {
const v = byId[teamId]!.logo_url as string;
if (typeof v === 'string' && v.startsWith('/')) return resolveBackendUrl(v);
return v;
}
// Prefer local club logo for our own team
try {
if (
teamId && settingsJSON?.club_id && String(teamId) === String(settingsJSON.club_id) && settingsJSON?.club_logo_url
) {
const local = settingsJSON.club_logo_url as string;
if (typeof local === 'string' && local.startsWith('/')) return resolveBackendUrl(local);
return local;
}
} catch {}
// If we have a team ID and no explicit override, use logoapi as a high-quality source
if (teamId) {
return `http://logoapi.sportcreative.eu/logos/${teamId}`;
}
// Fallback to by-name overrides or original
return getFallbackLogo(teamName, original);
};
@@ -351,6 +370,8 @@ const MatchesPage: React.FC = () => {
const [day, month, year] = (d || '').split('.');
const isoDate = (day && month && year) ? `${year}-${month.padStart(2,'0')}-${day.padStart(2,'0')}` : new Date().toISOString().slice(0,10);
const time = (t || '18:00').slice(0,5);
const homeName = (byId?.[m.home_id]?.name && String(byId[m.home_id].name).trim()) ? String(byId[m.home_id].name) : m.home;
const awayName = (byId?.[m.away_id]?.name && String(byId[m.away_id].name).trim()) ? String(byId[m.away_id].name) : m.away;
// Check if match is in the future - if so, ignore score
const matchTime = new Date(`${isoDate}T${time}:00`).getTime();
@@ -361,12 +382,12 @@ const MatchesPage: React.FC = () => {
id: m.match_id || idx + 1,
date: isoDate,
time,
home: m.home,
away: m.away,
home: homeName,
away: awayName,
home_id: m.home_id,
away_id: m.away_id,
home_logo_url: getOverrideLogo(m.home, m.home_id, m.home_logo_url),
away_logo_url: getOverrideLogo(m.away, m.away_id, m.away_logo_url),
home_logo_url: getOverrideLogo(homeName, m.home_id, m.home_logo_url),
away_logo_url: getOverrideLogo(awayName, m.away_id, m.away_logo_url),
score: actualScore,
facr_link: m.facr_link,
report_url: m.report_url,
+102 -58
View File
@@ -1,6 +1,7 @@
import React, { useMemo, useState } from 'react';
import {
Box,
Container,
Heading,
Text,
VStack,
@@ -15,6 +16,11 @@ import {
AlertIcon,
Card,
CardBody,
SimpleGrid,
Badge,
Divider,
Spinner,
Spacer,
} from '@chakra-ui/react';
import { useSearchParams, useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
@@ -54,8 +60,9 @@ const NewsletterPreferencesPage: React.FC = () => {
blogs: true,
matches: true,
events: true,
scores: false,
...(data?.preferences || {} as SubscriberPreferences),
scores: true,
weekly: true,
...(data?.preferences || ({} as SubscriberPreferences)),
}), [data]);
const [prefs, setPrefs] = useState<SubscriberPreferences>(initialPrefs);
@@ -117,7 +124,14 @@ const NewsletterPreferencesPage: React.FC = () => {
}
if (isLoading) {
return <Box maxW="640px" mx="auto" p={6}><Text>Načítání</Text></Box>;
return (
<Container maxW="container.md" py={8}>
<HStack>
<Spinner />
<Text>Načítání</Text>
</HStack>
</Container>
);
}
if (isError || !data) {
@@ -129,73 +143,103 @@ const NewsletterPreferencesPage: React.FC = () => {
}
// helpers to toggle all main content types on/off
const setAll = (on: boolean) => setPrefs({
...prefs,
blogs: on,
matches: on,
events: on,
scores: on,
weekly: on,
});
return (
<Box maxW="720px" mx="auto" p={6}>
<Heading size="lg" mb={2}>Nastavení newsletteru</Heading>
<Text color="gray.600" mb={6}>Spravujte, jaké e-maily chcete dostávat na adresu {data.email}.</Text>
<Container maxW="container.md" py={8}>
<HStack mb={2} align="center">
<Heading size="lg">Nastavení newsletteru</Heading>
<Spacer />
<Button size="sm" variant="outline" onClick={() => setAll(true)}>Zapnout vše</Button>
<Button size="sm" variant="ghost" onClick={() => setAll(false)}>Vypnout vše</Button>
</HStack>
<HStack mb={6} color="gray.600">
<Text>Spravujte, jaké e-maily chcete dostávat na adresu</Text>
<Badge colorScheme={data.is_active ? 'green' : 'red'}>{data.email}</Badge>
</HStack>
<Card mb={6}><CardBody>
<VStack spacing={4} align="stretch">
<FormControl display="flex" alignItems="center" justifyContent="space-between">
<FormLabel m={0}>Články (blog)</FormLabel>
<Switch isChecked={!!prefs.blogs} onChange={(e) => setPrefs({ ...prefs, blogs: e.target.checked })} />
</FormControl>
<FormControl display="flex" alignItems="center" justifyContent="space-between">
<FormLabel m={0}>Nadcházející zápasy</FormLabel>
<Switch isChecked={!!prefs.matches} onChange={(e) => setPrefs({ ...prefs, matches: e.target.checked })} />
</FormControl>
<FormControl display="flex" alignItems="center" justifyContent="space-between">
<FormLabel m={0}>Události</FormLabel>
<Switch isChecked={!!prefs.events} onChange={(e) => setPrefs({ ...prefs, events: e.target.checked })} />
</FormControl>
<FormControl display="flex" alignItems="center" justifyContent="space-between">
<FormLabel m={0}>Výsledky (souhrn týdne)</FormLabel>
<Switch isChecked={!!prefs.scores} onChange={(e) => setPrefs({ ...prefs, scores: e.target.checked })} />
</FormControl>
<FormControl>
<FormLabel>Preferované soutěže</FormLabel>
{Array.isArray(competitions) && competitions.length > 0 ? (
<VStack align="stretch" spacing={1} maxH="220px" overflowY="auto" borderWidth="1px" borderRadius="md" p={3}>
{competitions.map((c: any, idx: number) => {
const code = (c?.code || c?.id || c?.name || `comp-${idx}`) as string;
const name = (c?.name || c?.code || code) as string;
const checked = selectedCodes.has(String(code));
return (
<HStack key={code} justify="space-between">
<Text>{name}</Text>
<Switch isChecked={checked} onChange={(e) => toggleCode(String(code), e.target.checked)} />
</HStack>
);
})}
</VStack>
) : (
<Input placeholder="např. 5LM, POH" value={(prefs.competitions as string) || ''} onChange={(e) => setPrefs({ ...prefs, competitions: e.target.value })} />
)}
<Text mt={2} fontSize="sm" color="gray.500">Pokud není seznam k dispozici, můžete zadat kódy soutěží ručně, oddělené čárkou.</Text>
</FormControl>
</VStack>
</CardBody></Card>
<Card mb={6}>
<CardBody>
<VStack spacing={6} align="stretch">
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
<FormControl display="flex" alignItems="center" justifyContent="space-between">
<FormLabel m={0}>Články (blog)</FormLabel>
<Switch isChecked={!!prefs.blogs} onChange={(e) => setPrefs({ ...prefs, blogs: e.target.checked })} />
</FormControl>
<FormControl display="flex" alignItems="center" justifyContent="space-between">
<FormLabel m={0}>Nadcházející zápasy</FormLabel>
<Switch isChecked={!!prefs.matches} onChange={(e) => setPrefs({ ...prefs, matches: e.target.checked })} />
</FormControl>
<FormControl display="flex" alignItems="center" justifyContent="space-between">
<FormLabel m={0}>Události</FormLabel>
<Switch isChecked={!!prefs.events} onChange={(e) => setPrefs({ ...prefs, events: e.target.checked })} />
</FormControl>
<FormControl display="flex" alignItems="center" justifyContent="space-between">
<FormLabel m={0}>Výsledky (souhrn týdne)</FormLabel>
<Switch isChecked={!!prefs.scores} onChange={(e) => setPrefs({ ...prefs, scores: e.target.checked })} />
</FormControl>
<FormControl display="flex" alignItems="center" justifyContent="space-between">
<FormLabel m={0}>Týdenní souhrn (digest)</FormLabel>
<Switch isChecked={!!(prefs as any).weekly} onChange={(e) => setPrefs({ ...prefs, weekly: e.target.checked })} />
</FormControl>
</SimpleGrid>
<HStack spacing={3}>
<Button
colorScheme="blue"
onClick={() => saveMut.mutate()}
<Divider />
<FormControl>
<FormLabel>Preferované soutěže</FormLabel>
{Array.isArray(competitions) && competitions.length > 0 ? (
<VStack align="stretch" spacing={1} maxH="220px" overflowY="auto" borderWidth="1px" borderRadius="md" p={3}>
{competitions.map((c: any, idx: number) => {
const code = (c?.code || c?.id || c?.name || `comp-${idx}`) as string;
const name = (c?.name || c?.code || code) as string;
const checked = selectedCodes.has(String(code));
return (
<HStack key={code} justify="space-between">
<Text>{name}</Text>
<Switch isChecked={checked} onChange={(e) => toggleCode(String(code), e.target.checked)} />
</HStack>
);
})}
</VStack>
) : (
<Input placeholder="např. 5LM, POH" value={(prefs.competitions as string) || ''} onChange={(e) => setPrefs({ ...prefs, competitions: e.target.value })} />
)}
<Text mt={2} fontSize="sm" color="gray.500">Pokud není seznam k dispozici, můžete zadat kódy soutěží ručně, oddělené čárkou.</Text>
</FormControl>
</VStack>
</CardBody>
</Card>
<HStack spacing={3} pt={2} borderTopWidth="1px">
<Button
colorScheme="blue"
onClick={() => saveMut.mutate()}
isLoading={saveMut.isLoading}
data-umami-event="Save Preferences"
>
Uložit
</Button>
<Button
variant="outline"
<Button
variant="outline"
onClick={() => qc.invalidateQueries({ queryKey: ['newsletter', 'prefs', token] })}
data-umami-event="Refresh Preferences"
>
Obnovit
</Button>
<Button
colorScheme="red"
variant="outline"
onClick={() => unsubMut.mutate()}
<Spacer />
<Button
colorScheme="red"
variant="outline"
onClick={() => unsubMut.mutate()}
isLoading={unsubMut.isLoading}
data-umami-event="Unsubscribe"
data-umami-event-source="preferences"
@@ -203,7 +247,7 @@ const NewsletterPreferencesPage: React.FC = () => {
Zrušit odběr
</Button>
</HStack>
</Box>
</Container>
);
};
+2 -1
View File
@@ -1,6 +1,7 @@
import { Box, Container, Heading, Image, SimpleGrid, Spinner, Stack, Text, VStack, useColorModeValue } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { getPlayers } from '../services/public';
import type { Player } from '../services/public';
import { assetUrl } from '../utils/url';
import { Link as RouterLink } from 'react-router-dom';
import MainLayout from '../components/layout/MainLayout';
@@ -8,7 +9,7 @@ import SponsorsSection from '../components/common/SponsorsSection';
import NewsletterCTA from '../components/common/NewsletterCTA';
const PlayersPage: React.FC = () => {
const { data, isLoading, isError } = useQuery({ queryKey: ['players'], queryFn: getPlayers });
const { data, isLoading, isError } = useQuery<Player[]>({ queryKey: ['players'], queryFn: getPlayers });
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
const textSecondary = useColorModeValue('gray.600', 'gray.400');
+5 -4
View File
@@ -106,6 +106,7 @@ const SetupPage: React.FC = () => {
const [smtpUser, setSmtpUser] = useState('');
const [smtpPass, setSmtpPass] = useState('');
const [showSmtpPass, setShowSmtpPass] = useState(false);
const [smtpUserEdited, setSmtpUserEdited] = useState(false);
// Sender display name only; actual email is derived from smtpUser
const [smtpFromName, setSmtpFromName] = useState('');
const [smtpTLS, setSmtpTLS] = useState(true);
@@ -192,10 +193,10 @@ const SetupPage: React.FC = () => {
// Auto-fill SMTP username from contact email
useEffect(() => {
if (contactEmail && !smtpUser && isValidEmail(contactEmail)) {
if (contactEmail && isValidEmail(contactEmail) && !smtpUserEdited) {
setSmtpUser(contactEmail);
}
}, [contactEmail, smtpUser]);
}, [contactEmail, smtpUserEdited]);
const handleSelectClub = async (item: SearchResult) => {
const clubIdValue = item.club_id || '';
@@ -791,7 +792,7 @@ const SetupPage: React.FC = () => {
</FormControl>
<FormControl>
<FormLabel>E-mail</FormLabel>
<Input type="email" placeholder="kontakt@klub.cz" value={contactEmail} onChange={(e) => setContactEmail(e.target.value)} onBlur={() => { if (!smtpUser && isValidEmail(contactEmail)) { setSmtpUser(contactEmail); } }} />
<Input type="email" placeholder="kontakt@klub.cz" value={contactEmail} onChange={(e) => setContactEmail(e.target.value)} onBlur={() => { if (!smtpUserEdited && isValidEmail(contactEmail)) { setSmtpUser(contactEmail); } }} />
<FormHelperText>Hlavní kontaktní e-mail klubu</FormHelperText>
</FormControl>
</SimpleGrid>
@@ -816,7 +817,7 @@ const SetupPage: React.FC = () => {
</FormControl>
<FormControl mb={3}>
<FormLabel>SMTP uživatelské jméno</FormLabel>
<Input value={smtpUser} onChange={(e) => setSmtpUser(e.target.value)} />
<Input value={smtpUser} onChange={(e) => { setSmtpUser(e.target.value); setSmtpUserEdited(true); }} />
</FormControl>
<FormControl mb={3}>
<FormLabel>SMTP heslo</FormLabel>
+31
View File
@@ -0,0 +1,31 @@
import React, { useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { Center, Spinner, Text, VStack } from '@chakra-ui/react';
import { API_URL } from '../services/api';
const ShortRedirectPage: React.FC = () => {
const { code } = useParams<{ code: string }>();
useEffect(() => {
if (!code) return;
try {
const api = new URL(API_URL, typeof window !== 'undefined' ? window.location.origin : undefined);
const backendOrigin = api.origin;
const target = `${backendOrigin}/s/${code}`;
window.location.replace(target);
} catch {
window.location.href = `/s/${code}`;
}
}, [code]);
return (
<Center minH="60vh">
<VStack spacing={3}>
<Spinner />
<Text>Přesměrování</Text>
</VStack>
</Center>
);
};
export default ShortRedirectPage;
+10 -3
View File
@@ -141,15 +141,22 @@ const TablesPage: React.FC = () => {
)}
{!!competitions.length && (
<Tabs variant="enclosed">
<TabList>
<Tabs variant="enclosed" 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: 'var(--chakra-colors-gray-300)', borderRadius: '4px' },
}}>
{competitions.map((c) => (
<Tab
key={c.id}
_selected={{ bg: 'brand.primary', color: 'text.onPrimary', borderColor: 'brand.primary' }}
_hover={{ bg: 'rgba(0,0,0,0.04)' }}
flex="0 0 auto"
px={3}
py={2}
>
{c.name}
<Text as="span" noOfLines={1} maxW="300px" title={c.name}>{c.name}</Text>
</Tab>
))}
</TabList>
@@ -40,7 +40,7 @@ import {
useColorModeValue,
Image as ChakraImage,
} from '@chakra-ui/react';
import { FiEdit2, FiPlus, FiTrash2 } from 'react-icons/fi';
import { FiEdit2, FiPlus, FiTrash2, FiLink } from 'react-icons/fi';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Event } from '../../types/event';
import { uploadFile } from '../../services/articles';
@@ -60,9 +60,10 @@ import { getCachedYouTube, YouTubeVideo } from '../../services/youtube';
import SaveStatusIndicator from '../../components/common/SaveStatusIndicator';
import DraftRecoveryModal from '../../components/common/DraftRecoveryModal';
import { useAutoSave, loadDraft, getDraftMetadata } from '../../hooks/useAutoSave';
import { FiVideo, FiYoutube, FiLink } from 'react-icons/fi';
import { FiVideo, FiYoutube } from 'react-icons/fi';
import ThumbnailPreview from '../../components/common/ThumbnailPreview';
import { assetUrl } from '../../utils/url';
import { createShortLink } from '../../services/shortlinks';
const types: Array<{ value: Event['type']; label: string }> = [
{ value: 'match', label: 'Zápas' },
@@ -124,6 +125,13 @@ const AdminActivitiesPage: React.FC = () => {
});
const events = data || [];
// Localized label for event type
const typeLabel = (t?: string) => {
const v = String(t || '').trim() as any;
const found = types.find((x) => x.value === v);
return found ? found.label : 'Jiné';
};
// Load club YouTube videos
useEffect(() => {
(async () => {
@@ -266,22 +274,18 @@ const AdminActivitiesPage: React.FC = () => {
const e = editing || {};
// Build a helpful Czech prompt including known fields
const lines: string[] = [];
const clubName = String(settingsQ?.data?.club_name || '').trim();
if (clubName) lines.push(`Klub: ${clubName}`);
if (e.type) lines.push(`Typ: ${e.type}`);
if (e.location) lines.push(`Místo: ${e.location}`);
if (e.start_time) {
try { lines.push(`Začátek: ${new Date(e.start_time as any).toLocaleString('cs-CZ')}`); } catch {}
}
if (e.end_time) {
try { lines.push(`Konec: ${new Date(e.end_time as any).toLocaleString('cs-CZ')}`); } catch {}
}
if (e.description) lines.push(`Poznámky: ${e.description}`);
const base = lines.join('\n');
const toneText = aiTone === 'informative' ? 'informativním a věcným stylem' : aiTone === 'formal' ? 'formálním a profesionálním stylem' : 'přátelským, pozitivním a lákavým stylem';
const safeUserPrompt = (aiPrompt || 'Vytvoř krátké oznámení pro fanoušky o klubové aktivitě.').trim();
const prompt = `${safeUserPrompt}\n\nPiš ${toneText}, česky, s důrazem na jasnost a pozvánku k účasti.\nDetaily:\n${base}`.trim();
const constraints = 'Nevkládej datum ani místo (lokalitu) do textu. Neuváděj konkrétní čas nebo adresu.';
const prompt = `${safeUserPrompt}\n\nPiš ${toneText}, česky, s důrazem na jasnost a pozvánku k účasti. ${constraints}\nDetaily:\n${base}`.trim();
const { data } = await api.post('/ai/blog/generate', {
prompt,
audience: 'Fanoušci klubu, oznámení/pozvánka',
audience: clubName ? `Fanoušci klubu ${clubName}, oznámení/pozvánka` : 'Fanoušci klubu, oznámení/pozvánka',
min_words: 120,
});
@@ -485,7 +489,7 @@ const AdminActivitiesPage: React.FC = () => {
)}
</Td>
<Td>{ev.title}</Td>
<Td>{ev.type}</Td>
<Td>{typeLabel(ev.type as any)}</Td>
<Td>{new Date(ev.start_time).toLocaleString()}</Td>
<Td>{ev.end_time ? new Date(ev.end_time).toLocaleString() : '-'}</Td>
<Td>{ev.location || '-'}</Td>
@@ -494,6 +498,23 @@ const AdminActivitiesPage: React.FC = () => {
<HStack>
<IconButton aria-label="Upravit" size="sm" icon={<FiEdit2 />} onClick={() => openEdit(ev)} />
<IconButton aria-label="Smazat" size="sm" colorScheme="red" icon={<FiTrash2 />} onClick={() => deleteMut.mutate(ev.id)} />
<IconButton
aria-label="Zkrátit odkaz"
size="sm"
icon={<FiLink />}
title="Zkrátit odkaz pro sdílení"
onClick={async () => {
try {
const origin = window.location.origin;
const target = `${origin}/aktivita/${ev.id}`;
const res = await createShortLink({ target_url: target, title: ev.title, source_type: 'event', source_id: ev.id as any });
await navigator.clipboard.writeText(res.short_url);
toast({ title: 'Zkrácený odkaz zkopírován', description: res.short_url, status: 'success', duration: 4000 });
} catch (e: any) {
toast({ title: 'Vytvoření odkazu selhalo', description: e?.message || 'Zkuste to znovu', status: 'error' });
}
}}
/>
</HStack>
</Td>
</Tr>
@@ -70,7 +70,8 @@ import {
FiZap,
FiTrendingUp,
FiCalendar,
FiSearch
FiSearch,
FiInfo
} from 'react-icons/fi';
// Register ChartJS components
@@ -188,6 +189,11 @@ const getEventTranslation = (eventName: string): { name: string; source: string;
name: 'Kliknutí na externí odkaz',
source: 'Různé stránky',
description: 'Uživatel klikl na odkaz vedoucí mimo web'
},
'Poll Vote': {
name: 'Hlasování v anketě',
source: 'Ankety',
description: 'Uživatel hlasoval v anketě'
}
};
@@ -213,6 +219,7 @@ const AnalyticsAdminPage: React.FC = () => {
const [timeRange, setTimeRange] = useState('0'); // Default to "today"
const [hasData, setHasData] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [noDataInfo, setNoDataInfo] = useState<string | null>(null);
const [selectedCountry, setSelectedCountry] = useState<{
code: string;
name: string;
@@ -230,6 +237,7 @@ const AnalyticsAdminPage: React.FC = () => {
const fetchAnalytics = async (days: string) => {
setLoading(true);
setErrorMessage(null);
setNoDataInfo(null);
try {
const daysNum = parseInt(days);
@@ -292,14 +300,19 @@ const AnalyticsAdminPage: React.FC = () => {
setPageviewsData(pageviewsDataArray);
// Determine if we have data
const hasPageviews = pageviewsDataArray.length > 0 && pageviewsDataArray.some(d => d.value > 0);
const hasMetrics = pages.data?.length > 0 || countries.data?.length > 0;
setHasData(hasAnyStats || hasPageviews || hasMetrics);
// Set error message if no data
if (!hasAnyStats && !hasPageviews && !hasMetrics) {
setErrorMessage('Umami není správně nakonfigurováno nebo ještě nebyly zaznamenány žádné návštěvy. Zkontrolujte UMAMI_WEBSITE_ID v .env souboru.');
const noData = !hasAnyStats && !hasPageviews && !hasMetrics;
if (noData) {
if (daysNum <= 1) {
setNoDataInfo('Pro vybrané denní období zatím nebyla zaznamenána žádná návštěvnost. Zkuste později nebo zvolte delší období.');
} else {
setErrorMessage('Umami není správně nakonfigurováno nebo ještě nebyly zaznamenány žádné návštěvy. Zkontrolujte UMAMI_WEBSITE_ID v .env souboru.');
}
} else {
setNoDataInfo(null);
}
} catch (error) {
console.error('Failed to fetch analytics:', error);
@@ -478,6 +491,21 @@ const AnalyticsAdminPage: React.FC = () => {
</HStack>
</HStack>
{/* No data info for daily view */}
{noDataInfo && (
<Card bg="yellow.50" borderColor="yellow.300" borderWidth={2}>
<CardBody>
<HStack spacing={3} align="start">
<Icon as={FiInfo} color="yellow.600" boxSize={6} mt={1} />
<VStack align="start" spacing={1}>
<Text fontWeight="bold" color="yellow.800">Zatím žádná data</Text>
<Text fontSize="sm" color="yellow.700">{noDataInfo}</Text>
</VStack>
</HStack>
</CardBody>
</Card>
)}
{/* Stats Overview */}
<SimpleGrid columns={{ base: 1, md: 2, lg: 5 }} spacing={4}>
<Card bg={bgColor} borderColor={borderColor}>
+101 -152
View File
@@ -8,7 +8,7 @@ import {
Select, Badge, Tabs, TabList, TabPanels, Tab, TabPanel, Accordion, AccordionItem,
AccordionButton, AccordionPanel, AccordionIcon, AspectRatio, Link, Alert, AlertIcon
} from '@chakra-ui/react';
import { FiEdit2, FiTrash2, FiPlus, FiSearch, FiUpload, FiExternalLink, FiVideo, FiX, FiRefreshCcw } from 'react-icons/fi';
import { FiEdit2, FiTrash2, FiPlus, FiSearch, FiUpload, FiExternalLink, FiVideo, FiX, FiRefreshCcw, FiLink } from 'react-icons/fi';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import AdminLayout from '../../layouts/AdminLayout';
import { Article, deleteArticle, getArticles, createArticle, updateArticle, uploadFile, CreateArticlePayload, UpdateArticlePayload, getArticleMatchLink, putArticleMatchLink, deleteArticleMatchLink } from '../../services/articles';
@@ -30,6 +30,7 @@ import DraftRecoveryModal from '../../components/common/DraftRecoveryModal';
import { useAutoSave, loadDraft, getDraftMetadata } from '../../hooks/useAutoSave';
import { getCachedYouTube, YouTubeVideo } from '../../services/youtube';
import { createShortLink } from '../../services/shortlinks';
// Inline small component to show match link badge in list (with short label)
const MatchLinkBadge: React.FC<{ articleId: number }> = ({ articleId }) => {
@@ -581,15 +582,31 @@ const ArticlesAdminPage = () => {
return dateStr.includes(matchDateFilter);
});
}
// Sort by proximity to current date (recent matches first)
const now = Date.now();
const parseTime = (s?: string): number => {
if (!s) return Number.MAX_SAFE_INTEGER;
const m = s.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})(?:\s+(\d{1,2}):(\d{2}))?/);
if (m) {
const d = parseInt(m[1], 10);
const mo = parseInt(m[2], 10) - 1;
const y = parseInt(m[3], 10);
const h = m[4] ? parseInt(m[4], 10) : 0;
const mi = m[5] ? parseInt(m[5], 10) : 0;
return new Date(y, mo, d, h, mi).getTime();
}
const t = Date.parse(s);
return isNaN(t) ? Number.MAX_SAFE_INTEGER : t;
};
opts = opts.sort((a, b) => {
const dateA = new Date(a.date || 0).getTime();
const dateB = new Date(b.date || 0).getTime();
const diffA = Math.abs(now - dateA);
const diffB = Math.abs(now - dateB);
return diffA - diffB; // Closest to today first
const ta = parseTime(a.date);
const tb = parseTime(b.date);
const da = ta - now;
const db = tb - now;
const aUpcoming = da >= 0;
const bUpcoming = db >= 0;
if (aUpcoming !== bUpcoming) return aUpcoming ? -1 : 1;
if (aUpcoming) return da - db;
return Math.abs(da) - Math.abs(db);
});
return opts;
@@ -1267,6 +1284,25 @@ const ArticlesAdminPage = () => {
<HStack spacing={1}>
<IconButton aria-label="Upravit" size="sm" icon={<FiEdit2 />} onClick={() => openEdit(a)} />
<IconButton aria-label="Smazat" size="sm" colorScheme="red" icon={<FiTrash2 />} onClick={() => handleDeleteArticle(a)} />
<IconButton
aria-label="Zkrátit odkaz"
size="sm"
icon={<FiLink />}
title="Zkrátit odkaz pro sdílení"
onClick={async () => {
try {
const origin = window.location.origin;
const slug = (a as any)?.slug || (a as any)?.Slug;
const path = slug ? `/news/${slug}` : `/articles/${a.id}`;
const target = `${origin}${path}`;
const res = await createShortLink({ target_url: target, title: a.title, source_type: 'article', source_id: a.id });
await navigator.clipboard.writeText(res.short_url);
toast({ title: 'Zkrácený odkaz zkopírován', description: res.short_url, status: 'success', duration: 4000 });
} catch (e: any) {
toast({ title: 'Vytvoření odkazu selhalo', description: e?.message || 'Zkuste to znovu', status: 'error' });
}
}}
/>
</HStack>
</Td>
</Tr>
@@ -1299,8 +1335,8 @@ const ArticlesAdminPage = () => {
<Tab>Základní</Tab>
<Tab>Obsah</Tab>
<Tab>Média</Tab>
<Tab>SEO</Tab>
<Tab>Anketa</Tab>
<Tab>SEO</Tab>
</TabList>
<TabPanels>
{/* AI first */}
@@ -1880,6 +1916,60 @@ const ArticlesAdminPage = () => {
</VStack>
</TabPanel>
{/* Anketa (Poll) Tab */}
<TabPanel>
<VStack align="stretch" spacing={4}>
<Box borderWidth="1px" borderRadius="md" p={4} bg={useColorModeValue('purple.50', 'purple.900')}>
<Heading as="h3" size="sm" mb={2}>📊 Ankety k článku</Heading>
<Text fontSize="sm" color="gray.700" mb={3}>
Vytvořte nebo připojte ankety přímo k tomuto článku. Ankety se zobrazí automaticky na konci článku a čtenáři mohou hlasovat.
</Text>
</Box>
{editing?.id ? (
<PollLinker articleId={editing.id} onPollsChanged={() => {
// Invalidate queries to refresh polls
qc.invalidateQueries({ queryKey: ['linked-polls'] });
}} />
) : (
<Alert status="info" borderRadius="md">
<AlertIcon />
<VStack align="start" spacing={2}>
<Text fontWeight="semibold">
{saveStatus === 'saving' ? 'Ukládání článku...' : 'Článek se ukládá automaticky'}
</Text>
<Text fontSize="sm">
Začněte psát článek na záložkách výše. Systém automaticky ukládá každou změnu jako koncept. Jakmile bude článek uložen (v záhlaví se zobrazí "Uloženo"), budete moci přidat ankety.
</Text>
{saveStatus === 'saving' && <Spinner size="sm" color="blue.500" />}
{saveStatus === 'idle' && (
<Text fontSize="xs" color="gray.600">
💡 Vyplňte název článku pro aktivaci automatického ukládání
</Text>
)}
<Button
size="sm"
colorScheme="blue"
onClick={async () => {
// Force save if needed
try {
await forceSave();
// Switch to poll tab after save
setActiveTabIndex(4); // Poll tab is index 4 after reordering
} catch (error) {
// Error is handled by onSubmit
}
}}
isLoading={createMut.isLoading}
>
Uložit jako koncept a přidat ankety
</Button>
</VStack>
</Alert>
)}
</VStack>
</TabPanel>
{/* SEO last - minimized */}
<TabPanel>
<Text fontSize="sm" color="gray.600" mb={4}>
@@ -1923,60 +2013,6 @@ const ArticlesAdminPage = () => {
</AccordionItem>
</Accordion>
</TabPanel>
{/* Anketa (Poll) Tab */}
<TabPanel>
<VStack align="stretch" spacing={4}>
<Box borderWidth="1px" borderRadius="md" p={4} bg={useColorModeValue('purple.50', 'purple.900')}>
<Heading as="h3" size="sm" mb={2}>📊 Ankety k článku</Heading>
<Text fontSize="sm" color="gray.700" mb={3}>
Vytvořte nebo připojte ankety přímo k tomuto článku. Ankety se zobrazí automaticky na konci článku a čtenáři mohou hlasovat.
</Text>
</Box>
{editing?.id ? (
<PollLinker articleId={editing.id} onPollsChanged={() => {
// Invalidate queries to refresh polls
qc.invalidateQueries({ queryKey: ['linked-polls'] });
}} />
) : (
<Alert status="info" borderRadius="md">
<AlertIcon />
<VStack align="start" spacing={2}>
<Text fontWeight="semibold">
{saveStatus === 'saving' ? 'Ukládání článku...' : 'Článek se ukládá automaticky'}
</Text>
<Text fontSize="sm">
Začněte psát článek na záložkách výše. Systém automaticky ukládá každou změnu jako koncept. Jakmile bude článek uložen (v záhlaví se zobrazí "Uloženo"), budete moci přidat ankety.
</Text>
{saveStatus === 'saving' && <Spinner size="sm" color="blue.500" />}
{saveStatus === 'idle' && (
<Text fontSize="xs" color="gray.600">
💡 Vyplňte název článku pro aktivaci automatického ukládání
</Text>
)}
<Button
size="sm"
colorScheme="blue"
onClick={async () => {
// Force save if needed
try {
await forceSave();
// Switch to poll tab after save
setActiveTabIndex(5); // Poll tab is index 5
} catch (error) {
// Error is handled by onSubmit
}
}}
isLoading={createMut.isLoading}
>
Uložit jako koncept a přidat ankety
</Button>
</VStack>
</Alert>
)}
</VStack>
</TabPanel>
</TabPanels>
</Tabs>
</ModalBody>
@@ -2097,7 +2133,7 @@ const ArticlesAdminPage = () => {
</Modal>
{/* Zonerama Gallery Picker Modal */}
<Modal isOpen={isGalleryPickerOpen} onClose={onGalleryPickerClose} size="6xl">
<Modal isOpen={isGalleryPickerOpen} onClose={onGalleryPickerClose} size="6xl" scrollBehavior="inside">
<ModalOverlay />
<ModalContent maxH="90vh">
<ModalHeader>Vybrat fotku z galerie</ModalHeader>
@@ -2185,94 +2221,7 @@ const ArticlesAdminPage = () => {
</ModalContent>
</Modal>
{/* Zonerama Gallery Picker Modal */}
<Modal isOpen={isGalleryPickerOpen} onClose={onGalleryPickerClose} size="6xl">
<ModalOverlay />
<ModalContent maxH="90vh">
<ModalHeader>Vybrat fotku z galerie</ModalHeader>
<ModalCloseButton />
<ModalBody overflowY="auto">
<VStack align="stretch" spacing={4}>
{/* Loading State */}
{galleryLoading && (
<HStack spacing={2} justify="center" py={8}>
<Spinner size="lg" color="purple.500" />
<Text color="gray.600">Načítám alba z galerie...</Text>
</HStack>
)}
{/* Albums Grid */}
{!galleryLoading && cachedAlbums.length > 0 && (
<VStack align="stretch" spacing={6}>
{cachedAlbums.map((album) => (
<Box key={album.id} borderWidth="1px" borderRadius="md" p={4} bg={albumCardBg}>
<HStack justify="space-between" mb={3}>
<VStack align="start" spacing={0}>
<Text fontWeight="bold" fontSize="lg">{album.title || 'Album bez názvu'}</Text>
<Text fontSize="sm" color="gray.500">{album.date} {album.photos.length} fotografií</Text>
</VStack>
</HStack>
<SimpleGrid columns={{ base: 3, md: 4, lg: 6 }} spacing={2}>
{album.photos.map((photo) => (
<Box
key={photo.id}
borderWidth="1px"
borderRadius="md"
overflow="hidden"
cursor="pointer"
transition="all 0.2s"
_hover={{ boxShadow: 'lg', transform: 'scale(1.05)' }}
onClick={() => {
pickZoneramaImage({
id: photo.id,
album_id: album.id,
album_url: `https://eu.zonerama.com/FKKofolaKrnov/Album/${album.id}`,
page_url: photo.page_url,
image_url: photo.image_1500,
title: album.title
});
onGalleryPickerClose();
}}
>
<AspectRatio ratio={1}>
<Image
src={photo.image_1500}
alt={photo.id}
objectFit="cover"
/>
</AspectRatio>
</Box>
))}
</SimpleGrid>
</Box>
))}
</VStack>
)}
{/* Empty State */}
{!galleryLoading && cachedAlbums.length === 0 && (
<VStack py={8} spacing={3}>
<Icon as={FiSearch} boxSize={12} color="gray.400" />
<Text color="gray.600" textAlign="center">
Žádná alba nebyla nalezena v cache.
</Text>
<Text fontSize="sm" color="gray.500" textAlign="center">
Zkontrolujte nastavení Zonerama nebo obnovte cache.
</Text>
<Button size="sm" onClick={fetchCachedGallery} leftIcon={<FiRefreshCcw />}>
Obnovit seznam
</Button>
</VStack>
)}
</VStack>
</ModalBody>
<ModalFooter>
<Button variant="ghost" onClick={onGalleryPickerClose}>
Zavřít
</Button>
</ModalFooter>
</ModalContent>
</Modal>
{/* Draft Recovery Modal */}
<DraftRecoveryModal
+49 -27
View File
@@ -41,6 +41,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import AdminLayout from '../../layouts/AdminLayout';
import { putMatchOverride, patchMatchOverride, searchClubs, uploadImage, fetchLogoAsBlob, uploadToLogaSportcreative, fetchTeamLogoOverrides } from '../../services/adminMatches';
import { getPublicSettings } from '../../services/settings';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { parse } from 'date-fns';
@@ -546,28 +547,24 @@ const MatchesAdminPage = () => {
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(false);
const [showScrollHint, setShowScrollHint] = useState(true);
const thBg = useColorModeValue('gray.50', 'gray.700');
// Drag-to-scroll state
const [isDragging, setIsDragging] = useState(false);
const [startX, setStartX] = useState(0);
const [scrollLeft, setScrollLeft] = useState(0);
const [lastX, setLastX] = useState(0);
const [lastTime, setLastTime] = useState(0);
const lastXRef = useRef(0);
const lastTimeRef = useRef(0);
const velocityRef = useRef(0);
const animationRef = useRef<number | null>(null);
const scrollRaf = useRef<number | null>(null);
// Color modes for past/future matches
const pastMatchBg = useColorModeValue('gray.100', 'gray.700');
const futureMatchBg = useColorModeValue('white', 'gray.800');
const pastMatchHoverBg = useColorModeValue('gray.200', 'gray.600');
const futureMatchHoverBg = useColorModeValue('gray.50', 'gray.700');
const updateScrollShadow = () => {
const el = scrollRef.current;
if (!el) return;
setCanScrollLeft(el.scrollLeft > 0);
setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1);
const left = el.scrollLeft > 0;
const right = el.scrollLeft + el.clientWidth < el.scrollWidth - 1;
if (left !== canScrollLeft) setCanScrollLeft(left);
if (right !== canScrollRight) setCanScrollRight(right);
};
// Drag-to-scroll handlers
@@ -581,8 +578,8 @@ const MatchesAdminPage = () => {
setIsDragging(true);
setStartX(e.pageX - scrollRef.current.offsetLeft);
setScrollLeft(scrollRef.current.scrollLeft);
setLastX(e.pageX);
setLastTime(Date.now());
lastXRef.current = e.pageX;
lastTimeRef.current = Date.now();
velocityRef.current = 0;
scrollRef.current.style.cursor = 'grabbing';
scrollRef.current.style.userSelect = 'none';
@@ -632,13 +629,13 @@ const MatchesAdminPage = () => {
// Calculate velocity for momentum
const now = Date.now();
const timeDelta = now - lastTime;
const timeDelta = now - lastTimeRef.current;
if (timeDelta > 0) {
const currentX = e.pageX;
const distance = currentX - lastX;
const distance = currentX - lastXRef.current;
velocityRef.current = distance / timeDelta * 16; // Normalize to ~60fps
setLastX(currentX);
setLastTime(now);
lastXRef.current = currentX;
lastTimeRef.current = now;
}
};
@@ -653,8 +650,8 @@ const MatchesAdminPage = () => {
setIsDragging(true);
setStartX(touch.pageX - scrollRef.current.offsetLeft);
setScrollLeft(scrollRef.current.scrollLeft);
setLastX(touch.pageX);
setLastTime(Date.now());
lastXRef.current = touch.pageX;
lastTimeRef.current = Date.now();
velocityRef.current = 0;
if (scrollRef.current) scrollRef.current.style.scrollBehavior = 'auto';
};
@@ -667,13 +664,13 @@ const MatchesAdminPage = () => {
scrollRef.current.scrollLeft = scrollLeft - walk;
const now = Date.now();
const timeDelta = now - lastTime;
const timeDelta = now - lastTimeRef.current;
if (timeDelta > 0) {
const currentX = touch.pageX;
const distance = currentX - lastX;
const distance = currentX - lastXRef.current;
velocityRef.current = distance / timeDelta * 16;
setLastX(currentX);
setLastTime(now);
lastXRef.current = currentX;
lastTimeRef.current = now;
}
};
@@ -734,6 +731,12 @@ const MatchesAdminPage = () => {
const headerText = useColorModeValue('text.onPrimary', 'white');
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
const edgeGradientLeft = useColorModeValue('linear(to-r, white, transparent)', 'linear(to-r, gray.800, transparent)');
const edgeGradientRight = useColorModeValue('linear(to-l, white, transparent)', 'linear(to-l, gray.800, transparent)');
const pastMatchBg = useColorModeValue('gray.100', 'gray.700');
const futureMatchBg = useColorModeValue('white', 'gray.800');
const pastMatchHoverBg = useColorModeValue('gray.200', 'gray.600');
const futureMatchHoverBg = useColorModeValue('gray.50', 'gray.700');
return (
<AdminLayout requireAdmin={false}>
@@ -856,12 +859,24 @@ const MatchesAdminPage = () => {
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onScroll={(e) => {
updateScrollShadow();
if ((e.currentTarget as HTMLDivElement).scrollLeft > 0 && showScrollHint) setShowScrollHint(false);
if (scrollRaf.current == null) {
scrollRaf.current = requestAnimationFrame(() => {
const el = scrollRef.current;
if (el) {
updateScrollShadow();
if (el.scrollLeft > 0 && showScrollHint) setShowScrollHint(false);
}
scrollRaf.current = null;
});
}
}}
sx={{
WebkitOverflowScrolling: 'touch',
scrollBehavior: 'smooth',
transform: 'translateZ(0)',
willChange: 'transform',
overscrollBehaviorX: 'contain',
touchAction: 'pan-x',
'th, td': { whiteSpace: 'nowrap' },
'::-webkit-scrollbar': { height: '14px' },
'::-webkit-scrollbar-thumb': {
@@ -885,13 +900,13 @@ const MatchesAdminPage = () => {
{/* Gradient edges to indicate horizontal scroll */}
{canScrollLeft && (
<Box position="sticky" left={0} top={0} bottom={0} w="24px" pointerEvents="none"
bgGradient={useColorModeValue('linear(to-r, white, transparent)', 'linear(to-r, gray.800, transparent)')}
bgGradient={edgeGradientLeft}
zIndex={1}
/>
)}
{canScrollRight && (
<Box position="sticky" right={0} top={0} bottom={0} w="24px" pointerEvents="none"
bgGradient={useColorModeValue('linear(to-l, white, transparent)', 'linear(to-l, gray.800, transparent)')}
bgGradient={edgeGradientRight}
zIndex={1}
/>
)}
@@ -945,6 +960,9 @@ const MatchesAdminPage = () => {
alt={m.home || m.home_team || ''}
boxSize="24px"
objectFit="contain"
loading="lazy"
decoding="async"
draggable={false}
/>
<Text fontWeight={isPast ? 'normal' : 'medium'}>{m.home || m.home_team || ''}</Text>
<Button size="xs" variant="outline" onClick={() => openEdit(m, 'home')} borderRadius="md" _hover={{ borderColor: 'brand.primary', color: 'brand.primary' }}>Tým</Button>
@@ -962,6 +980,9 @@ const MatchesAdminPage = () => {
alt={m.away || m.away_team || ''}
boxSize="24px"
objectFit="contain"
loading="lazy"
decoding="async"
draggable={false}
/>
<Text fontWeight={isPast ? 'normal' : 'medium'}>{m.away || m.away_team || ''}</Text>
<Button size="xs" variant="outline" onClick={() => openEdit(m, 'away')} borderRadius="md" _hover={{ borderColor: 'brand.primary', color: 'brand.primary' }}>Tým</Button>
@@ -1167,6 +1188,7 @@ const MatchesAdminPage = () => {
</DrawerFooter>
</DrawerContent>
</Drawer>
</AdminLayout>
);
};
@@ -133,7 +133,77 @@ export default function NewsletterAdminPage() {
const [previewSubject, setPreviewSubject] = useState<string>('');
const [previewHtml, setPreviewHtml] = useState<string>('');
const [previewLoading, setPreviewLoading] = useState<boolean>(false);
type MailType = 'weekly' | 'matches' | 'scores' | 'blogs' | 'events';
const mailTypeLabel: Record<MailType, string> = { weekly: 'Týdenní přehled', matches: 'Zápasy', scores: 'Výsledky', blogs: 'Novinky', events: 'Akce' };
const [detailsOpen, setDetailsOpen] = useState(false);
const [activeType, setActiveType] = useState<MailType | null>(null);
const [typePreview, setTypePreview] = useState<Record<string, { subject: string; html: string } | undefined>>({});
const [detailsCompetitions, setDetailsCompetitions] = useState<string>('');
const [detailsLoading, setDetailsLoading] = useState<boolean>(false);
const [sendNowLoading, setSendNowLoading] = useState<boolean>(false);
const openDetails = (t: MailType) => { setActiveType(t); setDetailsOpen(true); };
const closeDetails = () => { setDetailsOpen(false); setActiveType(null); setDetailsCompetitions(''); };
const recipientsForType = (t: MailType): string[] => {
const key = t === 'weekly' ? 'weekly' : t;
return subscribers
.filter((s: any) => s.is_active && s?.preferences && s.preferences[key] === true)
.map((s: any) => s.email);
};
const getRecipientsFor = (t: MailType, comps?: string): string[] => {
const key = t === 'weekly' ? 'weekly' : t;
const base = (subscribers as any[]).filter((s: any) => s?.is_active && s?.preferences && s.preferences[key] === true);
if (!comps || !comps.trim() || (t !== 'matches' && t !== 'scores')) {
return base.map((s: any) => s.email);
}
const list = comps.split(',').map((v) => v.trim().toLowerCase()).filter(Boolean);
const filtered = base.filter((s: any) => {
const prefs: any = s?.preferences || {};
const raw: string = typeof prefs.competitions === 'string' && prefs.competitions ? prefs.competitions : (typeof prefs.categories === 'string' ? prefs.categories : '');
const arr = raw.split(',').map((x: string) => x.trim().toLowerCase()).filter(Boolean);
if (arr.length === 0) return true;
return arr.some((v: string) => list.includes(v));
});
return filtered.map((s: any) => s.email);
};
const exportRecipientsCSV = (t: MailType, comps?: string) => {
const list = getRecipientsFor(t, comps);
const safeCSV = (value: any) => {
const s = String(value ?? '');
return /^[=+\-@]/.test(s) ? `'${s}` : s;
};
const header = ['email','type','competitions'];
const lines = [
header.join(','),
...list.map(e => [safeCSV(e), t, (comps || '').trim()].join(','))
];
const blob = new Blob(["\ufeff" + lines.join('\n')], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `newsletter_recipients_${t}.csv`;
a.click();
URL.revokeObjectURL(url);
};
const loadPreviewForType = async (t: MailType, comps?: string) => {
const prefs: any = {};
if (t === 'weekly') { prefs.blogs = true; prefs.events = true; prefs.matches = true; prefs.scores = true; }
else { (prefs as any)[t] = true; }
if (comps && comps.trim()) { prefs.competitions = comps.trim(); }
const res = await previewNewsletter({ preferences: prefs });
setTypePreview(prev => ({ ...prev, [t]: { subject: res.subject, html: res.html } }));
};
useEffect(() => {
if (detailsOpen && activeType && !typePreview[activeType]) {
(async () => {
try {
setDetailsLoading(true);
await loadPreviewForType(activeType!, detailsCompetitions);
} finally {
setDetailsLoading(false);
}
})();
}
}, [detailsOpen, activeType]);
const { isOpen, onOpen, onClose } = useDisclosure();
const testModal = useDisclosure();
@@ -501,6 +571,29 @@ export default function NewsletterAdminPage() {
</Box>
) : null}
</Box>
<Box bg={cardBg} borderRadius="lg" boxShadow="sm" p={4} mb={6}>
<Heading size="md" mb={3}>Typy emailů</Heading>
<VStack align="stretch" spacing={3}>
{(['weekly','matches','scores','blogs','events'] as MailType[]).map((t)=>{
const count = recipientsForType(t).length;
const enabled = t === 'weekly' ? !!settings?.enable_weekly : t === 'matches' ? !!settings?.enable_match_reminders : t === 'scores' ? !!settings?.enable_results : undefined;
return (
<Flex key={t} align="center" justify="space-between" p={3} borderWidth="1px" borderRadius="md" _hover={{ bg: hoverBg }}>
<HStack spacing={3}>
<Text fontWeight="600">{mailTypeLabel[t]}</Text>
{enabled !== undefined && (
<Badge colorScheme={enabled ? 'green' : 'gray'}>{enabled ? 'Zapnuto' : 'Vypnuto'}</Badge>
)}
</HStack>
<HStack spacing={4}>
<Text color={textSecondary}>Příjemci: <b>{count}</b></Text>
<Button size="sm" onClick={()=> openDetails(t)}>Detail</Button>
</HStack>
</Flex>
);
})}
</VStack>
</Box>
</TabPanel>
<TabPanel p={0}>
{/* Scheduling controls */}
@@ -519,13 +612,13 @@ export default function NewsletterAdminPage() {
<FormControl maxW="220px">
<FormLabel>Den v týdnu</FormLabel>
<Select value={weeklyDay} onChange={(e)=> setWeeklyDay(e.target.value as any)}>
<option value="sun">Neděle</option>
<option value="mon">Pondělí</option>
<option value="tue">Úterý</option>
<option value="wed">Středa</option>
<option value="thu">Čtvrtek</option>
<option value="fri">Pátek</option>
<option value="sat">Sobota</option>
<option value="sun">Neděle</option>
</Select>
</FormControl>
<FormControl maxW="160px">
@@ -534,6 +627,41 @@ export default function NewsletterAdminPage() {
</FormControl>
</HStack>
<Box h="1px" bg={useColorModeValue('gray.200', 'gray.700')} my={2} />
<HStack justify="space-between">
<Text fontWeight="600">Připomínky zápasů</Text>
<Switch isChecked={enableMatchReminders} onChange={(e)=> setEnableMatchReminders(e.target.checked)} />
</HStack>
<HStack spacing={3}>
<FormControl maxW="220px">
<FormLabel>Odeslat před (hodin)</FormLabel>
<Input type="number" min={1} max={168} value={reminderLead} onChange={(e)=> setReminderLead(Math.max(1, Math.min(168, Number(e.target.value)||0)))} />
<FormHelperText>Výchozí 48 h před výkopem. Systém posílá i upozornění v den zápasu.</FormHelperText>
</FormControl>
</HStack>
<Box h="1px" bg={useColorModeValue('gray.200', 'gray.700')} my={2} />
<HStack justify="space-between">
<Text fontWeight="600">Výsledky po zápase</Text>
<Switch isChecked={enableResults} onChange={(e)=> setEnableResults(e.target.checked)} />
</HStack>
<HStack spacing={3}>
<FormControl maxW="160px">
<FormLabel>Tiché hodiny od</FormLabel>
<Input type="number" min={0} max={23} value={quietStart} onChange={(e)=> setQuietStart(Math.max(0, Math.min(23, Number(e.target.value)||0)))} />
</FormControl>
<FormControl maxW="160px">
<FormLabel>Tiché hodiny do</FormLabel>
<Input type="number" min={0} max={23} value={quietEnd} onChange={(e)=> setQuietEnd(Math.max(0, Math.min(23, Number(e.target.value)||0)))} />
<FormHelperText>E-maily s výsledky se neposílají v tomto intervalu.</FormHelperText>
</FormControl>
</HStack>
<HStack pt={2}>
<Button colorScheme="blue" onClick={()=> saveScheduleMutation.mutate()} isLoading={saveScheduleMutation.isLoading}>Uložit plánování</Button>
</HStack>
</VStack>
)}
</Box>
@@ -871,6 +999,159 @@ export default function NewsletterAdminPage() {
</ModalContent>
</Modal>
<Modal isOpen={detailsOpen} onClose={closeDetails} size="5xl">
<ModalOverlay />
<ModalContent maxW="95vw" maxH="90vh" overflowY="auto">
<ModalHeader>
{activeType ? `Detail: ${mailTypeLabel[activeType]}` : 'Detail'}
</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack align="stretch" spacing={4}>
<HStack spacing={4} align="flex-end">
<FormControl maxW="360px">
<FormLabel>Filtr soutěží (volitelné)</FormLabel>
<Input placeholder="NAPŘ. KP, I.A, I.B" value={detailsCompetitions} onChange={(e)=> setDetailsCompetitions(e.target.value)} />
</FormControl>
<Button onClick={async()=>{ if(!activeType) return; setDetailsLoading(true); try { await loadPreviewForType(activeType, detailsCompetitions); } finally { setDetailsLoading(false); } }} isLoading={detailsLoading}>Aktualizovat náhled</Button>
{activeType && typePreview[activeType]?.subject && (
<Badge colorScheme="blue">{typePreview[activeType]!.subject}</Badge>
)}
<Button colorScheme="blue" variant="solid" isLoading={sendNowLoading} onClick={async()=>{
if(!activeType) return;
const ok = window.confirm(`Odeslat "${mailTypeLabel[activeType]}" nyní? Email bude odeslán všem aktivním odběratelům.`);
if(!ok) return;
try {
setSendNowLoading(true);
await sendNewsletterDigest(activeType as DigestType, (detailsCompetitions || '').trim() || undefined);
toast({ title: 'Digest odeslán', status: 'success' });
} catch (e: any) {
toast({ title: 'Chyba při odeslání', description: e?.response?.data?.error || e?.message, status: 'error' });
} finally {
setSendNowLoading(false);
}
}}>Odeslat nyní</Button>
<Button variant="outline" onClick={()=>{ if(!activeType) return; exportRecipientsCSV(activeType, detailsCompetitions); }}>Export CSV</Button>
</HStack>
<Box p={3} bg={useColorModeValue('gray.50', 'gray.900')} borderRadius="md" borderWidth="1px">
<Box bg={cardBg} p={3} borderRadius="md" borderWidth="1px" dangerouslySetInnerHTML={{ __html: sanitizeHtml(activeType ? (typePreview[activeType]?.html || '<em>Náhled se zobrazí zde</em>') : '<em>Náhled se zobrazí zde</em>') }} />
</Box>
<Box>
<Heading size="sm" mb={2}>Příjemci</Heading>
{(() => {
let list: string[] = [];
if (activeType) {
const typeKey = activeType === 'weekly' ? 'weekly' : activeType;
const base = (subscribers as any[]).filter((s: any) => s?.is_active && s?.preferences && s.preferences[typeKey] === true);
let filtered = base;
const compsInput = (detailsCompetitions || '').trim();
if (compsInput && (activeType === 'matches' || activeType === 'scores')) {
const comps = compsInput.split(',').map((v) => v.trim().toLowerCase()).filter(Boolean);
if (comps.length > 0) {
filtered = base.filter((s: any) => {
const prefs: any = s?.preferences || {};
const raw: string = typeof prefs.competitions === 'string' && prefs.competitions
? prefs.competitions
: (typeof prefs.categories === 'string' ? prefs.categories : '');
const arr = raw.split(',').map((x: string) => x.trim().toLowerCase()).filter(Boolean);
if (arr.length === 0) return true;
return arr.some((v: string) => comps.includes(v));
});
}
}
list = filtered.map((s: any) => s.email);
}
const shown = list.slice(0, 50);
return (
<>
{shown.length === 0 ? (
<Text color="gray.600">Žádní příjemci pro tento typ.</Text>
) : (
<VStack align="stretch" spacing={1} maxH="240px" overflowY="auto" borderWidth="1px" borderRadius="md" p={3}>
{shown.map((e)=> (<Text key={e} fontFamily="mono">{e}</Text>))}
{list.length > shown.length && (
<Text color="gray.600"> a dalších {list.length - shown.length}</Text>
)}
</VStack>
)}
</>
);
})()}
</Box>
</VStack>
</ModalBody>
<ModalFooter>
<Button onClick={closeDetails}>Zavřít</Button>
</ModalFooter>
</ModalContent>
</Modal>
{/* SMTP Test Modal */}
<Modal isOpen={smtpModal.isOpen} onClose={smtpModal.onClose} size="lg">
<ModalOverlay />
<ModalContent maxW="90vw" maxH="90vh" overflowY="auto">
<ModalHeader>Otestovat SMTP</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack spacing={4} align="stretch">
<HStack spacing={3} align="flex-end">
<FormControl>
<FormLabel>Host</FormLabel>
<Input placeholder="smtp.example.com" value={smtpHost} onChange={(e)=> setSmtpHost(e.target.value)} />
</FormControl>
<FormControl maxW="140px">
<FormLabel>Port</FormLabel>
<Input type="number" placeholder="465" value={smtpPort} onChange={(e)=> setSmtpPort(Number(e.target.value)||0)} />
</FormControl>
<FormControl maxW="140px">
<FormLabel>&nbsp;</FormLabel>
<Checkbox isChecked={smtpTLS} onChange={(e)=> setSmtpTLS(e.target.checked)}>TLS/SSL</Checkbox>
</FormControl>
</HStack>
<HStack spacing={3}>
<FormControl>
<FormLabel>Uživatel</FormLabel>
<Input value={smtpUser} onChange={(e)=> setSmtpUser(e.target.value)} />
</FormControl>
<FormControl>
<FormLabel>Heslo</FormLabel>
<InputGroup>
<Input type={showSmtpPass ? 'text' : 'password'} value={smtpPass} onChange={(e)=> setSmtpPass(e.target.value)} />
<InputRightElement width="4.5rem">
<Button h="1.75rem" size="sm" onClick={()=> setShowSmtpPass(v=> !v)}>
{showSmtpPass ? 'Skrýt' : 'Zobrazit'}
</Button>
</InputRightElement>
</InputGroup>
</FormControl>
</HStack>
<HStack spacing={3}>
<FormControl>
<FormLabel>From</FormLabel>
<Input placeholder="club@example.com" value={smtpFrom} onChange={(e)=> setSmtpFrom(e.target.value)} />
</FormControl>
<FormControl>
<FormLabel>To (kam poslat test)</FormLabel>
<Input placeholder="you@example.com" value={smtpTo} onChange={(e)=> setSmtpTo(e.target.value)} />
</FormControl>
</HStack>
<FormControl>
<FormLabel>Předmět</FormLabel>
<Input value={smtpSubject} onChange={(e)=> setSmtpSubject(e.target.value)} />
</FormControl>
<FormControl>
<FormLabel>Tělo zprávy (HTML)</FormLabel>
<Textarea rows={6} value={smtpBody} onChange={(e)=> setSmtpBody(e.target.value)} />
</FormControl>
</VStack>
</ModalBody>
<ModalFooter>
<Button variant="ghost" mr={3} onClick={smtpModal.onClose}>Zavřít</Button>
<Button colorScheme="blue" onClick={()=> adminSmtpTestMutation.mutate()} isLoading={adminSmtpTestMutation.isLoading}>Odeslat test</Button>
</ModalFooter>
</ModalContent>
</Modal>
{/* Test Email Modal */}
<Modal isOpen={testModal.isOpen} onClose={testModal.onClose} size="md">
<ModalOverlay />
+11 -6
View File
@@ -235,7 +235,7 @@ const PlayersAdminPage: React.FC = () => {
const HEIGHT_MIN = 0;
const HEIGHT_MAX = 250;
const WEIGHT_MIN = 0;
const WEIGHT_MAX = 200;
const WEIGHT_MAX = 400;
// Local state to persist partial DOB selections so the user sees what they picked
const [dobParts, setDobParts] = useState<{ day: string; month: string; year: string }>({ day: '', month: '', year: '' });
@@ -325,6 +325,11 @@ const PlayersAdminPage: React.FC = () => {
toast({ title: 'Neplatná čísla', description: `Maxima: číslo dresu ${JERSEY_MAX}, výška ${HEIGHT_MAX} cm, váha ${WEIGHT_MAX} kg`, status: 'warning' });
return;
}
// Require date of birth: all three values must be selected
if (!dobParts.day || !dobParts.month || !dobParts.year) {
toast({ title: 'Datum narození je povinné', description: 'Vyberte den, měsíc i rok.', status: 'warning' });
return;
}
// Build payload by including only present values to satisfy backend validation
const payload: any = {
first_name: fn,
@@ -428,19 +433,19 @@ const PlayersAdminPage: React.FC = () => {
</FormControl>
{/* Custom DOB picker: day / month / year (timezone-safe) */}
<FormControl>
<FormControl isRequired>
<FormLabel>Datum narození</FormLabel>
<HStack>
<Select value={dobParts.day} onChange={(e) => updateDobPart('day', e.target.value)}>
<option value="">Den</option>
<option value="" disabled>Den</option>
{Array.from({ length: 31 }).map((_, i) => <option key={i+1} value={(i+1).toString()}>{i+1}</option>)}
</Select>
<Select value={dobParts.month} onChange={(e) => updateDobPart('month', e.target.value)}>
<option value="">Měsíc</option>
<option value="" disabled>Měsíc</option>
{Array.from({ length: 12 }).map((_, i) => <option key={i+1} value={(i+1).toString()}>{i+1}</option>)}
</Select>
<Select value={dobParts.year} onChange={(e) => updateDobPart('year', e.target.value)}>
<option value="">Rok</option>
<option value="" disabled>Rok</option>
{Array.from({ length: 80 }).map((_, i) => { const y = new Date().getFullYear() - i; return <option key={y} value={String(y)}>{y}</option>; })}
</Select>
</HStack>
@@ -542,7 +547,7 @@ const PlayersAdminPage: React.FC = () => {
<FormControl>
<FormLabel>Fotka</FormLabel>
<HStack>
<Image src={normalizeImageUrl(editing?.image_url)} alt="photo" boxSize="56px" objectFit="cover" borderRadius="md" />
<Image src={normalizeImageUrl(editing?.image_url)} alt="photo" boxSize="56px" objectFit="cover" borderRadius="md" fallbackSrc="/dist/img/logo-club-empty.svg" />
<Button as="label" type="button" leftIcon={<FiUpload />}>Nahrát
<Input
type="file"
+126 -71
View File
@@ -309,16 +309,19 @@ const PollsAdminPage: React.FC = () => {
};
const handleSave = () => {
// Validate that all options have text
const invalidOptions = formData.options.filter(opt => !opt.text || opt.text.trim() === '');
if (invalidOptions.length > 0) {
toast({
title: 'Chyba',
description: 'Všechny možnosti musí mít vyplněný text',
status: 'error',
duration: 3000,
});
return;
if (formData.type !== 'rating') {
const invalidOptions = formData.options.filter(
(opt) => !opt.text || opt.text.trim() === ''
);
if (invalidOptions.length > 0) {
toast({
title: 'Chyba',
description: 'Všechny možnosti musí mít vyplněný text',
status: 'error',
duration: 3000,
});
return;
}
}
if (editingPoll) {
@@ -398,6 +401,35 @@ const PollsAdminPage: React.FC = () => {
}
}, [isOpen, clubVideos.length, toast]);
// Keep rating polls consistent: enforce style and auto-generate options
useEffect(() => {
if (formData.type !== 'rating') return;
const currentStyle = (formData as any).style || 'auto';
const desiredStyle = currentStyle === 'rating-scale' ? 'rating-scale' : 'rating-stars';
const count = desiredStyle === 'rating-scale' ? 10 : 5;
const optionsMatch =
formData.options.length === count &&
formData.options.every((opt, idx) => String(opt.text) === String(idx + 1));
if (
currentStyle !== desiredStyle ||
formData.allow_multiple ||
(formData.max_choices || 1) !== 1 ||
!optionsMatch
) {
setFormData({
...formData,
style: desiredStyle as any,
allow_multiple: false,
max_choices: 1,
options: Array.from({ length: count }).map((_, i) => ({
text: String(i + 1),
display_order: i,
})),
});
}
}, [formData.type, (formData as any).style, formData.options, formData.allow_multiple, formData.max_choices]);
const getStatusBadge = (status: string) => {
const colorMap: Record<string, string> = {
draft: 'gray',
@@ -542,7 +574,7 @@ const PollsAdminPage: React.FC = () => {
<Tabs>
<TabList>
<Tab>Základní</Tab>
<Tab>Možnosti</Tab>
{formData.type !== 'rating' && <Tab>Možnosti</Tab>}
<Tab>Nastavení</Tab>
</TabList>
@@ -550,6 +582,14 @@ const PollsAdminPage: React.FC = () => {
{/* Basic Info Tab */}
<TabPanel>
<VStack spacing={4}>
<HStack w="full" justify="space-between">
<Text fontWeight="semibold">Doporučené předvolby</Text>
<HStack>
<Button size="sm" onClick={() => applyPreset('rating5')}>Hodnocení (5 hvězd)</Button>
<Button size="sm" onClick={() => applyPreset('rating10')}>Hodnocení (110)</Button>
<Button size="sm" onClick={() => applyPreset('attendance')}>Docházka</Button>
</HStack>
</HStack>
<FormControl isRequired>
<FormLabel>Název ankety</FormLabel>
<Input
@@ -625,6 +665,17 @@ const PollsAdminPage: React.FC = () => {
</FormControl>
</SimpleGrid>
{formData.type === 'rating' && (
<Box w="full" borderWidth="1px" borderRadius="md" p={3} bg="gray.50">
<Text fontSize="sm" mb={2}>Možnosti se generují automaticky podle stylu:</Text>
<Text fontSize="sm">
{Array.from({ length: ((formData as any).style === 'rating-scale' ? 10 : 5) })
.map((_, i) => String(i + 1))
.join(', ')}
</Text>
</Box>
)}
<SimpleGrid columns={2} spacing={4} w="full">
<FormControl>
<FormLabel>Datum zahájení</FormLabel>
@@ -722,73 +773,77 @@ const PollsAdminPage: React.FC = () => {
</TabPanel>
{/* Options Tab */}
<TabPanel>
<VStack spacing={4} align="stretch">
{formData.options.map((option, index) => (
<Card key={index}>
<CardBody>
<HStack align="start">
<VStack flex={1} spacing={3}>
<FormControl isRequired>
<FormLabel>Možnost {index + 1}</FormLabel>
<Input
value={option.text}
onChange={(e) =>
updateOption(index, 'text', e.target.value)
}
placeholder="Text možnosti"
{formData.type !== 'rating' && (
<TabPanel>
<VStack spacing={4} align="stretch">
{formData.options.map((option, index) => (
<Card key={index}>
<CardBody>
<HStack align="start">
<VStack flex={1} spacing={3}>
<FormControl isRequired>
<FormLabel>Možnost {index + 1}</FormLabel>
<Input
value={option.text}
onChange={(e) =>
updateOption(index, 'text', e.target.value)
}
placeholder="Text možnosti"
/>
</FormControl>
<FormControl>
<FormLabel>Popis (volitelné)</FormLabel>
<Input
value={option.description || ''}
onChange={(e) =>
updateOption(index, 'description', e.target.value)
}
placeholder="Doplňující informace"
/>
</FormControl>
</VStack>
{formData.options.length > 2 && (
<IconButton
aria-label="Odstranit možnost"
icon={<DeleteIcon />}
colorScheme="red"
variant="ghost"
onClick={() => removeOption(index)}
/>
</FormControl>
<FormControl>
<FormLabel>Popis (volitelné)</FormLabel>
<Input
value={option.description || ''}
onChange={(e) =>
updateOption(index, 'description', e.target.value)
}
placeholder="Doplňující informace"
/>
</FormControl>
</VStack>
{formData.options.length > 2 && (
<IconButton
aria-label="Odstranit možnost"
icon={<DeleteIcon />}
colorScheme="red"
variant="ghost"
onClick={() => removeOption(index)}
/>
)}
</HStack>
</CardBody>
</Card>
))}
)}
</HStack>
</CardBody>
</Card>
))}
<Button
leftIcon={<AddIcon />}
onClick={addOption}
variant="outline"
colorScheme="blue"
>
Přidat možnost
</Button>
</VStack>
</TabPanel>
<Button
leftIcon={<AddIcon />}
onClick={addOption}
variant="outline"
colorScheme="blue"
>
Přidat možnost
</Button>
</VStack>
</TabPanel>
)}
{/* Settings Tab */}
<TabPanel>
<VStack spacing={4}>
<FormControl display="flex" alignItems="center">
<FormLabel mb="0">Povolit více voleb</FormLabel>
<Switch
isChecked={formData.allow_multiple}
onChange={(e) =>
setFormData({ ...formData, allow_multiple: e.target.checked })
}
/>
</FormControl>
{formData.type !== 'rating' && (
<FormControl display="flex" alignItems="center">
<FormLabel mb="0">Povolit více voleb</FormLabel>
<Switch
isChecked={formData.allow_multiple}
onChange={(e) =>
setFormData({ ...formData, allow_multiple: e.target.checked })
}
/>
</FormControl>
)}
{formData.allow_multiple && (
{formData.type !== 'rating' && formData.allow_multiple && (
<FormControl>
<FormLabel>Max. počet voleb</FormLabel>
<NumberInput
@@ -178,7 +178,7 @@ const SettingsAdminPage: React.FC = () => {
smtp_from: (settings as any).smtp_from,
smtp_from_name: (settings as any).smtp_from_name,
smtp_encryption: (settings as any).smtp_encryption as any,
smtp_auth: (settings as any).smtp_auth as any,
...(typeof (settings as any).smtp_auth === 'boolean' ? { smtp_auth: (settings as any).smtp_auth as any } : {}),
smtp_skip_verify: (settings as any).smtp_skip_verify as any,
// videos module
videos_module_enabled: (settings as any).videos_module_enabled as any,
@@ -193,8 +193,9 @@ const SettingsAdminPage: React.FC = () => {
location_latitude: (settings as any).location_latitude as any,
location_longitude: (settings as any).location_longitude as any,
map_zoom_level: (settings as any).map_zoom_level as any,
// Auto-enable map display if coordinates are set
show_map_on_homepage: ((settings as any).location_latitude && (settings as any).location_longitude) as any,
show_map_on_homepage:
(typeof (settings as any).location_latitude === 'number') &&
(typeof (settings as any).location_longitude === 'number'),
map_style: (settings as any).map_style,
// homepage matches display
finished_match_display_days: (settings as any).finished_match_display_days as any,
@@ -0,0 +1,198 @@
import React from 'react';
import AdminLayout from '../../layouts/AdminLayout';
import {
Box,
Button,
HStack,
IconButton,
Input,
Table,
Tbody,
Td,
Th,
Thead,
Tr,
useToast,
Text,
VStack,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalCloseButton,
ModalBody,
ModalFooter,
useDisclosure,
Link as ChakraLink,
Badge,
} from '@chakra-ui/react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { createShortLink, listShortLinks, getShortLinkStats } from '../../services/shortlinks';
import { FiClipboard, FiExternalLink, FiRefreshCcw, FiBarChart2 } from 'react-icons/fi';
const ShortlinksAdminPage: React.FC = () => {
const toast = useToast();
const qc = useQueryClient();
const [targetUrl, setTargetUrl] = React.useState('');
const [title, setTitle] = React.useState('');
const [code, setCode] = React.useState('');
const [creating, setCreating] = React.useState(false);
const statsModal = useDisclosure();
const [statsLink, setStatsLink] = React.useState<any>(null);
const [statsData, setStatsData] = React.useState<any>(null);
const linksQ = useQuery({
queryKey: ['admin-shortlinks'],
queryFn: listShortLinks,
staleTime: 60_000,
});
const handleCreate = async () => {
const t = targetUrl.trim();
if (!t) { toast({ title: 'Zadejte cílovou URL', status: 'warning' }); return; }
try {
setCreating(true);
const res = await createShortLink({ target_url: t, title: title.trim() || undefined, code: code.trim() || undefined, active: true });
await navigator.clipboard.writeText(res.short_url);
toast({ title: 'Odkaz vytvořen', description: `Zkopírováno: ${res.short_url}`, status: 'success' });
setTargetUrl(''); setTitle(''); setCode('');
qc.invalidateQueries({ queryKey: ['admin-shortlinks'] });
} catch (e: any) {
toast({ title: 'Vytvoření selhalo', description: e?.message || 'Zkuste to znovu', status: 'error' });
} finally {
setCreating(false);
}
};
const openStats = async (item: any) => {
try {
setStatsLink(item);
setStatsData(null);
statsModal.onOpen();
const data = await getShortLinkStats(item.id);
setStatsData(data);
} catch (e: any) {
toast({ title: 'Načtení statistik selhalo', status: 'error' });
}
};
return (
<AdminLayout>
<Box>
<HStack justify="space-between" mb={4}>
<Text fontSize="xl" fontWeight="bold">Zkrácené odkazy</Text>
<IconButton aria-label="Obnovit" icon={<FiRefreshCcw />} onClick={() => qc.invalidateQueries({ queryKey: ['admin-shortlinks'] })} />
</HStack>
{/* Create form */}
<Box borderWidth="1px" borderRadius="lg" p={4} mb={6} bg="bg.card">
<Text fontWeight="semibold" mb={2}>Vytvořit nový odkaz</Text>
<HStack spacing={2} flexWrap="wrap">
<Input placeholder="https://…" value={targetUrl} onChange={(e)=>setTargetUrl(e.target.value)} flex={3} />
<Input placeholder="Titulek (volitelný)" value={title} onChange={(e)=>setTitle(e.target.value)} flex={2} />
<Input placeholder="Vlastní kód (volitelné)" value={code} onChange={(e)=>setCode(e.target.value)} flex={1} />
<Button onClick={handleCreate} isLoading={creating} colorScheme="blue">Vytvořit</Button>
</HStack>
</Box>
{/* List */}
<Box borderWidth="1px" borderRadius="lg" overflowX="auto" bg="bg.card">
<Table size="sm">
<Thead>
<Tr>
<Th>Kód</Th>
<Th>Cíl</Th>
<Th>Titulek</Th>
<Th>Zdroj</Th>
<Th>Prokliky</Th>
<Th>Akce</Th>
</Tr>
</Thead>
<Tbody>
{linksQ.data?.items?.map((it: any) => {
const shortUrl = `${window.location.origin}/s/${it.code}`;
const source = it.source_type ? `${it.source_type}${it.source_id ? `#${it.source_id}` : ''}` : '-';
return (
<Tr key={it.id}>
<Td><Badge colorScheme="blue">{it.code}</Badge></Td>
<Td maxW="420px">
<ChakraLink href={it.target_url} isExternal color="blue.600">{it.target_url}</ChakraLink>
</Td>
<Td>{it.title || '-'}</Td>
<Td>{source}</Td>
<Td>{it.click_count ?? 0}</Td>
<Td>
<HStack>
<IconButton aria-label="Otevřít krátkou URL" icon={<FiExternalLink />} as={ChakraLink as any} href={shortUrl} isExternal />
<IconButton aria-label="Zkopírovat" icon={<FiClipboard />} onClick={async ()=>{ await navigator.clipboard.writeText(shortUrl); toast({ title: 'Zkopírováno', description: shortUrl, status: 'success', duration: 2000 }); }} />
<IconButton aria-label="Statistiky" icon={<FiBarChart2 />} onClick={()=> openStats(it)} />
</HStack>
</Td>
</Tr>
);
})}
{(!linksQ.data?.items || linksQ.data.items.length === 0) && (
<Tr><Td colSpan={6}><Text p={3}>Žádné odkazy</Text></Td></Tr>
)}
</Tbody>
</Table>
</Box>
{/* Stats modal */}
<Modal isOpen={statsModal.isOpen} onClose={statsModal.onClose} size="xl">
<ModalOverlay />
<ModalContent>
<ModalHeader>Statistiky: {statsLink?.code}</ModalHeader>
<ModalCloseButton />
<ModalBody>
{!statsData ? (
<Text>Načítání</Text>
) : (
<VStack align="stretch" spacing={3}>
<Box>
<Text fontWeight="semibold" mb={1}>Prokliky za posledních 30 dní</Text>
<Table size="sm" variant="simple">
<Thead><Tr><Th>Den</Th><Th isNumeric>Počet</Th></Tr></Thead>
<Tbody>
{statsData.timeseries?.map((row: any, idx: number) => (
<Tr key={idx}><Td>{row.date}</Td><Td isNumeric>{row.count}</Td></Tr>
))}
</Tbody>
</Table>
</Box>
<Box>
<Text fontWeight="semibold" mb={1}>Referrers (Top)</Text>
<Table size="sm" variant="simple">
<Thead><Tr><Th>Referrer</Th><Th isNumeric>Počet</Th></Tr></Thead>
<Tbody>
{(statsData.referrers || []).map((r: any, i: number) => (
<Tr key={i}><Td>{r.Referrer || '-'}</Td><Td isNumeric>{r.Count}</Td></Tr>
))}
</Tbody>
</Table>
</Box>
<Box>
<Text fontWeight="semibold" mb={1}>UTM kombinace (Top)</Text>
<Table size="sm" variant="simple">
<Thead><Tr><Th>Source</Th><Th>Medium</Th><Th>Campaign</Th><Th isNumeric>Počet</Th></Tr></Thead>
<Tbody>
{(statsData.utms || []).map((r: any, i: number) => (
<Tr key={i}><Td>{r.Source || '-'}</Td><Td>{r.Medium || '-'}</Td><Td>{r.Campaign || '-'}</Td><Td isNumeric>{r.Count}</Td></Tr>
))}
</Tbody>
</Table>
</Box>
</VStack>
)}
</ModalBody>
<ModalFooter>
<Button onClick={statsModal.onClose}>Zavřít</Button>
</ModalFooter>
</ModalContent>
</Modal>
</Box>
</AdminLayout>
);
};
export default ShortlinksAdminPage;
+167 -64
View File
@@ -1,4 +1,5 @@
import { batchFetchLogosFromSportLogosAPI } from '../../utils/sportLogosAPI';
import { fetchLogoFromLogoAPI } from '../../utils/sportLogosAPI';
import {
Heading,
Text,
@@ -77,6 +78,8 @@ const TeamsAdminPage = () => {
});
const competitions: any[] = Array.isArray(data?.competitions) ? data!.competitions : [];
const mainClubId: string | undefined = (data?.club_id ? String(data.club_id).toLowerCase() : undefined);
const mainClubBase: string = useMemo(() => normalize(String(data?.name || '')), [data?.name]);
// Backend origin (used to resolve relative URLs like /uploads/...)
const backendOrigin = new URL(API_URL, typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000').origin;
@@ -86,6 +89,7 @@ const TeamsAdminPage = () => {
queryFn: fetchTeamLogoOverrides,
staleTime: 5 * 60 * 1000,
});
const overridesById: Record<string, { name?: string; logo_url?: string }> = (overrides as any)?.by_id || {};
// Fetch logos from logoapi.sportcreative.eu for all teams
const [sportLogosMap, setSportLogosMap] = useState<Record<string, string>>({});
@@ -100,6 +104,10 @@ const TeamsAdminPage = () => {
const rows: TableRow[] = comp?.table?.overall || [];
for (const r of rows) {
if (r.team_id) teamIds.add(r.team_id);
else {
const derived = deriveTeamIdFromLogoUrl(r.team_logo_url);
if (derived) teamIds.add(derived);
}
}
}
@@ -122,7 +130,7 @@ const TeamsAdminPage = () => {
// Unify various dash characters to a simple hyphen
out = out.replace(/[\u2012\u2013\u2014\u2015\u2212]/g, '-');
// Remove legal suffixes like ", z.s." / ", z. s." / " z.s." / "o.s." at end
out = out.replace(/[,\s]*(z\.?\s*s\.?|o\.?\s*s\.?)\s*$/g, '');
out = out.replace(/[,,\s]*(z\.?\s*s\.?|o\.?\s*s\.?)\s*$/g, '');
// Remove organization phrases/prefixes anywhere (keep core locality/name)
const orgPhrases = [
'fotbalovy klub',
@@ -133,7 +141,7 @@ const TeamsAdminPage = () => {
'futsal',
];
for (const phrase of orgPhrases) {
const re = new RegExp(`(^|\b)${phrase}(\b|$)`, 'g');
const re = new RegExp('(^|\\b)'+ phrase + '(\\b|$)', 'g');
out = out.replace(re, ' ');
}
// Remove common short prefixes (tokens) like FC, FK, MFK, TJ, SK, SFC, AFK at word boundaries
@@ -152,40 +160,61 @@ const TeamsAdminPage = () => {
}
return idx;
}, [byName]);
// Derive FACR team UUID from the logo URL if team_id is missing in the row
// Example: https://is1.fotbal.cz/media/kluby/<UUID>/<UUID>_crop.jpg
const deriveTeamIdFromLogoUrl = (url?: string): string | undefined => {
try {
const u = String(url || '');
if (!u) return undefined;
const m = u.match(/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/);
return m ? m[0].toLowerCase() : undefined;
} catch {
return undefined;
}
};
const getLogo = (teamName?: string, teamId?: string, original?: string) => {
if (!teamName) return assetUrl('/dist/img/logo-club-empty.svg') as string;
// Priority 1: Try logoapi.sportcreative.eu if we have a team ID
if (teamId && sportLogosMap[teamId]) {
return sportLogosMap[teamId];
// Priority 0: Admin override by team ID
if (teamId && overridesById[teamId] && overridesById[teamId]?.logo_url) {
const u = String(overridesById[teamId].logo_url);
if (u.startsWith('/')) return assetUrl(u) as string;
return u;
}
// Priority 2: Try exact match from local overrides
// Priority 1: Local admin override (exact + normalized)
let overrideUrl = byName[teamName];
if (!overrideUrl) {
// Fallback: diacritics-insensitive + case-insensitive + trimmed match
const norm = normalize(teamName);
overrideUrl = byNameNormalized[norm];
}
// Priority 3: Use override if found
if (overrideUrl) {
// Resolve against backend for relative assets
if (typeof overrideUrl === 'string' && overrideUrl.startsWith('/')) {
return assetUrl(overrideUrl) as string;
}
return overrideUrl;
}
// Priority 4: Use FACR original
// Priority 2: logoapi.sportcreative.eu if we have a team ID
if (teamId && sportLogosMap[teamId]) {
return sportLogosMap[teamId];
}
// Priority 3: FACR original
if (original) {
return original;
}
// Final fallback: empty logo
return '/dist/img/logo-club-empty.svg';
};
const getName = (teamName?: string, teamId?: string) => {
if (teamId && overridesById[teamId] && overridesById[teamId]?.name) {
return String(overridesById[teamId].name || '').trim() || String(teamName || '');
}
return String(teamName || '');
};
// View mode: 'table' per competition, or 'grid' of unique teams across competitions
const [viewMode, setViewMode] = useState<'table' | 'grid'>('table');
// Selected competition for quick switching (only applies in table mode)
@@ -204,32 +233,50 @@ const TeamsAdminPage = () => {
name: string; // representative name
logo: string;
variants: string[]; // all raw names found
teamId?: string;
};
const allTeamsUnique: TeamAggregate[] = useMemo(() => {
const map: Record<string, TeamAggregate> = {};
for (const comp of competitions) {
const rows: TableRow[] = comp?.table?.overall || [];
for (const r of rows) {
const teamName = (r.team || '').trim();
if (!teamName) continue;
const key = normalize(teamName);
const logo = getLogo(teamName, r.team_id, r.team_logo_url);
const rawName = (r.team || '').trim();
let teamId = ((r as any).team_id as string | undefined) || deriveTeamIdFromLogoUrl(r.team_logo_url);
if (!teamId && mainClubId) {
const rn = normalize(rawName);
if (
rn === mainClubBase ||
rn.endsWith(' ' + mainClubBase) ||
rn.startsWith(mainClubBase + ' ') ||
rn.includes(' ' + mainClubBase + ' ')
) {
teamId = mainClubId;
}
}
const canonicalName = getName(rawName, teamId);
if (!canonicalName) continue;
const key = teamId ? `id:${teamId}` : normalize(canonicalName);
const logo = getLogo(canonicalName, teamId, r.team_logo_url);
if (!map[key]) {
map[key] = { key, name: teamName, logo, variants: [teamName] };
map[key] = { key, name: canonicalName, logo, variants: [rawName, canonicalName], teamId };
} else {
map[key].variants.push(teamName);
map[key].variants.push(rawName);
map[key].variants.push(canonicalName);
// Update logo - prefer non-empty logos
const currentIsEmpty = !map[key].logo || /logo-club-empty\.svg$/.test(String(map[key].logo));
const newIsNotEmpty = logo && !/logo-club-empty\.svg$/.test(String(logo));
if (currentIsEmpty && newIsNotEmpty) {
map[key].logo = logo as string;
}
if (!map[key].teamId && teamId) {
map[key].teamId = teamId;
}
}
}
}
// Sort by representative name
return Object.values(map).sort((a, b) => a.name.localeCompare(b.name, 'cs', { sensitivity: 'base' }));
}, [competitions, getLogo]);
}, [competitions, getLogo, mainClubBase, mainClubId]);
// Fast lookup from normalized name to variant list
const variantsByKey = useMemo(() => {
@@ -253,6 +300,25 @@ const TeamsAdminPage = () => {
const [externalUploadStatus, setExternalUploadStatus] = useState<'idle' | 'uploading' | 'success' | 'error'>('idle');
const [externalUploadError, setExternalUploadError] = useState<string | null>(null);
const showExternalUploadInfo = useMemo(() => {
try {
if (uploadedFile) return true;
const raw = (form.logo_url || '').trim();
if (!raw) return false;
const abs = raw.startsWith('/') ? new URL(raw, backendOrigin).toString() : raw;
const u = new URL(abs);
const host = u.hostname.toLowerCase();
const path = u.pathname;
const backendHost = new URL(backendOrigin).hostname.toLowerCase();
const isFacr = host.endsWith('fotbal.cz') || host === 'is1.fotbal.cz';
const isLogoAPI = host === 'logoapi.sportcreative.eu';
const isLocalUpload = (host === backendHost && path.startsWith('/uploads/'));
return !isFacr && !isLogoAPI && isLocalUpload;
} catch {
return false;
}
}, [uploadedFile, form.logo_url, backendOrigin]);
// Club search
const [query, setQuery] = useState('');
const [debounced, setDebounced] = useState('');
@@ -266,7 +332,7 @@ const TeamsAdminPage = () => {
enabled: debounced.trim().length >= 2,
});
const onOpenEdit = (teamName: string, teamLogoUrl?: string, variantNames?: string[]) => {
const onOpenEdit = (teamName: string, teamLogoUrl?: string, variantNames?: string[], teamId?: string) => {
// If variants not explicitly provided (e.g., from table view), compute from normalized key
let v = variantNames;
if (!v || v.length === 0) {
@@ -274,7 +340,7 @@ const TeamsAdminPage = () => {
v = variantsByKey[key] || [];
}
setSelected({ teamName, teamLogoUrl, variantNames: v });
setForm({ external_team_id: '', team_name: teamName || '', logo_url: teamLogoUrl || '' });
setForm({ external_team_id: teamId || '', team_name: teamName || '', logo_url: teamLogoUrl || '' });
setQuery(teamName || '');
setIsOpen(true);
};
@@ -285,53 +351,79 @@ const TeamsAdminPage = () => {
throw new Error('Vyberte tým ze seznamu vyhledávání (chybí ID).');
}
let logoUrl = (form.logo_url || '').trim();
const primaryName = (selected?.teamName || form.team_name || '').trim();
// Prefer the edited input over the pre-selected name
const primaryName = (form.team_name || selected?.teamName || '').trim();
// All variants to update (deduped), always include the primary name
const names = Array.from(new Set([primaryName, ...((selected?.variantNames || []) as string[])]))
.map((s) => s.trim())
.filter(Boolean);
// Upload to logoapi.sportcreative.eu first (best-effort). If successful, prefer that URL for overrides.
if (logoUrl) {
setExternalUploadStatus('uploading');
setExternalUploadError(null);
try {
let logoFileToUpload: File | Blob | null = uploadedFile;
if (!logoFileToUpload && logoUrl) {
logoFileToUpload = await fetchLogoAsBlob(logoUrl);
// Prefer highest-quality logo from logoapi if available (unless uploading a new file)
try {
if (!uploadedFile && form.external_team_id) {
const apiLogo = await fetchLogoFromLogoAPI(form.external_team_id, primaryName);
if (apiLogo) {
logoUrl = apiLogo;
}
if (logoFileToUpload) {
const logaResult = await uploadToLogaSportcreative(
form.external_team_id,
logoFileToUpload,
{
filename: `${form.external_team_id}.${logoFileToUpload instanceof File ? logoFileToUpload.name.split('.').pop() : 'png'}`,
clubName: form.team_name || selected?.teamName || 'Neznámý klub'
}
);
if (logaResult.success) {
setExternalUploadStatus('success');
if (logaResult.url) {
logoUrl = logaResult.url;
}
} catch {}
if (logoUrl) {
let shouldUpload = Boolean(uploadedFile);
try {
const abs = logoUrl.startsWith('/') ? new URL(logoUrl, backendOrigin).toString() : logoUrl;
const u = new URL(abs);
const host = u.hostname.toLowerCase();
const path = u.pathname;
const backendHost = new URL(backendOrigin).hostname.toLowerCase();
const isFacr = host.endsWith('fotbal.cz') || host === 'is1.fotbal.cz';
const isLogoAPI = host === 'logoapi.sportcreative.eu';
const isLocalUpload = (host === backendHost && path.startsWith('/uploads/'));
if (!shouldUpload) {
shouldUpload = isLocalUpload;
}
if (isFacr || isLogoAPI) {
shouldUpload = false;
}
} catch {}
if (shouldUpload) {
setExternalUploadStatus('uploading');
setExternalUploadError(null);
try {
let logoFileToUpload: File | Blob | null = uploadedFile;
if (!logoFileToUpload && logoUrl) {
logoFileToUpload = await fetchLogoAsBlob(logoUrl);
}
if (logoFileToUpload) {
const logaResult = await uploadToLogaSportcreative(
form.external_team_id,
logoFileToUpload,
{
filename: `${form.external_team_id}.${logoFileToUpload instanceof File ? logoFileToUpload.name.split('.').pop() : 'png'}`,
clubName: form.team_name || selected?.teamName || 'Neznámý klub'
}
);
if (logaResult.success) {
setExternalUploadStatus('success');
if (logaResult.url) {
logoUrl = logaResult.url;
}
} else {
setExternalUploadStatus('error');
setExternalUploadError(logaResult.error || 'Nepodařilo se nahrát logo');
}
} else {
setExternalUploadStatus('error');
setExternalUploadError(logaResult.error || 'Nepodařilo se nahrát logo');
setExternalUploadError('Could not fetch logo file');
}
} else {
} catch (error: any) {
setExternalUploadStatus('error');
setExternalUploadError('Could not fetch logo file');
setExternalUploadError(error?.message || 'Upload failed');
}
} catch (error: any) {
setExternalUploadStatus('error');
setExternalUploadError(error?.message || 'Upload failed');
}
}
// Save override for each variant name so editing one updates all duplicates
await Promise.all(
names.map((n) => putTeamLogoOverride(form.external_team_id, n, logoUrl))
);
await putTeamLogoOverride(form.external_team_id, primaryName, logoUrl);
return true;
},
@@ -489,12 +581,12 @@ const TeamsAdminPage = () => {
<Td py={1.5}>
<HStack spacing={2} align="center">
<Image
src={getLogo(r.team, (r as any).team_id, r.team_logo_url)}
alt={r.team}
src={getLogo(r.team, ((r as any).team_id as any) || deriveTeamIdFromLogoUrl(r.team_logo_url), r.team_logo_url)}
alt={getName(r.team, ((r as any).team_id as any) || deriveTeamIdFromLogoUrl(r.team_logo_url))}
boxSize="24px"
objectFit="contain"
/>
<Text fontSize="xs" noOfLines={1}>{r.team}</Text>
<Text fontSize="xs" noOfLines={1}>{getName(r.team, ((r as any).team_id as any) || deriveTeamIdFromLogoUrl(r.team_logo_url))}</Text>
</HStack>
</Td>
<Td isNumeric py={1.5} fontSize="xs">{r.played}</Td>
@@ -504,7 +596,12 @@ const TeamsAdminPage = () => {
<Td isNumeric py={1.5} fontSize="xs">{r.score}</Td>
<Td isNumeric py={1.5} fontSize="xs" fontWeight="bold">{r.points}</Td>
<Td py={1.5}>
<Button size="xs" fontSize="xs" onClick={() => onOpenEdit(r.team || '', r.team_logo_url)}>Upravit</Button>
<Button size="xs" fontSize="xs" onClick={() => {
const tid = ((r as any).team_id as any) || deriveTeamIdFromLogoUrl(r.team_logo_url);
const displayName = getName(r.team, tid);
const key = tid ? `id:${tid}` : normalize(displayName);
onOpenEdit(displayName || '', getLogo(r.team, tid, r.team_logo_url), variantsByKey[key], tid);
}}>Upravit</Button>
</Td>
</Tr>
))}
@@ -558,7 +655,7 @@ const TeamsAdminPage = () => {
</Tooltip>
)}
</HStack>
<Button size="xs" fontSize="xs" onClick={() => onOpenEdit(t.name, t.logo, t.variants)} w="full">Upravit</Button>
<Button size="xs" fontSize="xs" onClick={() => onOpenEdit(t.name, t.logo, t.variants, t.teamId)} w="full">Upravit</Button>
</VStack>
</Box>
))}
@@ -604,9 +701,15 @@ const TeamsAdminPage = () => {
px={3}
py={2}
_hover={{ bg: 'gray.50', cursor: 'pointer' }}
onClick={() => {
onClick={async () => {
setForm((f) => ({ ...f, external_team_id: r.id, team_name: r.name, logo_url: r.logo_url || f.logo_url }));
setQuery(r.name);
try {
const apiUrl = await fetchLogoFromLogoAPI(r.id, r.name);
if (apiUrl) {
setForm((f) => ({ ...f, logo_url: apiUrl }));
}
} catch {}
}}
>
<HStack justify="space-between" spacing={3}>
@@ -656,7 +759,7 @@ const TeamsAdminPage = () => {
Upravíte také duplicitní názvy: {Array.from(new Set(selected.variantNames)).join(', ')}
</Alert>
)}
{form.logo_url && (
{showExternalUploadInfo && (
<Alert status="info" variant="left-accent">
<AlertIcon />
<VStack align="start" spacing={1}>
+111 -67
View File
@@ -236,50 +236,48 @@ const UsersAdminPage = () => {
Správa uživatelských úč a jejich oprávnění. <strong>Editor</strong> může vytvářet a upravovat články a aktivity. <strong>Admin</strong> přístup ke všem funkcím.
</Text>
<Box bg={bgColor} borderRadius="md" boxShadow="sm" overflowX="auto">
<Table variant="simple">
<Thead>
<Tr>
<Th>Name</Th>
<Th>Email</Th>
<Th>Role</Th>
<Th>Status</Th>
<Th>Created</Th>
<Th>Actions</Th>
</Tr>
</Thead>
<Tbody>
{users.map((user) => (
<Tr key={user.id}>
<Td>{user.name}</Td>
<Td>{user.email}</Td>
<Td>
<Badge colorScheme={user.role === 'admin' ? 'purple' : (user.role === 'editor' ? 'blue' : 'gray')}>
{user.role === 'admin' ? 'Admin' : user.role === 'editor' ? 'Editor' : 'Fan'}
</Badge>
</Td>
<Td>
<Badge colorScheme={user.isActive ? 'green' : 'red'}>
{user.isActive ? 'Active' : 'Inactive'}
</Badge>
</Td>
<Td>{new Date(user.createdAt).toLocaleDateString()}</Td>
<Td>
<Menu>
<MenuButton
as={IconButton}
aria-label="Options"
icon={<HamburgerIcon />}
size="sm"
variant="ghost"
/>
<MenuList>
<MenuItem
icon={<EditIcon />}
onClick={() => openEditModal(user)}
>
Edit
</MenuItem>
<Heading size="md" mb={2}>Admini a editoři</Heading>
<Box bg={bgColor} borderRadius="md" boxShadow="sm" overflowX="auto" mb={8}>
<Table variant="simple">
<Thead>
<Tr>
<Th>Name</Th>
<Th>Email</Th>
<Th>Role</Th>
<Th>Status</Th>
<Th>Created</Th>
<Th>Actions</Th>
</Tr>
</Thead>
<Tbody>
{users.filter(u => u.role !== 'fan').map((user) => (
<Tr key={user.id}>
<Td>{user.name}</Td>
<Td>{user.email}</Td>
<Td>
<Badge colorScheme={user.role === 'admin' ? 'purple' : 'blue'}>
{user.role === 'admin' ? 'Admin' : 'Editor'}
</Badge>
</Td>
<Td>
<Badge colorScheme={user.isActive ? 'green' : 'red'}>
{user.isActive ? 'Active' : 'Inactive'}
</Badge>
</Td>
<Td>{new Date(user.createdAt).toLocaleDateString()}</Td>
<Td>
<Menu>
<MenuButton
as={IconButton}
aria-label="Options"
icon={<HamburgerIcon />}
size="sm"
variant="ghost"
/>
<MenuList>
<MenuItem icon={<EditIcon />} onClick={() => openEditModal(user)}>
Edit
</MenuItem>
<MenuItem onClick={async () => {
try {
await api.post(`/admin/users/${user.id}/reset-password`);
@@ -287,34 +285,80 @@ const UsersAdminPage = () => {
} catch (e: any) {
const errorMsg = e?.response?.data?.message || e?.response?.data?.error || e?.message || 'Nelze odeslat reset hesla';
const errorDetails = e?.response?.data?.details;
toast({
title: 'Chyba při odesílání resetu hesla',
description: errorDetails ? `${errorMsg}\n\n${errorDetails}` : errorMsg,
status: 'error',
duration: 10000,
isClosable: true,
});
toast({ title: 'Chyba při odesílání resetu hesla', description: errorDetails ? `${errorMsg}\n\n${errorDetails}` : errorMsg, status: 'error', duration: 10000, isClosable: true });
}
}}>
Odeslat reset hesla
</MenuItem>
{user.role !== 'admin' && String(authUser?.id) !== String(user.id) && (
<MenuItem
icon={<DeleteIcon />}
color="red.500"
onClick={() => handleDelete(user.id)}
>
Delete
</MenuItem>
)}
</MenuList>
</Menu>
</Td>
{user.role !== 'admin' && String(authUser?.id) !== String(user.id) && (
<MenuItem icon={<DeleteIcon />} color="red.500" onClick={() => handleDelete(user.id)}>
Delete
</MenuItem>
)}
</MenuList>
</Menu>
</Td>
</Tr>
))}
</Tbody>
</Table>
</Box>
<Heading size="md" mb={2}>Fanoušci</Heading>
<Box bg={bgColor} borderRadius="md" boxShadow="sm" overflowX="auto">
<Table variant="simple">
<Thead>
<Tr>
<Th>Name</Th>
<Th>Email</Th>
<Th>Role</Th>
<Th>Status</Th>
<Th>Created</Th>
<Th>Actions</Th>
</Tr>
))}
</Tbody>
</Table>
</Box>
</Thead>
<Tbody>
{users.filter(u => u.role === 'fan').map((user) => (
<Tr key={user.id}>
<Td>{user.name}</Td>
<Td>{user.email}</Td>
<Td>
<Badge colorScheme={'gray'}>
Fan
</Badge>
</Td>
<Td>
<Badge colorScheme={user.isActive ? 'green' : 'red'}>
{user.isActive ? 'Active' : 'Inactive'}
</Badge>
</Td>
<Td>{new Date(user.createdAt).toLocaleDateString()}</Td>
<Td>
<Menu>
<MenuButton
as={IconButton}
aria-label="Options"
icon={<HamburgerIcon />}
size="sm"
variant="ghost"
/>
<MenuList>
<MenuItem icon={<EditIcon />} onClick={() => openEditModal(user)}>
Edit
</MenuItem>
{String(authUser?.id) !== String(user.id) && (
<MenuItem icon={<DeleteIcon />} color="red.500" onClick={() => handleDelete(user.id)}>
Delete
</MenuItem>
)}
</MenuList>
</Menu>
</Td>
</Tr>
))}
</Tbody>
</Table>
</Box>
{/* Add/Edit User Modal */}
<Modal isOpen={isOpen} onClose={onClose} size="lg">
+85 -23
View File
@@ -1,31 +1,93 @@
import { Box, Heading, Text, List, ListItem, Link, Container } from '@chakra-ui/react';
import { Container, Heading, Text, List, ListItem, Link, Box, VStack, Divider, Button, useColorModeValue } from '@chakra-ui/react';
import MainLayout from '../../components/layout/MainLayout';
const CookiePolicyPage: React.FC = () => {
const textColor = useColorModeValue('gray.700', 'gray.300');
const headingColor = useColorModeValue('gray.900', 'gray.100');
const boxBg = useColorModeValue('blue.50', 'blue.900');
const boxText = useColorModeValue('blue.900', 'blue.100');
const openCookieSettings = () => {
window.dispatchEvent(new Event('cookie-consent-open'));
};
return (
<MainLayout>
<Container maxW="3xl">
<Heading as="h1" size="lg" mb={4}>Pravidla používání souborů Cookies</Heading>
<Text mb={4}>
Tento web používá soubory cookies pro zajištění správného fungování webu, analýzu návštěvnosti a zlepšení nabízených služeb.
</Text>
<Heading as="h2" size="md" mt={6} mb={2}>Co jsou cookies?</Heading>
<Text mb={4}>
Cookies jsou malé textové soubory, které se ukládají do vašeho zařízení při návštěvě webových stránek. Umožňují webu rozpoznat vaše zařízení a přizpůsobit obsah.
</Text>
<Heading as="h2" size="md" mt={6} mb={2}>Jaké typy cookies používáme</Heading>
<List spacing={2} mb={4} styleType="disc" pl={6}>
<ListItem>Nezbytné cookies zajišťují základní funkce webu.</ListItem>
<ListItem>Preferenční cookies pamatují si vaše volby (např. jazyk).</ListItem>
<ListItem>Analytické cookies pomáhají nám porozumět, jak web používáte (anonymně).</ListItem>
</List>
<Heading as="h2" size="md" mt={6} mb={2}>Správa cookies</Heading>
<Text mb={4}>
Používání cookies můžete upravit nebo zakázat v nastavení vašeho prohlížeče. Upozorňujeme, že vypnutí některých cookies může ovlivnit funkčnost webu.
</Text>
<Text fontSize="sm" color="gray.600">
V případě dotazů nás kontaktujte na adrese <Link href="/kontakt">/kontakt</Link>.
</Text>
<Container maxW="3xl" py={8}>
<VStack align="stretch" spacing={6}>
<Heading as="h1" size="xl" mb={2} color={headingColor}>Pravidla používání souborů cookies</Heading>
<Text fontSize="sm" color={textColor}>
Poslední aktualizace: {new Date().toLocaleDateString('cs-CZ')}
</Text>
<Box bg={boxBg} p={4} borderRadius="md">
<Text fontWeight="bold" mb={2} color={boxText}>Shrnutí</Text>
<Text fontSize="sm" color={boxText}>
Cookies používáme pro zajištění nezbytných funkcí webu, zlepšení uživatelského zážitku a měření návštěvnosti. Nepovinné cookies ukládáme pouze na základě vašeho souhlasu.
</Text>
</Box>
<Divider />
<Box>
<Heading as="h2" size="md" mt={4} mb={3} color={headingColor}>1. Co jsou cookies?</Heading>
<Text color={textColor} mb={4}>
Cookies jsou malé textové soubory ukládané do vašeho zařízení při návštěvě webu. Umožňují rozpoznat prohlížeč, pamatovat si vaše volby a zajistit fungování webu.
</Text>
</Box>
<Box>
<Heading as="h2" size="md" mt={4} mb={3} color={headingColor}>2. Jaké cookies používáme</Heading>
<List spacing={2} mb={4} styleType="disc" pl={6} color={textColor}>
<ListItem><strong>Nezbytné:</strong> zajišťují bezpečnost a základní funkce webu. Vždy aktivní.</ListItem>
<ListItem><strong>Preferenční:</strong> pamatují si vaše volby (např. jazyk, zobrazení).</ListItem>
<ListItem><strong>Analytické:</strong> anonymní měření návštěvnosti a výkonu stránek.</ListItem>
<ListItem><strong>Marketingové:</strong> přizpůsobení obsahu a případného marketingu.</ListItem>
</List>
</Box>
<Box>
<Heading as="h2" size="md" mt={4} mb={3} color={headingColor}>3. Právní základy a účely</Heading>
<List spacing={2} mb={4} styleType="disc" pl={6} color={textColor}>
<ListItem><strong>Nezbytné cookies:</strong> oprávněný zájem na provozu a zabezpečení webu (GDPR čl. 6 odst. 1 písm. f).</ListItem>
<ListItem><strong>Preferenční, analytické, marketingové:</strong> pouze na základě vašeho souhlasu (GDPR čl. 6 odst. 1 písm. a), který můžete kdykoli odvolat.</ListItem>
</List>
</Box>
<Box>
<Heading as="h2" size="md" mt={4} mb={3} color={headingColor}>4. Správa souhlasu</Heading>
<Text color={textColor} mb={3}>
Nastavení cookies můžete kdykoli změnit pomocí tlačítka níže nebo v nastavení prohlížeče. Odvolání souhlasu nemá vliv na zákonnost předchozího zpracování.
</Text>
<Button onClick={openCookieSettings} colorScheme="blue" size="sm">Otevřít nastavení cookies</Button>
</Box>
<Box>
<Heading as="h2" size="md" mt={4} mb={3} color={headingColor}>5. Doba uchovávání</Heading>
<List spacing={2} mb={4} styleType="disc" pl={6} color={textColor}>
<ListItem><strong>Nezbytné cookies:</strong> po dobu nezbytnou pro fungování relace či bezpečnost.</ListItem>
<ListItem><strong>Preferenční a analytické:</strong> typicky 626 měsíců, nebo do odvolání souhlasu.</ListItem>
<ListItem><strong>Marketingové:</strong> dle konkrétní kategorie, nejdéle do odvolání souhlasu.</ListItem>
</List>
</Box>
<Box>
<Heading as="h2" size="md" mt={4} mb={3} color={headingColor}>6. Třetí strany</Heading>
<Text color={textColor} mb={4}>
Pro analytiku nebo měření můžeme využívat nástroje třetích stran. Tyto služby zpracovávají údaje pouze v rozsahu nezbytném pro poskytování měření a jsou vázány smluvními závazky ochrany údajů.
</Text>
</Box>
<Box>
<Heading as="h2" size="md" mt={4} mb={3} color={headingColor}>7. Další informace</Heading>
<Text color={textColor} mb={2}>
Podrobnosti o zpracování osobních údajů najdete v <Link href="/zasady-ochrany-osobnich-udaju" color="brand.primary">Zásadách ochrany osobních údajů</Link>.
</Text>
<Text color={textColor}>
V případě dotazů nás kontaktujte prostřednictvím stránky <Link href="/kontakt" color="brand.primary">Kontakt</Link>.
</Text>
</Box>
</VStack>
</Container>
</MainLayout>
);
@@ -39,7 +39,7 @@ const PrivacyPolicyPage: React.FC = () => {
<ListItem><strong>Kontaktní údaje:</strong> jméno, příjmení, e-mailová adresa, telefonní číslo - poskytnuté dobrovolně prostřednictvím kontaktního formuláře nebo při registraci k newsletteru</ListItem>
<ListItem><strong>IP adresa:</strong> automaticky zaznamenávaná při návštěvě webu pro účely bezpečnosti, analýzy návštěvnosti a prevence zneužití</ListItem>
<ListItem><strong>Technické údaje:</strong> informace o zařízení, prohlížeči, operačním systému, datum a čas návštěvy, navštívené stránky</ListItem>
<ListItem><strong>Cookies:</strong> soubory ukládané do vašeho zařízení (viz samostatná <Link href="/cookies" color="brand.primary">Pravidla používání cookies</Link>)</ListItem>
<ListItem><strong>Cookies:</strong> soubory ukládané do vašeho zařízení (viz samostatná <Link href="/pravidla-cookies" color="brand.primary">Pravidla používání cookies</Link>)</ListItem>
<ListItem><strong>Analytické údaje:</strong> anonymizovaná data o chování na webu prostřednictvím analytických nástrojů</ListItem>
</List>
</Box>
+100 -22
View File
@@ -1,30 +1,108 @@
import { Container, Heading, Text, List, ListItem } from '@chakra-ui/react';
import { Container, Heading, Text, List, ListItem, Link, Box, VStack, Divider, useColorModeValue } from '@chakra-ui/react';
import MainLayout from '../../components/layout/MainLayout';
const TermsPage: React.FC = () => {
const textColor = useColorModeValue('gray.700', 'gray.300');
const headingColor = useColorModeValue('gray.900', 'gray.100');
const boxBg = useColorModeValue('blue.50', 'blue.900');
const boxText = useColorModeValue('blue.900', 'blue.100');
return (
<MainLayout>
<Container maxW="3xl">
<Heading as="h1" size="lg" mb={4}>Obchodní podmínky</Heading>
<Text mb={4}>
Tyto obchodní podmínky upravují používání webových stránek a poskytované služby.
</Text>
<Heading as="h2" size="md" mt={6} mb={2}>1. Obecná ustanovení</Heading>
<Text mb={4}>
Provozovatelem webu je subjekt uvedený v kontaktech. Používáním webu vyjadřujete souhlas s těmito podmínkami.
</Text>
<Heading as="h2" size="md" mt={6} mb={2}>2. Obsah webu</Heading>
<Text mb={4}>
Veškerý obsah je poskytován pro informační účely. Provozovatel si vyhrazuje právo na změny bez předchozího upozornění.
</Text>
<Heading as="h2" size="md" mt={6} mb={2}>3. Odpovědnost</Heading>
<Text mb={4}>
Provozovatel nenese odpovědnost za škody vzniklé v souvislosti s používáním webu, ledaže je stanoveno jinak právními předpisy.
</Text>
<Heading as="h2" size="md" mt={6} mb={2}>4. Kontakt</Heading>
<Text mb={4}>
V případě dotazů nás kontaktujte na stránce Kontakt.
</Text>
<Container maxW="3xl" py={8}>
<VStack align="stretch" spacing={6}>
<Heading as="h1" size="xl" mb={2} color={headingColor}>Obchodní podmínky</Heading>
<Text fontSize="sm" color={textColor}>
Poslední aktualizace: {new Date().toLocaleDateString('cs-CZ')}
</Text>
<Box bg={boxBg} p={4} borderRadius="md">
<Text fontWeight="bold" mb={2} color={boxText}>Shrnutí</Text>
<Text fontSize="sm" color={boxText}>
Používáním tohoto webu souhlasíte s níže uvedenými podmínkami. Obsah slouží pro informační účely, bez záruky úplnosti a správnosti. Provozovatel může podmínky a obsah kdykoli měnit.
</Text>
</Box>
<Divider />
<Box>
<Heading as="h2" size="md" mt={4} mb={3} color={headingColor}>1. Provozovatel a kontakt</Heading>
<Text color={textColor} mb={4}>
Provozovatelem webových stránek je subjekt uvedený v sekci <Link href="/kontakt" color="brand.primary">Kontakt</Link>. Pro veškeré dotazy nebo podněty prosím využijte uvedené kontaktní údaje.
</Text>
</Box>
<Box>
<Heading as="h2" size="md" mt={4} mb={3} color={headingColor}>2. Rozsah a přijetí podmínek</Heading>
<Text color={textColor} mb={4}>
Tyto podmínky upravují používání tohoto webu a souvisejících služeb (např. newsletter). Vstupem na web a jeho používáním vyjadřujete souhlas s těmito podmínkami.
</Text>
</Box>
<Box>
<Heading as="h2" size="md" mt={4} mb={3} color={headingColor}>3. Obsah, zdroje dat a práva k duševnímu vlastnictví</Heading>
<List spacing={2} mb={4} styleType="disc" pl={6} color={textColor}>
<ListItem><strong>Autorská práva:</strong> Texty, grafika a prvky webu podléhají ochraně autorského práva. Kopírování či šíření je možné pouze s předchozím souhlasem.</ListItem>
<ListItem><strong>Externí data FAČR:</strong> Informace o zápasech, soutěžích a tabulkách pocházejí z veřejných zdrojů FAČR (<Link href="https://www.fotbal.cz" isExternal color="brand.primary">www.fotbal.cz</Link>) a jsou majetkem FAČR. Zobrazujeme je výhradně pro informační účely.</ListItem>
<ListItem><strong>Loga klubů a týmů:</strong> Loga náleží příslušným klubům a organizacím a slouží pouze k identifikaci.</ListItem>
<ListItem><strong>Fotografie a videa:</strong> Mediální obsah může pocházet z různých zdrojů, včetně YouTube a Zonerama. Veškerá práva náleží původním autorům.</ListItem>
</List>
</Box>
<Box>
<Heading as="h2" size="md" mt={4} mb={3} color={headingColor}>4. Pravidla chování uživatelů</Heading>
<List spacing={2} mb={4} styleType="disc" pl={6} color={textColor}>
<ListItem>Neporušujte právní předpisy ani práva třetích osob.</ListItem>
<ListItem>Nevkládejte škodlivý kód a nepokoušejte se narušit bezpečnost webu.</ListItem>
<ListItem>Nevyužívejte web k nevyžádané reklamě ani automatizovanému sběru dat (scraping) v rozporu s podmínkami.</ListItem>
</List>
</Box>
<Box>
<Heading as="h2" size="md" mt={4} mb={3} color={headingColor}>5. Newsletter a účty</Heading>
<Text color={textColor} mb={4}>
Přihlášení k odběru newsletteru je dobrovolné a lze jej kdykoli odhlásit prostřednictvím odkazu v emailu nebo na stránce <Link href="/newsletter/preferences" color="brand.primary">nastavení newsletteru</Link>.
</Text>
</Box>
<Box>
<Heading as="h2" size="md" mt={4} mb={3} color={headingColor}>6. Odpovědnost a záruky</Heading>
<Text color={textColor} mb={4}>
Obsah webu je poskytován tak jak je, bez jakýchkoli záruk. Provozovatel neodpovídá za případné škody vzniklé užíváním webu, v nejširším rozsahu povoleném právem. Neodpovídáme za dostupnost a obsah externích odkazů (např. FAČR, YouTube, Zonerama).
</Text>
</Box>
<Box>
<Heading as="h2" size="md" mt={4} mb={3} color={headingColor}>7. Eshop a externí služby</Heading>
<Text color={textColor} mb={4}>
Pokud web odkazuje na externí eshop nebo platební službu, nákup se ří podmínkami daného poskytovatele. Provozovatel tohoto webu není stranou takového smluvního vztahu, pokud není výslovně uvedeno jinak.
</Text>
</Box>
<Box>
<Heading as="h2" size="md" mt={4} mb={3} color={headingColor}>8. Ochrana osobních údajů a cookies</Heading>
<Text color={textColor} mb={4}>
Zpracování osobních údajů se ří dokumentem <Link href="/zasady-ochrany-osobnich-udaju" color="brand.primary">Zásady ochrany osobních údajů</Link>. Informace o cookies naleznete v <Link href="/pravidla-cookies" color="brand.primary">Pravidlech používání cookies</Link>.
</Text>
</Box>
<Box>
<Heading as="h2" size="md" mt={4} mb={3} color={headingColor}>9. Změny podmínek</Heading>
<Text color={textColor} mb={4}>
Provozovatel si vyhrazuje právo tyto podmínky kdykoli upravit nebo doplnit. Změny jsou účinné zveřejněním na tomto webu.
</Text>
</Box>
<Box>
<Heading as="h2" size="md" mt={4} mb={3} color={headingColor}>10. Rozhodné právo</Heading>
<Text color={textColor} mb={2}>
Tyto podmínky se ří právním řádem České republiky. Případné spory budou řešeny u příslušných soudů České republiky.
</Text>
<Text color={textColor}>
Máteli dotazy, kontaktujte nás prostřednictvím stránky <Link href="/kontakt" color="brand.primary">Kontakt</Link>.
</Text>
</Box>
</VStack>
</Container>
</MainLayout>
);
+10 -1
View File
@@ -178,7 +178,16 @@ export async function fetchLogoAsBlob(logoUrl: string): Promise<Blob | null> {
fullUrl = `${apiOrigin}${logoUrl}`;
}
const response = await fetch(fullUrl);
const apiOrigin = new URL(API_URL).origin;
let fetchUrl = fullUrl;
try {
const u = new URL(fullUrl);
if (u.origin !== apiOrigin) {
fetchUrl = `${apiOrigin}/api/v1/proxy/image?url=${encodeURIComponent(fullUrl)}`;
}
} catch {}
const response = await fetch(fetchUrl);
if (!response.ok) return null;
return await response.blob();
+2
View File
@@ -101,6 +101,8 @@ export async function getArticles(params: {
featured?: boolean;
q?: string;
slug?: string;
match_id?: string | number;
month?: string; // YYYY-MM
} = {}) {
// Backend returns shape: { items, total, page, page_size }
// Normalize to { data, total, page, page_size } expected by the frontend.
+69 -12
View File
@@ -49,10 +49,14 @@ const resolveBackendUrl = (path: string) => {
// Lazy-load public overrides with lightweight cache
let overridesCache: { data: any; ts: number } | null = null;
const loadOverrides = async (): Promise<Record<string, string>> => {
type OverridesPayload = {
by_name?: Record<string, string>;
by_id?: Record<string, { name?: string; logo_url?: string }>;
};
const loadOverrides = async (): Promise<OverridesPayload> => {
const now = Date.now();
if (overridesCache && now - overridesCache.ts < 60_000) {
return (overridesCache.data?.by_name || {}) as Record<string, string>;
return (overridesCache.data || {}) as OverridesPayload;
}
try {
const res = await fetch(resolveBackendUrl(`/api/v1/public/team-logo-overrides?t=${now}`), { cache: 'no-cache' });
@@ -65,7 +69,7 @@ const loadOverrides = async (): Promise<Record<string, string>> => {
// Invalidate internal FACR GET cache so consumers refetch with new logos
cache.clear();
}
return (json?.by_name || {}) as Record<string, string>;
return (json || {}) as OverridesPayload;
}
} catch {}
// Fallback to cached file if API failed
@@ -79,11 +83,11 @@ const loadOverrides = async (): Promise<Record<string, string>> => {
if (prev !== next) {
cache.clear();
}
return (json?.by_name || {}) as Record<string, string>;
return (json || {}) as OverridesPayload;
}
} catch {}
overridesCache = { data: { by_name: {} }, ts: now };
return {};
return { by_name: {} };
};
// Name normalization helpers
@@ -99,14 +103,21 @@ const stripPrefixes = (s: string) => {
x = x.replace(/\b(mestsky|m\.?f\.?k\.?|mfk|tj|sk|sokol|fotbalovy|fotbalový|fotbalovy\s+klub|fotbalovy\s+klub)\b/g, '');
return x.replace(/\s+/g, ' ').trim();
};
const applyOverridesToClub = (club: ClubInfo, byName: Record<string, string>) => {
const applyOverridesToClub = (club: ClubInfo, overrides: OverridesPayload) => {
if (!club?.competitions?.length) return club;
const byName = overrides?.by_name || {};
const byId = overrides?.by_id || {} as Record<string, { name?: string; logo_url?: string }>;
const byNameNorm: Record<string, string> = Object.keys(byName || {}).reduce((acc: Record<string, string>, k) => {
acc[norm(k)] = byName[k];
return acc;
}, {});
const strippedPairs = Object.keys(byName || {}).map((k) => ({ key: stripPrefixes(k), url: byName[k] }));
const pick = (teamName?: string, original?: string) => {
const pickLogo = (teamId?: string, teamName?: string, original?: string) => {
if (teamId && byId[teamId]?.logo_url) {
const v = byId[teamId]!.logo_url!;
if (typeof v === 'string' && v.startsWith('/')) return resolveBackendUrl(v);
return v;
}
if (!teamName) return original;
const exact = (byName || {})[teamName];
const n = norm(teamName);
@@ -122,12 +133,18 @@ const applyOverridesToClub = (club: ClubInfo, byName: Record<string, string>) =>
if (typeof chosen === 'string' && chosen.startsWith('/')) return resolveBackendUrl(chosen);
return chosen;
};
const pickName = (teamId?: string, original?: string) => {
const v = (teamId && byId[teamId]?.name) ? byId[teamId]!.name : undefined;
return (v && v.trim().length > 0) ? v : original;
};
club.competitions = (club.competitions || []).map((c) => ({
...c,
matches: (c.matches || []).map((m: any) => ({
...m,
home_logo_url: pick(m.home, m.home_logo_url),
away_logo_url: pick(m.away, m.away_logo_url),
home: pickName(m.home_id, m.home),
away: pickName(m.away_id, m.away),
home_logo_url: pickLogo(m.home_id, m.home, m.home_logo_url),
away_logo_url: pickLogo(m.away_id, m.away, m.away_logo_url),
})),
}));
return club;
@@ -232,8 +249,8 @@ export const facrApi = {
try {
const response = await apiClient.get<ClubInfo>(`/club/${clubType}/${clubId}`);
// Load overrides and apply before returning/caching consumers
const byName = await loadOverrides();
const patched = applyOverridesToClub(response.data, byName);
const overrides = await loadOverrides();
const patched = applyOverridesToClub(response.data, overrides);
return patched;
} catch (error) {
return handleApiError(error);
@@ -244,7 +261,47 @@ export const facrApi = {
getClubTable: async (clubId: string, clubType: 'football' | 'futsal' = 'football'): Promise<ClubInfo> => {
try {
const response = await apiClient.get<ClubInfo>(`/club/${clubType}/${clubId}/table`);
return response.data;
const data = response.data as any;
const overrides = await loadOverrides();
const byName = overrides?.by_name || {};
const byId = overrides?.by_id || {} as Record<string, { name?: string; logo_url?: string }>;
const byNameNorm: Record<string, string> = Object.keys(byName || {}).reduce((acc: Record<string, string>, k) => { acc[norm(k)] = byName[k]; return acc; }, {});
const strippedPairs = Object.keys(byName || {}).map((k) => ({ key: stripPrefixes(k), url: byName[k] }));
const pickLogo = (teamId?: string, teamName?: string, original?: string) => {
if (teamId && byId[teamId]?.logo_url) {
const v = byId[teamId]!.logo_url!;
if (typeof v === 'string' && v.startsWith('/')) return resolveBackendUrl(v);
return v;
}
if (!teamName) return original;
const exact = (byName || {})[teamName];
const n = norm(teamName);
let candidate = exact || byNameNorm[n];
if (!candidate) {
const s = stripPrefixes(teamName);
for (const { key, url } of strippedPairs) { if (!key) continue; if (s.endsWith(key) || key.endsWith(s)) { candidate = url; break; } }
}
const chosen = candidate || original;
if (typeof chosen === 'string' && chosen.startsWith('/')) return resolveBackendUrl(chosen);
return chosen;
};
const pickName = (teamId?: string, original?: string) => {
const v = (teamId && byId[teamId]?.name) ? byId[teamId]!.name : undefined;
return (v && v.trim().length > 0) ? v : original;
};
if (Array.isArray(data?.competitions)) {
data.competitions = data.competitions.map((c: any) => ({
...c,
table: {
overall: (c.table?.overall || []).map((r: any) => ({
...r,
team: pickName(r.team_id, r.team),
team_logo_url: pickLogo(r.team_id, r.team, r.team_logo_url),
})),
},
}));
}
return data;
} catch (error) {
return handleApiError(error);
}
+13
View File
@@ -114,7 +114,9 @@ export interface PredefinedElement {
export const PREDEFINED_ELEMENTS: PredefinedElement[] = [
// Layout - Rozvržení
{ name: 'style-pack', label: 'Styl balíček', description: 'Globální vizuální balíček pro celou stránku', icon: FaCube, category: 'layout', defaultVariant: 'default' },
{ name: 'header', label: 'Hlavička', description: 'Hlavička stránky s logem a navigací', icon: FaRegClipboard, category: 'layout', defaultVariant: 'unified' },
{ name: 'hero-topbar', label: 'Klub lišta nad hero', description: 'Pruh nad hero s logem klubu, názvem a akcemi', icon: FaCube, category: 'layout', defaultVariant: 'brand' },
{ name: 'hero', label: 'Hlavní Sekce', description: 'Hlavní obsahová oblast s úvodním obsahem', icon: FaBullseye, category: 'layout', defaultVariant: 'grid' },
{ name: 'footer', label: 'Patička', description: 'Spodní část stránky s odkazy a kontakty', icon: FaMapSigns, category: 'layout', defaultVariant: 'standard' },
{ name: 'sidebar', label: 'Boční Panel', description: 'Boční sloupec s doplňkovým obsahem', icon: FaColumns, category: 'layout', defaultVariant: 'right' },
@@ -155,6 +157,12 @@ export const PREDEFINED_ELEMENTS: PredefinedElement[] = [
];
export const ELEMENT_VARIANTS: Record<string, ElementVariant[]> = {
'style-pack': [
{ value: 'default', label: 'Výchozí', description: 'Základní sjednocený styl' },
{ value: 'modern', label: 'Moderní', description: 'Zaoblené rohy, lehké stíny, více prostoru' },
{ value: 'minimal', label: 'Minimal', description: 'Čisté, bez stínů, tenké rámečky' },
{ value: 'sparta', label: 'Sparta', description: 'Přiblížení k Sparta packu' },
],
header: [
{ value: 'unified', label: 'Jednotný', description: 'Klasická hlavička s logem a navigací' },
{ value: 'edge', label: 'Okrajový', description: 'Moderní hlavička s gradientem' },
@@ -166,6 +174,11 @@ export const ELEMENT_VARIANTS: Record<string, ElementVariant[]> = {
{ value: 'current', label: 'Současný', description: 'Stávající navigace' },
{ value: 'fullwidth', label: 'Šířka 100%', description: 'Navigace přes celou šířku obrazovky' },
],
'hero-topbar': [
{ value: 'brand', label: 'Brand', description: 'Barevná lišta s klubovými barvami a akcemi' },
{ value: 'minimal', label: 'Minimal', description: 'Průhledná/nenápadná lišta' },
{ value: 'badge', label: 'Badge', description: 'Pill styl s klubovou barvou' },
],
hero: [
{ value: 'grid', label: 'Mřížka', description: 'Rozložení ve formě mřížky' },
{ value: 'swiper', label: 'Karusel', description: 'Posuvný karusel' },
+18 -4
View File
@@ -170,12 +170,18 @@ export const getPolls = async (params?: {
event_id?: number;
video_url?: string;
}): Promise<Poll[]> => {
const response = await api.get('/polls', { params });
const response = await api.get('/polls', {
params,
headers: { 'X-Session-Token': generateSessionToken() },
});
return response.data;
};
export const getPoll = async (id: number): Promise<PollResponse> => {
const response = await api.get(`/polls/${id}`);
const token = generateSessionToken();
const response = await api.get(`/polls/${id}`, {
headers: { 'X-Session-Token': token },
});
return response.data;
};
@@ -183,12 +189,20 @@ export const votePoll = async (
id: number,
data: PollVoteRequest
): Promise<{ message: string; poll: Poll }> => {
const response = await api.post(`/polls/${id}/vote`, data);
const token = data.session_token || generateSessionToken();
const response = await api.post(
`/polls/${id}/vote`,
{ ...data, session_token: token },
{ headers: { 'X-Session-Token': token } }
);
return response.data;
};
export const getPollResults = async (id: number): Promise<PollResultsResponse> => {
const response = await api.get(`/polls/${id}/results`);
const token = generateSessionToken();
const response = await api.get(`/polls/${id}/results`, {
headers: { 'X-Session-Token': token },
});
return response.data;
};
+28 -6
View File
@@ -22,6 +22,27 @@ export type Player = {
export type Sponsor = { id: number; name: string; logo_url?: string; website_url?: string; tier?: string; display_order?: number };
export type Category = { id?: number; name: string; slug?: string; url?: string; children?: Category[] };
function normalizePlayer(p: any): Player {
if (!p) return p as any;
const id = p.id ?? p.ID;
return {
id: typeof id === 'string' ? Number(id) : id,
first_name: p.first_name ?? p.FirstName ?? '',
last_name: p.last_name ?? p.LastName ?? '',
position: p.position ?? p.Position ?? undefined,
jersey_number: p.jersey_number ?? p.JerseyNumber ?? undefined,
image_url: p.image_url ?? p.ImageURL ?? undefined,
is_active: Boolean(p.is_active ?? p.IsActive ?? true),
nationality: p.nationality ?? p.Nationality ?? undefined,
date_of_birth: p.date_of_birth ?? p.DateOfBirth ?? undefined,
height: p.height ?? p.Height ?? undefined,
weight: p.weight ?? p.Weight ?? undefined,
email: p.email ?? p.Email ?? undefined,
phone: p.phone ?? p.Phone ?? undefined,
team_id: p.team_id ?? p.TeamID ?? undefined,
} as Player;
}
export async function getMatches() {
const res = await api.get<Match[] | { data: Match[] }>('/matches');
return Array.isArray(res.data) ? res.data : res.data.data;
@@ -33,15 +54,16 @@ export async function getStandings() {
}
export async function getPlayers() {
const res = await api.get<Player[] | { data?: Player[]; items?: Player[] }>('/players');
if (Array.isArray(res.data)) return res.data as Player[];
const d = res.data as any;
return (d?.data || d?.items || []) as Player[];
const res = await api.get<any[] | { data?: any[]; items?: any[] }>('/players');
const raw = Array.isArray(res.data)
? res.data
: ((res.data as any).data || (res.data as any).items);
return (raw || []).map(normalizePlayer);
}
export async function getPlayer(id: number | string) {
const res = await api.get<Player>(`/players/${id}`);
return res.data;
const res = await api.get<any>(`/players/${id}`);
return normalizePlayer(res.data);
}
export async function getSponsors() {
+146 -86
View File
@@ -35,60 +35,50 @@ export interface SearchResults {
total: number;
}
// Enhanced scoring function for relevance with keyword support
// Enhanced scoring function for relevance with keyword support (accent-insensitive)
const scoreMatch = (text: string, query: string): number => {
const t = (text || '').toLowerCase();
const q = (query || '').toLowerCase();
if (!t || !q) return 0;
// Exact match - highest score
if (t === q) return 100;
// Starts with query - very high score
if (t.startsWith(q)) return 80;
// Contains query as whole substring
const idx = t.indexOf(q);
if (idx >= 0) return 60 - Math.min(idx, 30);
// Keyword matching - split query into words and check each
const keywords = q.split(/\s+/).filter(k => k.length > 1);
if (keywords.length > 1) {
let matchedKeywords = 0;
let totalScore = 0;
for (const keyword of keywords) {
if (t.includes(keyword)) {
matchedKeywords++;
const keywordIdx = t.indexOf(keyword);
// Score based on position and keyword match
totalScore += (keywordIdx === 0 ? 25 : 15 - Math.min(keywordIdx, 10));
const base = (t: string, q: string): number => {
if (!t || !q) return 0;
if (t === q) return 100;
if (t.startsWith(q)) return 80;
const idx = t.indexOf(q);
if (idx >= 0) return 60 - Math.min(idx, 30);
const keywords = q.split(/\s+/).filter(k => k.length > 1);
if (keywords.length > 1) {
let matchedKeywords = 0;
let totalScore = 0;
for (const keyword of keywords) {
if (t.includes(keyword)) {
matchedKeywords++;
const keywordIdx = t.indexOf(keyword);
totalScore += (keywordIdx === 0 ? 25 : 15 - Math.min(keywordIdx, 10));
}
}
if (matchedKeywords >= keywords.length / 2) {
return Math.min(55, totalScore * (matchedKeywords / keywords.length));
}
}
// If at least half the keywords match, return proportional score
if (matchedKeywords >= keywords.length / 2) {
return Math.min(55, totalScore * (matchedKeywords / keywords.length));
const chars = q.split('');
let lastIdx = -1;
let matched = 0;
for (const char of chars) {
const charIdx = t.indexOf(char, lastIdx + 1);
if (charIdx > lastIdx) {
matched++;
lastIdx = charIdx;
}
}
}
// Partial character matching for typos/fuzzy search
const chars = q.split('');
let lastIdx = -1;
let matched = 0;
for (const char of chars) {
const charIdx = t.indexOf(char, lastIdx + 1);
if (charIdx > lastIdx) {
matched++;
lastIdx = charIdx;
if (matched >= chars.length * 0.8) {
return Math.min(25, Math.floor((matched / chars.length) * 25));
}
}
if (matched >= chars.length * 0.8) {
return Math.min(25, Math.floor((matched / chars.length) * 25));
}
return 0;
return 0;
};
const strip = (s: string) => s.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
const t0 = (text || '').toLowerCase();
const q0 = (query || '').toLowerCase();
const t1 = strip(t0);
const q1 = strip(q0);
return Math.max(base(t0, q0), base(t1, q1));
};
// Resolve backend URLs for assets
@@ -105,6 +95,18 @@ const resolveBackendUrl = (path: string): string => {
}
};
// Small helper to fetch JSON from backend or cache with safe failure handling
const fetchJSON = async <T>(path: string): Promise<T | null> => {
try {
const url = resolveBackendUrl(path);
const res = await fetch(url, { cache: 'no-cache' });
if (!res.ok) return null;
return (await res.json()) as T;
} catch {
return null;
}
};
const normalizeName = (value: string) =>
String(value || '')
.normalize('NFD')
@@ -149,49 +151,77 @@ export async function searchAll(query: string): Promise<SearchResults> {
galleryRes,
] = await Promise.allSettled([
relatedClubsPromise,
// Clubs from FACR
facrApi.searchClubs(query).catch(() => ({ results: [] })),
// Matches (upcoming)
(async () => {
const url = resolveBackendUrl(`/api/v1/matches?q=${encodeURIComponent(query)}`);
const res = await fetch(url, { cache: 'no-cache' });
if (!res.ok) return [];
return await res.json();
const apiUrl = resolveBackendUrl(`/api/v1/matches?q=${encodeURIComponent(query)}`);
try {
const r = await fetch(apiUrl, { cache: 'no-cache' });
let arr: any = [];
if (r.ok) arr = await r.json();
if (!Array.isArray(arr) || arr.length === 0) {
const fallback = await fetchJSON<any[]>(`/cache/prefetch/matches.json`);
return Array.isArray(fallback) ? fallback : [];
}
return arr;
} catch {
const fallback = await fetchJSON<any[]>(`/cache/prefetch/matches.json`);
return Array.isArray(fallback) ? fallback : [];
}
})(),
// Matches (past)
(async () => {
const url = resolveBackendUrl(`/api/v1/matches/history?q=${encodeURIComponent(query)}`);
const res = await fetch(url, { cache: 'no-cache' });
if (!res.ok) return [];
return await res.json();
const apiUrl = resolveBackendUrl(`/api/v1/matches/history?q=${encodeURIComponent(query)}`);
try {
const r = await fetch(apiUrl, { cache: 'no-cache' });
let arr: any = [];
if (r.ok) arr = await r.json();
if (Array.isArray(arr) && arr.length > 0) return arr;
} catch {}
// Build from FACR club cache as fallback
const facr = await fetchJSON<any>(`/cache/prefetch/facr_club_info.json`);
if (!facr || !Array.isArray(facr?.competitions)) return [];
const now = new Date();
const out: any[] = [];
for (const c of facr.competitions) {
const compName = String(c?.name || c?.code || '').trim();
const matches = Array.isArray(c?.matches) ? c.matches : [];
for (const m of matches) {
const dt = String(m?.date_time || '').trim();
if (!dt) continue;
const [datePart, timePart = '00:00'] = dt.split(' ');
const [day, month, year] = String(datePart || '').split('.');
if (!day || !month || !year) continue;
const isoDate = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
const timeStr = String(timePart).slice(0, 5);
const ts = new Date(`${isoDate}T${timeStr || '00:00'}:00`);
if (!(ts instanceof Date) || isNaN(ts.getTime())) continue;
// Past only
if (ts.getTime() >= now.getTime()) continue;
out.push({
id: m?.match_id || m?.matchId,
home: m?.home,
away: m?.away,
competition: compName,
date: isoDate,
time: timeStr || undefined,
venue: m?.venue,
home_logo_url: m?.home_logo_url,
away_logo_url: m?.away_logo_url,
});
}
}
return out;
})(),
// Articles
getArticles({ q: query, published: true, page: 1, page_size: 50 }),
// Players
getPlayers(),
// Events
getUpcomingEvents(),
// Sponsors
getSponsors(),
// Teams
(async () => {
const res = await api.get('/teams');
return Array.isArray(res.data) ? res.data : res.data?.data || [];
})(),
// Contacts
(async () => {
try {
const res = await api.get('/contacts');
// Backend returns { categories: {...}, uncategorized: [...] }
// Flatten into a single array
const grouped = res.data?.categories || {};
const uncategorized = res.data?.uncategorized || [];
const allContacts = [...uncategorized];
@@ -205,8 +235,6 @@ export async function searchAll(query: string): Promise<SearchResults> {
return [];
}
})(),
// Gallery albums
(async () => {
try {
const res = await api.get('/gallery/albums');
@@ -232,14 +260,9 @@ export async function searchAll(query: string): Promise<SearchResults> {
const clubsData = clubsRes.status === 'fulfilled' ? (clubsRes.value as any)?.results || [] : [];
const clubs: SearchResult[] = clubsData
.filter((c: any) => {
// Filter out clubs with no name or empty name
const name = String(c.name || '').trim();
if (!name) return false;
if (!hasRelatedFilter) return true;
const idKey = String(c.club_id || c.id || '').toLowerCase();
const nameKey = normalizeName(c.name);
return (idKey && relatedById.has(idKey)) || (nameKey && relatedByName.has(nameKey));
return true;
})
.map((c: any) => {
const idKey = String(c.club_id || c.id || '').toLowerCase();
@@ -280,7 +303,26 @@ export async function searchAll(query: string): Promise<SearchResults> {
// Process matches (upcoming)
const matchesData = matchesRes.status === 'fulfilled' ? matchesRes.value : [];
const matches: SearchResult[] = (Array.isArray(matchesData) ? matchesData : [])
.filter((m: any) => matchPassesFilter(m.home, m.away, m.home_id || m.homeId, m.away_id || m.awayId))
.filter((m: any) => {
const comp = m.competition || m.competition_name || m.league || '';
const queryMatches = (
scoreMatch(m.home || '', q) > 0 ||
scoreMatch(m.away || '', q) > 0 ||
scoreMatch(m.venue || '', q) > 0 ||
scoreMatch(comp, q) > 0
);
// If related filter is active, allow either related match OR explicit query match
return matchPassesFilter(m.home, m.away, m.home_id || m.homeId, m.away_id || m.awayId) || queryMatches;
})
.filter((m: any) => {
const comp = m.competition || m.competition_name || m.league || '';
return (
scoreMatch(m.home || '', q) > 0 ||
scoreMatch(m.away || '', q) > 0 ||
scoreMatch(m.venue || '', q) > 0 ||
scoreMatch(comp, q) > 0
);
})
.map((m: any, idx: number) => ({
type: 'match' as const,
id: m.id || idx,
@@ -306,7 +348,25 @@ export async function searchAll(query: string): Promise<SearchResults> {
// Process matches (past)
const matchesPastData = matchesPastRes.status === 'fulfilled' ? matchesPastRes.value : [];
const matchesPast: SearchResult[] = (Array.isArray(matchesPastData) ? matchesPastData : [])
.filter((m: any) => matchPassesFilter(m.home, m.away, m.home_id || m.homeId, m.away_id || m.awayId))
.filter((m: any) => {
const comp = m.competition || m.competition_name || m.league || '';
const queryMatches = (
scoreMatch(m.home || '', q) > 0 ||
scoreMatch(m.away || '', q) > 0 ||
scoreMatch(m.venue || '', q) > 0 ||
scoreMatch(comp, q) > 0
);
return matchPassesFilter(m.home, m.away, m.home_id || m.homeId, m.away_id || m.awayId) || queryMatches;
})
.filter((m: any) => {
const comp = m.competition || m.competition_name || m.league || '';
return (
scoreMatch(m.home || '', q) > 0 ||
scoreMatch(m.away || '', q) > 0 ||
scoreMatch(m.venue || '', q) > 0 ||
scoreMatch(comp, q) > 0
);
})
.map((m: any, idx: number) => ({
type: 'match_past' as const,
id: `past-${m.id || idx}`,
+40
View File
@@ -0,0 +1,40 @@
import api from './api';
export interface CreateShortLinkPayload {
target_url: string;
title?: string;
source_type?: 'article' | 'event' | 'other' | string;
source_id?: number;
expires_at?: string | null;
code?: string;
active?: boolean;
}
export interface ShortLinkResponse {
id: number;
code: string;
short_url: string;
link: any;
}
export async function createShortLink(payload: CreateShortLinkPayload): Promise<ShortLinkResponse> {
try {
// Prefer editor-accessible endpoint
const res = await api.post<ShortLinkResponse>('/shortlinks', payload);
return res.data;
} catch (e: any) {
// Fallback to admin endpoint (for admin-only contexts)
const res2 = await api.post<ShortLinkResponse>('/admin/shortlinks', payload);
return res2.data;
}
}
export async function listShortLinks(): Promise<{ items: any[] }> {
const res = await api.get<{ items: any[] }>('/admin/shortlinks');
return res.data;
}
export async function getShortLinkStats(id: number | string): Promise<any> {
const res = await api.get(`/admin/shortlinks/${id}/stats`);
return res.data;
}
+65 -2
View File
@@ -1,13 +1,17 @@
const { createProxyMiddleware } = require('http-proxy-middleware');
function resolveBackendOrigin() {
const raw = process.env.REACT_APP_API_BASE_URL || process.env.REACT_APP_API_URL || 'http://127.0.0.1:8080/api/v1';
const raw = process.env.REACT_APP_API_BASE_URL || process.env.REACT_APP_API_URL || '';
const fallback = 'http://127.0.0.1:8080';
try {
if (!raw || raw.startsWith('/')) {
return fallback;
}
const u = new URL(raw);
u.pathname = '/';
return u.toString();
} catch (e) {
return 'http://127.0.0.1:8080';
return fallback;
}
}
@@ -50,6 +54,19 @@ module.exports = function(app) {
})
);
// Proxy /dist requests to backend (assets served by Go under /dist)
app.use(
'/dist',
createProxyMiddleware({
target: resolveBackendOrigin(),
changeOrigin: true,
logLevel: 'debug',
onError: (err, req, res) => {
console.error('Proxy error for /dist:', err);
},
})
);
// Proxy /cache requests to backend (for FACR cache files, etc.)
app.use(
'/cache',
@@ -62,4 +79,50 @@ module.exports = function(app) {
},
})
);
// Additional common static/image paths that may be referenced directly
app.use(
[
'/images',
'/img',
'/media',
'/files',
'/logos',
'/avatars',
'/downloads',
'/public',
'/favicon.ico',
],
createProxyMiddleware({
target: resolveBackendOrigin(),
changeOrigin: true,
logLevel: 'debug',
onError: (err, req, res) => {
console.error('Proxy error for image/static path:', err);
},
})
);
// Fallback: proxy any direct image file requests to the backend
// This will not affect Webpack Dev Server assets since they are handled earlier in the middleware chain
app.use(
createProxyMiddleware(
(pathname, req) => {
try {
if (pathname.startsWith('/sockjs-node') || pathname.startsWith('/ws')) return false;
return /\.(?:png|jpe?g|gif|svg|webp|ico|avif)$/i.test(pathname);
} catch {
return false;
}
},
{
target: resolveBackendOrigin(),
changeOrigin: true,
logLevel: 'debug',
onError: (err, req, res) => {
console.error('Proxy error for image extension fallback:', err);
},
}
)
);
};
+85
View File
@@ -0,0 +1,85 @@
/*
* Club Style Pack
* Generic CSS utilities and components leveraging ClubThemeContext CSS variables
* - Full-bleed utility
* - Hero Topbar (above hero) with variants
*/
/* Utility: full-bleed edge-to-edge content */
.full-bleed {
margin-left: calc(50% - 50vw);
margin-right: calc(50% - 50vw);
width: auto;
}
/* Hero Topbar (above hero) */
.club-hero-topbar {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
border-bottom: 1px solid rgba(0,0,0,0.06);
}
/* Brand variant: uses club colors and a subtle gradient */
.club-hero-topbar--brand {
color: var(--club-text-on-primary, #fff);
background: linear-gradient(90deg, var(--club-primary, #0b5cff), var(--club-accent, #141414));
border-bottom-color: rgba(255,255,255,0.12);
}
/* Minimal variant: transparent with subtle text */
.club-hero-topbar--minimal {
color: inherit;
background: transparent;
border-bottom-color: rgba(0,0,0,0.06);
}
/* Badge variant: pill-like container around the content */
.club-hero-topbar--badge {
color: var(--club-text-on-primary, #fff);
background: var(--club-primary, #0b5cff);
border-bottom-color: transparent;
}
.club-hero-topbar__logo {
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: 9999px;
background: rgba(255,255,255,0.08);
overflow: hidden;
}
.club-hero-topbar--minimal .club-hero-topbar__logo {
background: transparent;
}
.club-hero-topbar__title {
font-weight: 700;
line-height: 1.2;
}
.club-hero-topbar--brand .club-hero-topbar__title {
text-shadow: 0 1px 10px rgba(0,0,0,0.25);
}
.club-hero-topbar__tagline {
opacity: 0.8;
font-size: 0.85rem;
}
.club-hero-topbar__spacer {
flex: 1;
}
.club-hero-topbar__actions {
display: inline-flex;
gap: 8px;
}
/* Responsive tweaks */
@media (min-width: 768px) {
.club-hero-topbar { padding: 12px 16px; }
.club-hero-topbar__title { font-size: 1.125rem; }
}
+17 -1
View File
@@ -20,6 +20,9 @@
.ql-toolbar.ql-snow {
min-height: 42px !important;
height: auto !important;
position: relative !important;
z-index: 5000 !important;
overflow: visible !important;
}
.ql-container.ql-snow {
@@ -148,6 +151,8 @@
background: white;
max-height: 300px;
overflow-y: auto;
position: absolute !important;
z-index: 6000 !important;
}
.ql-toolbar.ql-snow .ql-picker-options .ql-picker-item {
@@ -452,10 +457,21 @@
border-radius: 4px;
white-space: nowrap;
margin-bottom: 4px;
z-index: 1000;
z-index: 6000;
pointer-events: none;
}
/* Ensure Quill built-in tooltip (e.g., link editor) is above modals and containers */
.ql-tooltip,
.ql-snow .ql-tooltip {
z-index: 6000 !important;
}
/* Ensure custom image resize overlay sits above content */
.custom-image-resize-container {
z-index: 6000 !important;
}
/* Loading State for Images */
.ql-editor img[src=""] {
opacity: 0.3;
+261
View File
@@ -0,0 +1,261 @@
/* base vars and helpers */
:root {
--pack-gap-sm: 12px;
--pack-gap-md: 16px;
--pack-gap-lg: 24px;
}
/* Global style-pack modifiers (toggle via body.class) */
body.style-pack-default {
--pack-radius: 12px;
--pack-shadow: 0 8px 20px rgba(0,0,0,0.06), 0 2px 6px rgba(0,0,0,0.04);
}
body.style-pack-modern {
--pack-radius: 16px;
--pack-shadow: 0 14px 32px rgba(0,0,0,0.10), 0 4px 12px rgba(0,0,0,0.06);
}
body.style-pack-minimal {
--pack-radius: 8px;
--pack-shadow: none;
}
body.style-pack-sparta {
--pack-radius: 12px;
--pack-shadow: 0 10px 28px rgba(0,0,0,0.12), 0 4px 10px rgba(0,0,0,0.08);
}
body.style-pack-sparta .section-head h3 { text-transform: uppercase; font-weight: 700; letter-spacing: 0.5px; }
body.style-pack-sparta .see-all { color: var(--club-primary, #0b5cff); font-weight: 700; }
body.style-pack-sparta .btn { border-radius: 10px; letter-spacing: 0.3px; }
body.style-pack-sparta .sponsor-tile:hover { transform: translateY(-8px) scale(1.1); }
/* Apply pack variables to common cards */
.card,
[data-element="news"] .blog-list .card,
[data-element="table"] .table-card,
.player-card,
.match-card,
.sponsor-tile,
.newsletter-cta .card {
border-radius: var(--pack-radius, 12px);
box-shadow: var(--pack-shadow, none);
}
/* Header & Footer tweaks */
[data-element="header"][data-variant="fullwidth"] { box-shadow: none; }
[data-element="footer"] { border-top: 1px solid var(--card-border, rgba(0,0,0,0.08)); }
/* Header variants */
[data-element="header"][data-variant="transparent"] {
background: transparent !important;
}
[data-element="header"][data-variant="minimal"] {
box-shadow: none !important;
border-bottom: 1px solid var(--card-border, rgba(0,0,0,0.08));
}
[data-element="header"][data-variant="modern"] {
box-shadow: 0 10px 24px rgba(0,0,0,0.08);
}
[data-element="header"][data-variant="sparta_navbar"] {
box-shadow: none;
}
/* Section heads under style packs */
body.style-pack-minimal .section-head h3::after { display: none; }
body.style-pack-minimal .section-head { margin-top: 16px; }
body.style-pack-modern .section-head h3::after { width: 64px; }
/* Banner placements */
[data-element="banner"][data-variant="top"],
[data-element="banner"][data-variant="bottom"] { text-align: center; }
[data-element="sidebar"] img { border-radius: 8px; }
/* two-column news + table layout */
.standings {
display: grid;
grid-template-columns: 1fr;
gap: var(--pack-gap-lg);
}
@media (min-width: 992px) {
.standings:not([data-variant="standard"]) {
grid-template-columns: 2fr 1fr;
}
}
/* News list */
[data-element="news"] .blog-list {
display: grid;
grid-template-columns: 1fr;
gap: var(--pack-gap-md);
}
@media (min-width: 768px) {
[data-element="news"] .blog-list {
grid-template-columns: 1fr 1fr;
}
}
[data-element="news"] .blog-list .card {
display: grid;
grid-template-columns: 140px 1fr;
gap: var(--pack-gap-md);
align-items: center;
border: 1px solid var(--card-border, rgba(0,0,0,0.08));
border-radius: 12px;
background: var(--card-bg, #fff);
transition: box-shadow .2s ease, border-color .2s ease, transform .2s ease;
}
[data-element="news"] .blog-list .card:hover {
transform: translateY(-2px);
border-color: var(--club-primary, #0b5cff);
box-shadow: 0 6px 20px rgba(0,0,0,.08);
}
[data-element="news"] .blog-list .card .thumb {
width: 100%;
aspect-ratio: 16 / 9;
border-top-left-radius: 12px;
border-bottom-left-radius: 12px;
background-size: cover;
background-position: center;
}
/* Table card */
[data-element="table"] .table-card {
border: 1px solid var(--card-border, rgba(0,0,0,0.08));
border-radius: 12px;
background: var(--card-bg, #fff);
padding: var(--pack-gap-md);
}
[data-element="table"] .standings-table-compact tbody tr:hover {
background: color-mix(in oklab, var(--club-primary, #0b5cff) 10%, transparent);
}
/* Next match */
[data-element="matches"] .next-match .team {
display: flex;
align-items: center;
gap: 8px;
}
[data-element="matches"] .next-match .logo {
width: 40px;
height: 40px;
object-fit: contain;
}
/* Matches slider */
[data-element="matches-slider"] .section-head {
display: flex;
align-items: center;
justify-content: space-between;
}
[data-element="matches-slider"] .see-all {
display: inline-flex;
align-items: center;
gap: 6px;
font-weight: 600;
color: var(--club-primary, #0b5cff);
text-decoration: none;
}
/* Activities */
[data-element="activities"] .events-grid {
display: grid;
grid-template-columns: 1fr;
gap: var(--pack-gap-md);
}
@media (min-width: 768px) {
[data-element="activities"] .events-grid {
grid-template-columns: 1fr 1fr;
}
}
[data-element="activities"] .blog-list .card { border-radius: var(--pack-radius, 12px); box-shadow: var(--pack-shadow, none); }
[data-element="activities"] .blog-list .card .thumb { border-radius: 10px; }
/* Team */
[data-element="team"] .player-card { border-radius: var(--pack-radius, 12px); box-shadow: var(--pack-shadow, none); }
[data-element="team"] .player-card .photo { border-radius: 10px; }
/* Gallery */
[data-element="gallery"] .section-head h3 { letter-spacing: 0.2px; }
/* Videos */
[data-element="videos"] .video-card { border-radius: var(--pack-radius, 12px); box-shadow: var(--pack-shadow, none); }
[data-element="videos"] iframe { border-radius: var(--pack-radius, 12px); }
/* Merch */
[data-element="merch"] .card, [data-element="merch"] .grid .item { border-radius: var(--pack-radius, 12px); box-shadow: var(--pack-shadow, none); }
/* Poll */
[data-element="poll"] .card { border-radius: var(--pack-radius, 12px); box-shadow: var(--pack-shadow, none); }
/* Minimal pack adjustments */
body.style-pack-minimal [data-element="news"] .blog-list .card,
body.style-pack-minimal [data-element="activities"] .blog-list .card,
body.style-pack-minimal [data-element="table"] .table-card,
body.style-pack-minimal [data-element="team"] .player-card,
body.style-pack-minimal [data-element="videos"] .video-card,
body.style-pack-minimal [data-element="merch"] .card,
body.style-pack-minimal [data-element="poll"] .card {
box-shadow: none; border: 1px solid var(--card-border, rgba(0,0,0,0.08));
}
/* Modern pack adjustments */
body.style-pack-modern [data-element="news"] .blog-list .card,
body.style-pack-modern [data-element="activities"] .blog-list .card,
body.style-pack-modern [data-element="team"] .player-card {
transform: translateZ(0);
}
/* Players scroller */
[data-element="team"].players-scroller .section-head {
display: flex;
align-items: center;
justify-content: space-between;
}
/* General sections */
.section-head h3 { margin: 0; }
[data-element="gallery"],
[data-element="videos"],
[data-element="merch"],
[data-element="poll"],
[data-element="newsletter"] {
scroll-margin-top: 72px;
}
/* Banners */
[data-element="banner"] { text-align: center; }
/* Sponsors */
[data-element="sponsors"] .section-head { margin-top: 0; }
/* Hero variants */
[data-element="hero"][data-variant="grid"] .hero-card {
border-radius: 16px;
overflow: hidden;
}
[data-element="hero"][data-variant="grid"] .hero-card .overlay {
background: linear-gradient(180deg, rgba(0,0,0,0) 0%, rgba(0,0,0,.65) 100%);
padding: 16px;
}
[data-element="hero"][data-variant="scroller"] { scroll-margin-top: 72px; }
[data-element="hero"][data-variant="swiper"] { scroll-margin-top: 72px; }
[data-element="hero"][data-variant="swiper_full"] {
margin-left: calc(50% - 50vw);
margin-right: calc(50% - 50vw);
}
/* Gallery */
[data-element="gallery"] .section-head { display: flex; justify-content: space-between; align-items: center; }
[data-element="gallery"] .see-all { color: var(--club-primary, #0b5cff); text-decoration: none; font-weight: 600; }
/* Videos */
[data-element="videos"] .section-head { display: flex; justify-content: space-between; align-items: center; }
[data-element="videos"] iframe { width: 100%; border-radius: 12px; border: 1px solid var(--card-border, rgba(0,0,0,0.08)); }
/* Merch */
[data-element="merch"] .section-head { display: flex; justify-content: space-between; align-items: center; }
[data-element="merch"] .grid { gap: var(--pack-gap-md); }
/* Poll */
[data-element="poll"] .section-head { display: flex; justify-content: space-between; align-items: center; }
[data-element="poll"] .card { border-radius: 12px; border: 1px solid var(--card-border, rgba(0,0,0,0.08)); }
/* Newsletter */
[data-element="newsletter"] .card { border-radius: 12px; border: 1px solid var(--card-border, rgba(0,0,0,0.08)); background: var(--card-bg, #fff); }
+8 -4
View File
@@ -10,14 +10,18 @@ export function assetUrl(pathOrUrl?: string | null): string | undefined {
}
// Known backend-served asset paths (/uploads, optionally /dist)
if (pathOrUrl.startsWith('/uploads') || pathOrUrl.startsWith('/dist')) {
// 1) Explicit override wins
const explicit = process.env.REACT_APP_ASSET_BASE_URL || process.env.REACT_APP_API_BASE_URL || process.env.REACT_APP_API_URL || '';
if (explicit) {
const explicit = process.env.REACT_APP_ASSET_BASE_URL || process.env.REACT_APP_API_BASE_URL || '';
if (explicit && !explicit.startsWith('/')) {
const baseUrl = new URL(explicit, typeof window !== 'undefined' ? window.location.origin : undefined);
baseUrl.pathname = '/';
return new URL(pathOrUrl, baseUrl).toString();
}
// 2) Keep relative so frontend dev proxy or edge proxy forwards to backend
if (process.env.NODE_ENV !== 'production') {
try {
const devOrigin = 'http://127.0.0.1:8080';
return new URL(pathOrUrl, devOrigin).toString();
} catch {}
}
return pathOrUrl;
}
// Otherwise return as-is (relative or other paths)