mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 02:32:57 +00:00
dev day #77
This commit is contained in:
@@ -287,7 +287,7 @@ const MobileMenu = ({ isOpen, onClose, isAdmin, isAuthenticated, menuBg, divider
|
||||
</Drawer>
|
||||
);
|
||||
|
||||
const Navbar: React.FC<{ fullWidth?: boolean }> = ({ fullWidth = false }) => {
|
||||
const Navbar: React.FC<{ fullWidth?: boolean; variant?: string }> = ({ fullWidth = false, variant = 'unified' }) => {
|
||||
const { colorMode, toggleColorMode } = useColorMode();
|
||||
const { isAuthenticated, logout, user } = useAuth();
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
@@ -726,6 +726,17 @@ const Navbar: React.FC<{ fullWidth?: boolean }> = ({ fullWidth = false }) => {
|
||||
return out;
|
||||
}, [navSplit]);
|
||||
|
||||
// Variant-driven styles for main nav bar
|
||||
const headerVariant = variant || 'unified';
|
||||
const navBgDefault = useColorModeValue('rgba(255,255,255,0.9)', 'rgba(15,17,21,0.85)');
|
||||
const navBgMinimal = useColorModeValue('white', '#0f1115');
|
||||
const isTransparent = headerVariant === 'transparent';
|
||||
const isMinimal = headerVariant === 'minimal';
|
||||
const navBg = isTransparent ? 'transparent' : (isMinimal ? navBgMinimal : navBgDefault);
|
||||
const navBackdrop = (isTransparent || isMinimal) ? 'none' : 'saturate(180%) blur(10px)';
|
||||
const navBorderBottomWidth = isTransparent ? '0px' : '1px';
|
||||
const navBoxShadow = (isTransparent || isMinimal) ? 'none' : (scrolled ? 'sm' : 'none');
|
||||
|
||||
return (
|
||||
<Box position="sticky" top={0} zIndex={1000}>
|
||||
{/* Top bar with socials and quick external links */}
|
||||
@@ -758,11 +769,11 @@ const Navbar: React.FC<{ fullWidth?: boolean }> = ({ fullWidth = false }) => {
|
||||
|
||||
{/* Main Nav Bar */}
|
||||
<Box
|
||||
bg={useColorModeValue('rgba(255,255,255,0.9)', 'rgba(15,17,21,0.85)')}
|
||||
backdropFilter="saturate(180%) blur(10px)"
|
||||
borderBottomWidth="1px"
|
||||
bg={navBg}
|
||||
backdropFilter={navBackdrop}
|
||||
borderBottomWidth={navBorderBottomWidth}
|
||||
borderColor="border.subtle"
|
||||
boxShadow={scrolled ? 'sm' : 'none'}
|
||||
boxShadow={navBoxShadow}
|
||||
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={filteredDynamicNavItems} navLoading={navLoading} />
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import React from 'react';
|
||||
import { IconButton, Button, useDisclosure, Modal, ModalOverlay, ModalContent, ModalHeader, ModalBody, ModalFooter, Textarea, useToast, Tooltip, Box } from '@chakra-ui/react';
|
||||
import { Share2 } from 'lucide-react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { createShortLink } from '../../services/shortlinks';
|
||||
import { Article, getArticleMatchLink } from '../../services/articles';
|
||||
import { API_URL } from '../../services/api';
|
||||
import { composeInstagramPostFromArticle, composeInstagramPostFromActivity, MatchSnapshot } from '../../services/instagram';
|
||||
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
||||
|
||||
interface Props {
|
||||
article?: Article;
|
||||
activity?: any;
|
||||
match?: MatchSnapshot | null;
|
||||
targetUrl?: string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
placement?: 'fixed' | 'inline';
|
||||
mr?: number | string;
|
||||
mb?: number | string;
|
||||
zIndex?: number;
|
||||
variant?: 'button' | 'icon';
|
||||
onGenerated?: (text: string, shortUrl: string) => void;
|
||||
}
|
||||
|
||||
const InstagramGeneratorButton: React.FC<Props> = ({
|
||||
article,
|
||||
activity,
|
||||
match,
|
||||
targetUrl,
|
||||
size = 'md',
|
||||
placement = 'fixed',
|
||||
mr = 6,
|
||||
mb = 6,
|
||||
zIndex = 40,
|
||||
variant = 'icon',
|
||||
onGenerated,
|
||||
}) => {
|
||||
const { user } = useAuth();
|
||||
const role = String(user?.role || '').toLowerCase();
|
||||
const isAdmin = role === 'admin';
|
||||
const { data: publicSettings } = usePublicSettings();
|
||||
const toast = useToast();
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const [text, setText] = React.useState('');
|
||||
const [shortUrl, setShortUrl] = React.useState('');
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
|
||||
if (!isAdmin) return null;
|
||||
|
||||
const computeTarget = () => {
|
||||
if (targetUrl) return targetUrl;
|
||||
if (typeof window !== 'undefined') return window.location.href;
|
||||
return '';
|
||||
};
|
||||
|
||||
const withUtm = (urlStr: string) => {
|
||||
try {
|
||||
const u = new URL(urlStr, typeof window !== 'undefined' ? window.location.origin : 'http://localhost');
|
||||
if (!u.searchParams.get('utm_source')) u.searchParams.set('utm_source', 'instagram');
|
||||
if (!u.searchParams.get('utm_medium')) u.searchParams.set('utm_medium', 'social');
|
||||
const campaignBase = article ? `article-${article.id}` : (activity ? `activity-${activity.id}` : 'share');
|
||||
if (!u.searchParams.get('utm_campaign')) u.searchParams.set('utm_campaign', campaignBase);
|
||||
return u.toString();
|
||||
} catch {
|
||||
return urlStr;
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerate = async (e?: React.MouseEvent) => {
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
try {
|
||||
setLoading(true);
|
||||
const fullUrl = withUtm(computeTarget());
|
||||
if (!fullUrl) throw new Error('Nelze zjistit URL článku/aktivity');
|
||||
|
||||
const payload = {
|
||||
target_url: fullUrl,
|
||||
title: article?.title || activity?.title || 'Link',
|
||||
source_type: article ? 'article' : (activity ? 'event' : 'other'),
|
||||
source_id: article?.id || activity?.id,
|
||||
} as any;
|
||||
const res = await createShortLink(payload);
|
||||
const sUrl = res?.short_url || '';
|
||||
setShortUrl(sUrl || fullUrl);
|
||||
|
||||
const clubName = publicSettings?.club_name || undefined;
|
||||
let composed = '';
|
||||
if (article) {
|
||||
let resolvedMatch = match || null;
|
||||
if (!resolvedMatch && article?.id) {
|
||||
try {
|
||||
const link = await getArticleMatchLink(article.id);
|
||||
const extId = (link as any)?.external_match_id;
|
||||
if (extId) {
|
||||
try {
|
||||
const origin = new URL(API_URL, window.location.origin).origin;
|
||||
const resp = await fetch(`${origin}/cache/prefetch/facr_club_info.json`, { cache: 'no-cache' });
|
||||
if (resp.ok) {
|
||||
const json = await resp.json();
|
||||
const comps = Array.isArray(json?.competitions) ? json.competitions : [];
|
||||
let snapshot: MatchSnapshot | null = null;
|
||||
for (const c of comps) {
|
||||
const matches = Array.isArray(c.matches) ? c.matches : [];
|
||||
for (const m of matches) {
|
||||
const mid = String(m.match_id || m.id);
|
||||
if (mid === String(extId)) {
|
||||
let score = '';
|
||||
if (m?.score && m.score !== 'vs') score = String(m.score);
|
||||
else if (m?.result_home != null && m?.result_away != null) score = `${m.result_home}:${m.result_away}`;
|
||||
snapshot = {
|
||||
external_match_id: String(extId),
|
||||
competition: String(c.name || ''),
|
||||
date_time: String(m.date_time || m.date || ''),
|
||||
venue: m.venue ? String(m.venue) : undefined,
|
||||
home: String(m.home || m.home_team || ''),
|
||||
away: String(m.away || m.away_team || ''),
|
||||
score,
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (snapshot) break;
|
||||
}
|
||||
if (snapshot) resolvedMatch = snapshot;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
composed = composeInstagramPostFromArticle({ article, trackingUrl: sUrl || fullUrl, clubName, match: resolvedMatch });
|
||||
} else if (activity) {
|
||||
composed = composeInstagramPostFromActivity({ activity, trackingUrl: sUrl || fullUrl, clubName });
|
||||
} else {
|
||||
composed = `${clubName || 'Náš klub'}\n\n🔗 ${sUrl || fullUrl}`;
|
||||
}
|
||||
setText(composed);
|
||||
onGenerated?.(composed, sUrl || fullUrl);
|
||||
onOpen();
|
||||
} catch (err: any) {
|
||||
toast({ status: 'error', title: 'Nelze vygenerovat příspěvek', description: err?.message || 'Zkuste to prosím znovu.' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast({ status: 'success', title: 'Zkopírováno', description: 'Text příspěvku byl zkopírován do schránky.' });
|
||||
} catch {
|
||||
toast({ status: 'warning', title: 'Nelze kopírovat', description: 'Zkopírujte prosím ručně.' });
|
||||
}
|
||||
};
|
||||
|
||||
const ButtonEl = (
|
||||
<Tooltip label="Vygenerovat Instagram příspěvek" placement="left">
|
||||
{variant === 'icon' ? (
|
||||
<IconButton aria-label="IG post" icon={<Share2 size={18} />} colorScheme="brand" onClick={handleGenerate} isLoading={loading} size={size} />
|
||||
) : (
|
||||
<Button leftIcon={<Share2 size={18} />} colorScheme="brand" onClick={handleGenerate} isLoading={loading} size={size}>
|
||||
Instagram post
|
||||
</Button>
|
||||
)}
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{placement === 'fixed' ? (
|
||||
<Box position="fixed" right={mr} bottom={mb} zIndex={zIndex}>
|
||||
{ButtonEl}
|
||||
</Box>
|
||||
) : (
|
||||
<Box position="absolute" top={2} right={2} zIndex={zIndex}>
|
||||
{ButtonEl}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="xl">
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>Instagram post</ModalHeader>
|
||||
<ModalBody>
|
||||
<Textarea value={text} onChange={(e) => setText(e.target.value)} rows={12} fontFamily="mono" />
|
||||
</ModalBody>
|
||||
<ModalFooter gap={3}>
|
||||
<Button variant="outline" onClick={handleCopy}>Kopírovat</Button>
|
||||
<Button onClick={onClose}>Zavřít</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default InstagramGeneratorButton;
|
||||
@@ -415,6 +415,71 @@ const PollLinker: React.FC<PollLinkerProps> = ({ articleId, eventId, onPollsChan
|
||||
{/* Tab 2: Create new poll */}
|
||||
<TabPanel px={0} py={3}>
|
||||
<VStack spacing={3} align="stretch">
|
||||
<HStack spacing={2} flexWrap="wrap">
|
||||
<Button size="xs" onClick={() => setNewPollData(prev => ({
|
||||
...prev,
|
||||
title: 'Hodnocení zápasu',
|
||||
description: 'Ohodnoťte zápas (1 = nejhorší, 5 = nejlepší)',
|
||||
type: 'rating',
|
||||
style: 'rating-stars',
|
||||
allow_multiple: false,
|
||||
max_choices: 1,
|
||||
options: Array.from({ length: 5 }).map((_, i) => ({ text: String(i+1), display_order: i+1 }))
|
||||
}))}>⭐ 5</Button>
|
||||
<Button size="xs" onClick={() => setNewPollData(prev => ({
|
||||
...prev,
|
||||
title: 'Hodnocení (1–10)',
|
||||
description: 'Ohodnoťte (1 = nejhorší, 10 = nejlepší)',
|
||||
type: 'rating',
|
||||
style: 'rating-scale',
|
||||
allow_multiple: false,
|
||||
max_choices: 1,
|
||||
options: Array.from({ length: 10 }).map((_, i) => ({ text: String(i+1), display_order: i+1 }))
|
||||
}))}>1–10</Button>
|
||||
<Button size="xs" onClick={() => setNewPollData(prev => ({
|
||||
...prev,
|
||||
title: 'Docházka',
|
||||
description: 'Dej vědět, zda dorazíš.',
|
||||
type: 'single',
|
||||
style: 'choices-chips',
|
||||
allow_multiple: false,
|
||||
max_choices: 1,
|
||||
options: [
|
||||
{ text: 'Ano', display_order: 0 },
|
||||
{ text: 'Ne', display_order: 1 },
|
||||
{ text: 'Možná', display_order: 2 },
|
||||
]
|
||||
}))}>Docházka</Button>
|
||||
<Button size="xs" onClick={() => setNewPollData(prev => ({
|
||||
...prev,
|
||||
title: 'Docházka (více možností)',
|
||||
description: 'Vyberte jednu nebo dvě možnosti.',
|
||||
type: 'multiple',
|
||||
style: 'choices-cards',
|
||||
allow_multiple: true,
|
||||
max_choices: 2,
|
||||
options: [
|
||||
{ text: 'Ano', display_order: 0 },
|
||||
{ text: 'Pozdě dorazím', display_order: 1 },
|
||||
{ text: 'Ne', display_order: 2 },
|
||||
]
|
||||
}))}>Docházka (multi)</Button>
|
||||
<Button size="xs" onClick={() => setNewPollData(prev => ({
|
||||
...prev,
|
||||
title: 'Výběr možností',
|
||||
description: 'Vyber až tři možnosti.',
|
||||
type: 'multiple',
|
||||
style: 'choices-list',
|
||||
allow_multiple: true,
|
||||
max_choices: 3,
|
||||
options: [
|
||||
{ text: 'A', display_order: 0 },
|
||||
{ text: 'B', display_order: 1 },
|
||||
{ text: 'C', display_order: 2 },
|
||||
{ text: 'D', display_order: 3 },
|
||||
]
|
||||
}))}>Multi (3)</Button>
|
||||
</HStack>
|
||||
<FormControl isRequired>
|
||||
<FormLabel fontSize="sm">Název ankety</FormLabel>
|
||||
<Input
|
||||
@@ -503,42 +568,6 @@ const PollLinker: React.FC<PollLinkerProps> = ({ articleId, eventId, onPollsChan
|
||||
>
|
||||
Přidat možnost
|
||||
</Button>
|
||||
<HStack>
|
||||
<Button size="xs" onClick={() => setNewPollData(prev => ({
|
||||
...prev,
|
||||
title: 'Hodnocení zápasu',
|
||||
description: 'Ohodnoťte zápas (1 = nejhorší, 5 = nejlepší)',
|
||||
type: 'rating',
|
||||
style: 'rating-stars',
|
||||
allow_multiple: false,
|
||||
max_choices: 1,
|
||||
options: Array.from({ length: 5 }).map((_, i) => ({ text: String(i+1), display_order: i+1 }))
|
||||
}))}>⭐ 5</Button>
|
||||
<Button size="xs" onClick={() => setNewPollData(prev => ({
|
||||
...prev,
|
||||
title: 'Hodnocení (1–10)',
|
||||
description: 'Ohodnoťte (1 = nejhorší, 10 = nejlepší)',
|
||||
type: 'rating',
|
||||
style: 'rating-scale',
|
||||
allow_multiple: false,
|
||||
max_choices: 1,
|
||||
options: Array.from({ length: 10 }).map((_, i) => ({ text: String(i+1), display_order: i+1 }))
|
||||
}))}>1–10</Button>
|
||||
<Button size="xs" onClick={() => setNewPollData(prev => ({
|
||||
...prev,
|
||||
title: 'Docházka',
|
||||
description: 'Dej vědět, zda dorazíš.',
|
||||
type: 'single',
|
||||
style: 'choices-chips',
|
||||
allow_multiple: false,
|
||||
max_choices: 1,
|
||||
options: [
|
||||
{ text: 'Ano', display_order: 0 },
|
||||
{ text: 'Ne', display_order: 1 },
|
||||
{ text: 'Možná', display_order: 2 },
|
||||
]
|
||||
}))}>Docházka</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</FormControl>
|
||||
|
||||
|
||||
@@ -105,11 +105,11 @@ import { DEFAULT_HOMEPAGE_ELEMENTS, HOMEPAGE_IMPLEMENTED_ELEMENTS } from '../../
|
||||
|
||||
const SUPPORTED_HOME_VARIANTS: Record<string, string[]> = {
|
||||
hero: ['grid', 'scroller', 'swiper', 'swiper_full'],
|
||||
news: ['grid_one', 'grid_two', 'grid', 'scroller'],
|
||||
news: ['grid_one', 'grid_two', 'grid', 'list', 'scroller'],
|
||||
matches: ['compact'],
|
||||
sponsors: ['grid', 'slider', 'scroller', 'pyramid'],
|
||||
gallery: ['grid'],
|
||||
videos: ['grid'],
|
||||
videos: ['grid', 'carousel'],
|
||||
merch: ['grid'],
|
||||
table: ['split_news'],
|
||||
banner: ['top'],
|
||||
@@ -426,11 +426,16 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
|
||||
...cfg,
|
||||
variant: normalizeVariant(cfg.element_name, cfg.variant)
|
||||
}));
|
||||
// Load saved custom CSS from settings
|
||||
// Load saved styles and custom CSS from settings
|
||||
const cssByElement: Record<string, string> = {};
|
||||
const stylesByElement: Record<string, Record<string, any>> = {};
|
||||
sanitizedConfigs.forEach(cfg => {
|
||||
const css = (cfg.settings && (cfg.settings as any).customCSS) || '';
|
||||
if (css) cssByElement[cfg.element_name] = String(css);
|
||||
const css = String(((cfg.settings as any)?.customCSS) || ((cfg.settings as any)?.styles?.customCSS) || '');
|
||||
if (css) cssByElement[cfg.element_name] = css;
|
||||
const st = (cfg.settings && (cfg.settings as any).styles) || {};
|
||||
if (st && typeof st === 'object' && Object.keys(st).length > 0) {
|
||||
stylesByElement[cfg.element_name] = st as Record<string, any>;
|
||||
}
|
||||
});
|
||||
setConfigs(sanitizedConfigs);
|
||||
const changes: Record<string, string> = {};
|
||||
@@ -448,16 +453,19 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
|
||||
setLocalChanges(changes);
|
||||
setVisibleElements(visible);
|
||||
setElementOrder(order);
|
||||
// Prime style state with saved custom CSS
|
||||
if (Object.keys(cssByElement).length > 0) {
|
||||
// Prime style state with saved styles + custom CSS
|
||||
if (Object.keys(stylesByElement).length > 0 || Object.keys(cssByElement).length > 0) {
|
||||
setElementStyles(prev => {
|
||||
const next = { ...prev } as Record<string, any>;
|
||||
Object.entries(stylesByElement).forEach(([name, st]) => {
|
||||
next[name] = { ...(next[name] || {}), ...st };
|
||||
});
|
||||
Object.entries(cssByElement).forEach(([name, css]) => {
|
||||
next[name] = { ...(next[name] || {}), customCSS: css };
|
||||
});
|
||||
return next;
|
||||
});
|
||||
// Inject saved CSS for preview (admin only)
|
||||
// Inject saved custom CSS for preview (admin only)
|
||||
Object.entries(cssByElement).forEach(([name, css]) => {
|
||||
try {
|
||||
const styleId = `custom-css-${name}`;
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Link as RouterLink } from 'react-router-dom';
|
||||
import { useClubTheme } from '../../contexts/ClubThemeContext';
|
||||
import { assetUrl } from '../../utils/url';
|
||||
import { Eye, Clock } from 'lucide-react';
|
||||
import InstagramGeneratorButton from '../admin/InstagramGeneratorButton';
|
||||
|
||||
const Card: React.FC<{ a: Article }> = ({ a }) => {
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
@@ -90,6 +91,14 @@ const Card: React.FC<{ a: Article }> = ({ a }) => {
|
||||
)}
|
||||
</HStack>
|
||||
</VStack>
|
||||
<Box position="absolute" top={2} right={2} zIndex={2}>
|
||||
<InstagramGeneratorButton
|
||||
article={a as any}
|
||||
targetUrl={typeof window !== 'undefined' ? new URL(link, window.location.origin).toString() : undefined}
|
||||
placement="inline"
|
||||
size="sm"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { getArticles, Article } from '../../services/articles';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import { assetUrl } from '../../utils/url';
|
||||
import InstagramGeneratorButton from '../admin/InstagramGeneratorButton';
|
||||
|
||||
const BlogCard: React.FC<{ article: Article }> = ({ article }) => {
|
||||
const link = article.slug ? `/news/${article.slug}` : `/articles/${article.id}`;
|
||||
@@ -24,6 +25,7 @@ const BlogCard: React.FC<{ article: Article }> = ({ article }) => {
|
||||
borderColor={border}
|
||||
_hover={{ boxShadow: '2xl', transform: 'translateY(-4px)' }}
|
||||
transition="all 0.3s cubic-bezier(0.4, 0, 0.2, 1)"
|
||||
position="relative"
|
||||
>
|
||||
<Box position="relative" overflow="hidden">
|
||||
<Image
|
||||
@@ -70,6 +72,14 @@ const BlogCard: React.FC<{ article: Article }> = ({ article }) => {
|
||||
)}
|
||||
</HStack>
|
||||
</VStack>
|
||||
<Box position="absolute" top={2} right={2} zIndex={2}>
|
||||
<InstagramGeneratorButton
|
||||
article={article as any}
|
||||
targetUrl={typeof window !== 'undefined' ? new URL(link, window.location.origin).toString() : undefined}
|
||||
placement="inline"
|
||||
size="sm"
|
||||
/>
|
||||
</Box>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Link as RouterLink } from 'react-router-dom';
|
||||
import { assetUrl } from '../../utils/url';
|
||||
import { useClubTheme } from '../../contexts/ClubThemeContext';
|
||||
import { Eye, Clock } from 'lucide-react';
|
||||
import InstagramGeneratorButton from '../admin/InstagramGeneratorButton';
|
||||
|
||||
const FeaturedBlog: React.FC = () => {
|
||||
const { data, isLoading } = useQuery({
|
||||
@@ -51,13 +52,21 @@ const FeaturedBlog: React.FC = () => {
|
||||
<Text fontSize="xs" bg={theme.secondary} color="black" px={2} py={0.5} borderRadius="md" w="fit-content">Novinka</Text>
|
||||
<Heading size="md">{main.title}</Heading>
|
||||
</VStack>
|
||||
<Box position="absolute" top={3} right={3} zIndex={2}>
|
||||
<InstagramGeneratorButton
|
||||
article={main as any}
|
||||
targetUrl={typeof window !== 'undefined' ? new URL(main.slug ? `/news/${main.slug}` : `/articles/${main.id}`, window.location.origin).toString() : undefined}
|
||||
placement="inline"
|
||||
size="sm"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</GridItem>
|
||||
<GridItem>
|
||||
<VStack spacing={4} align="stretch">
|
||||
{[side1, side2].filter(Boolean).map((a) => (
|
||||
<HStack key={(a as Article).id} align="stretch" spacing={3} as={RouterLink} to={(a as Article).slug ? `/news/${(a as Article).slug}` : `/articles/${(a as Article).id}`}>
|
||||
<HStack key={(a as Article).id} align="stretch" spacing={3} as={RouterLink} to={(a as Article).slug ? `/news/${(a as Article).slug}` : `/articles/${(a as Article).id}`} position="relative">
|
||||
<Image src={assetUrl((a as Article).image_url) || '/logo192.png'} alt={(a as Article).title} w="40%" h="120px" objectFit="cover" borderRadius="lg" />
|
||||
<VStack align="stretch" spacing={2} flex={1}>
|
||||
<HStack spacing={2} flexWrap="wrap">
|
||||
@@ -80,6 +89,14 @@ const FeaturedBlog: React.FC = () => {
|
||||
</HStack>
|
||||
<Heading size="sm" noOfLines={3}>{(a as Article).title}</Heading>
|
||||
</VStack>
|
||||
<Box position="absolute" top={2} right={2} zIndex={2}>
|
||||
<InstagramGeneratorButton
|
||||
article={a as any}
|
||||
targetUrl={typeof window !== 'undefined' ? new URL((a as Article).slug ? `/news/${(a as Article).slug}` : `/articles/${(a as Article).id}`, window.location.origin).toString() : undefined}
|
||||
placement="inline"
|
||||
size="sm"
|
||||
/>
|
||||
</Box>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
|
||||
@@ -19,7 +19,7 @@ const TeamScroller: React.FC = () => {
|
||||
<Text fontSize="sm" color={useColorModeValue('gray.600', 'gray.400')}>{p.position}</Text>
|
||||
{p.date_of_birth ? (
|
||||
<Text fontSize="sm" color={useColorModeValue('gray.600', 'gray.400')}>
|
||||
Věk: {calculateAge(p.date_of_birth)} let
|
||||
Věk: {(() => { const a = calculateAge(p.date_of_birth); return a != null ? `${a} ${czYears(a)}` : '' })()}
|
||||
</Text>
|
||||
) : null}
|
||||
</VStack>
|
||||
@@ -43,4 +43,14 @@ function calculateAge(dob: string): number | null {
|
||||
}
|
||||
}
|
||||
|
||||
// Czech pluralization for years
|
||||
function czYears(n: number): string {
|
||||
const mod100 = n % 100;
|
||||
if (mod100 >= 11 && mod100 <= 14) return 'let';
|
||||
const mod10 = n % 10;
|
||||
if (mod10 === 1) return 'rok';
|
||||
if (mod10 >= 2 && mod10 <= 4) return 'roky';
|
||||
return 'let';
|
||||
}
|
||||
|
||||
export default TeamScroller;
|
||||
|
||||
@@ -8,8 +8,8 @@ import { getCachedYouTube, YouTubeVideo } from '../../services/youtube';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
type Props = {
|
||||
// optional manual override
|
||||
videos?: string[];
|
||||
variant?: 'grid' | 'carousel';
|
||||
};
|
||||
|
||||
type RenderItem = {
|
||||
@@ -39,7 +39,7 @@ const toEmbed = (idOrUrl: string): string => {
|
||||
return `https://www.youtube.com/embed/${idOrUrl}`;
|
||||
};
|
||||
|
||||
const VideosSection: React.FC<Props> = ({ videos }) => {
|
||||
const VideosSection: React.FC<Props> = ({ videos, variant }) => {
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const theme = useClubTheme();
|
||||
const { data: settings } = usePublicSettings();
|
||||
@@ -55,7 +55,11 @@ const VideosSection: React.FC<Props> = ({ videos }) => {
|
||||
const enabled = (typeof (settings as any)?.videos_module_enabled === 'boolean')
|
||||
? Boolean((settings as any)?.videos_module_enabled)
|
||||
: (hasManualConfigured || ((settings?.videos_source || 'auto') === 'auto' && hasAutoConfigured));
|
||||
const style = settings?.videos_style || 'slider';
|
||||
const style = (() => {
|
||||
if (variant === 'carousel') return 'slider';
|
||||
if (variant === 'grid') return 'grid';
|
||||
return settings?.videos_style || 'slider';
|
||||
})();
|
||||
const source = settings?.videos_source || 'auto';
|
||||
// Default to 6 items on homepage unless overridden by settings (max 12)
|
||||
const limit = Math.max(1, Math.min(12, settings?.videos_limit ?? 6));
|
||||
|
||||
@@ -50,7 +50,7 @@ export const MainLayout: React.FC<MainLayoutProps> = ({ children, headerInsideCo
|
||||
{headerVariant === 'sparta_navbar' ? (
|
||||
<SpartaNavbar />
|
||||
) : (
|
||||
<Navbar fullWidth={headerVariant === 'fullwidth'} />
|
||||
<Navbar fullWidth={headerVariant === 'fullwidth'} variant={headerVariant} />
|
||||
)}
|
||||
</Box>
|
||||
{children}
|
||||
@@ -70,7 +70,7 @@ export const MainLayout: React.FC<MainLayoutProps> = ({ children, headerInsideCo
|
||||
{headerVariant === 'sparta_navbar' ? (
|
||||
<SpartaNavbar />
|
||||
) : (
|
||||
<Navbar fullWidth={headerVariant === 'fullwidth'} />
|
||||
<Navbar fullWidth={headerVariant === 'fullwidth'} variant={headerVariant} />
|
||||
)}
|
||||
</Box>
|
||||
<Container maxW="container.xl" py={8}>
|
||||
|
||||
@@ -25,7 +25,8 @@ const MatchesSlider: React.FC<{
|
||||
onActiveChange: (idx: number) => void;
|
||||
onMatchClick?: (m: SliderMatch, compName?: string) => void;
|
||||
elementProps?: any;
|
||||
}> = ({ title = 'Zápasy', comps, activeIndex, onActiveChange, onMatchClick, elementProps }) => {
|
||||
variant?: 'carousel' | 'scroller' | 'ticker' | 'compact_split';
|
||||
}> = ({ title = 'Zápasy', comps, activeIndex, onActiveChange, onMatchClick, elementProps, variant }) => {
|
||||
const trackRef = useRef<HTMLDivElement | null>(null);
|
||||
const current = comps[Math.max(0, Math.min(activeIndex, comps.length - 1))];
|
||||
|
||||
@@ -56,6 +57,52 @@ const MatchesSlider: React.FC<{
|
||||
} catch {}
|
||||
}, [activeIndex, JSON.stringify(current?.matches)]);
|
||||
|
||||
// Ticker variant - continuous belt animation
|
||||
if (variant === 'ticker') {
|
||||
const items = (current?.matches || []);
|
||||
const looped = [...items, ...items, ...items];
|
||||
return (
|
||||
<section className="matches-slider matches-ticker" {...(elementProps || {})}>
|
||||
<div className="section-head" style={{ marginTop: 16, marginBottom: 8 }}>
|
||||
<h3>{title}</h3>
|
||||
<a href="/kalendar" className="see-all">Všechny zápasy</a>
|
||||
</div>
|
||||
<div className="ticker-belt">
|
||||
{looped.map((m, idx) => (
|
||||
<div
|
||||
key={`${m.id || idx}-ticker`}
|
||||
className="match-card"
|
||||
onClick={(e) => { e.preventDefault(); onMatchClick?.(m, current?.name); }}
|
||||
style={{ cursor: onMatchClick ? 'pointer' as const : 'default' as const }}
|
||||
>
|
||||
<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>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="matches-slider" {...(elementProps || {})}>
|
||||
<div className="section-head" style={{ marginTop: 16, marginBottom: 16 }}>
|
||||
|
||||
@@ -71,6 +71,67 @@ export const useAllPageElementConfigs = (pageType: string) => {
|
||||
});
|
||||
};
|
||||
|
||||
// Helper: inject style properties for each element into a single <style> tag
|
||||
// This ensures style applying/previewing works even if components do not spread getStyles()
|
||||
const updateInjectedStyleProps = (stylesMap: Record<string, Record<string, any>>) => {
|
||||
try {
|
||||
const styleId = 'myuibrix-style-props';
|
||||
let styleEl = document.getElementById(styleId) as HTMLStyleElement | null;
|
||||
if (!styleEl) {
|
||||
styleEl = document.createElement('style');
|
||||
styleEl.id = styleId;
|
||||
document.head.appendChild(styleEl);
|
||||
}
|
||||
|
||||
const cssBlocks: string[] = [];
|
||||
const toPx = (v: any): string => (typeof v === 'number' ? `${v}px` : `${v}`);
|
||||
const addDecl = (decls: string[], prop: string, val: any, unit?: 'px' | '') => {
|
||||
if (val === undefined || val === null || val === '') return;
|
||||
const needsPx = unit === 'px';
|
||||
const v = typeof val === 'number' && needsPx ? `${val}px` : `${val}`;
|
||||
decls.push(`${prop}: ${v} !important;`);
|
||||
};
|
||||
|
||||
Object.entries(stylesMap || {}).forEach(([name, st]) => {
|
||||
if (!st || typeof st !== 'object') return;
|
||||
const decls: string[] = [];
|
||||
addDecl(decls, 'font-family', st.fontFamily);
|
||||
addDecl(decls, 'font-size', st.fontSize, 'px');
|
||||
addDecl(decls, 'font-weight', st.fontWeight);
|
||||
addDecl(decls, 'line-height', st.lineHeight);
|
||||
addDecl(decls, 'letter-spacing', st.letterSpacing, 'px');
|
||||
addDecl(decls, 'text-transform', st.textTransform);
|
||||
addDecl(decls, 'color', st.color);
|
||||
addDecl(decls, 'background-color', st.backgroundColor);
|
||||
addDecl(decls, 'padding-top', st.paddingTop, 'px');
|
||||
addDecl(decls, 'padding-right', st.paddingRight, 'px');
|
||||
addDecl(decls, 'padding-bottom', st.paddingBottom, 'px');
|
||||
addDecl(decls, 'padding-left', st.paddingLeft, 'px');
|
||||
addDecl(decls, 'margin-top', st.marginTop, 'px');
|
||||
addDecl(decls, 'margin-right', st.marginRight, 'px');
|
||||
addDecl(decls, 'margin-bottom', st.marginBottom, 'px');
|
||||
addDecl(decls, 'margin-left', st.marginLeft, 'px');
|
||||
// width/height may be numbers (px) or strings (%, auto, etc.)
|
||||
if (st.width !== undefined) addDecl(decls, 'width', st.width, typeof st.width === 'number' ? 'px' : '');
|
||||
if (st.height !== undefined) addDecl(decls, 'height', st.height, typeof st.height === 'number' ? 'px' : '');
|
||||
addDecl(decls, 'display', st.display);
|
||||
addDecl(decls, 'grid-template-columns', st.gridTemplateColumns);
|
||||
addDecl(decls, 'grid-template-rows', st.gridTemplateRows);
|
||||
addDecl(decls, 'grid-auto-flow', st.gridAutoFlow);
|
||||
addDecl(decls, 'grid-column-gap', st.gridColumnGap, 'px');
|
||||
addDecl(decls, 'grid-row-gap', st.gridRowGap, 'px');
|
||||
addDecl(decls, 'align-items', st.alignItems);
|
||||
addDecl(decls, 'justify-items', st.justifyItems);
|
||||
|
||||
if (decls.length > 0) {
|
||||
cssBlocks.push(`[data-element="${name}"] { ${decls.join(' ')} }`);
|
||||
}
|
||||
});
|
||||
|
||||
styleEl.textContent = cssBlocks.join('\n');
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const loadConfigs = async () => {
|
||||
try {
|
||||
const data = await getPageElementConfigs(pageType);
|
||||
@@ -123,8 +184,22 @@ export const useAllPageElementConfigs = (pageType: string) => {
|
||||
setVisibility(visMap);
|
||||
if (Object.keys(stylesMap).length > 0) {
|
||||
setStyles(stylesMap);
|
||||
} else {
|
||||
setStyles({});
|
||||
}
|
||||
setElementOrder(order);
|
||||
// Apply style-pack body class
|
||||
try {
|
||||
const sp = configMap['style-pack'] || 'default';
|
||||
const body = document.body;
|
||||
// Remove any previous style-pack-* classes
|
||||
Array.from(body.classList)
|
||||
.filter(cls => cls.startsWith('style-pack-'))
|
||||
.forEach(cls => body.classList.remove(cls));
|
||||
body.classList.add(`style-pack-${sp}`);
|
||||
} catch {}
|
||||
// Inject style properties so they apply even without inline spreading
|
||||
updateInjectedStyleProps(stylesMap);
|
||||
|
||||
// Apply initial order to DOM only in editor/preview mode
|
||||
const isEditingMode = (() => {
|
||||
@@ -172,6 +247,16 @@ export const useAllPageElementConfigs = (pageType: string) => {
|
||||
...prev,
|
||||
[elementName]: visible
|
||||
}));
|
||||
// If style-pack changed, toggle body class for live preview
|
||||
if (elementName === 'style-pack') {
|
||||
try {
|
||||
const body = document.body;
|
||||
Array.from(body.classList)
|
||||
.filter(cls => cls.startsWith('style-pack-'))
|
||||
.forEach(cls => body.classList.remove(cls));
|
||||
body.classList.add(`style-pack-${variant || 'default'}`);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Force React to re-render by incrementing refresh key
|
||||
setRefreshKey(prev => prev + 1);
|
||||
@@ -199,10 +284,15 @@ export const useAllPageElementConfigs = (pageType: string) => {
|
||||
if (previewMode) {
|
||||
// Only update state - let React apply the styles through component rendering
|
||||
// This prevents conflicts with React's virtual DOM
|
||||
setStyles(prev => ({
|
||||
...prev,
|
||||
[elementName]: newStyles
|
||||
}));
|
||||
setStyles(prev => {
|
||||
const next = {
|
||||
...prev,
|
||||
[elementName]: newStyles
|
||||
};
|
||||
// Also update injected CSS for global preview application
|
||||
updateInjectedStyleProps(next);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}) as EventListener;
|
||||
|
||||
@@ -215,6 +305,10 @@ export const useAllPageElementConfigs = (pageType: string) => {
|
||||
window.removeEventListener('myuibrix-change', handleMyUIbrixChange);
|
||||
window.removeEventListener('myuibrix-reorder', handleMyUIbrixReorder);
|
||||
window.removeEventListener('myuibrix-style-change', handleMyUIbrixStyleChange);
|
||||
try {
|
||||
const s = document.getElementById('myuibrix-style-props');
|
||||
if (s) s.remove();
|
||||
} catch {}
|
||||
};
|
||||
}, [pageType]);
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ import ReactDOM from 'react-dom/client';
|
||||
import './index.css';
|
||||
import './styles/global-enhancements.css';
|
||||
import './styles/admin-enhancements.css';
|
||||
import './styles/home-style-pack.css';
|
||||
import './styles/sparta-styles.css';
|
||||
// Quill editor styles (MUST be imported globally) - CRITICAL for rich text editor
|
||||
import 'react-quill/dist/quill.snow.css';
|
||||
import 'react-image-crop/dist/ReactCrop.css';
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from '@chakra-ui/icons';
|
||||
import { addMonths, format, isSameDay, isSameMonth, startOfMonth, startOfWeek } from 'date-fns';
|
||||
import { addMonths, format, isSameDay, isSameMonth, startOfMonth, startOfWeek, addDays, parse } from 'date-fns';
|
||||
import { cs } from 'date-fns/locale';
|
||||
import { getEvents } from '../services/eventService';
|
||||
|
||||
@@ -69,7 +69,7 @@ const ActivitiesCalendarPage: React.FC = () => {
|
||||
const weeks = useMemo(() => {
|
||||
const start = startOfWeek(startOfMonth(monthRef), { weekStartsOn: 1 });
|
||||
const days: Date[] = [];
|
||||
for (let i = 0; i < 42; i++) days.push(new Date(start.getTime() + i * 86400000));
|
||||
for (let i = 0; i < 42; i++) days.push(addDays(start, i));
|
||||
return days;
|
||||
}, [monthRef]);
|
||||
|
||||
@@ -227,7 +227,22 @@ const ActivitiesCalendarPage: React.FC = () => {
|
||||
const key = format(day, 'yyyy-MM-dd');
|
||||
const list = byDate.get(key) || [];
|
||||
const faded = !isSameMonth(day, monthRef);
|
||||
const today = isSameDay(day, new Date());
|
||||
const today = (() => {
|
||||
try {
|
||||
const parts = new Intl.DateTimeFormat('cs-CZ', {
|
||||
timeZone: 'Europe/Prague',
|
||||
year: 'numeric', month: '2-digit', day: '2-digit'
|
||||
}).formatToParts(new Date());
|
||||
const y = parts.find(p => p.type === 'year')?.value;
|
||||
const m = parts.find(p => p.type === 'month')?.value;
|
||||
const d = parts.find(p => p.type === 'day')?.value;
|
||||
if (y && m && d) {
|
||||
const pragueToday = parse(`${y}-${m}-${d}`, 'yyyy-MM-dd', new Date());
|
||||
return isSameDay(day, pragueToday);
|
||||
}
|
||||
} catch {}
|
||||
return isSameDay(day, new Date());
|
||||
})();
|
||||
return (
|
||||
<Box
|
||||
key={idx}
|
||||
@@ -290,7 +305,7 @@ const ActivitiesCalendarPage: React.FC = () => {
|
||||
<Box px={3} py={2} bg={listHeaderBg} borderLeftWidth="4px" borderLeftColor={'brand.primary'}>
|
||||
<Flex align="center" gap={2}>
|
||||
<Text fontWeight="semibold">
|
||||
{format(new Date(k), 'EEEE d. M. yyyy', { locale: cs })}
|
||||
{format(parse(k, 'yyyy-MM-dd', new Date()), 'EEEE d. M. yyyy', { locale: cs })}
|
||||
</Text>
|
||||
<Badge colorScheme="purple" borderRadius="full">{dayEvents.length}</Badge>
|
||||
</Flex>
|
||||
|
||||
@@ -11,6 +11,7 @@ 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';
|
||||
import InstagramGeneratorButton from '../components/admin/InstagramGeneratorButton';
|
||||
|
||||
const ActivityDetailPage: React.FC = () => {
|
||||
const { id } = useParams();
|
||||
@@ -116,6 +117,12 @@ const ActivityDetailPage: React.FC = () => {
|
||||
return (
|
||||
<MainLayout>
|
||||
<Box py={10} bg="transparent">
|
||||
<InstagramGeneratorButton
|
||||
activity={data}
|
||||
targetUrl={typeof window !== 'undefined' ? window.location.href : undefined}
|
||||
placement="fixed"
|
||||
size="md"
|
||||
/>
|
||||
<Container maxW="3xl">
|
||||
{loading && (
|
||||
<HStack><Spinner size="sm" /><Text>Načítání…</Text></HStack>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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';
|
||||
import { getArticle, getArticleBySlug, getArticleMatchLink, trackArticleView, getArticles } from '../services/articles';
|
||||
import MainLayout from '../components/layout/MainLayout';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { Helmet } from 'react-helmet-async';
|
||||
@@ -18,6 +18,11 @@ import { extractPalette } from '../utils/colors';
|
||||
import { getTeamLogo } from '../utils/sportLogosAPI';
|
||||
import FilePreview from '../components/common/FilePreview';
|
||||
import { usePublicSettings } from '../hooks/usePublicSettings';
|
||||
import InstagramGeneratorButton from '../components/admin/InstagramGeneratorButton';
|
||||
import { MatchSnapshot } from '../services/instagram';
|
||||
import { Widget } from '../components/widgets/Widget';
|
||||
import { MatchesWidget } from '../components/widgets/MatchesWidget';
|
||||
import { getUpcomingEvents } from '../services/eventService';
|
||||
|
||||
const toText = (html?: string) => {
|
||||
if (!html) return '';
|
||||
@@ -123,6 +128,38 @@ const ArticleDetailPage: React.FC = () => {
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
// Build a snapshot usable for sharing if available (FACR data or article fallback)
|
||||
const matchSnapshot: MatchSnapshot | null = React.useMemo(() => {
|
||||
const m: any = facrMatchQuery?.data as any;
|
||||
if (m) {
|
||||
let score = '';
|
||||
if (m?.score && m.score !== 'vs') score = String(m.score);
|
||||
else if (m?.result_home != null && m?.result_away != null) score = `${m.result_home}:${m.result_away}`;
|
||||
return {
|
||||
external_match_id: String((matchLinkQuery.data as any)?.external_match_id || ''),
|
||||
competition: String(m.competitionName || ''),
|
||||
date_time: String(m.date_time || m.date || ''),
|
||||
venue: m.venue ? String(m.venue) : undefined,
|
||||
home: String(m.home || m.home_team || ''),
|
||||
away: String(m.away || m.away_team || ''),
|
||||
score,
|
||||
};
|
||||
}
|
||||
const snap: any = (data as any)?.match_snapshot;
|
||||
if (snap) {
|
||||
return {
|
||||
external_match_id: snap.external_match_id,
|
||||
competition: snap.competition || snap.competitionName,
|
||||
date_time: snap.date_time || snap.date,
|
||||
venue: snap.venue,
|
||||
home: snap.home,
|
||||
away: snap.away,
|
||||
score: snap.score,
|
||||
} as MatchSnapshot;
|
||||
}
|
||||
return null;
|
||||
}, [facrMatchQuery?.data, (matchLinkQuery.data as any)?.external_match_id, (data as any)?.match_snapshot]);
|
||||
|
||||
// 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 || (data as any)?.gallery_album_url],
|
||||
@@ -239,6 +276,24 @@ const ArticleDetailPage: React.FC = () => {
|
||||
});
|
||||
}, [(data as any)?.content, toAbsoluteUploads]);
|
||||
|
||||
const relatedArticlesQuery = useQuery({
|
||||
queryKey: ['related-articles', (data as any)?.category?.id || 'none', (data as any)?.id],
|
||||
enabled: Boolean((data as any)?.id),
|
||||
queryFn: () => getArticles({
|
||||
page: 1,
|
||||
page_size: 6,
|
||||
published: true,
|
||||
...(((data as any)?.category?.id) ? { category_id: (data as any).category.id } : {}),
|
||||
}),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const upcomingEventsQuery = useQuery({
|
||||
queryKey: ['upcoming-events-sidebar'],
|
||||
queryFn: getUpcomingEvents,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
if (isLoading) return <Spinner />;
|
||||
if (isError || !data) return <Text color="red.500">Článek nenalezen</Text>;
|
||||
|
||||
@@ -256,6 +311,13 @@ const ArticleDetailPage: React.FC = () => {
|
||||
return (
|
||||
<MainLayout>
|
||||
<Box>
|
||||
<InstagramGeneratorButton
|
||||
article={data as any}
|
||||
match={matchSnapshot}
|
||||
targetUrl={typeof window !== 'undefined' ? window.location.href : undefined}
|
||||
placement="fixed"
|
||||
size="md"
|
||||
/>
|
||||
<Helmet>
|
||||
<title>{title}</title>
|
||||
<meta name="description" content={description} />
|
||||
@@ -340,183 +402,243 @@ const ArticleDetailPage: React.FC = () => {
|
||||
</Container>
|
||||
</Box>
|
||||
<Container maxW="7xl">
|
||||
<Stack spacing={6}>
|
||||
{/* Featured Image - smaller with subtle overlay */}
|
||||
{data.image_url && (
|
||||
<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 - smaller and rounded */}
|
||||
{(data as any)?.youtube_video_id && (
|
||||
<Box borderWidth="1px" borderRadius="lg" p={{ base: 3, md: 4 }} bg={videoBg}>
|
||||
<Heading as="h3" size="md" mb={2}>🎬 Video k článku</Heading>
|
||||
<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 - Card with logos, score/countdown, venue/date */}
|
||||
{(matchLinkQuery.data as any)?.external_match_id && (
|
||||
<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" />
|
||||
<SimpleGrid columns={{ base: 1, lg: 12 }} spacing={6}>
|
||||
<Box gridColumn={{ base: '1 / -1', lg: 'span 8' }}>
|
||||
<Stack spacing={6}>
|
||||
{/* Featured Image - smaller with subtle overlay */}
|
||||
{data.image_url && (
|
||||
<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>
|
||||
)}
|
||||
<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>
|
||||
{/* YouTube Video Section - smaller and rounded */}
|
||||
{(data as any)?.youtube_video_id && (
|
||||
<Box borderWidth="1px" borderRadius="lg" p={{ base: 3, md: 4 }} bg={videoBg}>
|
||||
<Heading as="h3" size="md" mb={2}>🎬 Video k článku</Heading>
|
||||
<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 - Card with logos, score/countdown, venue/date */}
|
||||
{(matchLinkQuery.data as any)?.external_match_id && (
|
||||
<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" />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Text color={textMuted}>Propojeno s FACR ID: {(matchLinkQuery.data as any)?.external_match_id}</Text>
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Text color={textMuted}>Propojeno s FACR ID: {(matchLinkQuery.data as any)?.external_match_id}</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Article Content - Main Section with editor-like lists */}
|
||||
<Box
|
||||
className="article-content"
|
||||
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 }}
|
||||
/>
|
||||
{/* Article Content - Main Section with editor-like lists */}
|
||||
<Box
|
||||
className="article-content"
|
||||
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 - 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={(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 galerii
|
||||
</Button>
|
||||
</HStack>
|
||||
{/* Custom 5-image mosaic */}
|
||||
{Array.isArray(galleryAlbumQuery.data?.photos) && (galleryAlbumQuery.data?.photos?.length || 0) > 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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
)}
|
||||
{/* 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={(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 galerii
|
||||
</Button>
|
||||
</HStack>
|
||||
{/* Custom 5-image mosaic */}
|
||||
{Array.isArray(galleryAlbumQuery.data?.photos) && (galleryAlbumQuery.data?.photos?.length || 0) > 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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
{/* Embedded Poll - directly under content/gallery */}
|
||||
{data?.id && <EmbeddedPoll articleId={(data as any).id} maxPolls={3} />}
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<VStack align="stretch" spacing={6} gridColumn={{ base: '1 / -1', lg: 'span 4' }}>
|
||||
<Widget title="Podobné články">
|
||||
{relatedArticlesQuery.isLoading ? (
|
||||
<Text color={textMuted}>Načítám…</Text>
|
||||
) : (() => {
|
||||
const list = ((relatedArticlesQuery.data as any)?.data || [])
|
||||
.filter((a: any) => a?.id !== (data as any)?.id)
|
||||
.slice(0, 4);
|
||||
if (!list.length) return <Text color={textMuted}>Žádné související články</Text>;
|
||||
return (
|
||||
<VStack spacing={3} align="stretch">
|
||||
{list.map((a: any) => {
|
||||
const link = a.slug ? `/news/${a.slug}` : `/articles/${a.id}`;
|
||||
return (
|
||||
<HStack key={a.id} align="flex-start" spacing={3} as={RouterLink} to={link} _hover={{ textDecoration: 'none' }}>
|
||||
<Image src={assetUrl(a.image_url) || '/stadium-placeholder.jpg'} alt={a.title} boxSize="64px" objectFit="cover" borderRadius="md" />
|
||||
<VStack align="start" spacing={1} flex={1} minW={0}>
|
||||
<Text fontWeight="600" noOfLines={2}>{a.title}</Text>
|
||||
{a.published_at && (
|
||||
<Text fontSize="sm" color={textMuted}>{new Date(a.published_at).toLocaleDateString('cs-CZ')}</Text>
|
||||
)}
|
||||
</VStack>
|
||||
</HStack>
|
||||
);
|
||||
})}
|
||||
</VStack>
|
||||
);
|
||||
})()}
|
||||
</Widget>
|
||||
|
||||
<MatchesWidget />
|
||||
|
||||
<Widget title="Nejbližší aktivity">
|
||||
{upcomingEventsQuery.isLoading ? (
|
||||
<Text color={textMuted}>Načítám…</Text>
|
||||
) : (() => {
|
||||
const items = Array.isArray(upcomingEventsQuery.data) ? (upcomingEventsQuery.data as any[]).slice(0, 3) : [];
|
||||
if (!items.length) return <Text color={textMuted}>Žádné plánované aktivity</Text>;
|
||||
return (
|
||||
<VStack spacing={3} align="stretch">
|
||||
{items.map((ev: any) => (
|
||||
<HStack key={ev.id} as={RouterLink} to={`/aktivita/${ev.id}`} _hover={{ textDecoration: 'none' }} align="flex-start" spacing={3}>
|
||||
<Box flex={1} minW={0}>
|
||||
<Text fontWeight="600" noOfLines={2}>{ev.title}</Text>
|
||||
<Text fontSize="sm" color={textMuted}>
|
||||
{(() => { try { const d = new Date(ev.start_time); return d.toLocaleDateString('cs-CZ') + (ev.location ? ` • ${ev.location}` : ''); } catch { return ev.start_time; } })()}
|
||||
</Text>
|
||||
</Box>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
);
|
||||
})()}
|
||||
</Widget>
|
||||
</VStack>
|
||||
</SimpleGrid>
|
||||
</Container>
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { getCategories, CategoryItem } from '../services/categories';
|
||||
import SponsorsSection from '../components/common/SponsorsSection';
|
||||
import NewsletterCTA from '../components/common/NewsletterCTA';
|
||||
import { Eye, Clock, Search, X } from 'lucide-react';
|
||||
import InstagramGeneratorButton from '../components/admin/InstagramGeneratorButton';
|
||||
|
||||
const BlogTile: React.FC<{ article: Article; variant?: 'large' | 'small' }> = ({ article, variant }) => {
|
||||
const link = article.slug ? `/news/${article.slug}` : `/articles/${article.id}`;
|
||||
@@ -32,6 +33,7 @@ const BlogTile: React.FC<{ article: Article; variant?: 'large' | 'small' }> = ({
|
||||
borderWidth="0"
|
||||
_hover={{ boxShadow: 'xl', transform: 'translateY(-3px)' }}
|
||||
transition="all 0.25s ease"
|
||||
position="relative"
|
||||
>
|
||||
<Box position="relative">
|
||||
<Image src={assetUrl(article.image_url) || '/stadium-placeholder.jpg'} alt={article.title} w="100%" h={imageH} objectFit="cover" />
|
||||
@@ -106,6 +108,14 @@ const BlogTile: React.FC<{ article: Article; variant?: 'large' | 'small' }> = ({
|
||||
{article.title}
|
||||
</Heading>
|
||||
</Box>
|
||||
<Box position="absolute" top={2} right={2} zIndex={2}>
|
||||
<InstagramGeneratorButton
|
||||
article={article as any}
|
||||
targetUrl={typeof window !== 'undefined' ? new URL(link, window.location.origin).toString() : undefined}
|
||||
placement="inline"
|
||||
size="sm"
|
||||
/>
|
||||
</Box>
|
||||
</LinkBox>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useEffect, useMemo, useState } from 'react';
|
||||
import MainLayout from '../components/layout/MainLayout';
|
||||
import { Box, Container, Heading, Text, Tabs, TabList, TabPanels, Tab, TabPanel, Flex, Badge, Stack, Spinner, Grid, IconButton, ButtonGroup, Button, Link, Image, useDisclosure, Modal, ModalOverlay, ModalContent, ModalHeader, ModalCloseButton, ModalBody, ModalFooter, useToast, Input, useColorModeValue } from '@chakra-ui/react';
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from '@chakra-ui/icons';
|
||||
import { addMonths, format, isSameDay, isSameMonth, startOfMonth, startOfWeek } from 'date-fns';
|
||||
import { addMonths, format, isSameDay, isSameMonth, startOfMonth, startOfWeek, addDays, parse } from 'date-fns';
|
||||
import { cs } from 'date-fns/locale';
|
||||
import { getCompetitionAliasesPublic, CompetitionAlias } from '../services/competitionAliases';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
@@ -466,7 +466,21 @@ const CalendarPage: React.FC = () => {
|
||||
const target = e.currentTarget as HTMLElement & { dataset?: any };
|
||||
const href = (target.getAttribute && target.getAttribute('data-href')) || (target as any).dataset?.href;
|
||||
if (href) {
|
||||
window.open(href as string, '_blank', 'noopener');
|
||||
try {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const link = document.createElement('a');
|
||||
link.href = href as string;
|
||||
link.target = '_blank';
|
||||
link.rel = 'noopener noreferrer';
|
||||
link.style.display = 'none';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
setTimeout(() => { try { window.focus(); } catch {} }, 0);
|
||||
} catch {
|
||||
window.open(href as string, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -483,7 +497,7 @@ const CalendarPage: React.FC = () => {
|
||||
// Build 6 weeks x 7 days
|
||||
const days: Date[] = [];
|
||||
for (let i = 0; i < 42; i++) {
|
||||
days.push(new Date(start.getTime() + i * 86400000));
|
||||
days.push(addDays(start, i));
|
||||
}
|
||||
return days;
|
||||
}, [monthRef]);
|
||||
@@ -664,7 +678,19 @@ const CalendarPage: React.FC = () => {
|
||||
{latestResults.map((m) => {
|
||||
const href = mkHref(m);
|
||||
return (
|
||||
<Box key={`latest-${c.id}-${m.id}`} data-href={href || undefined} onMouseDown={handleMatchMouseDown} onClick={() => openMatchModal(m, c)} borderWidth="1px" borderRadius="md" p={2} _hover={{ textDecoration: 'none', bg: 'rgba(0,0,0,0.03)', borderColor: 'brand.primary', cursor: 'pointer' }}>
|
||||
<Box key={`latest-${c.id}-${m.id}`} position="relative" data-href={href || undefined} onMouseDown={handleMatchMouseDown} onClick={() => openMatchModal(m, c)} borderWidth="1px" borderRadius="md" p={2} _hover={{ textDecoration: 'none', bg: 'rgba(0,0,0,0.03)', borderColor: 'brand.primary', cursor: 'pointer' }}>
|
||||
{href && (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => { if (!(e.ctrlKey || e.metaKey || e.shiftKey || e.altKey)) e.preventDefault(); }}
|
||||
onMouseDown={(e) => { if (e.button === 1) { e.preventDefault(); e.stopPropagation(); try { const w = window.open(href as string, '_blank', 'noopener,noreferrer'); (w as any)?.blur?.(); setTimeout(() => { try { window.focus(); } catch {} }, 0); } catch {} } }}
|
||||
onAuxClick={(e) => { if ((e as any).button === 1) { e.preventDefault(); e.stopPropagation(); try { const w = window.open(href as string, '_blank', 'noopener,noreferrer'); (w as any)?.blur?.(); setTimeout(() => { try { window.focus(); } catch {} }, 0); } catch {} } }}
|
||||
style={{ position: 'absolute', inset: 0, zIndex: 1 }}
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
<Flex align="center" justify="space-between" mb={2}>
|
||||
<Text fontSize="sm" color="gray.700">{m.date} {m.time || ''}</Text>
|
||||
<Badge colorScheme="purple">{m.__compName || c.name}</Badge>
|
||||
@@ -758,7 +784,22 @@ const CalendarPage: React.FC = () => {
|
||||
const key = format(day, 'yyyy-MM-dd');
|
||||
const list = byDate.get(key) || [];
|
||||
const faded = !isSameMonth(day, monthRef);
|
||||
const today = isSameDay(day, new Date());
|
||||
const today = (() => {
|
||||
try {
|
||||
const parts = new Intl.DateTimeFormat('cs-CZ', {
|
||||
timeZone: 'Europe/Prague',
|
||||
year: 'numeric', month: '2-digit', day: '2-digit'
|
||||
}).formatToParts(new Date());
|
||||
const y = parts.find(p => p.type === 'year')?.value;
|
||||
const m = parts.find(p => p.type === 'month')?.value;
|
||||
const d = parts.find(p => p.type === 'day')?.value;
|
||||
if (y && m && d) {
|
||||
const pragueToday = parse(`${y}-${m}-${d}`, 'yyyy-MM-dd', new Date());
|
||||
return isSameDay(day, pragueToday);
|
||||
}
|
||||
} catch {}
|
||||
return isSameDay(day, new Date());
|
||||
})();
|
||||
return (
|
||||
<Box
|
||||
key={idx}
|
||||
@@ -783,7 +824,19 @@ const CalendarPage: React.FC = () => {
|
||||
const isPast = new Date(`${m.date}T${(m.time||'00:00')}:00`).getTime() < Date.now();
|
||||
const countdown = liveCountdowns[String(m.id)];
|
||||
return (
|
||||
<Box key={m.id} _hover={{ textDecoration: 'none' }} data-href={href || undefined} onMouseDown={handleMatchMouseDown} onClick={() => openMatchModal(m, c)}>
|
||||
<Box key={m.id} position="relative" _hover={{ textDecoration: 'none' }} data-href={href || undefined} onMouseDown={handleMatchMouseDown} onClick={() => openMatchModal(m, c)}>
|
||||
{href && (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => { if (!(e.ctrlKey || e.metaKey || e.shiftKey || e.altKey)) e.preventDefault(); }}
|
||||
onMouseDown={(e) => { if (e.button === 1) { e.preventDefault(); e.stopPropagation(); try { const w = window.open(href as string, '_blank', 'noopener,noreferrer'); (w as any)?.blur?.(); setTimeout(() => { try { window.focus(); } catch {} }, 0); } catch {} } }}
|
||||
onAuxClick={(e) => { if ((e as any).button === 1) { e.preventDefault(); e.stopPropagation(); try { const w = window.open(href as string, '_blank', 'noopener,noreferrer'); (w as any)?.blur?.(); setTimeout(() => { try { window.focus(); } catch {} }, 0); } catch {} } }}
|
||||
style={{ position: 'absolute', inset: 0, zIndex: 1 }}
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
<Box p={2} borderWidth="1px" borderRadius="md" bg={calendarMatchBg} _hover={{ bg: calendarMatchHoverBg, borderColor: 'brand.primary', cursor: 'pointer' }} textAlign="center">
|
||||
{!isPast && countdown ? (
|
||||
<>
|
||||
@@ -832,7 +885,19 @@ const CalendarPage: React.FC = () => {
|
||||
<Stack spacing={4}>
|
||||
{(() => {
|
||||
const keys = Array.from(byDate.keys());
|
||||
const todayStr = format(new Date(), 'yyyy-MM-dd');
|
||||
const todayStr = (() => {
|
||||
try {
|
||||
const parts = new Intl.DateTimeFormat('cs-CZ', {
|
||||
timeZone: 'Europe/Prague',
|
||||
year: 'numeric', month: '2-digit', day: '2-digit'
|
||||
}).formatToParts(new Date());
|
||||
const y = parts.find(p => p.type === 'year')?.value;
|
||||
const m = parts.find(p => p.type === 'month')?.value;
|
||||
const d = parts.find(p => p.type === 'day')?.value;
|
||||
if (y && m && d) return `${y}-${m}-${d}`;
|
||||
} catch {}
|
||||
return format(new Date(), 'yyyy-MM-dd');
|
||||
})();
|
||||
const pastKeys = keys.filter(k => k < todayStr).sort().reverse();
|
||||
const futureKeys = keys.filter(k => k >= todayStr).sort();
|
||||
const renderGroup = (dKey: string, highlight: boolean) => {
|
||||
@@ -848,7 +913,7 @@ const CalendarPage: React.FC = () => {
|
||||
>
|
||||
<Flex align="center" gap={2}>
|
||||
<Text fontWeight="semibold" color={highlight ? 'brand.primary' : listGroupHeaderText}>
|
||||
{format(new Date(dKey), 'EEEE d. M. yyyy', { locale: cs })}
|
||||
{format(parse(dKey, 'yyyy-MM-dd', new Date()), 'EEEE d. M. yyyy', { locale: cs })}
|
||||
</Text>
|
||||
{highlight && (
|
||||
<Badge colorScheme="blue" variant="subtle" borderRadius="full">Dnes</Badge>
|
||||
@@ -862,7 +927,19 @@ const CalendarPage: React.FC = () => {
|
||||
const sentiment = isPast ? getSentiment(m) : null;
|
||||
const countdown = liveCountdowns[String(m.id)];
|
||||
return (
|
||||
<Box key={m.id} _hover={{ textDecoration: 'none' }} data-href={href || undefined} onMouseDown={handleMatchMouseDown} onClick={() => openMatchModal(m, c)}>
|
||||
<Box key={m.id} position="relative" _hover={{ textDecoration: 'none' }} data-href={href || undefined} onMouseDown={handleMatchMouseDown} onClick={() => openMatchModal(m, c)}>
|
||||
{href && (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => { e.preventDefault(); }}
|
||||
onMouseDown={(e) => { if (e.button === 1) { e.preventDefault(); e.stopPropagation(); try { const w = window.open(href as string, '_blank', 'noopener,noreferrer'); (w as any)?.blur?.(); setTimeout(() => { try { window.focus(); } catch {} }, 0); } catch {} } }}
|
||||
onAuxClick={(e) => { if ((e as any).button === 1) { e.preventDefault(); e.stopPropagation(); try { const w = window.open(href as string, '_blank', 'noopener,noreferrer'); (w as any)?.blur?.(); setTimeout(() => { try { window.focus(); } catch {} }, 0); } catch {} } }}
|
||||
style={{ position: 'absolute', inset: 0, zIndex: 1 }}
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
<Flex
|
||||
align="center"
|
||||
justify="space-between"
|
||||
@@ -1064,7 +1141,7 @@ const CalendarPage: React.FC = () => {
|
||||
<Text fontSize="lg" fontWeight="semibold" color="gray.800" mb={1}>
|
||||
{(() => {
|
||||
try {
|
||||
return format(new Date(selected.match.date), 'EEEE d. MMMM yyyy', { locale: cs });
|
||||
return format(parse(selected.match.date, 'yyyy-MM-dd', new Date()), 'EEEE d. MMMM yyyy', { locale: cs });
|
||||
} catch {
|
||||
return selected.match.date;
|
||||
}
|
||||
|
||||
@@ -1049,7 +1049,7 @@ const HomePage: React.FC = () => {
|
||||
<div className="name">{p.name}</div>
|
||||
<div className="role">{p.position || 'Hráč'}</div>
|
||||
{typeof p.number !== 'undefined' && <div className="number">#{p.number}</div>}
|
||||
{typeof p.age === 'number' && <div className="age">{p.age} let</div>}
|
||||
{typeof p.age === 'number' && <div className="age">{p.age} {czYears(p.age)}</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -1338,7 +1338,7 @@ const HomePage: React.FC = () => {
|
||||
// }
|
||||
|
||||
return (
|
||||
<MainLayout headerInsideContainer showSponsorsSection={false}>
|
||||
<MainLayout showSponsorsSection={false}>
|
||||
<div className="container" data-element="container" style={{ ...getStyles('container') }}>
|
||||
<div data-element="style-pack" data-variant={stylePack} style={{ display: 'none' }} />
|
||||
{/* Above-hero club bar (MyUIbrix managed) */}
|
||||
@@ -1520,6 +1520,7 @@ const HomePage: React.FC = () => {
|
||||
setSelectedMatch({ ...m, competition: compName, competitionName: compName });
|
||||
setIsMatchModalOpen(true);
|
||||
}}
|
||||
variant={getVariant('matches-slider', 'carousel') as any}
|
||||
elementProps={{ 'data-element': 'matches-slider', 'data-variant': getVariant('matches-slider', 'carousel'), style: { position: 'relative', ...getStyles('matches-slider') } }}
|
||||
/>
|
||||
)}
|
||||
@@ -1554,7 +1555,11 @@ const HomePage: React.FC = () => {
|
||||
<h3>Další aktuality</h3>
|
||||
<a href="/news" className="see-all" style={{ fontSize: '0.85rem' }}>Zobrazit vše <FiArrowRight size={14} /></a>
|
||||
</div>
|
||||
<NewsList items={news as any} />
|
||||
{newsVariant === 'scroller' ? (
|
||||
<BlogCardsScroller />
|
||||
) : (
|
||||
<NewsList items={news as any} />
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -1642,7 +1647,7 @@ const HomePage: React.FC = () => {
|
||||
{isVisible('videos', false) && (
|
||||
<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 />
|
||||
<VideosSection variant={(getVariant('videos', 'grid') as any) as 'grid' | 'carousel'} />
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
@@ -1831,4 +1836,13 @@ const HomePage: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
function czYears(n: number): string {
|
||||
const mod100 = n % 100;
|
||||
if (mod100 >= 11 && mod100 <= 14) return 'let';
|
||||
const mod10 = n % 10;
|
||||
if (mod10 === 1) return 'rok';
|
||||
if (mod10 >= 2 && mod10 <= 4) return 'roky';
|
||||
return 'let';
|
||||
}
|
||||
|
||||
export default HomePage;
|
||||
|
||||
@@ -23,6 +23,8 @@ const PlayerDetailPage: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
if (isError || !data) {
|
||||
return (
|
||||
<MainLayout>
|
||||
@@ -73,7 +75,7 @@ const PlayerDetailPage: React.FC = () => {
|
||||
<Text><b>Národnost:</b> {translateNationality(data.nationality)}</Text>
|
||||
)}
|
||||
{data.date_of_birth && (
|
||||
<Text><b>Datum narození:</b> {new Date(data.date_of_birth).toLocaleDateString('cs-CZ')} — {calculateAge(data.date_of_birth)} let</Text>
|
||||
<Text><b>Datum narození:</b> {new Date(data.date_of_birth).toLocaleDateString('cs-CZ')} — {(() => { const a = calculateAge(data.date_of_birth); return a != null ? `${a} ${czYears(a)}` : '' })()}</Text>
|
||||
)}
|
||||
{(data.height || data.weight) && (
|
||||
<Text>
|
||||
@@ -81,10 +83,10 @@ const PlayerDetailPage: React.FC = () => {
|
||||
</Text>
|
||||
)}
|
||||
{data.email && (
|
||||
<Text><b>Email:</b> {data.email}</Text>
|
||||
<Text><b>Email:</b> <a href={`mailto:${data.email}`}>{data.email}</a></Text>
|
||||
)}
|
||||
{data.phone && (
|
||||
<Text><b>Telefon:</b> {data.phone}</Text>
|
||||
<Text><b>Telefon:</b> <a href={`tel:${normalizeTel(data.phone)}`}>{data.phone}</a></Text>
|
||||
)}
|
||||
{typeof data.team_id === 'number' && data.team_id > 0 && (
|
||||
<Text><b>Tým ID:</b> {data.team_id}</Text>
|
||||
@@ -95,10 +97,8 @@ const PlayerDetailPage: React.FC = () => {
|
||||
</VStack>
|
||||
</Container>
|
||||
|
||||
{/* Newsletter CTA */}
|
||||
<NewsletterCTA />
|
||||
|
||||
{/* Sponsors Section */}
|
||||
<SponsorsSection />
|
||||
</Box>
|
||||
</MainLayout>
|
||||
@@ -119,4 +119,25 @@ function calculateAge(iso: string): number | null {
|
||||
}
|
||||
}
|
||||
|
||||
function czYears(n: number): string {
|
||||
const mod100 = n % 100;
|
||||
if (mod100 >= 11 && mod100 <= 14) return 'let';
|
||||
const mod10 = n % 10;
|
||||
if (mod10 === 1) return 'rok';
|
||||
if (mod10 >= 2 && mod10 <= 4) return 'roky';
|
||||
return 'let';
|
||||
}
|
||||
|
||||
function normalizeTel(input: string): string {
|
||||
if (!input) return '';
|
||||
let s = String(input).trim();
|
||||
s = s.replace(/[\s\-()]/g, '');
|
||||
if (s.startsWith('00')) s = '+' + s.slice(2);
|
||||
s = s.replace(/(?!^)[^\d]/g, '');
|
||||
if (s[0] !== '+' && s.startsWith('+')) {
|
||||
// keep + if present
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
export default PlayerDetailPage;
|
||||
|
||||
@@ -53,6 +53,7 @@ const SearchPage: React.FC = () => {
|
||||
teams: [],
|
||||
contacts: [],
|
||||
gallery: [],
|
||||
categories: [],
|
||||
total: 0,
|
||||
});
|
||||
const [activeTab, setActiveTab] = useState<string>('all');
|
||||
@@ -87,6 +88,7 @@ const SearchPage: React.FC = () => {
|
||||
teams: [],
|
||||
contacts: [],
|
||||
gallery: [],
|
||||
categories: [],
|
||||
total: 0,
|
||||
});
|
||||
return;
|
||||
@@ -186,6 +188,33 @@ const SearchPage: React.FC = () => {
|
||||
<Button type="submit" mt={3} colorScheme="blue" size="lg">Vyhledat</Button>
|
||||
</Box>
|
||||
|
||||
{/* Quick type filter chips */}
|
||||
{!loading && hasAny && (
|
||||
<HStack spacing={2} wrap="wrap">
|
||||
<Button size="sm" variant={activeTab === 'all' ? 'solid' : 'outline'} colorScheme="blue" leftIcon={<FaSearch />} onClick={() => setActiveTab('all')}>
|
||||
Vše ({results.total})
|
||||
</Button>
|
||||
<Button size="sm" variant={activeTab === 'clubs' ? 'solid' : 'outline'} onClick={() => setActiveTab('clubs')}>
|
||||
Kluby ({validClubs.length})
|
||||
</Button>
|
||||
<Button size="sm" variant={activeTab === 'matches' ? 'solid' : 'outline'} onClick={() => setActiveTab('matches')}>
|
||||
Zápasy ({results.matches.length + results.matchesPast.length})
|
||||
</Button>
|
||||
<Button size="sm" variant={activeTab === 'articles' ? 'solid' : 'outline'} onClick={() => setActiveTab('articles')}>
|
||||
Články ({results.articles.length})
|
||||
</Button>
|
||||
<Button size="sm" variant={activeTab === 'players' ? 'solid' : 'outline'} leftIcon={<FaUsers />} onClick={() => setActiveTab('players')}>
|
||||
Hráči ({results.players.length})
|
||||
</Button>
|
||||
<Button size="sm" variant={activeTab === 'events' ? 'solid' : 'outline'} leftIcon={<FaCalendar />} onClick={() => setActiveTab('events')}>
|
||||
Akce ({results.events.length})
|
||||
</Button>
|
||||
<Button size="sm" variant={activeTab === 'other' ? 'solid' : 'outline'} onClick={() => setActiveTab('other')}>
|
||||
Ostatní ({results.teams.length + results.sponsors.length + results.contacts.length + results.gallery.length + results.categories.length})
|
||||
</Button>
|
||||
</HStack>
|
||||
)}
|
||||
|
||||
|
||||
{loading && (
|
||||
<Flex justify="center" my={12}>
|
||||
@@ -227,7 +256,7 @@ const SearchPage: React.FC = () => {
|
||||
<Tab>Články ({results.articles.length})</Tab>
|
||||
<Tab><Icon as={FaUsers} mr={2} />Hráči ({results.players.length})</Tab>
|
||||
<Tab><Icon as={FaCalendar} mr={2} />Akce ({results.events.length})</Tab>
|
||||
<Tab>Ostatní ({results.teams.length + results.sponsors.length + results.contacts.length + results.gallery.length})</Tab>
|
||||
<Tab>Ostatní ({results.teams.length + results.sponsors.length + results.contacts.length + results.gallery.length + results.categories.length})</Tab>
|
||||
</TabList>
|
||||
|
||||
<TabPanels>
|
||||
@@ -260,6 +289,29 @@ const SearchPage: React.FC = () => {
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Categories */}
|
||||
{results.categories.length > 0 && (
|
||||
<Box>
|
||||
<HStack justify="space-between" mb={4}>
|
||||
<Heading size="md">Kategorie</Heading>
|
||||
<Badge colorScheme="pink" fontSize="md">{results.categories.length}</Badge>
|
||||
</HStack>
|
||||
<SimpleGrid columns={{ base: 2, md: 3, lg: 4 }} spacing={4}>
|
||||
{results.categories.slice(0, 8).map((c) => (
|
||||
<Button
|
||||
key={c.id}
|
||||
as={RouterLink}
|
||||
to={c.url || '#'}
|
||||
variant="outline"
|
||||
colorScheme="pink"
|
||||
justifyContent="flex-start"
|
||||
>
|
||||
{highlight(c.title, q)}
|
||||
</Button>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
)}
|
||||
{/* Players */}
|
||||
{results.players.length > 0 && (
|
||||
<Box>
|
||||
@@ -572,6 +624,26 @@ const SearchPage: React.FC = () => {
|
||||
{/* Other Tab - Teams, Sponsors, Contacts, Gallery */}
|
||||
<TabPanel px={0}>
|
||||
<VStack align="stretch" spacing={8}>
|
||||
{results.categories.length > 0 && (
|
||||
<Box>
|
||||
<Heading size="sm" mb={3}>Kategorie</Heading>
|
||||
<SimpleGrid columns={{ base: 2, md: 3, lg: 4 }} spacing={3}>
|
||||
{results.categories.map((c) => (
|
||||
<Button
|
||||
key={c.id}
|
||||
as={RouterLink}
|
||||
to={c.url || '#'}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
colorScheme="pink"
|
||||
justifyContent="flex-start"
|
||||
>
|
||||
{highlight(c.title, q)}
|
||||
</Button>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
)}
|
||||
{results.teams.length > 0 && (
|
||||
<Box>
|
||||
<Heading size="sm" mb={3}>Týmy</Heading>
|
||||
@@ -681,7 +753,7 @@ const SearchPage: React.FC = () => {
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{results.teams.length === 0 && results.sponsors.length === 0 && results.contacts.length === 0 && results.gallery.length === 0 && (
|
||||
{results.categories.length === 0 && results.teams.length === 0 && results.sponsors.length === 0 && results.contacts.length === 0 && results.gallery.length === 0 && (
|
||||
<Text color="gray.500">Žádné další výsledky</Text>
|
||||
)}
|
||||
</VStack>
|
||||
|
||||
@@ -118,6 +118,12 @@ const SetupPage: React.FC = () => {
|
||||
const [youtubeUrl, setYoutubeUrl] = useState('');
|
||||
const [galleryUrl, setGalleryUrl] = useState('');
|
||||
|
||||
const [frontendBaseUrl, setFrontendBaseUrl] = useState('');
|
||||
const [apiBaseUrl, setApiBaseUrl] = useState('');
|
||||
const [isDomainHost, setIsDomainHost] = useState(false);
|
||||
const [showAdvancedApi, setShowAdvancedApi] = useState(false);
|
||||
const [apiUrlTouched, setApiUrlTouched] = useState(false);
|
||||
|
||||
const toast = useToast();
|
||||
const navigate = useNavigate();
|
||||
const bg = useColorModeValue('white', 'gray.800');
|
||||
@@ -166,6 +172,26 @@ const SetupPage: React.FC = () => {
|
||||
return () => { mounted = false; };
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const loc = window.location;
|
||||
const host = loc.hostname;
|
||||
const origin = loc.origin;
|
||||
const isLocal = /^(localhost|127\.0\.0\.1)$/i.test(host);
|
||||
const isIPv4 = /^\d{1,3}(?:\.\d{1,3}){3}$/.test(host);
|
||||
if (isLocal || isIPv4) {
|
||||
setIsDomainHost(false);
|
||||
setFrontendBaseUrl(origin);
|
||||
const apiOrigin = `${loc.protocol}//${host}:8080`;
|
||||
setApiBaseUrl(apiOrigin.replace(/\/$/, '') + '/api/v1');
|
||||
} else {
|
||||
setIsDomainHost(true);
|
||||
setFrontendBaseUrl(origin);
|
||||
setApiBaseUrl(origin.replace(/\/$/, '') + '/api/v1');
|
||||
}
|
||||
} catch {}
|
||||
}, []);
|
||||
|
||||
// Auto-generate JWT secret when setup is required
|
||||
useEffect(() => {
|
||||
if (requiresSetup && !jwtSecret) {
|
||||
@@ -183,6 +209,14 @@ const SetupPage: React.FC = () => {
|
||||
return () => clearTimeout(t);
|
||||
}, [clubQuery, searchClubs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDomainHost && !showAdvancedApi) {
|
||||
if (frontendBaseUrl) {
|
||||
setApiBaseUrl(frontendBaseUrl.replace(/\/$/, '') + '/api/v1');
|
||||
}
|
||||
}
|
||||
}, [isDomainHost, showAdvancedApi, frontendBaseUrl]);
|
||||
|
||||
// Load and apply selected font for preview
|
||||
useEffect(() => {
|
||||
const pairing = FONT_PAIRINGS.find((f) => f.id === selectedFont);
|
||||
@@ -279,6 +313,8 @@ const SetupPage: React.FC = () => {
|
||||
club_name: clubName || undefined,
|
||||
club_logo_url: clubLogoUrl || undefined,
|
||||
club_url: clubUrl || undefined,
|
||||
frontend_base_url: frontendBaseUrl || undefined,
|
||||
api_base_url: apiBaseUrl || undefined,
|
||||
frontpage_style: frontpageStyle || undefined,
|
||||
primary_color: primaryColor || undefined,
|
||||
secondary_color: secondaryColor || undefined,
|
||||
@@ -313,19 +349,49 @@ const SetupPage: React.FC = () => {
|
||||
use_tls: smtpTLS,
|
||||
} : null,
|
||||
};
|
||||
try {
|
||||
const fb = (frontendBaseUrl || '').trim().replace(/\/$/, '');
|
||||
let ab = (apiBaseUrl || '').trim();
|
||||
if (fb || ab) {
|
||||
try {
|
||||
const u = new URL(ab || '', fb || (typeof window !== 'undefined' ? window.location.origin : ''));
|
||||
if (!/\/api\//.test(u.pathname)) { u.pathname = u.pathname.replace(/\/$/, '') + '/api/v1'; }
|
||||
ab = u.toString();
|
||||
} catch {}
|
||||
try { localStorage.setItem('fc_frontend_base_url', fb); } catch {}
|
||||
try { localStorage.setItem('fc_api_base_url', ab); } catch {}
|
||||
try { localStorage.setItem('api_base_url', ab); } catch {}
|
||||
try { (await import('../services/api')).default.defaults.baseURL = ab; } catch {}
|
||||
}
|
||||
} catch {}
|
||||
await initializeSetup(payload);
|
||||
// Set sensible default SEO based on setup data
|
||||
try {
|
||||
const origin = (typeof window !== 'undefined' ? window.location.origin : '').replace(/\/$/, '');
|
||||
const canonical = (frontendBaseUrl || origin || '').replace(/\/$/, '');
|
||||
await updateSeoSettings({
|
||||
site_title: clubName || 'Fotbal Club',
|
||||
site_description: clubName ? `${clubName} – oficiální klubový web: aktuality, zápasy, tabulky, hráči.` : 'Oficiální klubový web: aktuality, zápasy, tabulky, hráči.',
|
||||
default_og_image_url: clubLogoUrl || undefined,
|
||||
canonical_base_url: origin || undefined,
|
||||
canonical_base_url: canonical || undefined,
|
||||
enable_indexing: true,
|
||||
});
|
||||
} catch {}
|
||||
toast({ title: 'Nastavení dokončeno', status: 'success', duration: 3000, isClosable: true });
|
||||
try {
|
||||
const fb = (frontendBaseUrl || '').trim().replace(/\/$/, '');
|
||||
let ab = (apiBaseUrl || '').trim();
|
||||
if (fb || ab) {
|
||||
try {
|
||||
const u = new URL(ab || '', fb || (typeof window !== 'undefined' ? window.location.origin : ''));
|
||||
if (!/\/api\//.test(u.pathname)) { u.pathname = u.pathname.replace(/\/$/, '') + '/api/v1'; }
|
||||
ab = u.toString();
|
||||
} catch {}
|
||||
try { localStorage.setItem('fc_frontend_base_url', fb); } catch {}
|
||||
try { localStorage.setItem('fc_api_base_url', ab); } catch {}
|
||||
try { localStorage.setItem('api_base_url', ab); } catch {}
|
||||
}
|
||||
} catch {}
|
||||
navigate('/login', { replace: true });
|
||||
// Force full reload to ensure app picks up fresh server state and env
|
||||
setTimeout(() => {
|
||||
@@ -599,6 +665,35 @@ const SetupPage: React.FC = () => {
|
||||
|
||||
{/* removed overall style preview per request */}
|
||||
|
||||
<>
|
||||
<Divider my={6} />
|
||||
<Heading as="h3" size="md" mb={2} fontFamily={fontHeading}>🌐 Adresa webu</Heading>
|
||||
<Text fontSize="sm" mb={3} color="gray.600">Zadejte adresu, kde bude web dostupný.</Text>
|
||||
{!isDomainHost && (
|
||||
<Alert status="warning" borderRadius="md" mb={4}>
|
||||
<AlertIcon />
|
||||
<Box>
|
||||
<Text fontSize="sm" fontWeight="medium">Doporučujeme nastavit finální doménu</Text>
|
||||
<Text fontSize="sm">Aktuálně používáte localhost/IP. Nastavení bez vlastní domény nemusí fungovat správně (CORS, cookies, přihlášení, galerie…). Doménu můžete doplnit nyní nebo kdykoli později v Nastavení nebo zde.</Text>
|
||||
</Box>
|
||||
</Alert>
|
||||
)}
|
||||
<VStack align="stretch" spacing={4}>
|
||||
<FormControl isRequired>
|
||||
<FormLabel>URL webu</FormLabel>
|
||||
<Input placeholder="https://www.vasklub.cz" value={frontendBaseUrl} onChange={(e) => { const val = e.target.value; setFrontendBaseUrl(val); let h = ''; try { const u = /^https?:\/\//i.test(val) ? new URL(val) : new URL('https://' + val); h = u.hostname; } catch {} const isLocal = /^(localhost|127\.0\.0\.1)$/i.test(h); const isIPv4 = /^\d{1,3}(?:\.\d{1,3}){3}$/.test(h); const isDomain = !!h && !isLocal && !isIPv4; if (isDomain) { setShowAdvancedApi(true); if (!apiUrlTouched) { let base = (val || '').trim(); if (base && !/^https?:\/\//i.test(base)) base = 'https://' + base; try { const u2 = new URL(base); if (!/\/api\//.test(u2.pathname)) { u2.pathname = u2.pathname.replace(/\/$/, '') + '/api/v1'; } setApiBaseUrl(u2.toString()); } catch { setApiBaseUrl(base.replace(/\/$/, '') + '/api/v1'); } } } }} />
|
||||
</FormControl>
|
||||
<Checkbox isChecked={showAdvancedApi} onChange={(e) => setShowAdvancedApi(e.target.checked)}>Zadat vlastní API URL</Checkbox>
|
||||
{showAdvancedApi && (
|
||||
<FormControl>
|
||||
<FormLabel>API URL</FormLabel>
|
||||
<Input placeholder="https://api.vasklub.cz/api/v1" value={apiBaseUrl} onChange={(e) => { setApiUrlTouched(true); setApiBaseUrl(e.target.value); }} />
|
||||
<FormHelperText>Výchozí: {frontendBaseUrl ? `${frontendBaseUrl.replace(/\/$/, '')}/api/v1` : ''}</FormHelperText>
|
||||
</FormControl>
|
||||
)}
|
||||
</VStack>
|
||||
</>
|
||||
|
||||
<Divider my={6} />
|
||||
|
||||
<Heading as="h3" size="md" mb={2} fontFamily={fontHeading}>🎨 Barvy a vzhled webu</Heading>
|
||||
|
||||
@@ -85,7 +85,7 @@ const AdminActivitiesPage: React.FC = () => {
|
||||
const [draftKey, setDraftKey] = useState<string>('');
|
||||
const [aiPrompt, setAiPrompt] = useState<string>('');
|
||||
const [aiLoading, setAiLoading] = useState<boolean>(false);
|
||||
const [aiTone, setAiTone] = useState<'informative'|'friendly'|'formal'>('friendly');
|
||||
const [aiTone, setAiTone] = useState<'informative'|'friendly'|'formal'>('informative');
|
||||
const [aiOverwrite, setAiOverwrite] = useState<boolean>(true);
|
||||
// Location coordinates for map preview
|
||||
const [locationLat, setLocationLat] = useState<number | undefined>(undefined);
|
||||
@@ -93,6 +93,8 @@ const AdminActivitiesPage: React.FC = () => {
|
||||
// YouTube videos from club channel
|
||||
const [clubVideos, setClubVideos] = useState<YouTubeVideo[]>([]);
|
||||
const [youtubeTab, setYoutubeTab] = useState<'club' | 'custom'>('club');
|
||||
const [savedLocations, setSavedLocations] = useState<Array<{ id: string; label: string; address: string; lat?: number; lng?: number }>>([]);
|
||||
const [selectedSavedId, setSelectedSavedId] = useState<string>('');
|
||||
|
||||
// Auto-save hook - saves draft automatically
|
||||
const { saveStatus, lastSaved, forceSave, clearDraft } = useAutoSave({
|
||||
@@ -153,6 +155,66 @@ const AdminActivitiesPage: React.FC = () => {
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem('admin_saved_locations');
|
||||
const base: Array<{ id: string; label: string; address: string; lat?: number; lng?: number }> = raw ? JSON.parse(raw) : [];
|
||||
const s: any = settingsQ.data || {};
|
||||
const clubAddrParts = [s.contact_address, s.contact_city, s.contact_zip].filter((x: any) => String(x || '').trim());
|
||||
const clubAddr = clubAddrParts.join(', ');
|
||||
const hasCoords = typeof s.location_latitude === 'number' && typeof s.location_longitude === 'number' && !isNaN(s.location_latitude) && !isNaN(s.location_longitude);
|
||||
const label = s.club_name ? `Klub – ${s.club_name}` : 'Klub – Hlavní místo';
|
||||
if (clubAddr || hasCoords) {
|
||||
const exists = base.some((it) => (clubAddr && it.address === clubAddr) || (hasCoords && it.lat === s.location_latitude && it.lng === s.location_longitude));
|
||||
if (!exists) {
|
||||
base.unshift({ id: 'club-main', label, address: clubAddr || (s.contact_city || 'Klub'), lat: hasCoords ? s.location_latitude : undefined, lng: hasCoords ? s.location_longitude : undefined });
|
||||
}
|
||||
}
|
||||
setSavedLocations(base);
|
||||
} catch {
|
||||
setSavedLocations([]);
|
||||
}
|
||||
}, [settingsQ.data]);
|
||||
|
||||
const persistSavedLocations = (list: Array<{ id: string; label: string; address: string; lat?: number; lng?: number }>) => {
|
||||
try { localStorage.setItem('admin_saved_locations', JSON.stringify(list)); } catch {}
|
||||
setSavedLocations(list);
|
||||
};
|
||||
|
||||
const addCurrentLocationToSaved = () => {
|
||||
const address = String(editing?.location || '').trim();
|
||||
const hasCoords = typeof locationLat === 'number' || typeof locationLng === 'number';
|
||||
if (!address && !hasCoords) {
|
||||
toast({ title: 'Nelze uložit místo', description: 'Zadejte název/adresu nebo vyberte souřadnice.', status: 'warning' });
|
||||
return;
|
||||
}
|
||||
const id = String(Date.now());
|
||||
const label = address || 'Uložené místo';
|
||||
const next = [...savedLocations, { id, label, address, lat: locationLat, lng: locationLng }];
|
||||
persistSavedLocations(next);
|
||||
setSelectedSavedId(id);
|
||||
toast({ title: 'Místo uloženo', status: 'success', duration: 2000 });
|
||||
};
|
||||
|
||||
const applySavedLocation = (id: string) => {
|
||||
setSelectedSavedId(id);
|
||||
const item = savedLocations.find((x) => x.id === id);
|
||||
if (!item) return;
|
||||
setLocationLat(item.lat);
|
||||
setLocationLng(item.lng);
|
||||
setEditing(prev => ({ ...(prev || {}), location: item.address, latitude: item.lat as any, longitude: item.lng as any } as any));
|
||||
toast({ title: 'Místo vybráno', description: item.label, status: 'success', duration: 1500 });
|
||||
};
|
||||
|
||||
const deleteSelectedSaved = () => {
|
||||
if (!selectedSavedId) return;
|
||||
if (selectedSavedId === 'club-main') { toast({ title: 'Nelze smazat klubové místo', status: 'info' }); return; }
|
||||
const next = savedLocations.filter((x) => x.id !== selectedSavedId);
|
||||
persistSavedLocations(next);
|
||||
setSelectedSavedId('');
|
||||
toast({ title: 'Uložené místo smazáno', status: 'success', duration: 1500 });
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
// Check for existing draft
|
||||
const key = 'draft-activity-new';
|
||||
@@ -279,14 +341,18 @@ const AdminActivitiesPage: React.FC = () => {
|
||||
if (e.type) lines.push(`Typ: ${e.type}`);
|
||||
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 constraints = 'Nevkládej datum ani místo (lokalitu) do textu. Neuváděj konkrétní čas nebo adresu.';
|
||||
const toneText = aiTone === 'informative'
|
||||
? 'neutrálním, věcným a stručným stylem (bez nadsázky)'
|
||||
: aiTone === 'formal'
|
||||
? 'formálním a profesionálním stylem (bez příkras)'
|
||||
: 'přátelským, ale věcným a stručným stylem (bez nadsázky)';
|
||||
const safeUserPrompt = (aiPrompt || 'Napiš krátkou neutrální pozvánku na klubovou aktivitu.').trim();
|
||||
const constraints = 'Nevkládej datum ani místo (lokalitu) do textu. Neuváděj konkrétní čas nebo adresu. Vyhýbej se superlativům, hyperbolám a marketingovým frázím. Nepoužívej slova jako „neopakovatelný“, „epický“, „úchvatný“ apod. Preferuj 1–2 krátké odstavce nebo stručné odrážky. Dbej na věcný a střízlivý tón.';
|
||||
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: clubName ? `Fanoušci klubu ${clubName}, oznámení/pozvánka` : 'Fanoušci klubu, oznámení/pozvánka',
|
||||
min_words: 120,
|
||||
min_words: 60,
|
||||
});
|
||||
|
||||
// Handle potential JSON string response from AI (defensive parsing)
|
||||
@@ -831,6 +897,30 @@ const AdminActivitiesPage: React.FC = () => {
|
||||
<Box mt={4}>
|
||||
<Heading size="sm" mb={3}>Místo konání</Heading>
|
||||
|
||||
<Box bg={useColorModeValue('gray.50', 'gray.900')} p={4} borderRadius="md" borderWidth="1px" mb={3}>
|
||||
<FormControl>
|
||||
<FormLabel fontSize="sm">Uložená místa (rychlý výběr)</FormLabel>
|
||||
<HStack spacing={2} align="center">
|
||||
<Select
|
||||
size="sm"
|
||||
placeholder="Vyberte uložené místo..."
|
||||
value={selectedSavedId}
|
||||
onChange={(e) => applySavedLocation(e.target.value)}
|
||||
flex={1}
|
||||
>
|
||||
{savedLocations.map((loc) => (
|
||||
<option key={loc.id} value={loc.id}>
|
||||
{loc.label}{loc.address ? ` — ${loc.address}` : ''}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Button size="sm" variant="outline" onClick={addCurrentLocationToSaved}>Uložit aktuální</Button>
|
||||
<Button size="sm" variant="ghost" colorScheme="red" onClick={deleteSelectedSaved} isDisabled={!selectedSavedId || selectedSavedId === 'club-main'}>Smazat</Button>
|
||||
</HStack>
|
||||
<Text fontSize="xs" color={textSecondary} mt={1}>Vyberte klubové nebo dříve uložené místo. „Uložit aktuální“ přidá současný název/adresu a souřadnice.</Text>
|
||||
</FormControl>
|
||||
</Box>
|
||||
|
||||
{/* MapLinkImporter */}
|
||||
<Box bg={useColorModeValue('gray.50', 'gray.900')} p={4} borderRadius="md" borderWidth="1px" mb={3}>
|
||||
<Text fontSize="sm" fontWeight="semibold" mb={2}>Importovat z odkazu na mapu</Text>
|
||||
|
||||
@@ -39,12 +39,12 @@ import {
|
||||
} from '@chakra-ui/react';
|
||||
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 { putMatchOverride, 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';
|
||||
import { parse, format } from 'date-fns';
|
||||
import { assetUrl } from '../../utils/url';
|
||||
import { batchFetchLogosFromSportLogosAPI } from '../../utils/sportLogosAPI';
|
||||
import { API_URL } from '../../services/api';
|
||||
@@ -53,15 +53,10 @@ const MatchesAdminPage = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const toast = useToast();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [focusSide, setFocusSide] = useState<'home' | 'away' | null>(null);
|
||||
const [selected, setSelected] = useState<any | null>(null);
|
||||
const [form, setForm] = useState({
|
||||
home_name_override: '',
|
||||
away_name_override: '',
|
||||
venue_override: '',
|
||||
date_time_override: '',
|
||||
home_logo_url: '',
|
||||
away_logo_url: '',
|
||||
date_time_edit: '',
|
||||
notes: '',
|
||||
});
|
||||
|
||||
@@ -70,6 +65,7 @@ const MatchesAdminPage = () => {
|
||||
queryFn: fetchTeamLogoOverrides,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
const overridesById: Record<string, { name?: string; logo_url?: string }> = (overrides as any)?.by_id || {};
|
||||
|
||||
const normalizeName = (s: string) => {
|
||||
let out = String(s || '');
|
||||
@@ -102,56 +98,58 @@ const MatchesAdminPage = () => {
|
||||
for (const k of Object.keys(byName)) idx[normalizeName(k)] = byName[k];
|
||||
return idx;
|
||||
}, [byName]);
|
||||
// Build name index from overrides by_id for cases where team_id is missing in cached data
|
||||
const overridesNameIndex = useMemo(() => {
|
||||
const idx: Record<string, { id: string; name: string; logo_url: string }> = {};
|
||||
try {
|
||||
for (const [id, v] of Object.entries(overridesById)) {
|
||||
const name = String((v as any)?.name || '').trim();
|
||||
const logo = String((v as any)?.logo_url || '').trim();
|
||||
if (!name) continue;
|
||||
const norm = normalizeName(name);
|
||||
if (!norm) continue;
|
||||
idx[norm] = { id, name, logo_url: logo };
|
||||
}
|
||||
} catch {}
|
||||
return idx;
|
||||
}, [overridesById]);
|
||||
|
||||
const [sportLogosMap, setSportLogosMap] = useState<Record<string, string>>({});
|
||||
|
||||
|
||||
const getLogo = (teamName?: string, teamId?: string, facrOriginal?: string) => {
|
||||
if (!teamName) return assetUrl('/dist/img/logo-club-empty.svg') as string;
|
||||
// 0) Admin override by team ID takes precedence
|
||||
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;
|
||||
}
|
||||
// 0.5) If no ID, but override exists for normalized name, use it
|
||||
try {
|
||||
const hit = overridesNameIndex[normalizeName(teamName)];
|
||||
if (hit && hit.logo_url) {
|
||||
const u = String(hit.logo_url);
|
||||
if (u.startsWith('/')) return assetUrl(u) as string;
|
||||
return u;
|
||||
}
|
||||
} catch {}
|
||||
// 1) LogoAPI map by team ID
|
||||
if (teamId && sportLogosMap[String(teamId)]) return sportLogosMap[String(teamId)];
|
||||
// 2) Local/legacy overrides by name
|
||||
let overrideUrl = byName[teamName];
|
||||
if (!overrideUrl) overrideUrl = byNameNormalized[normalizeName(teamName)];
|
||||
if (overrideUrl) {
|
||||
if (overrideUrl.startsWith('/')) return assetUrl(overrideUrl) as string;
|
||||
return overrideUrl;
|
||||
}
|
||||
// 3) FACR original if provided
|
||||
if (facrOriginal) return facrOriginal;
|
||||
// Fallback placeholder
|
||||
return '/dist/img/logo-club-empty.svg';
|
||||
};
|
||||
|
||||
// External logo upload helpers/state
|
||||
const [homeExternalTeamId, setHomeExternalTeamId] = useState<string>('');
|
||||
const [awayExternalTeamId, setAwayExternalTeamId] = useState<string>('');
|
||||
const [homeUploadedFile, setHomeUploadedFile] = useState<File | null>(null);
|
||||
const [awayUploadedFile, setAwayUploadedFile] = useState<File | null>(null);
|
||||
|
||||
// Team search state
|
||||
const [homeQuery, setHomeQuery] = useState('');
|
||||
const [awayQuery, setAwayQuery] = useState('');
|
||||
const [debouncedHome, setDebouncedHome] = useState('');
|
||||
const [debouncedAway, setDebouncedAway] = useState('');
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebouncedHome(homeQuery), 300);
|
||||
return () => clearTimeout(t);
|
||||
}, [homeQuery]);
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebouncedAway(awayQuery), 300);
|
||||
return () => clearTimeout(t);
|
||||
}, [awayQuery]);
|
||||
const { data: homeResults = [] } = useQuery({
|
||||
queryKey: ['club-search-home', debouncedHome],
|
||||
queryFn: () => searchClubs(debouncedHome),
|
||||
enabled: debouncedHome.trim().length >= 2,
|
||||
});
|
||||
const { data: awayResults = [] } = useQuery({
|
||||
queryKey: ['club-search-away', debouncedAway],
|
||||
queryFn: () => searchClubs(debouncedAway),
|
||||
enabled: debouncedAway.trim().length >= 2,
|
||||
});
|
||||
|
||||
// Upload refs
|
||||
const homeFileRef = useRef<HTMLInputElement | null>(null);
|
||||
const awayFileRef = useRef<HTMLInputElement | null>(null);
|
||||
// Team name/logo editing removed
|
||||
|
||||
const { data: matches = [], isLoading, error } = useQuery<any[], Error>({
|
||||
queryKey: ['admin-matches-list-cache'],
|
||||
@@ -170,6 +168,17 @@ const MatchesAdminPage = () => {
|
||||
|
||||
// Optional: stable sort by date ascending
|
||||
const FACR_DATE_FMT = 'dd.MM.yyyy HH:mm';
|
||||
const formatDisplayDate = (s: string): string => {
|
||||
const str = String(s || '').trim();
|
||||
if (!str) return '';
|
||||
try {
|
||||
const dt = parse(str, FACR_DATE_FMT, new Date());
|
||||
if (!isNaN(dt.getTime())) return format(dt, FACR_DATE_FMT);
|
||||
} catch {}
|
||||
const d2 = new Date(str);
|
||||
if (!isNaN(d2.getTime())) return format(d2, FACR_DATE_FMT);
|
||||
return str;
|
||||
};
|
||||
items.sort((a, b) => {
|
||||
const da = parse(String(a.date_time || a.date), FACR_DATE_FMT, new Date()).getTime();
|
||||
const db = parse(String(b.date_time || b.date), FACR_DATE_FMT, new Date()).getTime();
|
||||
@@ -218,6 +227,17 @@ const MatchesAdminPage = () => {
|
||||
const [sideFilter, setSideFilter] = useState<'home' | 'away' | ''>('');
|
||||
const normalizedTeam = teamFilter.trim().toLowerCase();
|
||||
const FACR_DATE_FMT = 'dd.MM.yyyy HH:mm';
|
||||
const formatDisplayDate = (s: string): string => {
|
||||
const str = String(s || '').trim();
|
||||
if (!str) return '';
|
||||
try {
|
||||
const dt = parse(str, FACR_DATE_FMT, new Date());
|
||||
if (!isNaN(dt.getTime())) return format(dt, FACR_DATE_FMT);
|
||||
} catch {}
|
||||
const d2 = new Date(str);
|
||||
if (!isNaN(d2.getTime())) return format(d2, FACR_DATE_FMT);
|
||||
return str;
|
||||
};
|
||||
// Club name (for side filter)
|
||||
const { data: publicSettings } = useQuery({
|
||||
queryKey: ['public-settings'],
|
||||
@@ -410,78 +430,28 @@ const MatchesAdminPage = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
// Datetime validation (RFC3339-ish)
|
||||
const isDateInvalid = form.date_time_override.trim() !== '' && isNaN(Date.parse(form.date_time_override));
|
||||
// Datetime validation for datetime-local
|
||||
const isDateInvalid = form.date_time_edit.trim() !== '' && isNaN(new Date(form.date_time_edit).getTime());
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const externalMatchId: string = selected?.match_id || selected?.id;
|
||||
if (!externalMatchId) throw new Error('Chybí match_id');
|
||||
const payload: any = { ...form };
|
||||
// normalize empty strings to null so backend can clear values
|
||||
const payload: any = {
|
||||
venue_override: form.venue_override,
|
||||
date_time_override: form.date_time_edit,
|
||||
notes: form.notes,
|
||||
};
|
||||
Object.keys(payload).forEach((k) => {
|
||||
if (payload[k as keyof typeof payload] === '') payload[k as keyof typeof payload] = null;
|
||||
});
|
||||
// First store current overrides
|
||||
await putMatchOverride(externalMatchId, payload);
|
||||
|
||||
// Best-effort upload to logoapi.sportcreative.eu for home/away
|
||||
const results: { home?: { success: boolean; error?: string }; away?: { success: boolean; error?: string } } = {};
|
||||
|
||||
const processSide = async (
|
||||
side: 'home' | 'away',
|
||||
externalTeamId: string,
|
||||
uploadedFile: File | null,
|
||||
nameOverride: string,
|
||||
logoUrl: string | null
|
||||
) => {
|
||||
try {
|
||||
if (!externalTeamId) return { success: false, error: 'Chybí ID týmu' };
|
||||
let file: File | Blob | null = uploadedFile;
|
||||
if (!file && logoUrl) {
|
||||
file = await fetchLogoAsBlob(logoUrl);
|
||||
}
|
||||
if (!file) return { success: false, error: 'Nelze získat soubor loga' };
|
||||
const up = await uploadToLogaSportcreative(externalTeamId, file, {
|
||||
filename: file instanceof File ? file.name : `${externalTeamId}.png`,
|
||||
clubName: nameOverride || 'Neznámý klub',
|
||||
clubType: 'football',
|
||||
});
|
||||
if (!up.success) return { success: false, error: up.error || 'Upload selhal' };
|
||||
if (up.url) {
|
||||
// Patch override to immediately use external URL
|
||||
await patchMatchOverride(
|
||||
externalMatchId,
|
||||
side === 'home' ? { home_logo_url: up.url } : { away_logo_url: up.url }
|
||||
);
|
||||
}
|
||||
return { success: true };
|
||||
} catch (e: any) {
|
||||
return { success: false, error: e?.message || 'Chyba při uploadu' };
|
||||
}
|
||||
};
|
||||
|
||||
if (homeExternalTeamId && (form.home_logo_url || homeUploadedFile)) {
|
||||
results.home = await processSide('home', homeExternalTeamId, homeUploadedFile, form.home_name_override, form.home_logo_url);
|
||||
}
|
||||
if (awayExternalTeamId && (form.away_logo_url || awayUploadedFile)) {
|
||||
results.away = await processSide('away', awayExternalTeamId, awayUploadedFile, form.away_name_override, form.away_logo_url);
|
||||
}
|
||||
|
||||
return { ok: true, results };
|
||||
return { ok: true };
|
||||
},
|
||||
onSuccess: (res: any) => {
|
||||
const r = res?.results || {};
|
||||
const parts: string[] = [];
|
||||
if (r.home) parts.push(r.home.success ? 'Logo domácích nahráno' : `Domácí: ${r.home.error || 'chyba'}`);
|
||||
if (r.away) parts.push(r.away.success ? 'Logo hostů nahráno' : `Hosté: ${r.away.error || 'chyba'}`);
|
||||
const description = parts.length ? parts.join(' • ') : undefined;
|
||||
toast({ title: 'Uloženo', description, status: 'success' });
|
||||
onSuccess: () => {
|
||||
toast({ title: 'Uloženo', status: 'success' });
|
||||
setIsOpen(false);
|
||||
setSelected(null);
|
||||
setHomeUploadedFile(null);
|
||||
setAwayUploadedFile(null);
|
||||
// Invalidate the cache-backed list to refresh any merged overrides
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-matches-list-cache'] });
|
||||
},
|
||||
onError: (e: any) => {
|
||||
@@ -489,57 +459,34 @@ const MatchesAdminPage = () => {
|
||||
},
|
||||
});
|
||||
|
||||
const openEdit = (m: any, side?: 'home' | 'away') => {
|
||||
const openEdit = (m: any) => {
|
||||
setSelected(m);
|
||||
// Convert FACR-style date (e.g., 25.08.2025 18:30) to RFC3339 for backend
|
||||
const facrStr: string = m.date_time || m.date || '';
|
||||
let iso = '';
|
||||
let localStr = '';
|
||||
if (facrStr) {
|
||||
try {
|
||||
const dt = parse(String(facrStr), 'dd.MM.yyyy HH:mm', new Date());
|
||||
if (!isNaN(dt.getTime())) iso = dt.toISOString();
|
||||
if (!isNaN(dt.getTime())) {
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
localStr = `${dt.getFullYear()}-${pad(dt.getMonth() + 1)}-${pad(dt.getDate())}T${pad(dt.getHours())}:${pad(dt.getMinutes())}`;
|
||||
}
|
||||
} catch (_) {
|
||||
// If it's already ISO or another parseable format, keep as-is if valid
|
||||
const d2 = new Date(facrStr);
|
||||
if (!isNaN(d2.getTime())) iso = d2.toISOString();
|
||||
if (!isNaN(d2.getTime())) {
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
localStr = `${d2.getFullYear()}-${pad(d2.getMonth() + 1)}-${pad(d2.getDate())}T${pad(d2.getHours())}:${pad(d2.getMinutes())}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
setForm({
|
||||
home_name_override: m.home || m.home_team || '',
|
||||
away_name_override: m.away || m.away_team || '',
|
||||
venue_override: m.venue || '',
|
||||
date_time_override: iso,
|
||||
home_logo_url: m.home_logo_url || '',
|
||||
away_logo_url: m.away_logo_url || '',
|
||||
date_time_edit: localStr,
|
||||
notes: '',
|
||||
});
|
||||
setIsOpen(true);
|
||||
setFocusSide(side ?? null);
|
||||
// Reset external selections and uploaded files to avoid stale state
|
||||
setHomeExternalTeamId('');
|
||||
setAwayExternalTeamId('');
|
||||
setHomeUploadedFile(null);
|
||||
setAwayUploadedFile(null);
|
||||
};
|
||||
|
||||
// Autofocus on the selected team input when drawer opens
|
||||
const homeInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const awayInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const handleHomeInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setHomeQuery(e.target.value);
|
||||
};
|
||||
const handleAwayInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setAwayQuery(e.target.value);
|
||||
};
|
||||
useEffect(() => {
|
||||
if (isOpen && focusSide) {
|
||||
const t = setTimeout(() => {
|
||||
if (focusSide === 'home') homeInputRef.current?.focus();
|
||||
if (focusSide === 'away') awayInputRef.current?.focus();
|
||||
}, 50);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
}, [isOpen, focusSide]);
|
||||
// Removed autofocus logic for team inputs
|
||||
|
||||
const drawerSize = useBreakpointValue({ base: 'full', md: 'md' });
|
||||
// Horizontal scroll affordance
|
||||
@@ -943,7 +890,7 @@ const MatchesAdminPage = () => {
|
||||
>
|
||||
<Td>
|
||||
<HStack spacing={2}>
|
||||
<Text>{m.date_time || m.date || ''}</Text>
|
||||
<Text>{formatDisplayDate(String(m.date_time || m.date || ''))}</Text>
|
||||
{isPast && <Badge colorScheme="gray" fontSize="xs">Odehráno</Badge>}
|
||||
{!isPast && <Badge colorScheme="green" fontSize="xs">Nadcházející</Badge>}
|
||||
</HStack>
|
||||
@@ -965,7 +912,7 @@ const MatchesAdminPage = () => {
|
||||
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>
|
||||
<Button size="xs" variant="outline" onClick={() => openEdit(m)} borderRadius="md" _hover={{ borderColor: 'brand.primary', color: 'brand.primary' }}>Tým</Button>
|
||||
</HStack>
|
||||
</Td>
|
||||
<Td textAlign="center">
|
||||
@@ -985,7 +932,7 @@ const MatchesAdminPage = () => {
|
||||
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>
|
||||
<Button size="xs" variant="outline" onClick={() => openEdit(m)} borderRadius="md" _hover={{ borderColor: 'brand.primary', color: 'brand.primary' }}>Tým</Button>
|
||||
</HStack>
|
||||
</Td>
|
||||
<Td>{m.venue || ''}</Td>
|
||||
@@ -1023,11 +970,11 @@ const MatchesAdminPage = () => {
|
||||
) : (
|
||||
<Stack spacing={4}>
|
||||
<FormControl>
|
||||
<FormLabel>Datum a čas (ISO)</FormLabel>
|
||||
<FormLabel>Datum a čas</FormLabel>
|
||||
<Input
|
||||
placeholder="YYYY-MM-DDTHH:mm:ss.sssZ"
|
||||
value={form.date_time_override}
|
||||
onChange={(e) => setForm((f) => ({ ...f, date_time_override: e.target.value }))}
|
||||
type="datetime-local"
|
||||
value={form.date_time_edit}
|
||||
onChange={(e) => setForm((f) => ({ ...f, date_time_edit: e.target.value }))}
|
||||
/>
|
||||
{isDateInvalid && (
|
||||
<FormErrorMessage>Neplatný formát data/času</FormErrorMessage>
|
||||
@@ -1043,129 +990,7 @@ const MatchesAdminPage = () => {
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
{/* Home team */}
|
||||
<FormControl>
|
||||
<FormLabel>Domácí tým (název)</FormLabel>
|
||||
<InputGroup>
|
||||
<Input
|
||||
ref={homeInputRef}
|
||||
placeholder="Zadejte název týmu"
|
||||
value={form.home_name_override}
|
||||
onChange={(e) => {
|
||||
setForm((f) => ({ ...f, home_name_override: e.target.value }));
|
||||
handleHomeInput(e);
|
||||
}}
|
||||
/>
|
||||
<InputRightElement width="4.5rem">
|
||||
<Button h="1.75rem" size="sm" onClick={() => homeFileRef.current?.click()}>Logo</Button>
|
||||
</InputRightElement>
|
||||
</InputGroup>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
hidden
|
||||
ref={homeFileRef}
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
const up = await uploadImage(file);
|
||||
setForm((f) => ({ ...f, home_logo_url: up.url }));
|
||||
setHomeUploadedFile(file);
|
||||
toast({ title: 'Logo nahráno (domácí)', status: 'success' });
|
||||
} catch (err: any) {
|
||||
toast({ title: 'Nahrání se nezdařilo', description: err?.message || '', status: 'error' });
|
||||
} finally {
|
||||
if (homeFileRef.current) homeFileRef.current.value = '' as any;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{homeResults.length > 0 && (
|
||||
<Box mt={2} borderWidth="1px" borderRadius="md" p={2} maxH="180px" overflowY="auto">
|
||||
<List spacing={1}>
|
||||
{homeResults.map((r: any) => (
|
||||
<ListItem key={r.id}>
|
||||
<Button size="xs" variant="ghost" onClick={() => {
|
||||
setForm((f) => ({ ...f, home_name_override: r.name, home_logo_url: r.logo_url || f.home_logo_url }));
|
||||
setHomeQuery(r.name);
|
||||
setHomeExternalTeamId(String(r.id || ''));
|
||||
}}>
|
||||
{r.name}
|
||||
</Button>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
)}
|
||||
{form.home_logo_url && (
|
||||
<HStack mt={2} spacing={3}>
|
||||
<Image src={form.home_logo_url} alt="home logo" boxSize="28px" objectFit="contain" />
|
||||
<Button size="xs" variant="outline" onClick={() => setForm((f) => ({ ...f, home_logo_url: '' }))}>Odebrat logo</Button>
|
||||
</HStack>
|
||||
)}
|
||||
</FormControl>
|
||||
|
||||
{/* Away team */}
|
||||
<FormControl>
|
||||
<FormLabel>Hostující tým (název)</FormLabel>
|
||||
<InputGroup>
|
||||
<Input
|
||||
ref={awayInputRef}
|
||||
placeholder="Zadejte název týmu"
|
||||
value={form.away_name_override}
|
||||
onChange={(e) => {
|
||||
setForm((f) => ({ ...f, away_name_override: e.target.value }));
|
||||
handleAwayInput(e);
|
||||
}}
|
||||
/>
|
||||
<InputRightElement width="4.5rem">
|
||||
<Button h="1.75rem" size="sm" onClick={() => awayFileRef.current?.click()}>Logo</Button>
|
||||
</InputRightElement>
|
||||
</InputGroup>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
hidden
|
||||
ref={awayFileRef}
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
const up = await uploadImage(file);
|
||||
setForm((f) => ({ ...f, away_logo_url: up.url }));
|
||||
setAwayUploadedFile(file);
|
||||
toast({ title: 'Logo nahráno (hosté)', status: 'success' });
|
||||
} catch (err: any) {
|
||||
toast({ title: 'Nahrání se nezdařilo', description: err?.message || '', status: 'error' });
|
||||
} finally {
|
||||
if (awayFileRef.current) awayFileRef.current.value = '' as any;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{awayResults.length > 0 && (
|
||||
<Box mt={2} borderWidth="1px" borderRadius="md" p={2} maxH="180px" overflowY="auto">
|
||||
<List spacing={1}>
|
||||
{awayResults.map((r: any) => (
|
||||
<ListItem key={r.id}>
|
||||
<Button size="xs" variant="ghost" onClick={() => {
|
||||
setForm((f) => ({ ...f, away_name_override: r.name, away_logo_url: r.logo_url || f.away_logo_url }));
|
||||
setAwayQuery(r.name);
|
||||
setAwayExternalTeamId(String(r.id || ''));
|
||||
}}>
|
||||
{r.name}
|
||||
</Button>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
)}
|
||||
{form.away_logo_url && (
|
||||
<HStack mt={2} spacing={3}>
|
||||
<Image src={form.away_logo_url} alt="away logo" boxSize="28px" objectFit="contain" />
|
||||
<Button size="xs" variant="outline" onClick={() => setForm((f) => ({ ...f, away_logo_url: '' }))}>Odebrat logo</Button>
|
||||
</HStack>
|
||||
)}
|
||||
</FormControl>
|
||||
{/* Team name/logo editing removed */}
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>Poznámka</FormLabel>
|
||||
|
||||
@@ -438,7 +438,7 @@ const PlayersAdminPage: React.FC = () => {
|
||||
</Select>
|
||||
</HStack>
|
||||
<Box mt={2} fontSize="sm" color="gray.500">
|
||||
{formatDobPreview(dobParts)}{calculateAgeFromParts(dobParts) != null ? ` — ${calculateAgeFromParts(dobParts)} let` : ''}
|
||||
{formatDobPreview(dobParts)}{(() => { const a = calculateAgeFromParts(dobParts); return a != null ? ` — ${a} ${czYears(a)}` : ''; })()}
|
||||
</Box>
|
||||
</FormControl>
|
||||
|
||||
@@ -529,6 +529,9 @@ const PlayersAdminPage: React.FC = () => {
|
||||
<FormLabel>Telefon (nepovinné)</FormLabel>
|
||||
<Input type="tel" value={(editing as any)?.phone || ''} onChange={(e) => setEditing((p) => ({ ...(p as any), phone: e.target.value }))} />
|
||||
</FormControl>
|
||||
<Box gridColumn="1 / -1" fontSize="sm" color="gray.600">
|
||||
Upozornění: telefonní číslo a e‑mail budou viditelné na hlavní stránce. Údaje nejsou povinné — pokud je nechcete zadávat, ponechte je prázdné.
|
||||
</Box>
|
||||
</SimpleGrid>
|
||||
|
||||
<FormControl>
|
||||
@@ -615,6 +618,16 @@ const PlayersAdminPage: React.FC = () => {
|
||||
return age;
|
||||
}
|
||||
|
||||
// Czech pluralization for years: 1 rok, 2–4 roky, 5+ let (11–14 let)
|
||||
function czYears(n: number): string {
|
||||
const mod100 = n % 100;
|
||||
if (mod100 >= 11 && mod100 <= 14) return 'let';
|
||||
const mod10 = n % 10;
|
||||
if (mod10 === 1) return 'rok';
|
||||
if (mod10 >= 2 && mod10 <= 4) return 'roky';
|
||||
return 'let';
|
||||
}
|
||||
|
||||
// Update DOB parts and, when complete, compose YYYY-MM-DD. Clamp day to month length.
|
||||
function updateDobPart(part: 'day'|'month'|'year', value: string) {
|
||||
setDobParts((prev) => {
|
||||
|
||||
@@ -197,6 +197,8 @@ const SettingsAdminPage: React.FC = () => {
|
||||
(typeof (settings as any).location_latitude === 'number') &&
|
||||
(typeof (settings as any).location_longitude === 'number'),
|
||||
map_style: (settings as any).map_style,
|
||||
frontend_base_url: (settings as any).frontend_base_url,
|
||||
api_base_url: (settings as any).api_base_url,
|
||||
// homepage matches display
|
||||
finished_match_display_days: (settings as any).finished_match_display_days as any,
|
||||
};
|
||||
@@ -205,6 +207,22 @@ const SettingsAdminPage: React.FC = () => {
|
||||
toast({ title: 'Uloženo', description: 'Nastavení bylo úspěšně aktualizováno', status: 'success' });
|
||||
// Try to refresh prefetch caches
|
||||
try { await triggerPrefetch(); } catch {}
|
||||
try {
|
||||
const fb = String(((saved as any).frontend_base_url || (settings as any).frontend_base_url || '')).replace(/\/$/, '');
|
||||
let ab = String(((saved as any).api_base_url || (settings as any).api_base_url || '')).trim();
|
||||
if (fb || ab) {
|
||||
try {
|
||||
const u = new URL(ab || '', fb || (typeof window !== 'undefined' ? window.location.origin : ''));
|
||||
if (!/\/api\//.test(u.pathname)) { u.pathname = u.pathname.replace(/\/$/, '') + '/api/v1'; }
|
||||
ab = u.toString();
|
||||
} catch {}
|
||||
try { localStorage.setItem('fc_frontend_base_url', fb); } catch {}
|
||||
try { localStorage.setItem('fc_api_base_url', ab); } catch {}
|
||||
try { localStorage.setItem('api_base_url', ab); } catch {}
|
||||
try { (api as any).defaults.baseURL = ab; } catch {}
|
||||
setTimeout(() => { try { window.location.reload(); } catch {} }, 600);
|
||||
}
|
||||
} catch {}
|
||||
} catch (e: any) {
|
||||
toast({ title: 'Chyba', description: e?.message || 'Uložení nastavení se nezdařilo', status: 'error' });
|
||||
} finally {
|
||||
@@ -271,6 +289,17 @@ const SettingsAdminPage: React.FC = () => {
|
||||
|
||||
<Divider />
|
||||
|
||||
<Heading size="sm">Nastavení URL</Heading>
|
||||
<FormControl>
|
||||
<FormLabel>URL webu</FormLabel>
|
||||
<Input value={(settings as any).frontend_base_url || ''} onChange={handleChange('frontend_base_url' as any)} placeholder="https://www.vasklub.cz" />
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>API URL</FormLabel>
|
||||
<Input value={(settings as any).api_base_url || ''} onChange={handleChange('api_base_url' as any)} placeholder="https://api.vasklub.cz/api/v1" />
|
||||
<FormHelperText>Ujistěte se, že adresa končí na /api/v1</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
<Heading size="sm">Zobrazení zápasů</Heading>
|
||||
<FormControl>
|
||||
<FormLabel>Počet dní zobrazení dokončených zápasů</FormLabel>
|
||||
|
||||
@@ -71,6 +71,7 @@ function normalize(s: string): string {
|
||||
'sportovni klub',
|
||||
'telovychovna jednota',
|
||||
'skolni sportovni klub',
|
||||
'spolek',
|
||||
'fotbal',
|
||||
'futsal',
|
||||
];
|
||||
@@ -139,6 +140,21 @@ const TeamsAdminPage = () => {
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
const overridesById: Record<string, { name?: string; logo_url?: string }> = (overrides as any)?.by_id || {};
|
||||
// Build an index by normalized team name for overrides that carry an ID
|
||||
const overridesNameIndex = useMemo(() => {
|
||||
const idx: Record<string, { id: string; name: string; logo_url: string }> = {};
|
||||
try {
|
||||
for (const [id, v] of Object.entries(overridesById)) {
|
||||
const name = String((v as any)?.name || '').trim();
|
||||
const logo = String((v as any)?.logo_url || '').trim();
|
||||
if (!name) continue;
|
||||
const norm = normalize(name);
|
||||
if (!norm) continue;
|
||||
idx[norm] = { id, name, logo_url: logo };
|
||||
}
|
||||
} catch {}
|
||||
return idx;
|
||||
}, [overridesById]);
|
||||
|
||||
// Fetch logos from logoapi.sportcreative.eu for all teams
|
||||
const [sportLogosMap, setSportLogosMap] = useState<Record<string, string>>({});
|
||||
@@ -186,6 +202,15 @@ const TeamsAdminPage = () => {
|
||||
if (u.startsWith('/')) return assetUrl(u) as string;
|
||||
return u;
|
||||
}
|
||||
// Priority 0.5: Try match by override name when team_id is missing
|
||||
try {
|
||||
const hit = overridesNameIndex[normalize(teamName)];
|
||||
if (hit && hit.logo_url) {
|
||||
const u = String(hit.logo_url);
|
||||
if (u.startsWith('/')) return assetUrl(u) as string;
|
||||
return u;
|
||||
}
|
||||
} catch {}
|
||||
// Priority 1: Local admin override (exact + normalized)
|
||||
let overrideUrl = byName[teamName];
|
||||
if (!overrideUrl) {
|
||||
@@ -217,6 +242,15 @@ const TeamsAdminPage = () => {
|
||||
if (teamId && overridesById[teamId] && overridesById[teamId]?.name) {
|
||||
return String(overridesById[teamId].name || '').trim() || String(teamName || '');
|
||||
}
|
||||
// If no ID, but override exists for the normalized name, use canonical override name
|
||||
try {
|
||||
if (teamName) {
|
||||
const hit = overridesNameIndex[normalize(teamName)];
|
||||
if (hit && hit.name) {
|
||||
return hit.name;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
return String(teamName || '');
|
||||
};
|
||||
|
||||
|
||||
@@ -828,6 +828,19 @@ html {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
/* Ticker variant - continuous left-moving belt */
|
||||
.matches-slider.matches-ticker .ticker-belt {
|
||||
display: flex;
|
||||
gap: 28px;
|
||||
padding: 8px 2px 12px 2px;
|
||||
width: max-content;
|
||||
animation: matches-ticker-left 35s linear infinite;
|
||||
}
|
||||
.matches-slider.matches-ticker:hover .ticker-belt { animation-play-state: paused; }
|
||||
@keyframes matches-ticker-left {
|
||||
0% { transform: translateX(0); }
|
||||
100% { transform: translateX(-33.333%); }
|
||||
}
|
||||
/* Variant: compact_split - two columns (slider left, tabs right) */
|
||||
.matches-slider[data-variant="compact_split"] .matches-grid {
|
||||
display: grid;
|
||||
|
||||
@@ -1,24 +1,23 @@
|
||||
import axios, { AxiosInstance, AxiosResponse, InternalAxiosRequestConfig } from 'axios';
|
||||
import { getToken } from '../utils/auth';
|
||||
|
||||
// Resolve API URL. Some code uses REACT_APP_API_URL (full api path including /api/v1),
|
||||
// others set REACT_APP_API_BASE_URL (backend origin). Normalize so baseURL always points to API root.
|
||||
const envApiUrl = process.env.REACT_APP_API_URL || process.env.REACT_APP_API_BASE_URL;
|
||||
let API_URL = envApiUrl || '/api/v1';
|
||||
function readStored(key: string): string | null {
|
||||
try { return localStorage.getItem(key); } catch { return null; }
|
||||
}
|
||||
|
||||
const storedApi = typeof window !== 'undefined' ? (readStored('fc_api_base_url') || readStored('api_base_url')) : null;
|
||||
const envApiUrl = process.env.REACT_APP_API_URL || process.env.REACT_APP_API_BASE_URL;
|
||||
let API_URL = storedApi || envApiUrl || '/api/v1';
|
||||
|
||||
// If the provided base looks like a backend origin (no /api/), append /api/v1
|
||||
try {
|
||||
const maybe = new URL(API_URL, typeof window !== 'undefined' ? window.location.origin : undefined);
|
||||
if (!/\/api\//.test(maybe.pathname)) {
|
||||
// ensure single trailing slash then append api/v1
|
||||
maybe.pathname = maybe.pathname.replace(/\/$/, '') + '/api/v1';
|
||||
API_URL = maybe.toString();
|
||||
} else {
|
||||
API_URL = maybe.toString();
|
||||
}
|
||||
} catch {
|
||||
// If URL parsing fails, keep API_URL as-is
|
||||
}
|
||||
} catch {}
|
||||
|
||||
export const api: AxiosInstance = axios.create({
|
||||
baseURL: API_URL,
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { Article } from './articles';
|
||||
|
||||
export interface MatchSnapshot {
|
||||
external_match_id?: string;
|
||||
competition?: string;
|
||||
date_time?: string;
|
||||
venue?: string;
|
||||
home?: string;
|
||||
away?: string;
|
||||
score?: string;
|
||||
}
|
||||
|
||||
export function stripHtml(html?: string): string {
|
||||
if (!html) return '';
|
||||
if (typeof window === 'undefined') {
|
||||
return html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
const div = document.createElement('div');
|
||||
div.innerHTML = html;
|
||||
return (div.textContent || div.innerText || '')?.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
export function composeInstagramPostFromArticle(params: {
|
||||
article: Article;
|
||||
trackingUrl: string;
|
||||
clubName?: string;
|
||||
hashtags?: string[];
|
||||
match?: MatchSnapshot | null;
|
||||
}): string {
|
||||
const { article, trackingUrl, clubName, hashtags = [], match } = params;
|
||||
const title = article.title?.trim() || '';
|
||||
const plain = stripHtml(article.content).slice(0, 280);
|
||||
const defaultTags = hashtags.length ? hashtags : [
|
||||
`#${normalizeTag(clubName || 'FKKrnov')}`,
|
||||
'#fotbal',
|
||||
'#modrazluta',
|
||||
];
|
||||
|
||||
if (match && (match.home || match.away)) {
|
||||
const home = match.home || '';
|
||||
const away = match.away || '';
|
||||
const comp = match.competition ? `${match.competition}` : '';
|
||||
const date = match.date_time ? formatDateTime(match.date_time) : '';
|
||||
const score = match.score && /\d/.test(match.score) ? match.score : '';
|
||||
|
||||
const header = `💙💛 ${clubName || 'Náš klub'}: ${title} 💛💙`;
|
||||
const lines = [
|
||||
header,
|
||||
'',
|
||||
score ? `Výsledek: ${home} ${score} ${away}` : `${home} vs ${away}`,
|
||||
comp || date ? `${comp}${comp && date ? ' • ' : ''}${date}` : '',
|
||||
match.venue ? `Místo: ${match.venue}` : '',
|
||||
'',
|
||||
plain ? `${plain}${plain.length === 280 ? '…' : ''}` : '',
|
||||
'',
|
||||
'📸 Celý článek najdeš tady 👇',
|
||||
`🔗 ${trackingUrl}`,
|
||||
'',
|
||||
defaultTags.join(' '),
|
||||
'💙💛'
|
||||
].filter(Boolean);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// Informative/general article
|
||||
const header = `💙💛 ${clubName || 'Náš klub'}: ${title} 💛💙`;
|
||||
const lines = [
|
||||
header,
|
||||
'',
|
||||
plain,
|
||||
'',
|
||||
'📸 Celý článek najdeš tady 👇',
|
||||
`🔗 ${trackingUrl}`,
|
||||
'',
|
||||
defaultTags.join(' '),
|
||||
'💙💛'
|
||||
];
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function composeInstagramPostFromActivity(params: {
|
||||
activity: any;
|
||||
trackingUrl: string;
|
||||
clubName?: string;
|
||||
hashtags?: string[];
|
||||
}): string {
|
||||
const { activity, trackingUrl, clubName, hashtags = [] } = params;
|
||||
const title = String(activity?.title || '').trim();
|
||||
const desc = stripHtml(String(activity?.description || '')).slice(0, 280);
|
||||
const date = activity?.start_time ? formatDateTime(activity.start_time) : '';
|
||||
const place = activity?.location ? String(activity.location) : '';
|
||||
|
||||
const defaultTags = hashtags.length ? hashtags : [
|
||||
`#${normalizeTag(clubName || 'FKKrnov')}`,
|
||||
'#aktivity',
|
||||
'#fotbal',
|
||||
];
|
||||
|
||||
const header = `💙💛 ${clubName || 'Náš klub'}: ${title} 💛💙`;
|
||||
const lines = [
|
||||
header,
|
||||
'',
|
||||
date || place ? `${date}${date && place ? ' • ' : ''}${place}` : '',
|
||||
desc,
|
||||
'',
|
||||
'📸 Více informací najdeš tady 👇',
|
||||
`🔗 ${trackingUrl}`,
|
||||
'',
|
||||
defaultTags.join(' '),
|
||||
'💙💛'
|
||||
].filter(Boolean);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function formatDateTime(dt: string): string {
|
||||
try {
|
||||
const d = new Date(dt);
|
||||
return `${d.toLocaleDateString('cs-CZ')} ${d.toLocaleTimeString('cs-CZ', { hour: '2-digit', minute: '2-digit' })}`;
|
||||
} catch {
|
||||
return dt;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeTag(s: string): string {
|
||||
return s
|
||||
.replace(/[^\p{L}\p{N}]+/gu, '') // remove spaces/symbols
|
||||
.replace(/[áä]/gi, 'a')
|
||||
.replace(/[č]/gi, 'c')
|
||||
.replace(/[ď]/gi, 'd')
|
||||
.replace(/[éěë]/gi, 'e')
|
||||
.replace(/[íï]/gi, 'i')
|
||||
.replace(/[ľĺ]/gi, 'l')
|
||||
.replace(/[ň]/gi, 'n')
|
||||
.replace(/[óö]/gi, 'o')
|
||||
.replace(/[ř]/gi, 'r')
|
||||
.replace(/[š]/gi, 's')
|
||||
.replace(/[ť]/gi, 't')
|
||||
.replace(/[úůü]/gi, 'u')
|
||||
.replace(/[ý]/gi, 'y')
|
||||
.replace(/[ž]/gi, 'z');
|
||||
}
|
||||
@@ -215,6 +215,7 @@ export const ELEMENT_VARIANTS: Record<string, ElementVariant[]> = {
|
||||
{ value: 'carousel', label: 'Karusel', description: 'Horizontální karusel zápasů' },
|
||||
{ value: 'scroller', label: 'Posuvník', description: 'Plynulý horizontální posuvník' },
|
||||
{ value: 'ticker', label: 'Ticker', description: 'Úzký ticker výsledků a zápasů' },
|
||||
{ value: 'compact_split', label: 'Kompaktní rozdělený', description: 'Slider vlevo a taby jako svislé menu vpravo' },
|
||||
],
|
||||
sponsors: [
|
||||
{ value: 'grid', label: 'Mřížka', description: 'Mřížkové rozložení' },
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import api, { API_URL } from './api';
|
||||
import { getArticles } from './articles';
|
||||
import { getCategories } from './categories';
|
||||
import { getPlayers } from './public';
|
||||
import { getUpcomingEvents } from './eventService';
|
||||
import { getSponsors } from './sponsors';
|
||||
@@ -7,7 +8,7 @@ import facrApi from './facr/facrApi';
|
||||
import { getRelatedClubs, RelatedClub } from './relatedClubs';
|
||||
|
||||
export interface SearchResult {
|
||||
type: 'club' | 'match' | 'match_past' | 'article' | 'player' | 'event' | 'sponsor' | 'team' | 'contact' | 'gallery';
|
||||
type: 'club' | 'match' | 'match_past' | 'article' | 'player' | 'event' | 'sponsor' | 'team' | 'contact' | 'gallery' | 'category';
|
||||
id: string | number;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
@@ -32,6 +33,7 @@ export interface SearchResults {
|
||||
teams: SearchResult[];
|
||||
contacts: SearchResult[];
|
||||
gallery: SearchResult[];
|
||||
categories: SearchResult[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
@@ -128,6 +130,7 @@ export async function searchAll(query: string): Promise<SearchResults> {
|
||||
teams: [],
|
||||
contacts: [],
|
||||
gallery: [],
|
||||
categories: [],
|
||||
total: 0,
|
||||
};
|
||||
}
|
||||
@@ -149,6 +152,7 @@ export async function searchAll(query: string): Promise<SearchResults> {
|
||||
teamsRes,
|
||||
contactsRes,
|
||||
galleryRes,
|
||||
categoriesRes,
|
||||
] = await Promise.allSettled([
|
||||
relatedClubsPromise,
|
||||
facrApi.searchClubs(query).catch(() => ({ results: [] })),
|
||||
@@ -243,6 +247,7 @@ export async function searchAll(query: string): Promise<SearchResults> {
|
||||
return [];
|
||||
}
|
||||
})(),
|
||||
getCategories(),
|
||||
]);
|
||||
|
||||
const relatedClubs: RelatedClub[] = relatedClubsRes.status === 'fulfilled' ? relatedClubsRes.value : [];
|
||||
@@ -336,6 +341,7 @@ export async function searchAll(query: string): Promise<SearchResults> {
|
||||
home_logo_url: m.home_logo_url,
|
||||
away_logo_url: m.away_logo_url,
|
||||
venue: m.venue,
|
||||
external_match_id: m.match_id || m.id,
|
||||
},
|
||||
score: Math.max(
|
||||
scoreMatch(m.home || '', q),
|
||||
@@ -380,6 +386,7 @@ export async function searchAll(query: string): Promise<SearchResults> {
|
||||
home_logo_url: m.home_logo_url,
|
||||
away_logo_url: m.away_logo_url,
|
||||
venue: m.venue,
|
||||
external_match_id: m.match_id || m.id,
|
||||
},
|
||||
score: Math.max(
|
||||
scoreMatch(m.home || '', q),
|
||||
@@ -389,14 +396,15 @@ export async function searchAll(query: string): Promise<SearchResults> {
|
||||
),
|
||||
})).sort((a: SearchResult, b: SearchResult) => (b.score || 0) - (a.score || 0));
|
||||
|
||||
// Process articles
|
||||
// Process articles (base by query)
|
||||
const articlesData = articlesRes.status === 'fulfilled' ? articlesRes.value?.data || [] : [];
|
||||
const articles: SearchResult[] = articlesData
|
||||
const baseArticles: SearchResult[] = articlesData
|
||||
.filter((a: any) => {
|
||||
const titleMatch = scoreMatch(a.title || '', q);
|
||||
const excerptMatch = scoreMatch(a.excerpt || '', q);
|
||||
const contentMatch = scoreMatch(a.content || '', q);
|
||||
return titleMatch > 0 || excerptMatch > 0 || contentMatch > 0;
|
||||
const categoryMatch = scoreMatch((a?.category?.name || a?.category_name || '') as string, q);
|
||||
return titleMatch > 0 || excerptMatch > 0 || contentMatch > 0 || categoryMatch > 0;
|
||||
})
|
||||
.map((a: any) => ({
|
||||
type: 'article' as const,
|
||||
@@ -409,11 +417,56 @@ export async function searchAll(query: string): Promise<SearchResults> {
|
||||
score: Math.max(
|
||||
scoreMatch(a.title || '', q),
|
||||
scoreMatch(a.excerpt || '', q) * 0.7,
|
||||
scoreMatch(a.content || '', q) * 0.3
|
||||
scoreMatch(a.content || '', q) * 0.3,
|
||||
scoreMatch((a?.category?.name || a?.category_name || '') as string, q) * 0.8
|
||||
),
|
||||
}))
|
||||
.sort((a: SearchResult, b: SearchResult) => (b.score || 0) - (a.score || 0));
|
||||
|
||||
// Enrichment: articles linked to top matched matches
|
||||
const collectTopMatchIds = (arr: SearchResult[], limit: number): string[] => {
|
||||
const out: string[] = [];
|
||||
for (const item of arr) {
|
||||
if (out.length >= limit) break;
|
||||
const mid = String(item?.metadata?.external_match_id || '').trim();
|
||||
if (mid && !out.includes(mid)) out.push(mid);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
const topUpcoming = collectTopMatchIds(matches, 3);
|
||||
const topPast = collectTopMatchIds(matchesPast, 3);
|
||||
const matchIds = Array.from(new Set([...topUpcoming, ...topPast])).slice(0, 5);
|
||||
let linkedArticlesData: any[] = [];
|
||||
if (matchIds.length > 0) {
|
||||
const linkedSettled = await Promise.allSettled(
|
||||
matchIds.map((id) => getArticles({ match_id: id, published: true, page: 1, page_size: 10 }))
|
||||
);
|
||||
for (const r of linkedSettled) {
|
||||
if (r.status === 'fulfilled' && r.value && Array.isArray((r.value as any).data)) {
|
||||
linkedArticlesData.push(...((r.value as any).data as any[]));
|
||||
}
|
||||
}
|
||||
}
|
||||
const mapArticle = (a: any): SearchResult => ({
|
||||
type: 'article' as const,
|
||||
id: a.id,
|
||||
title: a.title,
|
||||
description: a.excerpt,
|
||||
image_url: a.image_url,
|
||||
url: `/blog/${a.slug || a.id}`,
|
||||
date: a.published_at || a.created_at,
|
||||
score: Math.max(
|
||||
scoreMatch(a.title || '', q),
|
||||
scoreMatch(a.excerpt || '', q) * 0.7,
|
||||
scoreMatch(a.content || '', q) * 0.3,
|
||||
scoreMatch((a?.category?.name || a?.category_name || '') as string, q) * 0.8
|
||||
) + 5, // slight boost for match-linked relevance
|
||||
});
|
||||
const linkedMapped: SearchResult[] = (linkedArticlesData || []).map(mapArticle);
|
||||
const seen = new Set<number | string>(baseArticles.map((a) => a.id));
|
||||
const linkedUnique = linkedMapped.filter((a) => !seen.has(a.id));
|
||||
const articles: SearchResult[] = [...baseArticles, ...linkedUnique].sort((a, b) => (b.score || 0) - (a.score || 0));
|
||||
|
||||
// Process players
|
||||
const playersData = playersRes.status === 'fulfilled' ? playersRes.value : [];
|
||||
const players: SearchResult[] = (Array.isArray(playersData) ? playersData : [])
|
||||
@@ -551,6 +604,20 @@ export async function searchAll(query: string): Promise<SearchResults> {
|
||||
}))
|
||||
.sort((a: any, b: any) => (b.score || 0) - (a.score || 0));
|
||||
|
||||
// Process categories
|
||||
const categoriesList: any[] = categoriesRes && categoriesRes.status === 'fulfilled' ? (categoriesRes.value as any[]) : [];
|
||||
const categories: SearchResult[] = (Array.isArray(categoriesList) ? categoriesList : [])
|
||||
.filter((c: any) => scoreMatch(c?.name || '', q) > 0)
|
||||
.map((c: any) => ({
|
||||
type: 'category' as const,
|
||||
id: c.id,
|
||||
title: c.name,
|
||||
description: c.description,
|
||||
url: `/blog?category_id=${c.id}`,
|
||||
score: scoreMatch(c?.name || '', q),
|
||||
}))
|
||||
.sort((a: any, b: any) => (b.score || 0) - (a.score || 0));
|
||||
|
||||
const total =
|
||||
clubs.length +
|
||||
matches.length +
|
||||
@@ -561,7 +628,8 @@ export async function searchAll(query: string): Promise<SearchResults> {
|
||||
sponsors.length +
|
||||
teams.length +
|
||||
contacts.length +
|
||||
gallery.length;
|
||||
gallery.length +
|
||||
categories.length;
|
||||
|
||||
return {
|
||||
clubs,
|
||||
@@ -574,6 +642,7 @@ export async function searchAll(query: string): Promise<SearchResults> {
|
||||
teams,
|
||||
contacts,
|
||||
gallery,
|
||||
categories,
|
||||
total,
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -589,6 +658,7 @@ export async function searchAll(query: string): Promise<SearchResults> {
|
||||
teams: [],
|
||||
contacts: [],
|
||||
gallery: [],
|
||||
categories: [],
|
||||
total: 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -81,13 +81,13 @@ body.style-pack-modern .section-head h3::after { width: 64px; }
|
||||
}
|
||||
|
||||
/* News list */
|
||||
[data-element="news"] .blog-list {
|
||||
[data-element="news"][data-variant="grid_two"] .blog-list {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--pack-gap-md);
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
[data-element="news"] .blog-list {
|
||||
[data-element="news"][data-variant="grid_two"] .blog-list {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user