This commit is contained in:
Tomas Dvorak
2025-11-02 01:04:02 +01:00
parent ac886502e0
commit b9cea0cd77
153 changed files with 43713 additions and 1700 deletions
+158 -90
View File
@@ -6,14 +6,14 @@ import MainLayout from '../components/layout/MainLayout';
import DOMPurify from 'dompurify';
import { Helmet } from 'react-helmet-async';
import NewsletterCTA from '../components/common/NewsletterCTA';
import SponsorsSection from '../components/common/SponsorsSection';
import EmbeddedPoll from '../components/polls/EmbeddedPoll';
import { ExternalLink, ArrowRight, Eye, Clock } from 'lucide-react';
import { ArrowRight, Eye, Clock, SearchX } from 'lucide-react';
import React from 'react';
import { trackEvent as umamiTrackEvent, trackMatchView as umamiTrackMatchView, trackVideoPlay as umamiTrackVideoPlay, trackArticleView as umamiTrackArticleView } from '../utils/umami';
import { assetUrl } from '../utils/url';
import { API_URL } from '../services/api';
import TeamLogo from '../components/common/TeamLogo';
import MatchModal from '../components/home/MatchModal';
import { extractPalette } from '../utils/colors';
import { getTeamLogo } from '../utils/sportLogosAPI';
import FilePreview from '../components/common/FilePreview';
@@ -23,6 +23,7 @@ import { MatchSnapshot } from '../services/instagram';
import { Widget } from '../components/widgets/Widget';
import { MatchesWidget } from '../components/widgets/MatchesWidget';
import { getUpcomingEvents } from '../services/eventService';
import CommentsSection from '../components/comments/CommentsSection';
const toText = (html?: string) => {
if (!html) return '';
@@ -39,8 +40,7 @@ const ArticleDetailPage: React.FC = () => {
enabled: Boolean(slug || id),
});
// UI colors and public settings
const { data: publicSettings } = usePublicSettings();
const cardBg = useColorModeValue('white','gray.900');
@@ -54,6 +54,8 @@ const ArticleDetailPage: React.FC = () => {
// Derive opponent color (for right edge fade) from team logo
const [opponentColor, setOpponentColor] = React.useState<string | null>(null);
const [isMatchModalOpen, setIsMatchModalOpen] = React.useState(false);
const [selectedMatch, setSelectedMatch] = React.useState<any>(null);
// Placeholders; moved tracking effects below to avoid using variables before declaration
@@ -272,7 +274,7 @@ const ArticleDetailPage: React.FC = () => {
return DOMPurify.sanitize(transformed || '', {
USE_PROFILES: { html: true },
ADD_TAGS: ['iframe'],
ADD_ATTR: ['target', 'rel', 'allow', 'allowfullscreen'],
ADD_ATTR: ['target', 'rel', 'allow', 'allowfullscreen', 'style', 'data-filters', 'data-img-id'],
});
}, [(data as any)?.content, toAbsoluteUploads]);
@@ -294,8 +296,74 @@ const ArticleDetailPage: React.FC = () => {
staleTime: 60_000,
});
const latestArticlesQuery = useQuery({
queryKey: ['latest-articles'],
queryFn: () => getArticles({ page: 1, page_size: 6, published: true }),
enabled: Boolean((isError || !data) && (slug || id)),
staleTime: 60_000,
});
if (isLoading) return <Spinner />;
if (isError || !data) return <Text color="red.500">Článek nenalezen</Text>;
if (isError || !data) return (
<MainLayout>
<Helmet>
<title>Článek nenalezen</title>
<meta name="robots" content="noindex" />
</Helmet>
<Container maxW="4xl" py={{ base: 12, md: 16 }}>
<Stack spacing={6} align="center" textAlign="center">
<Box color="blue.500">
<SearchX size={64} />
</Box>
<Heading size="xl">Článek nenalezen</Heading>
<Text color={textMuted} maxW="2xl">
Je nám líto, ale hledaný článek neexistuje, byl smazán nebo byl přesunut.
</Text>
<HStack spacing={3}>
<Button as={RouterLink} to="/news" colorScheme="blue">Zpět na novinky</Button>
<Button as={RouterLink} to="/" variant="ghost">Domů</Button>
</HStack>
</Stack>
{Array.isArray((latestArticlesQuery.data as any)?.data) && ((latestArticlesQuery.data as any)?.data?.length || 0) > 0 && (
<Box mt={12}>
<Heading as="h2" size="md" mb={4}>Nejnovější články</Heading>
<SimpleGrid columns={{ base: 1, sm: 2, md: 3 }} spacing={4}>
{((latestArticlesQuery.data as any).data || []).slice(0, 6).map((a: any) => {
const link = a.slug ? `/news/${a.slug}` : `/articles/${a.id}`;
return (
<Box
key={a.id}
as={RouterLink}
to={link}
borderWidth="1px"
borderRadius="lg"
p={3}
bg={cardBg}
_hover={{ textDecoration: 'none', boxShadow: 'md' }}
>
<Image
src={assetUrl(a.image_url) || '/stadium-placeholder.jpg'}
alt={a.title}
w="100%"
h="140px"
objectFit="cover"
borderRadius="md"
mb={2}
/>
<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>
)}
</Box>
);
})}
</SimpleGrid>
</Box>
)}
</Container>
</MainLayout>
);
const title = (data as any).seo_title || data.title;
const description = (data as any).seo_description || toText(data.content).slice(0, 160);
@@ -317,6 +385,7 @@ const ArticleDetailPage: React.FC = () => {
targetUrl={typeof window !== 'undefined' ? window.location.href : undefined}
placement="fixed"
size="md"
align="left"
/>
<Helmet>
<title>{title}</title>
@@ -407,35 +476,14 @@ const ArticleDetailPage: React.FC = () => {
<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 borderRadius="xl" overflow="hidden">
<Image src={assetUrl(data.image_url) || data.image_url} alt={data.title} w="100%" h="auto" objectFit="contain" />
</Box>
)}
{(data as any)?.id ? (
<CommentsSection targetType="article" targetId={String((data as any).id)} />
) : null}
{/* 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">
@@ -476,12 +524,12 @@ const ArticleDetailPage: React.FC = () => {
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>;
})()}
{(facrMatchQuery.data as any).venue && <Text fontSize="sm" color={textMuted}>{String((facrMatchQuery.data as any).venue)}</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 || '')} />
@@ -507,10 +555,45 @@ const ArticleDetailPage: React.FC = () => {
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 } }}
sx={{
'ul, ol': { pl: 6, listStylePosition: 'outside' },
'ul': { listStyleType: 'disc' },
'ol': { listStyleType: 'decimal' },
'li': { mb: 2 },
'img': {
display: 'block',
maxWidth: '100%',
height: 'auto',
mt: 2,
border: 'none !important',
outline: 'none !important',
boxShadow: 'none !important',
cursor: 'default !important',
},
}}
dangerouslySetInnerHTML={{ __html: safeContentHTML }}
/>
{/* YouTube Video Section - simplified */}
{(data as any)?.youtube_video_id && (
<Box>
<AspectRatio ratio={16 / 9}>
<Box
as="iframe"
src={`https://www.youtube-nocookie.com/embed/${(data as any).youtube_video_id}`}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
title={(data as any).youtube_video_title || 'YouTube video'}
onLoad={() => umamiTrackEvent('Video Widget Shown', { id: (data as any).youtube_video_id, title: (data as any).youtube_video_title })}
onClick={() => umamiTrackVideoPlay((data as any).youtube_video_id, (data as any).youtube_video_title)}
/>
</AspectRatio>
{(data as any).youtube_video_title ? (
<Text mt={2} color={videoTitleColor}>{(data as any).youtube_video_title}</Text>
) : null}
</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}>
@@ -529,70 +612,46 @@ const ArticleDetailPage: React.FC = () => {
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}>
<SimpleGrid columns={{ base: 2, sm: 3 }} spacing={2} role="group">
{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%)' }} />
<Image key={p.id} src={p.image_1500} alt={String(p.id)} w="100%" h="140px" objectFit="cover" filter="grayscale(100%) blur(1px)" transition="filter 0.2s ease" _hover={{ filter: 'grayscale(0%) blur(0)' }} _groupHover={{ filter: 'grayscale(0%) blur(0)' }} />
))}
</SimpleGrid>
);
}
return (
<Box position="relative" sx={{
<Box position="relative" role="group" 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" />
<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%) blur(1px)" transition="filter 0.2s ease" _hover={{ filter: 'grayscale(0%) blur(0)' }} _groupHover={{ filter: 'grayscale(0%) blur(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%) blur(1px)" transition="filter 0.2s ease" _hover={{ filter: 'grayscale(0%) blur(0)' }} _groupHover={{ filter: 'grayscale(0%) blur(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%) blur(1px)" transition="filter 0.2s ease" _hover={{ filter: 'grayscale(0%) blur(0)' }} _groupHover={{ filter: 'grayscale(0%) blur(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%) blur(1px)" transition="filter 0.2s ease" _hover={{ filter: 'grayscale(0%) blur(0)' }} _groupHover={{ filter: 'grayscale(0%) blur(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%) blur(1px)" transition="filter 0.2s ease" _hover={{ filter: 'grayscale(0%) blur(0)' }} _groupHover={{ filter: 'grayscale(0%) blur(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>
</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 (
{relatedArticlesQuery.isLoading ? null : (() => {
const list = ((relatedArticlesQuery.data as any)?.data || [])
.filter((a: any) => a?.id !== (data as any)?.id)
.slice(0, 4);
if (!list.length) return null;
return (
<Widget title="Podobné články">
<VStack spacing={3} align="stretch">
{list.map((a: any) => {
const link = a.slug ? `/news/${a.slug}` : `/articles/${a.id}`;
@@ -609,19 +668,28 @@ const ArticleDetailPage: React.FC = () => {
);
})}
</VStack>
);
})()}
</Widget>
</Widget>
);
})()}
<MatchesWidget />
<MatchesWidget
categoryName={(data as any)?.category?.name}
hideEmpty
onMatchClick={(m: any) => {
setSelectedMatch({ ...m, competition: (m as any).competitionName, competitionName: (m as any).competitionName });
setIsMatchModalOpen(true);
}}
/>
<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 (
{(() => {
const all = Array.isArray(upcomingEventsQuery.data) ? (upcomingEventsQuery.data as any[]) : [];
const cat = (data as any)?.category?.name;
const items = cat
? all.filter((ev: any) => !ev?.category_name || String(ev.category_name) === String(cat)).slice(0, 3)
: all.slice(0, 3);
if (!items.length) return null;
return (
<Widget title="Nejbližší aktivity">
<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}>
@@ -634,9 +702,9 @@ const ArticleDetailPage: React.FC = () => {
</HStack>
))}
</VStack>
);
})()}
</Widget>
</Widget>
);
})()}
</VStack>
</SimpleGrid>
</Container>
@@ -658,11 +726,11 @@ const ArticleDetailPage: React.FC = () => {
</Box>
</Container>
)}
{/* Polls (Ankety) above CTA */}
{data?.id && <EmbeddedPoll articleId={(data as any).id} maxPolls={3} />}
{/* Newsletter CTA */}
<NewsletterCTA />
{/* Sponsors Section */}
<SponsorsSection />
<MatchModal isOpen={isMatchModalOpen} onClose={() => setIsMatchModalOpen(false)} match={selectedMatch} />
</MainLayout>
);
};