This commit is contained in:
Tomas Dvorak
2025-10-31 18:22:04 +01:00
parent 16e4533202
commit ac886502e0
65 changed files with 3211 additions and 553 deletions
+19 -4
View File
@@ -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>
+294 -172
View File
@@ -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>
+10
View File
@@ -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>
);
};
+87 -10
View File
@@ -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;
}
+18 -4
View File
@@ -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;
+26 -5
View File
@@ -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;
+74 -2
View File
@@ -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>
+96 -1
View File
@@ -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 12 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>
+91 -266
View File
@@ -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>
+14 -1
View File
@@ -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 email 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, 24 roky, 5+ let (1114 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 || '');
};
+13
View File
@@ -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;