mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-03 18:22:57 +00:00
758 lines
38 KiB
TypeScript
758 lines
38 KiB
TypeScript
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, getArticles } from '../services/articles';
|
|
import { articleRead } from '../services/engagement';
|
|
import MainLayout from '../components/layout/MainLayout';
|
|
import DOMPurify from 'dompurify';
|
|
import { Helmet } from 'react-helmet-async';
|
|
import NewsletterCTA from '../components/common/NewsletterCTA';
|
|
import EmbeddedPoll from '../components/polls/EmbeddedPoll';
|
|
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';
|
|
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';
|
|
import CommentsSection from '../components/comments/CommentsSection';
|
|
|
|
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),
|
|
});
|
|
|
|
|
|
// UI colors and public settings
|
|
const { data: publicSettings } = usePublicSettings();
|
|
const cardBg = useColorModeValue('white','gray.900');
|
|
const videoBg = useColorModeValue('gray.50','gray.800');
|
|
const textMuted = useColorModeValue('gray.600','gray.400');
|
|
// Hoist all color mode values to top-level to avoid conditional hook calls
|
|
const videoTitleColor = useColorModeValue('gray.700','gray.300');
|
|
const galleryBg = useColorModeValue('blue.50','blue.900');
|
|
const galleryBorder = useColorModeValue('blue.200','blue.700');
|
|
const attachmentsBg = useColorModeValue('gray.50','gray.800');
|
|
|
|
// Derive opponent color (for right edge fade) from team logo
|
|
const [opponentColor, setOpponentColor] = React.useState<string | null>(null);
|
|
const [isMatchModalOpen, setIsMatchModalOpen] = React.useState(false);
|
|
const [selectedMatch, setSelectedMatch] = React.useState<any>(null);
|
|
|
|
// 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]);
|
|
|
|
// Award engagement for article read after 15s dwell (once per article per device)
|
|
React.useEffect(() => {
|
|
const aid = (data as any)?.id;
|
|
if (!aid) return;
|
|
let timer: any;
|
|
const key = `fc_ar_read_${aid}`;
|
|
const already = (() => { try { return localStorage.getItem(key) === '1'; } catch { return false; } })();
|
|
if (!already) {
|
|
timer = setTimeout(async () => {
|
|
try {
|
|
await articleRead(Number(aid));
|
|
try { localStorage.setItem(key, '1'); } catch {}
|
|
} catch {}
|
|
}, 15000);
|
|
}
|
|
return () => { if (timer) clearTimeout(timer); };
|
|
}, [(data as any)?.id]);
|
|
|
|
// Delegated click tracking for normal links inside content
|
|
const contentRef = React.useRef<HTMLDivElement | null>(null);
|
|
React.useEffect(() => {
|
|
const el = contentRef.current;
|
|
if (!el) return;
|
|
const handler = (e: MouseEvent) => {
|
|
let target = e.target as HTMLElement | null;
|
|
if (!target) return;
|
|
// Find nearest anchor
|
|
const anchor = (target.closest ? target.closest('a') : null) as HTMLAnchorElement | null;
|
|
if (anchor && anchor.href) {
|
|
try {
|
|
const href = anchor.getAttribute('href') || anchor.href;
|
|
umamiTrackEvent('Link Click', { href, page: window.location.pathname, context: 'article_content' });
|
|
} catch {}
|
|
}
|
|
};
|
|
el.addEventListener('click', handler);
|
|
return () => { el.removeEventListener('click', handler); };
|
|
}, [contentRef.current]);
|
|
|
|
// Fetch linked match (public)
|
|
const matchLinkQuery = useQuery({
|
|
queryKey: ['article-match-link', (data as any)?.id],
|
|
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 origin = new URL(API_URL, window.location.origin).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,
|
|
});
|
|
|
|
// 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],
|
|
enabled: Boolean((data as any)?.gallery_album_id || (data as any)?.gallery_album_url),
|
|
queryFn: async () => {
|
|
const albumId = (data as any)?.gallery_album_id;
|
|
let photoIds: string[] = [];
|
|
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 origin = new URL(API_URL, window.location.origin).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 };
|
|
}
|
|
}
|
|
|
|
// Fallback: load by URL via proxy endpoint
|
|
const albumUrl = (data as any)?.gallery_album_url;
|
|
if (albumUrl) {
|
|
const params = new URLSearchParams({ link: albumUrl, photo_limit: '12', rendered: 'true' });
|
|
const resp = await fetch(`${API_URL}/zonerama-album?${params.toString()}`);
|
|
if (resp.ok) {
|
|
const payload = await resp.json();
|
|
let photos: any[] = [];
|
|
if (Array.isArray(payload?.albums) && payload.albums.length > 0) {
|
|
photos = payload.albums[0]?.photos || [];
|
|
} else if (Array.isArray(payload?.photos)) {
|
|
photos = payload.photos;
|
|
}
|
|
if (photoIds.length > 0) photos = photos.filter((p: any) => photoIds.includes(p.id));
|
|
return { id: albumUrl, title: 'Album', date: '', photos } as any;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
},
|
|
staleTime: 60_000,
|
|
});
|
|
|
|
// 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]);
|
|
|
|
// 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 origin = new URL(API_URL, typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000').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}`)
|
|
// Also rewrite absolute localhost/127.0.0.1 uploads links to backend origin
|
|
.replace(/src=("|')\s*https?:\/\/(?:localhost|127\.0\.0\.1)(?::\d+)?(\/uploads\/[^"']+)("')/g, (_m, q1, p2, q3) => `src=${q1}${origin}${p2}${q3}`)
|
|
.replace(/href=("|')\s*https?:\/\/(?:localhost|127\.0\.0\.1)(?::\d+)?(\/uploads\/[^"']+)("')/g, (_m, q1, p2, q3) => `href=${q1}${origin}${p2}${q3}`);
|
|
} catch {
|
|
return html;
|
|
}
|
|
}, []);
|
|
|
|
const safeContentHTML = React.useMemo(() => {
|
|
const transformed = toAbsoluteUploads((data as any)?.content);
|
|
return DOMPurify.sanitize(transformed || '', {
|
|
USE_PROFILES: { html: true },
|
|
ADD_TAGS: ['iframe'],
|
|
ADD_ATTR: ['target', 'rel', 'allow', 'allowfullscreen', 'style', 'data-filters', 'data-img-id'],
|
|
});
|
|
}, [(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,
|
|
});
|
|
|
|
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 (
|
|
<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);
|
|
const ogImageRaw = (data as any).og_image_url || data.image_url || '/logo512.png';
|
|
const ogImage = assetUrl(ogImageRaw) || ogImageRaw;
|
|
const canonical = typeof window !== 'undefined' ? window.location.href : undefined;
|
|
const publishedAt = (data as any).published_at || (data as any).created_at;
|
|
const monthParam = (() => {
|
|
if (!publishedAt) return '';
|
|
try { const d = new Date(publishedAt); return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}`; } catch { return ''; }
|
|
})();
|
|
|
|
return (
|
|
<MainLayout>
|
|
<Box>
|
|
<InstagramGeneratorButton
|
|
article={data as any}
|
|
match={matchSnapshot}
|
|
targetUrl={typeof window !== 'undefined' ? window.location.href : undefined}
|
|
placement="fixed"
|
|
size="md"
|
|
align="left"
|
|
/>
|
|
<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={2}>
|
|
<Container maxW="7xl">
|
|
<Heading as="h1" size={{ base: 'xl', md: '2xl' }} mb={2}>{data.title}</Heading>
|
|
<HStack spacing={2} rowGap={2} wrap="wrap" fontSize="sm" color={textMuted}>
|
|
{((data as any).read_time || (data as any).estimated_read_minutes) ? (
|
|
<HStack spacing={1}>
|
|
<Clock size={16} />
|
|
<Text>{(data as any).read_time || (data as any).estimated_read_minutes} min čtení</Text>
|
|
</HStack>
|
|
) : null}
|
|
{publishedAt && (
|
|
<Tag as={RouterLink} to={`/news?month=${monthParam}`} size="sm" variant="subtle">{new Date(publishedAt).toLocaleDateString('cs-CZ')}</Tag>
|
|
)}
|
|
{(data as any)?.category?.id && (
|
|
<Tag as={RouterLink} to={`/news?category_id=${(data as any).category.id}`} size="sm" variant="subtle">{(data as any).category.name || 'Kategorie'}</Tag>
|
|
)}
|
|
{(matchLinkQuery.data as any)?.external_match_id && (
|
|
<Tag as={RouterLink} to={`/news?match_id=${(matchLinkQuery.data as any).external_match_id}`} size="sm" variant="subtle">Zápas</Tag>
|
|
)}
|
|
{(data as any).view_count ? (
|
|
<HStack spacing={1} ml={{ base: 0, md: 2 }}>
|
|
<Eye size={16} />
|
|
<Text>{(data as any).view_count} zobrazení</Text>
|
|
</HStack>
|
|
) : null}
|
|
</HStack>
|
|
</Container>
|
|
</Box>
|
|
<Container maxW="7xl">
|
|
<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 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">
|
|
{/* Edge fades */}
|
|
<Box position="absolute" top={0} left={0} bottom={0} w={{ base: '6px', md: '12px' }} bgGradient={`linear(to-r, var(--club-primary, #0b5cff), transparent)`} pointerEvents="none" />
|
|
{opponentColor && (
|
|
<Box position="absolute" top={0} right={0} bottom={0} w={{ base: '6px', md: '12px' }} bgGradient={`linear(to-l, ${opponentColor}, transparent)`} pointerEvents="none" />
|
|
)}
|
|
<Heading as="h3" size="md" mb={3}>Zápas k článku</Heading>
|
|
{facrMatchQuery.isLoading ? (
|
|
<Text color={textMuted}>Načítám údaje o zápasu…</Text>
|
|
) : facrMatchQuery.data ? (
|
|
<>
|
|
<HStack spacing={2} wrap="wrap" mb={3}>
|
|
{facrMatchQuery.data.competitionName && (
|
|
<Badge colorScheme="blue">{String(facrMatchQuery.data.competitionName)}</Badge>
|
|
)}
|
|
<Badge>{String((facrMatchQuery.data as any).date_time || (facrMatchQuery.data as any).date || '')}</Badge>
|
|
</HStack>
|
|
<Flex align="center" justify="space-between" gap={4}>
|
|
<VStack flex={1} spacing={2} minW="0">
|
|
<TeamLogo size="custom" style={{ width: 64, height: 64 }} teamId={String((facrMatchQuery.data as any).home_team_id || (facrMatchQuery.data as any).home_id || '')} teamName={String((facrMatchQuery.data as any).home || (facrMatchQuery.data as any).home_team || '')} />
|
|
<Text fontWeight="600" noOfLines={2} textAlign="center">{String((facrMatchQuery.data as any).home || (facrMatchQuery.data as any).home_team || '')}</Text>
|
|
</VStack>
|
|
<VStack minW={{ base: '100px', md: '140px' }}>
|
|
{(() => {
|
|
const dRaw = String((facrMatchQuery.data as any).date_time || (facrMatchQuery.data as any).date || '');
|
|
const d = new Date(dRaw);
|
|
const hasScore = ((facrMatchQuery.data as any).result_home != null && (facrMatchQuery.data as any).result_away != null) || Boolean((facrMatchQuery.data as any).score && (facrMatchQuery.data as any).score !== 'vs');
|
|
if (hasScore) {
|
|
const score = String((facrMatchQuery.data as any).score || `${(facrMatchQuery.data as any).result_home}:${(facrMatchQuery.data as any).result_away}`);
|
|
return (<Heading size="2xl">{score}</Heading>);
|
|
}
|
|
const now = Date.now();
|
|
const ms = d.getTime() - now;
|
|
const days = Math.max(0, Math.floor(ms / (1000*60*60*24)));
|
|
const hours = Math.max(0, Math.floor((ms % (1000*60*60*24))/(1000*60*60)));
|
|
const mins = Math.max(0, Math.floor((ms % (1000*60*60))/(1000*60)));
|
|
return (<Text fontSize="lg" fontWeight="700">Za {days} d {hours} h {mins} min</Text>);
|
|
})()}
|
|
{(() => {
|
|
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 || '')} />
|
|
<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>
|
|
)}
|
|
|
|
{/* 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 },
|
|
'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}>
|
|
<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>
|
|
{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} 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%) blur(1px)" transition="filter 0.2s ease" _hover={{ filter: 'grayscale(0%) blur(0)' }} _groupHover={{ filter: 'grayscale(0%) blur(0)' }} />
|
|
))}
|
|
</SimpleGrid>
|
|
);
|
|
}
|
|
return (
|
|
<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%) 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>
|
|
);
|
|
})()}
|
|
</Box>
|
|
</Box>
|
|
)}
|
|
</Stack>
|
|
</Box>
|
|
<VStack align="stretch" spacing={6} gridColumn={{ base: '1 / -1', lg: 'span 4' }}>
|
|
{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}`;
|
|
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
|
|
categoryName={(data as any)?.category?.name}
|
|
hideEmpty
|
|
onMatchClick={(m: any) => {
|
|
setSelectedMatch({ ...m, competition: (m as any).competitionName, competitionName: (m as any).competitionName });
|
|
setIsMatchModalOpen(true);
|
|
}}
|
|
/>
|
|
|
|
{(() => {
|
|
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}>
|
|
<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>
|
|
|
|
{/* Attachments - bottom above CTA */}
|
|
{Array.isArray((data as any)?.attachments) && (data as any).attachments.length > 0 && (
|
|
<Container maxW="7xl" mt={4}>
|
|
<Box borderWidth="1px" borderRadius="lg" p={{ base: 3, md: 4 }} bg={attachmentsBg}>
|
|
<Heading as="h3" size="md" mb={2}>Přílohy</Heading>
|
|
<Stack spacing={2}>
|
|
{(data as any).attachments.map((f: any, idx: number) => (
|
|
<HStack key={idx} justify="space-between">
|
|
<Text noOfLines={1}>{f.name || f.url}</Text>
|
|
<FilePreview url={assetUrl(f.url) || f.url} name={f.name || ''} mimeType={f.mime_type || ''} size={f.size} />
|
|
</HStack>
|
|
))}
|
|
</Stack>
|
|
</Box>
|
|
</Container>
|
|
)}
|
|
{/* Polls (Ankety) above CTA */}
|
|
{data?.id && <EmbeddedPoll articleId={(data as any).id} maxPolls={3} />}
|
|
{/* Newsletter CTA */}
|
|
<NewsletterCTA />
|
|
<MatchModal isOpen={isMatchModalOpen} onClose={() => setIsMatchModalOpen(false)} match={selectedMatch} />
|
|
</MainLayout>
|
|
);
|
|
};
|
|
|
|
export default ArticleDetailPage;
|