mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 02:32:57 +00:00
446 lines
20 KiB
TypeScript
446 lines
20 KiB
TypeScript
import { Box, Container, Heading, Image, Spinner, Stack, Text, HStack, Badge, Link, SimpleGrid, Button, AspectRatio } 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 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 React from 'react';
|
|
import { trackEvent as umamiTrackEvent, trackMatchView as umamiTrackMatchView, trackVideoPlay as umamiTrackVideoPlay, trackArticleView as umamiTrackArticleView } from '../utils/umami';
|
|
import { assetUrl } from '../utils/url';
|
|
|
|
const toText = (html?: string) => {
|
|
if (!html) return '';
|
|
const div = document.createElement('div');
|
|
div.innerHTML = html;
|
|
return (div.textContent || div.innerText || '').replace(/\s+/g, ' ').trim();
|
|
};
|
|
|
|
const ArticleDetailPage: React.FC = () => {
|
|
const { id, slug } = useParams<{ id?: string; slug?: string }>();
|
|
const { data, isLoading, isError } = useQuery({
|
|
queryKey: ['article', slug ? `slug:${slug}` : `id:${id}`],
|
|
queryFn: () => (slug ? getArticleBySlug(slug!) : getArticle(id!)),
|
|
enabled: Boolean(slug || id),
|
|
});
|
|
|
|
// Placeholders; moved tracking effects below to avoid using variables before declaration
|
|
|
|
// Track article view when data is loaded
|
|
React.useEffect(() => {
|
|
if (data && (data as any).id) {
|
|
trackArticleView((data as any).id);
|
|
// Umami + backend generic event
|
|
umamiTrackArticleView((data as any).id, (data as any).title);
|
|
}
|
|
}, [data]);
|
|
|
|
// Fetch linked match (public)
|
|
const matchLinkQuery = useQuery({
|
|
queryKey: ['article-match-link', (data as any)?.id],
|
|
queryFn: () => getArticleMatchLink((data as any)?.id),
|
|
enabled: Boolean((data as any)?.id),
|
|
staleTime: 60_000,
|
|
});
|
|
|
|
// Track match view when resolved
|
|
React.useEffect(() => {
|
|
const mid = (matchLinkQuery.data as any)?.external_match_id;
|
|
if (mid) {
|
|
umamiTrackMatchView(mid);
|
|
}
|
|
}, [(matchLinkQuery.data as any)?.external_match_id]);
|
|
|
|
// From cached FACR club info, resolve the match details
|
|
const facrMatchQuery = useQuery({
|
|
queryKey: ['facr-cached-match', (matchLinkQuery.data as any)?.external_match_id],
|
|
enabled: Boolean((matchLinkQuery.data as any)?.external_match_id),
|
|
queryFn: async () => {
|
|
const apiUrl = process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1';
|
|
const origin = new URL(apiUrl).origin;
|
|
const url = `${origin}/cache/prefetch/facr_club_info.json`;
|
|
const res = await fetch(url, { cache: 'no-cache' });
|
|
if (!res.ok) return null as any;
|
|
const json = await res.json();
|
|
const comps = Array.isArray(json?.competitions) ? json.competitions : [];
|
|
for (const c of comps) {
|
|
const matches = Array.isArray(c.matches) ? c.matches : [];
|
|
for (const m of matches) {
|
|
const mid = String(m.match_id || m.id);
|
|
if (mid === String((matchLinkQuery.data as any)?.external_match_id)) {
|
|
return { ...m, competitionName: c.name };
|
|
}
|
|
}
|
|
}
|
|
return null as any;
|
|
},
|
|
staleTime: 60_000,
|
|
});
|
|
|
|
// Fetch gallery album if article has one
|
|
const galleryAlbumQuery = useQuery({
|
|
queryKey: ['article-gallery-album', (data as any)?.gallery_album_id],
|
|
enabled: Boolean((data as any)?.gallery_album_id),
|
|
queryFn: async () => {
|
|
const albumId = (data as any)?.gallery_album_id;
|
|
let photoIds: string[] = [];
|
|
const raw = (data as any)?.gallery_photo_ids;
|
|
if (Array.isArray(raw)) {
|
|
photoIds = raw as string[];
|
|
} else if (typeof raw === 'string') {
|
|
// Try JSON parse, otherwise split by comma
|
|
try {
|
|
const parsed = JSON.parse(raw);
|
|
if (Array.isArray(parsed)) {
|
|
photoIds = parsed.map(String);
|
|
} else {
|
|
photoIds = raw.split(',').map(s => s.trim()).filter(Boolean);
|
|
}
|
|
} catch {
|
|
photoIds = raw.split(',').map(s => s.trim()).filter(Boolean);
|
|
}
|
|
}
|
|
|
|
const apiUrl = process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1';
|
|
const origin = new URL(apiUrl).origin;
|
|
|
|
// Try to find album in both sources
|
|
const [profileRes, albumsRes] = await Promise.allSettled([
|
|
fetch(`${origin}/cache/prefetch/zonerama_profile.json`, { cache: 'no-cache' }),
|
|
fetch(`${origin}/cache/prefetch/zonerama_albums.json`, { cache: 'no-cache' })
|
|
]);
|
|
|
|
// Check profile albums first
|
|
if (profileRes.status === 'fulfilled' && profileRes.value.ok) {
|
|
const profileData = await profileRes.value.json();
|
|
const album = (profileData.albums || []).find((a: any) => a.id === albumId);
|
|
if (album) {
|
|
// Filter photos by selected IDs if available
|
|
const photos = photoIds.length > 0
|
|
? album.photos.filter((p: any) => photoIds.includes(p.id))
|
|
: album.photos;
|
|
return { ...album, photos };
|
|
}
|
|
}
|
|
|
|
// Check blog albums
|
|
if (albumsRes.status === 'fulfilled' && albumsRes.value.ok) {
|
|
const albumsData = await albumsRes.value.json();
|
|
const blogAlbums = Array.isArray(albumsData) ? albumsData : [];
|
|
const album = blogAlbums.find((a: any) => a.id === albumId);
|
|
if (album) {
|
|
const photos = photoIds.length > 0
|
|
? album.photos.filter((p: any) => photoIds.includes(p.id))
|
|
: album.photos;
|
|
return { ...album, photos };
|
|
}
|
|
}
|
|
|
|
return null;
|
|
},
|
|
staleTime: 60_000,
|
|
});
|
|
|
|
// Track gallery section when loaded
|
|
React.useEffect(() => {
|
|
const albumId = (data as any)?.gallery_album_id;
|
|
if (albumId && galleryAlbumQuery.data) {
|
|
umamiTrackEvent('Gallery Section Shown', { album_id: albumId, photos: galleryAlbumQuery.data?.photos?.length || 0 });
|
|
}
|
|
}, [(data as any)?.gallery_album_id, galleryAlbumQuery.data]);
|
|
|
|
if (isLoading) return <Spinner />;
|
|
if (isError || !data) return <Text color="red.500">Článek nenalezen</Text>;
|
|
|
|
const title = (data as any).seo_title || data.title;
|
|
const description = (data as any).seo_description || toText(data.content).slice(0, 160);
|
|
const ogImageRaw = (data as any).og_image_url || data.image_url || '/logo512.png';
|
|
const ogImage = assetUrl(ogImageRaw) || ogImageRaw;
|
|
const canonical = typeof window !== 'undefined' ? window.location.href : undefined;
|
|
|
|
// Transform content to ensure /uploads URLs resolve against API origin and allow iframes
|
|
// Memoize the transformation function to prevent infinite loops
|
|
const toAbsoluteUploads = React.useCallback((html?: string) => {
|
|
if (!html) return '';
|
|
try {
|
|
const base = process.env.REACT_APP_API_BASE_URL || process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1';
|
|
const origin = new URL(base).origin;
|
|
// Replace src="/uploads... and href="/uploads...
|
|
return html
|
|
.replace(/src=("|')\s*(\/uploads\/[^"']+)("|')/g, (_m, q1, p2, q3) => `src=${q1}${origin}${p2}${q3}`)
|
|
.replace(/href=("|')\s*(\/uploads\/[^"']+)("|')/g, (_m, q1, p2, q3) => `href=${q1}${origin}${p2}${q3}`);
|
|
} catch {
|
|
return html;
|
|
}
|
|
}, []);
|
|
|
|
const safeContentHTML = React.useMemo(() => {
|
|
const transformed = toAbsoluteUploads(data.content);
|
|
return DOMPurify.sanitize(transformed || '', {
|
|
USE_PROFILES: { html: true },
|
|
ADD_TAGS: ['iframe'],
|
|
ADD_ATTR: ['target', 'rel', 'allow', 'allowfullscreen'],
|
|
});
|
|
}, [data.content, toAbsoluteUploads]);
|
|
|
|
return (
|
|
<MainLayout>
|
|
<Box>
|
|
<Helmet>
|
|
<title>{title}</title>
|
|
<meta name="description" content={description} />
|
|
<meta property="og:type" content="article" />
|
|
<meta property="og:title" content={title} />
|
|
<meta property="og:description" content={description} />
|
|
<meta property="og:image" content={ogImage} />
|
|
<meta name="twitter:card" content="summary_large_image" />
|
|
<meta name="twitter:title" content={title} />
|
|
<meta name="twitter:description" content={description} />
|
|
<meta name="twitter:image" content={ogImage} />
|
|
{canonical && <link rel="canonical" href={canonical} />}
|
|
<script type="application/ld+json">
|
|
{JSON.stringify({
|
|
'@context': 'https://schema.org',
|
|
'@type': 'NewsArticle',
|
|
headline: title,
|
|
image: [ogImage],
|
|
datePublished: (data as any).published_at || data.created_at,
|
|
dateModified: (data as any).updated_at || (data as any).published_at || data.created_at,
|
|
author: data.author ? {
|
|
'@type': 'Person',
|
|
name: `${data.author.first_name || ''} ${data.author.last_name || ''}`.trim() || data.author.email
|
|
} : undefined,
|
|
description,
|
|
mainEntityOfPage: canonical || undefined,
|
|
})}
|
|
</script>
|
|
<script type="application/ld+json">
|
|
{JSON.stringify({
|
|
'@context': 'https://schema.org',
|
|
'@type': 'BreadcrumbList',
|
|
itemListElement: [
|
|
{
|
|
'@type': 'ListItem',
|
|
position: 1,
|
|
name: 'Domů',
|
|
item: (typeof window !== 'undefined' ? window.location.origin : '') + '/'
|
|
},
|
|
{
|
|
'@type': 'ListItem',
|
|
position: 2,
|
|
name: 'Blog',
|
|
item: (typeof window !== 'undefined' ? window.location.origin : '') + '/blog'
|
|
},
|
|
{
|
|
'@type': 'ListItem',
|
|
position: 3,
|
|
name: title,
|
|
item: canonical || undefined
|
|
}
|
|
]
|
|
})}
|
|
</script>
|
|
</Helmet>
|
|
<Box bg="transparent" color="inherit" py={{ base: 6, md: 8 }} mb={4}>
|
|
<Container maxW="7xl">
|
|
<Heading as="h1" size={{ base: 'xl', md: '2xl' }} mb={3}>{data.title}</Heading>
|
|
<HStack spacing={4} fontSize="sm" color="gray.600">
|
|
{((data as any).read_time || (data as any).estimated_read_minutes) && (
|
|
<HStack spacing={1}>
|
|
<Clock size={16} />
|
|
<Text>{(data as any).read_time || (data as any).estimated_read_minutes} min čtení</Text>
|
|
</HStack>
|
|
)}
|
|
{(data as any).view_count !== undefined && (data as any).view_count > 0 && (
|
|
<HStack spacing={1}>
|
|
<Eye size={16} />
|
|
<Text>{(data as any).view_count} zobrazení</Text>
|
|
</HStack>
|
|
)}
|
|
</HStack>
|
|
</Container>
|
|
</Box>
|
|
<Container maxW="7xl">
|
|
<Stack spacing={6}>
|
|
{/* Featured Image - Top */}
|
|
{data.image_url && (
|
|
<Image src={assetUrl(data.image_url) || data.image_url} alt={data.title} borderRadius="lg" />
|
|
)}
|
|
|
|
{/* YouTube Video Section - If attached to article */}
|
|
{(data as any)?.youtube_video_id && (
|
|
<Box borderWidth="1px" borderRadius="lg" p={{ base: 3, md: 4 }} bg="gray.50">
|
|
<Heading as="h3" size="md" mb={2}>🎬 Video k článku</Heading>
|
|
<AspectRatio ratio={16 / 9}>
|
|
<Box
|
|
as="iframe"
|
|
src={`https://www.youtube-nocookie.com/embed/${(data as any).youtube_video_id}`}
|
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
|
allowFullScreen
|
|
title={(data as any).youtube_video_title || 'YouTube video'}
|
|
onLoad={() => umamiTrackEvent('Video Widget Shown', { id: (data as any).youtube_video_id, title: (data as any).youtube_video_title })}
|
|
onClick={() => umamiTrackVideoPlay((data as any).youtube_video_id, (data as any).youtube_video_title)}
|
|
/>
|
|
</AspectRatio>
|
|
{ (data as any).youtube_video_title ? (
|
|
<Text mt={2} color="gray.700">{(data as any).youtube_video_title}</Text>
|
|
) : null }
|
|
</Box>
|
|
)}
|
|
|
|
{/* Match Section - After Image */}
|
|
{(matchLinkQuery.data as any)?.external_match_id && (
|
|
<Box borderWidth="1px" borderRadius="lg" p={{ base: 3, md: 4 }} bg="gray.50">
|
|
<HStack justify="space-between" align="start">
|
|
<Box>
|
|
<Heading as="h3" size="md" mb={1}>Zápas k článku</Heading>
|
|
{facrMatchQuery.isLoading ? (
|
|
<Text color="gray.600">Načítám údaje o zápasu…</Text>
|
|
) : facrMatchQuery.data ? (
|
|
<>
|
|
<HStack spacing={2} wrap="wrap">
|
|
{facrMatchQuery.data.competitionName && (
|
|
<Badge colorScheme="blue">{String(facrMatchQuery.data.competitionName)}</Badge>
|
|
)}
|
|
<Badge>{String(facrMatchQuery.data.date_time || facrMatchQuery.data.date || '')}</Badge>
|
|
</HStack>
|
|
<Text mt={2} fontWeight="600">
|
|
{String(facrMatchQuery.data.home || facrMatchQuery.data.home_team || '')}
|
|
{' '}
|
|
{String(facrMatchQuery.data.score || (facrMatchQuery.data.result_home!=null && facrMatchQuery.data.result_away!=null ? `${facrMatchQuery.data.result_home}:${facrMatchQuery.data.result_away}` : 'vs'))}
|
|
{' '}
|
|
{String(facrMatchQuery.data.away || facrMatchQuery.data.away_team || '')}
|
|
</Text>
|
|
{facrMatchQuery.data.venue && (
|
|
<Text color="gray.600">{String(facrMatchQuery.data.venue)}</Text>
|
|
)}
|
|
{facrMatchQuery.data.report_url && (
|
|
<Box mt={2}>
|
|
<Link href={String(facrMatchQuery.data.report_url)} isExternal color="blue.600">Protokol zápasu (fotbal.cz)</Link>
|
|
</Box>
|
|
)}
|
|
</>
|
|
) : (
|
|
<Text color="gray.600">Propojeno s FACR ID: {(matchLinkQuery.data as any)?.external_match_id}</Text>
|
|
)}
|
|
</Box>
|
|
</HStack>
|
|
</Box>
|
|
)}
|
|
|
|
{/* Article Content - Main Section */}
|
|
<Box
|
|
className="article-content"
|
|
bg="white"
|
|
borderRadius="lg"
|
|
p={{ base: 4, md: 6 }}
|
|
dangerouslySetInnerHTML={{ __html: safeContentHTML }}
|
|
/>
|
|
|
|
{/* Gallery Section - At the End */}
|
|
{(data as any)?.gallery_album_id && (
|
|
<Box borderWidth="1px" borderRadius="lg" p={{ base: 3, md: 4 }} bg="blue.50" borderColor="blue.200">
|
|
<Box mb={3}>
|
|
<HStack justify="space-between" align="center" mb={2}>
|
|
<Heading as="h3" size="md">Fotogalerie k článku</Heading>
|
|
<Button
|
|
as={RouterLink}
|
|
to={`/galerie/album/${(data as any).gallery_album_id}`}
|
|
size="sm"
|
|
colorScheme="blue"
|
|
variant="outline"
|
|
rightIcon={<ArrowRight size={16} />}
|
|
onClick={() => umamiTrackEvent('Gallery Link Click', { album_id: (data as any).gallery_album_id })}
|
|
>
|
|
Zobrazit celé album
|
|
</Button>
|
|
</HStack>
|
|
{galleryAlbumQuery.isLoading ? (
|
|
<Text color="gray.600">Načítám fotografie…</Text>
|
|
) : galleryAlbumQuery.data ? (
|
|
<>
|
|
<HStack spacing={2} mb={3}>
|
|
<Badge colorScheme="purple">
|
|
{galleryAlbumQuery.data.title}
|
|
</Badge>
|
|
{galleryAlbumQuery.data.date && (
|
|
<Badge>{galleryAlbumQuery.data.date}</Badge>
|
|
)}
|
|
<Badge colorScheme="blue">
|
|
{galleryAlbumQuery.data.photos?.length || 0} foto
|
|
</Badge>
|
|
</HStack>
|
|
|
|
{/* Photo Grid */}
|
|
{galleryAlbumQuery.data.photos && galleryAlbumQuery.data.photos.length > 0 && (
|
|
<SimpleGrid columns={{ base: 2, sm: 3, md: 4, lg: 6 }} spacing={2}>
|
|
{galleryAlbumQuery.data.photos.slice(0, 12).map((photo: any) => (
|
|
<Box
|
|
key={photo.id}
|
|
as={RouterLink}
|
|
to={`/galerie/album/${(data as any).gallery_album_id}`}
|
|
position="relative"
|
|
borderRadius="md"
|
|
overflow="hidden"
|
|
cursor="pointer"
|
|
transition="all 0.2s"
|
|
_hover={{ transform: 'scale(1.05)', boxShadow: 'lg' }}
|
|
onClick={() => umamiTrackEvent('Gallery Photo Click', { album_id: (data as any).gallery_album_id, photo_id: photo.id })}
|
|
>
|
|
<Image
|
|
src={photo.image_1500}
|
|
alt={`Fotka ${photo.id}`}
|
|
w="100%"
|
|
h="120px"
|
|
objectFit="cover"
|
|
/>
|
|
</Box>
|
|
))}
|
|
</SimpleGrid>
|
|
)}
|
|
|
|
{/* Zonerama Attribution */}
|
|
<HStack mt={3} spacing={1} fontSize="xs" color="blue.700">
|
|
<Text>📸 Fotografie z</Text>
|
|
<Link
|
|
href={(data as any).gallery_album_url || `https://zonerama.com`}
|
|
isExternal
|
|
fontWeight="600"
|
|
color="blue.600"
|
|
display="inline-flex"
|
|
alignItems="center"
|
|
gap={1}
|
|
>
|
|
Zonerama
|
|
<ExternalLink size={12} />
|
|
</Link>
|
|
</HStack>
|
|
</>
|
|
) : (
|
|
<Text color="gray.600">Album s ID: {(data as any).gallery_album_id}</Text>
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
)}
|
|
</Stack>
|
|
</Container>
|
|
</Box>
|
|
|
|
{/* Embedded Poll - shows polls related to this article */}
|
|
{data?.id && <EmbeddedPoll articleId={data.id} />}
|
|
|
|
{/* Newsletter CTA */}
|
|
<NewsletterCTA />
|
|
|
|
{/* Sponsors Section */}
|
|
<SponsorsSection />
|
|
</MainLayout>
|
|
);
|
|
};
|
|
|
|
export default ArticleDetailPage;
|