mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 02:32:57 +00:00
dev day #79
This commit is contained in:
@@ -339,9 +339,6 @@ const AboutPage: React.FC = () => {
|
||||
|
||||
{/* Newsletter CTA */}
|
||||
<NewsletterCTA />
|
||||
|
||||
{/* Sponsors Section */}
|
||||
<SponsorsSection />
|
||||
</MainLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@ import EventLocationMap from '../components/events/EventLocationMap';
|
||||
import EmbeddedPoll from '../components/polls/EmbeddedPoll';
|
||||
import FilePreview from '../components/common/FilePreview';
|
||||
import InstagramGeneratorButton from '../components/admin/InstagramGeneratorButton';
|
||||
import CommentsSection from '../components/comments/CommentsSection';
|
||||
|
||||
const ActivityDetailPage: React.FC = () => {
|
||||
const { id } = useParams();
|
||||
@@ -122,6 +123,7 @@ const ActivityDetailPage: React.FC = () => {
|
||||
targetUrl={typeof window !== 'undefined' ? window.location.href : undefined}
|
||||
placement="fixed"
|
||||
size="md"
|
||||
align="left"
|
||||
/>
|
||||
<Container maxW="3xl">
|
||||
{loading && (
|
||||
@@ -179,10 +181,24 @@ const ActivityDetailPage: React.FC = () => {
|
||||
' p': { lineHeight: 1.8, mb: 3 },
|
||||
' ul, ol': { pl: 6, mb: 3 },
|
||||
' a': { color: linkColor, textDecoration: 'underline', _hover: { color: linkHoverColor } },
|
||||
' img': { maxWidth: '100%', borderRadius: 'md' },
|
||||
' img': {
|
||||
display: 'block',
|
||||
maxWidth: '100%',
|
||||
height: 'auto',
|
||||
mt: 2,
|
||||
border: 'none !important',
|
||||
outline: 'none !important',
|
||||
boxShadow: 'none !important',
|
||||
cursor: 'default',
|
||||
borderRadius: 'md',
|
||||
},
|
||||
}}
|
||||
ref={contentRef}
|
||||
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(toAbsoluteUploads(String(data.description))) }}
|
||||
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(toAbsoluteUploads(String(data.description)), {
|
||||
USE_PROFILES: { html: true },
|
||||
ADD_TAGS: ['iframe'],
|
||||
ADD_ATTR: ['target', 'rel', 'allow', 'allowfullscreen', 'style', 'data-filters', 'data-img-id'],
|
||||
}) }}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -194,7 +210,9 @@ const ActivityDetailPage: React.FC = () => {
|
||||
src={getYouTubeEmbedUrl(data.youtube_url) || ''}
|
||||
title={data.title}
|
||||
frameBorder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
loading="lazy"
|
||||
referrerPolicy="strict-origin-when-cross-origin"
|
||||
allowFullScreen
|
||||
/>
|
||||
</Box>
|
||||
@@ -214,6 +232,10 @@ const ActivityDetailPage: React.FC = () => {
|
||||
<EmbeddedPoll eventId={data.id} maxPolls={2} />
|
||||
)}
|
||||
|
||||
{data?.id && (
|
||||
<CommentsSection targetType="event" targetId={String(data.id)} />
|
||||
)}
|
||||
|
||||
{(Array.isArray(data.attachments) && data.attachments.length > 0) && (
|
||||
<VStack align="stretch" spacing={3}>
|
||||
<Heading as="h3" size="sm">Přílohy</Heading>
|
||||
|
||||
@@ -22,6 +22,7 @@ import { ChevronRight, ExternalLink, Calendar, Image as ImageIcon } from 'lucide
|
||||
import MainLayout from '../components/layout/MainLayout';
|
||||
import { API_URL } from '../services/api';
|
||||
import PhotoModal from '../components/gallery/PhotoModal';
|
||||
import CommentsSection from '../components/comments/CommentsSection';
|
||||
|
||||
interface Photo {
|
||||
id: string;
|
||||
@@ -287,6 +288,13 @@ const AlbumDetailPage: React.FC = () => {
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Comments */}
|
||||
{album.id && (
|
||||
<Box mt={6}>
|
||||
<CommentsSection targetType="gallery_album" targetId={String(album.id)} />
|
||||
</Box>
|
||||
)}
|
||||
</Container>
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import React from 'react';
|
||||
import { Box, Container, Heading, VStack, Image, Text, Skeleton, LinkBox, HStack, Select, Badge, useColorModeValue, Input, InputGroup, InputLeftElement, InputRightElement, IconButton, Grid, GridItem } from '@chakra-ui/react';
|
||||
import { Box, Container, Heading, VStack, Image, Text, Skeleton, LinkBox, HStack, Select, Badge, useColorModeValue, Input, InputGroup, InputLeftElement, InputRightElement, IconButton, Grid, GridItem, useMediaQuery } from '@chakra-ui/react';
|
||||
import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
|
||||
import { getArticles, Article, Paginated, getFeaturedArticles } from '../services/articles';
|
||||
import { getBanners, Banner as UIBanner } from '../services/banners';
|
||||
import { Link as RouterLink, useSearchParams } from 'react-router-dom';
|
||||
import { assetUrl } from '../utils/url';
|
||||
import MainLayout from '../components/layout/MainLayout';
|
||||
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';
|
||||
import { Helmet } from 'react-helmet-async';
|
||||
|
||||
const BlogTile: React.FC<{ article: Article; variant?: 'large' | 'small' }> = ({ article, variant }) => {
|
||||
const link = article.slug ? `/news/${article.slug}` : `/articles/${article.id}`;
|
||||
@@ -36,7 +37,17 @@ const BlogTile: React.FC<{ article: Article; variant?: 'large' | 'small' }> = ({
|
||||
position="relative"
|
||||
>
|
||||
<Box position="relative">
|
||||
<Image src={assetUrl(article.image_url) || '/stadium-placeholder.jpg'} alt={article.title} w="100%" h={imageH} objectFit="cover" />
|
||||
<Image
|
||||
src={assetUrl(article.image_url) || '/stadium-placeholder.jpg'}
|
||||
alt={article.title}
|
||||
w="100%"
|
||||
h={imageH}
|
||||
objectFit="cover"
|
||||
loading={variant === 'large' ? 'eager' : 'lazy'}
|
||||
decoding="async"
|
||||
sizes={variant === 'large' ? '(min-width: 768px) 60vw, 100vw' : '100vw'}
|
||||
fetchPriority={variant === 'large' ? 'high' as any : 'auto' as any}
|
||||
/>
|
||||
<Box position="absolute" inset={0} bgGradient="linear(to-t, rgba(0,0,0,0.55), rgba(0,0,0,0.15))" />
|
||||
{categoryName && (
|
||||
<Badge
|
||||
@@ -204,6 +215,17 @@ const BlogPage: React.FC = () => {
|
||||
const featuredIdSet = React.useMemo(() => new Set((featuredList || []).map((a) => a.id)), [featuredList]);
|
||||
const visibleArticles = featuredList.length ? articles.filter((a) => !featuredIdSet.has(a.id)) : articles;
|
||||
|
||||
// Fetch inline article banners (active, placement=article_inline)
|
||||
const articleBannersQ = useQuery<UIBanner[]>(
|
||||
['banners', { placement: 'article_inline' }],
|
||||
() => getBanners({ active: true, placement: 'article_inline' }),
|
||||
{ staleTime: 60 * 1000 }
|
||||
);
|
||||
const articleBanners = (articleBannersQ.data || []) as UIBanner[];
|
||||
// Decide insertion index depending on layout (1 column vs multi-column)
|
||||
const [isSmUp] = useMediaQuery('(min-width: 30em)'); // Chakra sm breakpoint (~480px)
|
||||
const insertionIndex = isSmUp ? 5 : 2;
|
||||
|
||||
// Infinite scroll via intersection observer
|
||||
const sentinelRef = React.useRef<HTMLDivElement | null>(null);
|
||||
React.useEffect(() => {
|
||||
@@ -219,8 +241,42 @@ const BlogPage: React.FC = () => {
|
||||
return () => io.disconnect();
|
||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
// Derive page SEO title/description
|
||||
const parts: string[] = ['Blog'];
|
||||
if (categoryId) {
|
||||
const cat = categories.find((c) => c.id === Number(categoryId));
|
||||
if (cat?.name) parts.push(cat.name);
|
||||
}
|
||||
if (month) parts.push(month);
|
||||
if (matchId) parts.push('Zápas');
|
||||
if (qParam) parts.push(`Hledání: ${qParam}`);
|
||||
const pageTitle = parts.join(' · ');
|
||||
const pageDesc = qParam
|
||||
? `Výsledky hledání článků pro „${qParam}“.`
|
||||
: categoryId
|
||||
? `Články v kategorii ${(categories.find((c) => c.id === Number(categoryId))?.name) || ''}.`
|
||||
: 'Nejnovější články, rozhovory a novinky z klubu.';
|
||||
const canonical = typeof window !== 'undefined' ? window.location.href : undefined;
|
||||
|
||||
return (
|
||||
<MainLayout>
|
||||
<Helmet>
|
||||
<title>{pageTitle}</title>
|
||||
<meta name="description" content={pageDesc} />
|
||||
{canonical && <link rel="canonical" href={canonical} />}
|
||||
<script type="application/ld+json">
|
||||
{JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'ItemList',
|
||||
itemListElement: (featuredList.concat(visibleArticles)).slice(0, 12).map((a, idx) => ({
|
||||
'@type': 'ListItem',
|
||||
position: idx + 1,
|
||||
url: (typeof window !== 'undefined' ? window.location.origin : '') + (a.slug ? `/news/${a.slug}` : `/articles/${a.id}`),
|
||||
name: a.title,
|
||||
})),
|
||||
})}
|
||||
</script>
|
||||
</Helmet>
|
||||
<Box>
|
||||
{/* Header like blog.html */}
|
||||
<Box bg="transparent" color="inherit" py={{ base: 8, md: 10 }} mb={4} borderBottom="1px" borderColor={borderColor}>
|
||||
@@ -322,18 +378,46 @@ const BlogPage: React.FC = () => {
|
||||
{isLoading && Array.from({ length: 9 }).map((_, i) => (
|
||||
<Skeleton key={i} h={{ base: '220px', md: '260px' }} borderRadius="md" mb={7} />
|
||||
))}
|
||||
{!isLoading && visibleArticles.map((a) => (
|
||||
<Box
|
||||
key={a.id}
|
||||
mb={7}
|
||||
sx={{
|
||||
breakInside: 'avoid',
|
||||
WebkitColumnBreakInside: 'avoid',
|
||||
pageBreakInside: 'avoid',
|
||||
}}
|
||||
>
|
||||
<BlogTile article={a} />
|
||||
</Box>
|
||||
{!isLoading && visibleArticles.map((a, idx) => (
|
||||
<React.Fragment key={`row-${a.id}`}>
|
||||
<Box
|
||||
mb={7}
|
||||
sx={{
|
||||
breakInside: 'avoid',
|
||||
WebkitColumnBreakInside: 'avoid',
|
||||
pageBreakInside: 'avoid',
|
||||
}}
|
||||
>
|
||||
<BlogTile article={a} />
|
||||
</Box>
|
||||
{articleBanners.length > 0 && idx === insertionIndex && (
|
||||
<Box
|
||||
key={`banner-inline-${articleBanners[0].id}`}
|
||||
mb={7}
|
||||
sx={{
|
||||
breakInside: 'avoid',
|
||||
WebkitColumnBreakInside: 'avoid',
|
||||
pageBreakInside: 'avoid',
|
||||
}}
|
||||
>
|
||||
<a
|
||||
href={articleBanners[0].click_url || '#'}
|
||||
target={articleBanners[0].click_url ? '_blank' : undefined}
|
||||
rel={articleBanners[0].click_url ? 'noopener noreferrer' : undefined}
|
||||
style={{ display: 'block' }}
|
||||
>
|
||||
{/* eslint-disable-next-line jsx-a11y/alt-text */}
|
||||
<img
|
||||
src={assetUrl(articleBanners[0].image_url) || '/images/sponsors/placeholder.png'}
|
||||
alt={articleBanners[0].name}
|
||||
style={{ width: '100%', height: 'auto', display: 'block' }}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</a>
|
||||
</Box>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Box>
|
||||
{!isLoading && !featuredList.length && !visibleArticles.length && (
|
||||
@@ -353,8 +437,6 @@ const BlogPage: React.FC = () => {
|
||||
{/* Newsletter CTA */}
|
||||
<NewsletterCTA />
|
||||
|
||||
{/* Sponsors Section */}
|
||||
<SponsorsSection />
|
||||
</Box>
|
||||
</MainLayout>
|
||||
);
|
||||
|
||||
@@ -7,7 +7,6 @@ import { cs } from 'date-fns/locale';
|
||||
import { getCompetitionAliasesPublic, CompetitionAlias } from '../services/competitionAliases';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useCountdown, useMultipleCountdowns } from '../hooks/useCountdown';
|
||||
import SponsorsSection from '../components/common/SponsorsSection';
|
||||
import NewsletterCTA from '../components/common/NewsletterCTA';
|
||||
import { sortCategoriesWithOrder } from '../utils/categorySort';
|
||||
import ClubModal from '../components/home/ClubModal';
|
||||
@@ -1298,9 +1297,6 @@ const CalendarPage: React.FC = () => {
|
||||
|
||||
{/* Newsletter CTA */}
|
||||
<NewsletterCTA />
|
||||
|
||||
{/* Sponsors Section */}
|
||||
<SponsorsSection />
|
||||
|
||||
{/* Club Modal for team statistics */}
|
||||
<ClubModal
|
||||
|
||||
@@ -3,7 +3,6 @@ import MainLayout from '../components/layout/MainLayout';
|
||||
import { Box, Container, Heading, Text, Stack, Image, SimpleGrid, Divider } from '@chakra-ui/react';
|
||||
import { usePublicSettings } from '../hooks/usePublicSettings';
|
||||
import { assetUrl } from '../utils/url';
|
||||
import SponsorsSection from '../components/common/SponsorsSection';
|
||||
import NewsletterCTA from '../components/common/NewsletterCTA';
|
||||
|
||||
const ClubPage: React.FC = () => {
|
||||
@@ -48,8 +47,6 @@ const ClubPage: React.FC = () => {
|
||||
{/* Newsletter CTA */}
|
||||
<NewsletterCTA />
|
||||
|
||||
{/* Sponsors Section */}
|
||||
<SponsorsSection />
|
||||
</MainLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -33,9 +33,10 @@ import { useSettings } from '../hooks/useSettings';
|
||||
import MainLayout from '../components/layout/MainLayout';
|
||||
import { FiMail, FiPhone, FiMapPin } from 'react-icons/fi';
|
||||
import { trackContactSubmit, trackFormSubmit } from '../utils/umami';
|
||||
import SponsorsSection from '../components/common/SponsorsSection';
|
||||
import ContactMap from '../components/home/ContactMap';
|
||||
import { getPublicContacts, GroupedContacts } from '../services/contactInfo';
|
||||
import { facrApi } from '../services/facr/facrApi';
|
||||
import { getCompetitionAliasesPublic } from '../services/competitionAliases';
|
||||
|
||||
type ContactFormData = {
|
||||
name: string;
|
||||
@@ -56,6 +57,9 @@ const ContactPage: React.FC = () => {
|
||||
// Public contacts (grouped by category)
|
||||
const [contactsData, setContactsData] = useState<GroupedContacts | null>(null);
|
||||
const [contactsLoading, setContactsLoading] = useState(true);
|
||||
// Club competitions (for tabs fallback) and aliases map
|
||||
const [competitions, setCompetitions] = useState<Array<{ code?: string; name: string }>>([]);
|
||||
const [aliasesMap, setAliasesMap] = useState<Record<string, string>>({});
|
||||
|
||||
const {
|
||||
register,
|
||||
@@ -121,6 +125,32 @@ const ContactPage: React.FC = () => {
|
||||
return () => { mounted = false; };
|
||||
}, []);
|
||||
|
||||
// Load club competitions + aliases (used to populate tabs when no contact categories are defined)
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const clubId = (settings as any)?.club_id || '';
|
||||
const clubType = ((settings as any)?.club_type || 'football') as 'football' | 'futsal';
|
||||
let comps: Array<{ code?: string; name: string }> = [];
|
||||
if (clubId) {
|
||||
try {
|
||||
const club = await facrApi.getClub(String(clubId), clubType);
|
||||
const arr = Array.isArray((club as any)?.competitions) ? (club as any).competitions : [];
|
||||
arr.forEach((c: any) => comps.push({ code: c.code, name: c.name || c.code }));
|
||||
} catch {}
|
||||
}
|
||||
let amap: Record<string, string> = {};
|
||||
try {
|
||||
const list = await getCompetitionAliasesPublic();
|
||||
list.forEach((a) => { if (a.code && a.alias) amap[a.code] = a.alias; });
|
||||
} catch {}
|
||||
const withAliases = comps.map((c) => ({ code: c.code, name: (c.code && amap[c.code]) ? amap[c.code] : c.name }));
|
||||
setAliasesMap(amap);
|
||||
setCompetitions(withAliases);
|
||||
} catch {}
|
||||
})();
|
||||
}, [settings]);
|
||||
|
||||
const onSubmit = (data: ContactFormData) => {
|
||||
mutate(data);
|
||||
};
|
||||
@@ -222,91 +252,148 @@ const ContactPage: React.FC = () => {
|
||||
{hasContacts && (
|
||||
<Box bg={bgColor} p={4} borderRadius="lg" borderWidth="1px" borderColor={borderColor} boxShadow="sm">
|
||||
<Heading size="md" mb={3}>Kontaktní osoby</Heading>
|
||||
<Tabs colorScheme="blue" isFitted>
|
||||
<TabList>
|
||||
{categories.map(([name]) => (
|
||||
<Tab key={name}>{name}</Tab>
|
||||
))}
|
||||
{uncategorized.length > 0 && <Tab>Ostatní</Tab>}
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
{categories.map(([name, persons]) => (
|
||||
<TabPanel key={name} pt={4}>
|
||||
<SimpleGrid columns={{ base: 1, sm: 2 }} spacing={4}>
|
||||
{persons.map((contact) => (
|
||||
<Box key={contact.id} bg={bgColor} p={4} borderRadius="md" borderWidth="1px" borderColor={borderColor}>
|
||||
<VStack align="start" spacing={3}>
|
||||
{contact.image_url && (
|
||||
<Avatar src={contact.image_url} name={contact.name} size="lg" />
|
||||
)}
|
||||
<Box>
|
||||
<Heading size="sm">{contact.name}</Heading>
|
||||
{contact.position && (
|
||||
<Badge colorScheme="blue" mt={1}>{contact.position}</Badge>
|
||||
)}
|
||||
</Box>
|
||||
{contact.description && (
|
||||
<Text fontSize="sm" color="gray.600">{contact.description}</Text>
|
||||
)}
|
||||
<VStack align="start" spacing={1}>
|
||||
{contact.email && (
|
||||
<HStack spacing={2}>
|
||||
<Icon as={FiMail} color="blue.500" />
|
||||
<Link href={`mailto:${contact.email}`} color="blue.500" fontSize="sm">{contact.email}</Link>
|
||||
</HStack>
|
||||
)}
|
||||
{contact.phone && (
|
||||
<HStack spacing={2}>
|
||||
<Icon as={FiPhone} color="blue.500" />
|
||||
<Link href={`tel:${contact.phone}`} color="blue.500" fontSize="sm">{contact.phone}</Link>
|
||||
</HStack>
|
||||
)}
|
||||
</VStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
<Tabs colorScheme="blue" isFitted isLazy>
|
||||
{(() => {
|
||||
const categoryEntries = Object.entries(contactsData?.categories || {});
|
||||
const compNames = competitions.map((c) => (c.code && aliasesMap[c.code]) ? aliasesMap[c.code] : c.name).filter(Boolean);
|
||||
const useCategories = categoryEntries.length > 0;
|
||||
const tabs = useCategories ? categoryEntries.map(([n]) => n) : compNames;
|
||||
const hasOthers = uncategorized.length > 0;
|
||||
return (
|
||||
<>
|
||||
<TabList>
|
||||
{tabs.map((n) => (
|
||||
<Tab key={n}>{n}</Tab>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</TabPanel>
|
||||
))}
|
||||
{uncategorized.length > 0 && (
|
||||
<TabPanel pt={4}>
|
||||
<SimpleGrid columns={{ base: 1, sm: 2 }} spacing={4}>
|
||||
{uncategorized.map((contact) => (
|
||||
<Box key={contact.id} bg={bgColor} p={4} borderRadius="md" borderWidth="1px" borderColor={borderColor}>
|
||||
<VStack align="start" spacing={3}>
|
||||
{contact.image_url && (
|
||||
<Avatar src={contact.image_url} name={contact.name} size="lg" />
|
||||
)}
|
||||
<Box>
|
||||
<Heading size="sm">{contact.name}</Heading>
|
||||
{contact.position && (
|
||||
<Badge colorScheme="blue" mt={1}>{contact.position}</Badge>
|
||||
)}
|
||||
</Box>
|
||||
{contact.description && (
|
||||
<Text fontSize="sm" color="gray.600">{contact.description}</Text>
|
||||
)}
|
||||
<VStack align="start" spacing={1}>
|
||||
{contact.email && (
|
||||
<HStack spacing={2}>
|
||||
<Icon as={FiMail} color="blue.500" />
|
||||
<Link href={`mailto:${contact.email}`} color="blue.500" fontSize="sm">{contact.email}</Link>
|
||||
</HStack>
|
||||
)}
|
||||
{contact.phone && (
|
||||
<HStack spacing={2}>
|
||||
<Icon as={FiPhone} color="blue.500" />
|
||||
<Link href={`tel:${contact.phone}`} color="blue.500" fontSize="sm">{contact.phone}</Link>
|
||||
</HStack>
|
||||
)}
|
||||
</VStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</TabPanel>
|
||||
)}
|
||||
</TabPanels>
|
||||
{hasOthers && <Tab>Ostatní</Tab>}
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
{useCategories
|
||||
? categoryEntries.map(([name, persons]) => (
|
||||
<TabPanel key={name} pt={4}>
|
||||
<SimpleGrid columns={{ base: 1, sm: 2 }} spacing={4}>
|
||||
{persons.map((contact) => (
|
||||
<Box key={contact.id} bg={bgColor} p={4} borderRadius="md" borderWidth="1px" borderColor={borderColor}>
|
||||
<VStack align="start" spacing={3}>
|
||||
{contact.image_url && (
|
||||
<Avatar src={contact.image_url} name={contact.name} size="lg" />
|
||||
)}
|
||||
<Box>
|
||||
<Heading size="sm">{contact.name}</Heading>
|
||||
{contact.position && (
|
||||
<Badge colorScheme="blue" mt={1}>{contact.position}</Badge>
|
||||
)}
|
||||
</Box>
|
||||
{contact.description && (
|
||||
<Text fontSize="sm" color="gray.600">{contact.description}</Text>
|
||||
)}
|
||||
<VStack align="start" spacing={1}>
|
||||
{contact.email && (
|
||||
<HStack spacing={2}>
|
||||
<Icon as={FiMail} color="blue.500" />
|
||||
<Link href={`mailto:${contact.email}`} color="blue.500" fontSize="sm">{contact.email}</Link>
|
||||
</HStack>
|
||||
)}
|
||||
{contact.phone && (
|
||||
<HStack spacing={2}>
|
||||
<Icon as={FiPhone} color="blue.500" />
|
||||
<Link href={`tel:${contact.phone}`} color="blue.500" fontSize="sm">{contact.phone}</Link>
|
||||
</HStack>
|
||||
)}
|
||||
</VStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</TabPanel>
|
||||
))
|
||||
: tabs.map((name) => {
|
||||
const persons = (contactsData?.categories || {})[name] || [];
|
||||
return (
|
||||
<TabPanel key={name} pt={4}>
|
||||
{persons.length > 0 ? (
|
||||
<SimpleGrid columns={{ base: 1, sm: 2 }} spacing={4}>
|
||||
{persons.map((contact) => (
|
||||
<Box key={contact.id} bg={bgColor} p={4} borderRadius="md" borderWidth="1px" borderColor={borderColor}>
|
||||
<VStack align="start" spacing={3}>
|
||||
{contact.image_url && (
|
||||
<Avatar src={contact.image_url} name={contact.name} size="lg" />
|
||||
)}
|
||||
<Box>
|
||||
<Heading size="sm">{contact.name}</Heading>
|
||||
{contact.position && (
|
||||
<Badge colorScheme="blue" mt={1}>{contact.position}</Badge>
|
||||
)}
|
||||
</Box>
|
||||
{contact.description && (
|
||||
<Text fontSize="sm" color="gray.600">{contact.description}</Text>
|
||||
)}
|
||||
<VStack align="start" spacing={1}>
|
||||
{contact.email && (
|
||||
<HStack spacing={2}>
|
||||
<Icon as={FiMail} color="blue.500" />
|
||||
<Link href={`mailto:${contact.email}`} color="blue.500" fontSize="sm">{contact.email}</Link>
|
||||
</HStack>
|
||||
)}
|
||||
{contact.phone && (
|
||||
<HStack spacing={2}>
|
||||
<Icon as={FiPhone} color="blue.500" />
|
||||
<Link href={`tel:${contact.phone}`} color="blue.500" fontSize="sm">{contact.phone}</Link>
|
||||
</HStack>
|
||||
)}
|
||||
</VStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
<Text color="gray.500">Pro tuto kategorii zatím nemáme kontaktní osobu.</Text>
|
||||
)}
|
||||
</TabPanel>
|
||||
);
|
||||
})}
|
||||
{hasOthers && (
|
||||
<TabPanel pt={4}>
|
||||
<SimpleGrid columns={{ base: 1, sm: 2 }} spacing={4}>
|
||||
{uncategorized.map((contact) => (
|
||||
<Box key={contact.id} bg={bgColor} p={4} borderRadius="md" borderWidth="1px" borderColor={borderColor}>
|
||||
<VStack align="start" spacing={3}>
|
||||
{contact.image_url && (
|
||||
<Avatar src={contact.image_url} name={contact.name} size="lg" />
|
||||
)}
|
||||
<Box>
|
||||
<Heading size="sm">{contact.name}</Heading>
|
||||
{contact.position && (
|
||||
<Badge colorScheme="blue" mt={1}>{contact.position}</Badge>
|
||||
)}
|
||||
</Box>
|
||||
{contact.description && (
|
||||
<Text fontSize="sm" color="gray.600">{contact.description}</Text>
|
||||
)}
|
||||
<VStack align="start" spacing={1}>
|
||||
{contact.email && (
|
||||
<HStack spacing={2}>
|
||||
<Icon as={FiMail} color="blue.500" />
|
||||
<Link href={`mailto:${contact.email}`} color="blue.500" fontSize="sm">{contact.email}</Link>
|
||||
</HStack>
|
||||
)}
|
||||
{contact.phone && (
|
||||
<HStack spacing={2}>
|
||||
<Icon as={FiPhone} color="blue.500" />
|
||||
<Link href={`tel:${contact.phone}`} color="blue.500" fontSize="sm">{contact.phone}</Link>
|
||||
</HStack>
|
||||
)}
|
||||
</VStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</TabPanel>
|
||||
)}
|
||||
</TabPanels>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</Tabs>
|
||||
</Box>
|
||||
)}
|
||||
@@ -422,9 +509,6 @@ const ContactPage: React.FC = () => {
|
||||
</Box>
|
||||
</VStack>
|
||||
</Container>
|
||||
|
||||
{/* Sponsors Section */}
|
||||
<SponsorsSection />
|
||||
</MainLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
import { Calendar, Image as ImageIcon, ExternalLink } from 'lucide-react';
|
||||
import MainLayout from '../components/layout/MainLayout';
|
||||
import { API_URL } from '../services/api';
|
||||
import SponsorsSection from '../components/common/SponsorsSection';
|
||||
import NewsletterCTA from '../components/common/NewsletterCTA';
|
||||
|
||||
interface Album {
|
||||
@@ -317,9 +316,6 @@ const GalleryPage: React.FC = () => {
|
||||
|
||||
{/* Newsletter CTA */}
|
||||
<NewsletterCTA />
|
||||
|
||||
{/* Sponsors Section */}
|
||||
<SponsorsSection />
|
||||
</MainLayout>
|
||||
);
|
||||
};
|
||||
|
||||
+201
-98
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useRef, useState, useMemo } from 'react';
|
||||
import React, { useEffect, useRef, useState, useMemo, Suspense } from 'react';
|
||||
import { IconButton, Tooltip } from '@chakra-ui/react';
|
||||
import MainLayout from '../components/layout/MainLayout';
|
||||
import { FiArrowRight, FiCalendar, FiUsers, FiAward, FiChevronLeft, FiChevronRight } from 'react-icons/fi';
|
||||
import { FiArrowRight, FiCalendar, FiUsers, FiAward, FiChevronLeft, FiChevronRight, FiEdit } from 'react-icons/fi';
|
||||
import '../styles/theme.css';
|
||||
import '../styles/sparta-styles.css';
|
||||
import '../styles/club-styles.css';
|
||||
@@ -11,19 +12,20 @@ import { assetUrl, sanitizeClubName } from '../utils/url';
|
||||
import { getPlayers as apiGetPlayers, Player as ApiPlayer } from '../services/players';
|
||||
import { getSponsors as apiGetSponsors, Sponsor as ApiSponsor } from '../services/sponsors';
|
||||
import { getBanners as apiGetBanners, Banner as ApiBanner } from '../services/banners';
|
||||
import BannerDisplay from '../components/banners/BannerDisplay';
|
||||
import BlogCardsScroller from '../components/home/BlogCardsScroller';
|
||||
import BlogSwiper from '../components/home/BlogSwiper';
|
||||
import VideosSection from '../components/home/VideosSection';
|
||||
import MerchSection from '../components/home/MerchSection';
|
||||
import PollsWidget from '../components/home/PollsWidget';
|
||||
import GallerySection from '../components/home/GallerySection';
|
||||
import { translateNationality, getCountryFlag } from '../utils/nationality';
|
||||
const BannerDisplay = React.lazy(() => import('../components/banners/BannerDisplay'));
|
||||
const BlogCardsScroller = React.lazy(() => import('../components/home/BlogCardsScroller'));
|
||||
const BlogSwiper = React.lazy(() => import('../components/home/BlogSwiper'));
|
||||
const VideosSection = React.lazy(() => import('../components/home/VideosSection'));
|
||||
const MerchSection = React.lazy(() => import('../components/home/MerchSection'));
|
||||
const PollsWidget = React.lazy(() => import('../components/home/PollsWidget'));
|
||||
const GallerySection = React.lazy(() => import('../components/home/GallerySection'));
|
||||
import { getArticles as apiGetArticles, Article as ApiArticle } from '../services/articles';
|
||||
import { getCompetitionAliasesPublic, CompetitionAlias } from '../services/competitionAliases';
|
||||
import { getUpcomingEvents } from '../services/eventService';
|
||||
import NewsletterSubscribe from '../components/newsletter/NewsletterSubscribe';
|
||||
import MyUIbrixStyleEditor from '../components/editor/MyUIbrixEditor';
|
||||
import MyUIbrixErrorBoundary from '../components/editor/MyUIbrixErrorBoundary';
|
||||
const NewsletterSubscribe = React.lazy(() => import('../components/newsletter/NewsletterSubscribe'));
|
||||
const MyUIbrixStyleEditor = React.lazy(() => import('../components/editor/MyUIbrixEditor'));
|
||||
const MyUIbrixErrorBoundary = React.lazy(() => import('../components/editor/MyUIbrixErrorBoundary'));
|
||||
import ClubModal from '../components/home/ClubModal';
|
||||
import MatchModal from '../components/home/MatchModal';
|
||||
import { useAllPageElementConfigs } from '../hooks/usePageElementConfig';
|
||||
@@ -31,10 +33,11 @@ import { API_URL } from '../services/api';
|
||||
import { TeamLogo } from '../components/common/TeamLogo';
|
||||
import ClubHeroTopbar from '../components/home/ClubHeroTopbar';
|
||||
import NewsList from '../components/pack/NewsList';
|
||||
import StandingsCard from '../components/pack/StandingsCard';
|
||||
const StandingsCard = React.lazy(() => import('../components/pack/StandingsCard'));
|
||||
import NextMatch from '../components/pack/NextMatch';
|
||||
import MatchesSlider from '../components/pack/MatchesSlider';
|
||||
const MatchesSlider = React.lazy(() => import('../components/pack/MatchesSlider'));
|
||||
import ActivitiesList from '../components/pack/ActivitiesList';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
// Types for real API-driven data
|
||||
type NewsItem = {
|
||||
@@ -100,7 +103,7 @@ const HomePage: React.FC = () => {
|
||||
// Matches slider auto-centering handled internally by MatchesSlider component
|
||||
|
||||
// API-driven players and sponsors
|
||||
type UiPlayer = { id:number|string; name:string; number?:number; position?:string; image?:string; slug?:string; age?: number };
|
||||
type UiPlayer = { id:number|string; name:string; number?:number; position?:string; image?:string; slug?:string; age?: number; nationality?: string };
|
||||
type UiSponsor = { id:number|string; name:string; logo:string; url?:string; tier?: string };
|
||||
type UiBanner = { id:number|string; name:string; image:string; url?:string; placement?:string; width?:number; height?:number };
|
||||
type UiMerch = { id?: number|string; title?: string; image_url: string; url?: string };
|
||||
@@ -114,11 +117,14 @@ const HomePage: React.FC = () => {
|
||||
const [merchItems, setMerchItems] = useState<UiMerch[]>([]);
|
||||
const [merchEnabled, setMerchEnabled] = useState<boolean>(false);
|
||||
const [upcomingEvents, setUpcomingEvents] = useState<UiEvent[]>([]);
|
||||
const [defer, setDefer] = useState<boolean>(false);
|
||||
// Aliases
|
||||
const [aliases, setAliases] = useState<CompetitionAlias[]>([]);
|
||||
const [aliasMap, setAliasMap] = useState<Record<string, { alias: string; original_name?: string }>>({});
|
||||
const [settings, setSettings] = useState<any>(null);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [isEditingMode, setIsEditingMode] = useState<boolean>(false);
|
||||
const { user } = useAuth();
|
||||
|
||||
// MyUIbrix element configuration hook for live preview
|
||||
const { getVariant, isVisible, getStyles, loading: configLoading, refreshKey } = useAllPageElementConfigs('homepage');
|
||||
@@ -133,6 +139,18 @@ const HomePage: React.FC = () => {
|
||||
} catch {}
|
||||
}, [stylePack]);
|
||||
|
||||
useEffect(() => {
|
||||
const ric: any = (window as any).requestIdleCallback || ((cb: any) => setTimeout(cb, 1));
|
||||
ric(() => setDefer(true));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const has = typeof document !== 'undefined' && document.body.classList.contains('myuibrix-edit-mode');
|
||||
setIsEditingMode(!!has);
|
||||
} catch {}
|
||||
}, []);
|
||||
|
||||
const heroFallbackArticles = useMemo(() => featured.map((item, index) => ({
|
||||
id: typeof item.id === 'number' ? item.id : index,
|
||||
title: item.title,
|
||||
@@ -402,6 +420,7 @@ const HomePage: React.FC = () => {
|
||||
number: p.jersey_number,
|
||||
position: p.position,
|
||||
image: assetUrl(p.image_url) || undefined,
|
||||
nationality: (p as any).nationality,
|
||||
age: (function(iso?: string){
|
||||
if (!iso) return undefined;
|
||||
const d = new Date(iso);
|
||||
@@ -1343,7 +1362,7 @@ const HomePage: React.FC = () => {
|
||||
<div data-element="style-pack" data-variant={stylePack} style={{ display: 'none' }} />
|
||||
{/* Above-hero club bar (MyUIbrix managed) */}
|
||||
{isVisible('hero-topbar', true) && (
|
||||
<section data-element="hero-topbar" data-variant={getVariant('hero-topbar', 'minimal')} style={{ ...getStyles('hero-topbar') }}>
|
||||
<section key={`hero-topbar-${refreshKey}-${getVariant('hero-topbar', 'minimal')}`} data-element="hero-topbar" data-variant={getVariant('hero-topbar', 'minimal')} style={{ ...getStyles('hero-topbar') }}>
|
||||
<ClubHeroTopbar
|
||||
variant={(getVariant('hero-topbar', 'minimal') as any) as 'brand' | 'minimal' | 'badge'}
|
||||
fullBleed={getVariant('header', 'unified') === 'fullwidth'}
|
||||
@@ -1425,31 +1444,48 @@ const HomePage: React.FC = () => {
|
||||
|
||||
{/* Featured articles are now shown in the hero grid above, not here */}
|
||||
|
||||
{/* Sidebar banners (homepage_sidebar) */}
|
||||
{/* Sidebar banners (homepage_sidebar) - fixed edge rail, left/right via MyUIbrix variant */}
|
||||
{(banners || []).some(b => b.placement === 'homepage_sidebar') && (
|
||||
<section data-element="sidebar" data-variant={getVariant('sidebar', 'right')} className="banner banner-sidebar" style={{ margin: '24px 0', ...getStyles('sidebar') }}>
|
||||
{/* Simple responsive behavior: stack on mobile, sticky right rail on desktop */}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<div style={{ width: 320, maxWidth: '100%', position: 'sticky' as const, top: 96 }}>
|
||||
{(banners || []).filter(b => b.placement === 'homepage_sidebar').map((b) => (
|
||||
<a key={b.id} href={b.url || '#'} target={b.url ? '_blank' : undefined} rel={b.url ? 'noopener noreferrer' : undefined} style={{ display: 'block', marginBottom: 12 }}>
|
||||
{/* eslint-disable-next-line jsx-a11y/alt-text */}
|
||||
<img loading="lazy" src={b.image} alt={b.name} style={{ width: b.width ? `${b.width}px` : '100%', height: b.height ? `${b.height}px` : 'auto', maxWidth: '100%' }} />
|
||||
</a>
|
||||
))}
|
||||
<section
|
||||
key={`sidebar-${refreshKey}-${getVariant('sidebar', 'right')}`}
|
||||
data-element="sidebar"
|
||||
data-variant={getVariant('sidebar', 'right')}
|
||||
className={`banner banner-sidebar sidebar-${getVariant('sidebar', 'right')}`}
|
||||
style={{
|
||||
// Use configured styles but force fixed rail placement
|
||||
...getStyles('sidebar'),
|
||||
position: 'fixed',
|
||||
top: 112,
|
||||
left: getVariant('sidebar', 'right') === 'left' ? 12 : 'auto',
|
||||
right: getVariant('sidebar', 'right') === 'left' ? 'auto' : 12,
|
||||
width: 320,
|
||||
maxWidth: '100%',
|
||||
zIndex: 50,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
{(banners || []).filter(b => b.placement === 'homepage_sidebar').map((b) => (
|
||||
<div key={b.id} className="card" style={{ display: 'block', marginBottom: 12, pointerEvents: 'auto', padding: 4 }}>
|
||||
<a href={b.url || '#'} target={b.url ? '_blank' : undefined} rel={b.url ? 'noopener noreferrer' : undefined} style={{ display: 'block' }}>
|
||||
{/* eslint-disable-next-line jsx-a11y/alt-text */}
|
||||
<img loading="lazy" src={b.image} alt={b.name} style={{ width: b.width ? `${b.width}px` : '100%', height: b.height ? `${b.height}px` : 'auto', maxWidth: '100%' }} />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
{getVariant('hero', heroStyle) === 'scroller' && isVisible('hero', true) && (
|
||||
<section key={`hero-scroller-${refreshKey}`} data-element="hero" data-variant={getVariant('hero', heroStyle)} style={{ position: 'relative', ...getStyles('hero') }}>
|
||||
<BlogCardsScroller />
|
||||
<Suspense fallback={<div style={{ minHeight: 240 }} />}>
|
||||
<BlogCardsScroller />
|
||||
</Suspense>
|
||||
</section>
|
||||
)}
|
||||
{(getVariant('hero', heroStyle) === 'swiper' || getVariant('hero', heroStyle) === 'swiper_full') && isVisible('hero', true) && (
|
||||
<section key={`hero-swiper-${refreshKey}`} data-element="hero" data-variant={getVariant('hero', heroStyle)} style={getVariant('hero', heroStyle) === 'swiper_full' ? { position: 'relative', marginLeft: 'calc(50% - 50vw)', marginRight: 'calc(50% - 50vw)', ...getStyles('hero') } : { position: 'relative', ...getStyles('hero') }}>
|
||||
<BlogSwiper fallbackArticles={heroFallbackArticles}
|
||||
/>
|
||||
<Suspense fallback={<div style={{ minHeight: 280 }} />}>
|
||||
<BlogSwiper fallbackArticles={heroFallbackArticles} />
|
||||
</Suspense>
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -1493,36 +1529,41 @@ const HomePage: React.FC = () => {
|
||||
);
|
||||
})()
|
||||
) : isVisible('matches', true) ? (
|
||||
<NextMatch
|
||||
data={{
|
||||
home: matches[0]?.homeTeam || clubName,
|
||||
home_logo_url: matches[0]?.homeLogoURL || clubLogo,
|
||||
away: matches[0]?.awayTeam || 'Soupeř',
|
||||
away_logo_url: matches[0]?.awayLogoURL,
|
||||
}}
|
||||
countdown={countdown}
|
||||
elementProps={{ 'data-element': 'matches', 'data-variant': getVariant('matches', 'compact'), style: { position: 'relative', ...getStyles('matches') } }}
|
||||
/>
|
||||
<div className="card">
|
||||
<NextMatch
|
||||
key={`matches-${refreshKey}-${getVariant('matches', 'compact')}`}
|
||||
data={{
|
||||
home: matches[0]?.homeTeam || clubName,
|
||||
home_logo_url: matches[0]?.homeLogoURL || clubLogo,
|
||||
away: matches[0]?.awayTeam || 'Soupeř',
|
||||
away_logo_url: matches[0]?.awayLogoURL,
|
||||
}}
|
||||
countdown={countdown}
|
||||
elementProps={{ 'data-element': 'matches', 'data-variant': getVariant('matches', 'compact'), style: { position: 'relative', ...getStyles('matches') } }}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Full-bleed top banner (homepage_top) */}
|
||||
{(banners || []).some(b => b.placement === 'homepage_top') && (
|
||||
<BannerDisplay banners={banners as any} placement="homepage_top" />
|
||||
)}
|
||||
{/* (Removed) Full-bleed top banner (homepage_top) */}
|
||||
|
||||
{/* Matches slider with scores by competition (moved after news+tables) */}
|
||||
{facrCompetitions.length > 0 && (
|
||||
<MatchesSlider
|
||||
comps={facrCompetitions as any}
|
||||
activeIndex={matchesTab}
|
||||
onActiveChange={setMatchesTab}
|
||||
onMatchClick={(m: any, compName?: string) => {
|
||||
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') } }}
|
||||
/>
|
||||
defer ? (
|
||||
<Suspense fallback={null}>
|
||||
<MatchesSlider
|
||||
key={`matches-slider-${refreshKey}-${getVariant('matches-slider', 'carousel')}`}
|
||||
comps={facrCompetitions as any}
|
||||
activeIndex={matchesTab}
|
||||
onActiveChange={setMatchesTab}
|
||||
onMatchClick={(m: any, compName?: string) => {
|
||||
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') } }}
|
||||
/>
|
||||
</Suspense>
|
||||
) : null
|
||||
)}
|
||||
|
||||
{/* News + Tables: split into two independent sections */}
|
||||
@@ -1545,12 +1586,13 @@ const HomePage: React.FC = () => {
|
||||
|
||||
return (
|
||||
<section
|
||||
key={`news-table-${refreshKey}-${newsVariant}-${getVariant('table', 'split_news')}`}
|
||||
className="standings"
|
||||
data-variant={variant}
|
||||
style={{ marginTop: 32 }}
|
||||
>
|
||||
{showNews && (
|
||||
<section data-element="news" data-variant={newsVariant} className="news-list" style={{ ...getStyles('news') }}>
|
||||
<section key={`news-${refreshKey}-${newsVariant}`} data-element="news" data-variant={newsVariant} className="news-list" style={{ ...getStyles('news') }}>
|
||||
<div className="section-head" style={{ marginTop: 0 }}>
|
||||
<h3>Další aktuality</h3>
|
||||
<a href="/news" className="see-all" style={{ fontSize: '0.85rem' }}>Zobrazit vše <FiArrowRight size={14} /></a>
|
||||
@@ -1564,46 +1606,55 @@ const HomePage: React.FC = () => {
|
||||
)}
|
||||
|
||||
{showTable && (
|
||||
<div data-element="table" data-variant={getVariant('table', 'split_news')} style={{ ...getStyles('table') }}>
|
||||
<div key={`table-${refreshKey}-${getVariant('table', 'split_news')}`} data-element="table" data-variant={getVariant('table', 'split_news')} style={{ ...getStyles('table') }}>
|
||||
<div className="section-head" style={{ marginTop: 0, marginBottom: 12 }}>
|
||||
<h3>Tabulky</h3>
|
||||
<a href="/tabulky" className="see-all" style={{ fontSize: '0.85rem' }}>Zobrazit vše <FiArrowRight size={14} /></a>
|
||||
</div>
|
||||
<StandingsCard
|
||||
variant={((): 'logos'|'plain' => { const v = getVariant('table_rows', 'logos'); return v === 'plain' ? 'plain' : 'logos'; })()}
|
||||
rows={(matchingStanding?.table || matchingStanding?.rows || []) as any}
|
||||
onRowClick={(row) => {
|
||||
const clubData = {
|
||||
team: (row as any).team?.name ?? (row as any).team ?? (row as any).club ?? '-',
|
||||
team_id: (row as any).team_id || '',
|
||||
team_logo_url: (row as any).team_logo_url,
|
||||
rank: (row as any).position ?? (row as any).pos ?? (row as any).rank ?? 0,
|
||||
played: (row as any).played ?? (row as any).matches ?? '-',
|
||||
wins: (row as any).wins ?? (row as any).win ?? '-',
|
||||
draws: (row as any).draws ?? (row as any).draw ?? '-',
|
||||
losses: (row as any).losses ?? (row as any).loss ?? '-',
|
||||
score: (row as any).score ?? '-',
|
||||
points: (row as any).points ?? (row as any).pts ?? '-',
|
||||
};
|
||||
setSelectedClub(clubData);
|
||||
setIsModalOpen(true);
|
||||
}}
|
||||
/>
|
||||
{defer ? (
|
||||
<Suspense fallback={null}>
|
||||
<StandingsCard
|
||||
variant={((): 'logos'|'plain' => { const v = getVariant('table_rows', 'logos'); return v === 'plain' ? 'plain' : 'logos'; })()}
|
||||
rows={(matchingStanding?.table || matchingStanding?.rows || []) as any}
|
||||
onRowClick={(row) => {
|
||||
const clubData = {
|
||||
team: (row as any).team?.name ?? (row as any).team ?? (row as any).club ?? '-',
|
||||
team_id: (row as any).team_id || '',
|
||||
team_logo_url: (row as any).team_logo_url,
|
||||
rank: (row as any).position ?? (row as any).pos ?? (row as any).rank ?? 0,
|
||||
played: (row as any).played ?? (row as any).matches ?? '-',
|
||||
wins: (row as any).wins ?? (row as any).win ?? '-',
|
||||
draws: (row as any).draws ?? (row as any).draw ?? '-',
|
||||
losses: (row as any).losses ?? (row as any).loss ?? '-',
|
||||
score: (row as any).score ?? '-',
|
||||
points: (row as any).points ?? (row as any).pts ?? '-',
|
||||
};
|
||||
setSelectedClub(clubData);
|
||||
setIsModalOpen(true);
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
) : null}
|
||||
{/* Banners under the table, inside the table column */}
|
||||
{(banners || []).some(b => b.placement === 'homepage_under_table') && (
|
||||
defer ? (
|
||||
<Suspense fallback={null}>
|
||||
<BannerDisplay banners={banners as any} placement="homepage_under_table" />
|
||||
</Suspense>
|
||||
) : null
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Banner under tables (homepage_under_table) */}
|
||||
{(banners || []).some(b => b.placement === 'homepage_under_table') && (
|
||||
<BannerDisplay banners={banners as any} placement="homepage_under_table" />
|
||||
)}
|
||||
{/* (Moved) Banner under tables now renders inside the table column above */}
|
||||
|
||||
{/* Competition tables moved into right column below */}
|
||||
|
||||
{upcomingEvents.length > 0 && isVisible('activities', true) && (
|
||||
<section data-element="activities" data-variant={getVariant('activities', 'list')} style={{ marginTop: 32, marginBottom: 16, position: 'relative', ...getStyles('activities') }}>
|
||||
<section key={`activities-${refreshKey}-${getVariant('activities', 'list')}`} data-element="activities" data-variant={getVariant('activities', 'list')} style={{ marginTop: 32, marginBottom: 16, position: 'relative', ...getStyles('activities') }}>
|
||||
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
|
||||
<div className="section-head" style={{ marginTop: 0 }}>
|
||||
<h3>Aktivity</h3>
|
||||
@@ -1616,17 +1667,18 @@ const HomePage: React.FC = () => {
|
||||
|
||||
{/* Players scroller */}
|
||||
{players.length > 0 && isVisible('team', false) && (
|
||||
<section data-element="team" data-variant={getVariant('team', 'grid')} className="players-scroller" style={{ marginTop: 32, position: 'relative', ...getStyles('team') }}>
|
||||
<section key={`team-${refreshKey}-${getVariant('team', 'grid')}`} data-element="team" data-variant={getVariant('team', 'grid')} className="players-scroller" style={{ marginTop: 32, position: 'relative', ...getStyles('team') }}>
|
||||
<div className="section-head">
|
||||
<h3>Hráči</h3>
|
||||
<a href="/players" className="see-all">Zobrazit vše <FiArrowRight /></a>
|
||||
</div>
|
||||
<div className="scroll-x">
|
||||
{players.map((p) => (
|
||||
<a key={p.id} href={p.slug ? `/players/${p.slug}` : `/players/${p.id}`} className="player-card">
|
||||
<a key={p.id} href={p.slug ? `/players/${p.slug}` : `/players/${p.id}`} className="player-card card">
|
||||
<div className="photo" style={{ backgroundImage: `url(${assetUrl(p.image) || p.image})` }} />
|
||||
<div className="meta">{typeof p.number !== 'undefined' ? (<><span className="nr">#{p.number}</span> {p.name}</>) : p.name}</div>
|
||||
<div className="pos">{p.position}</div>
|
||||
{p.nationality ? (<div className="nat"><span className="flag" style={{ marginRight: 6 }}>{getCountryFlag(p.nationality)}</span>{translateNationality(p.nationality)}</div>) : null}
|
||||
{typeof p.age === 'number' && <div className="age">{p.age} let</div>}
|
||||
</a>
|
||||
))}
|
||||
@@ -1636,35 +1688,56 @@ const HomePage: React.FC = () => {
|
||||
|
||||
{/* Gallery */}
|
||||
{isVisible('gallery', false) && (
|
||||
<section data-element="gallery" data-variant={getVariant('gallery', 'grid')} style={{ marginTop: 32, marginBottom: 32, position: 'relative', ...getStyles('gallery') }}>
|
||||
<section key={`gallery-${refreshKey}-${getVariant('gallery', 'grid')}`} data-element="gallery" data-variant={getVariant('gallery', 'grid')} style={{ marginTop: 32, marginBottom: 32, position: 'relative', ...getStyles('gallery') }}>
|
||||
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
|
||||
<GallerySection zoneramaUrl={galleryUrl} />
|
||||
{defer ? (
|
||||
<Suspense fallback={null}>
|
||||
<GallerySection zoneramaUrl={galleryUrl} />
|
||||
</Suspense>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Videos */}
|
||||
{isVisible('videos', false) && (
|
||||
<section data-element="videos" data-variant={getVariant('videos', 'grid')} style={{ marginTop: 32, marginBottom: 32, position: 'relative', ...getStyles('videos') }}>
|
||||
<section key={`videos-${refreshKey}-${getVariant('videos', 'carousel')}`} data-element="videos" data-variant={getVariant('videos', 'carousel')} style={{ marginTop: 32, marginBottom: 32, position: 'relative', ...getStyles('videos') }}>
|
||||
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
|
||||
<VideosSection variant={(getVariant('videos', 'grid') as any) as 'grid' | 'carousel'} />
|
||||
{defer ? (
|
||||
<Suspense fallback={null}>
|
||||
<VideosSection
|
||||
key={`videos-comp-${refreshKey}-${getVariant('videos', 'carousel')}`}
|
||||
variant={(getVariant('videos', 'carousel') as any) as 'grid' | 'carousel'}
|
||||
/>
|
||||
</Suspense>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{isVisible('merch', true) && (
|
||||
<section data-element="merch" data-variant={getVariant('merch', 'grid')} style={{ marginTop: 24, marginBottom: 24, position: 'relative', ...getStyles('merch') }}>
|
||||
<section key={`merch-${refreshKey}-${getVariant('merch', 'grid')}`} data-element="merch" data-variant={getVariant('merch', 'grid')} style={{ marginTop: 24, marginBottom: 24, position: 'relative', ...getStyles('merch') }}>
|
||||
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
|
||||
<MerchSection />
|
||||
{defer ? (
|
||||
<Suspense fallback={null}>
|
||||
<MerchSection variant={(getVariant('merch', 'grid') as any) as 'grid' | 'carousel' | 'featured' | 'list'} />
|
||||
</Suspense>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Polls / Voting */}
|
||||
{isVisible('poll', false) && (
|
||||
<section data-element="poll" data-variant={getVariant('poll', 'vertical')} style={{ marginTop: 32, marginBottom: 32, position: 'relative', ...getStyles('poll') }}>
|
||||
<section key={`poll-${refreshKey}-${getVariant('poll', 'vertical')}`} data-element="poll" data-variant={getVariant('poll', 'vertical')} style={{ marginTop: 32, marginBottom: 32, position: 'relative', ...getStyles('poll') }}>
|
||||
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
|
||||
<PollsWidget featuredOnly={true} maxPolls={1} title="Anketa" />
|
||||
{defer ? (
|
||||
<Suspense fallback={null}>
|
||||
<div className="card">
|
||||
<PollsWidget featuredOnly={true} maxPolls={1} title="Anketa" />
|
||||
</div>
|
||||
</Suspense>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
@@ -1683,9 +1756,13 @@ const HomePage: React.FC = () => {
|
||||
|
||||
{/* CTA (Newsletter) moved up */}
|
||||
{isVisible('newsletter', false) && (
|
||||
<section data-element="newsletter" data-variant={getVariant('newsletter', 'default')} className="newsletter-cta" style={{ marginTop: 24, marginBottom: 24, position: 'relative', ...getStyles('newsletter') }}>
|
||||
<section key={`newsletter-${refreshKey}-${getVariant('newsletter', 'default')}`} data-element="newsletter" data-variant={getVariant('newsletter', 'default')} className="newsletter-cta" style={{ marginTop: 24, marginBottom: 24, position: 'relative', ...getStyles('newsletter') }}>
|
||||
<div className="card" style={{ maxWidth: 960, margin: '0 auto' }}>
|
||||
<NewsletterSubscribe />
|
||||
{defer ? (
|
||||
<Suspense fallback={null}>
|
||||
<NewsletterSubscribe />
|
||||
</Suspense>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
@@ -1829,9 +1906,35 @@ const HomePage: React.FC = () => {
|
||||
console.log('Team clicked:', teamName);
|
||||
}}
|
||||
/>
|
||||
<MyUIbrixErrorBoundary>
|
||||
<MyUIbrixStyleEditor pageType="homepage" />
|
||||
</MyUIbrixErrorBoundary>
|
||||
{isEditingMode ? (
|
||||
<Suspense fallback={null}>
|
||||
<MyUIbrixErrorBoundary>
|
||||
<MyUIbrixStyleEditor pageType="homepage" />
|
||||
</MyUIbrixErrorBoundary>
|
||||
</Suspense>
|
||||
) : null}
|
||||
{user?.role === 'admin' && !isEditingMode ? (
|
||||
<div style={{ position: 'fixed', left: 16, bottom: 16, zIndex: 10000 }}>
|
||||
<Tooltip label="Aktivovat MyUIbrix Editor" placement="right">
|
||||
<IconButton
|
||||
aria-label="Upravit stránku"
|
||||
icon={<FiEdit />}
|
||||
colorScheme="blue"
|
||||
size="lg"
|
||||
borderRadius="full"
|
||||
onClick={() => {
|
||||
try {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('myuibrix', 'edit');
|
||||
window.history.replaceState({}, '', url.toString());
|
||||
} catch {}
|
||||
try { document.body.classList.add('myuibrix-edit-mode'); } catch {}
|
||||
setIsEditingMode(true);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : null}
|
||||
</MainLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,7 +5,6 @@ import { Link as RouterLink, useParams } from 'react-router-dom';
|
||||
import { usePublicSettings } from '../hooks/usePublicSettings';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getCompetitionAliasesPublic } from '../services/competitionAliases';
|
||||
import SponsorsSection from '../components/common/SponsorsSection';
|
||||
import NewsletterCTA from '../components/common/NewsletterCTA';
|
||||
import { API_URL } from '../services/api';
|
||||
|
||||
@@ -291,9 +290,6 @@ const MatchDetailPage: React.FC = () => {
|
||||
|
||||
{/* Newsletter CTA */}
|
||||
<NewsletterCTA />
|
||||
|
||||
{/* Sponsors Section */}
|
||||
<SponsorsSection />
|
||||
</MainLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,15 +3,16 @@ import { useParams, Link as RouterLink } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getPlayer } from '../services/public';
|
||||
import { assetUrl } from '../utils/url';
|
||||
import { Box, Badge, Button, Container, Divider, Heading, HStack, Image, SimpleGrid, Skeleton, Stack, Text, VStack } from '@chakra-ui/react';
|
||||
import { Box, Badge, Button, Container, Divider, Heading, HStack, Image, SimpleGrid, Skeleton, Stack, Text, VStack, useColorModeValue } from '@chakra-ui/react';
|
||||
import MainLayout from '../components/layout/MainLayout';
|
||||
import SponsorsSection from '../components/common/SponsorsSection';
|
||||
import NewsletterCTA from '../components/common/NewsletterCTA';
|
||||
import { translateNationality } from '../utils/nationality';
|
||||
import { translateNationality, getCountryFlag } from '../utils/nationality';
|
||||
|
||||
const PlayerDetailPage: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { data, isLoading, isError } = useQuery({ queryKey: ['player', id], queryFn: () => getPlayer(String(id)) });
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -45,6 +46,12 @@ const PlayerDetailPage: React.FC = () => {
|
||||
<HStack justify="space-between">
|
||||
<HStack spacing={3}>
|
||||
<Heading as="h1" size={{ base: 'xl', md: '2xl' }}>{fullName}</Heading>
|
||||
{typeof data.jersey_number === 'number' && (
|
||||
<Badge colorScheme="blue" fontSize="md" px={3} py={1}>#{data.jersey_number}</Badge>
|
||||
)}
|
||||
{data.position && (
|
||||
<Badge variant="subtle" colorScheme="purple" fontSize="md" px={3} py={1}>{data.position}</Badge>
|
||||
)}
|
||||
{!data.is_active && (
|
||||
<Badge colorScheme="gray" fontSize="md" px={3} py={1}>Neaktivní</Badge>
|
||||
)}
|
||||
@@ -63,7 +70,7 @@ const PlayerDetailPage: React.FC = () => {
|
||||
h={{ base: '300px', md: '400px' }}
|
||||
/>
|
||||
</Box>
|
||||
<Stack spacing={3} bg="white" borderWidth="1px" borderRadius="lg" p={6} shadow="sm">
|
||||
<Stack spacing={3} bg={cardBg} borderWidth="1px" borderColor={borderColor} borderRadius="lg" p={6} shadow="sm">
|
||||
<Heading size="md" mb={2}>Informace o hráči</Heading>
|
||||
{data.position && (
|
||||
<Text><b>Pozice:</b> {data.position}</Text>
|
||||
@@ -72,11 +79,25 @@ const PlayerDetailPage: React.FC = () => {
|
||||
<Text><b>Číslo dresu:</b> <Text as="span" color="brand.primary" fontWeight="700">#{data.jersey_number}</Text></Text>
|
||||
)}
|
||||
{data.nationality && (
|
||||
<Text><b>Národnost:</b> {translateNationality(data.nationality)}</Text>
|
||||
<HStack>
|
||||
<Text><b>Národnost:</b></Text>
|
||||
<Text as="span" fontSize="xl">{getCountryFlag(data.nationality)}</Text>
|
||||
<Text>{translateNationality(data.nationality)}</Text>
|
||||
{data.date_of_birth ? (
|
||||
<Text color={useColorModeValue('gray.600', 'gray.400')}>
|
||||
— {(() => { const a = calculateAge(data.date_of_birth); return a != null ? `${a} ${czYears(a)}` : ''; })()}
|
||||
</Text>
|
||||
) : null}
|
||||
</HStack>
|
||||
)}
|
||||
{data.date_of_birth && (
|
||||
<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.team?.name ? (
|
||||
<Text><b>Tým:</b> {data.team.name}</Text>
|
||||
) : (typeof data.team_id === 'number' && data.team_id > 0) ? (
|
||||
<Text><b>Tým ID:</b> {data.team_id}</Text>
|
||||
) : null}
|
||||
{(data.height || data.weight) && (
|
||||
<Text>
|
||||
<b>Výška/Váha:</b> {data.height ? `${data.height} cm` : '-'} / {data.weight ? `${data.weight} kg` : '-'}
|
||||
@@ -88,18 +109,12 @@ const PlayerDetailPage: React.FC = () => {
|
||||
{data.phone && (
|
||||
<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>
|
||||
)}
|
||||
</Stack>
|
||||
</SimpleGrid>
|
||||
|
||||
</VStack>
|
||||
</Container>
|
||||
|
||||
<NewsletterCTA />
|
||||
|
||||
<SponsorsSection />
|
||||
</Box>
|
||||
</MainLayout>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Box, Container, Heading, Image, SimpleGrid, Spinner, Stack, Text, VStack, useColorModeValue } from '@chakra-ui/react';
|
||||
import { Box, Container, Heading, HStack, Image, SimpleGrid, Spinner, Stack, Text, VStack, useColorModeValue, Badge } from '@chakra-ui/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getPlayers } from '../services/public';
|
||||
import type { Player } from '../services/public';
|
||||
import { assetUrl } from '../utils/url';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import MainLayout from '../components/layout/MainLayout';
|
||||
import SponsorsSection from '../components/common/SponsorsSection';
|
||||
import NewsletterCTA from '../components/common/NewsletterCTA';
|
||||
import { translateNationality, getCountryFlag } from '../utils/nationality';
|
||||
|
||||
const PlayersPage: React.FC = () => {
|
||||
const { data, isLoading, isError } = useQuery<Player[]>({ queryKey: ['players'], queryFn: getPlayers });
|
||||
@@ -55,18 +55,25 @@ const PlayersPage: React.FC = () => {
|
||||
transition="all 0.2s ease"
|
||||
spacing={3}
|
||||
>
|
||||
<Image
|
||||
src={assetUrl(p.image_url) || '/logo512.png'}
|
||||
alt={`${p.first_name} ${p.last_name}`}
|
||||
objectFit="cover"
|
||||
borderRadius="md"
|
||||
w="100%"
|
||||
h="240px"
|
||||
/>
|
||||
<Box position="relative" borderRadius="md" overflow="hidden">
|
||||
<Image
|
||||
src={assetUrl(p.image_url) || '/logo512.png'}
|
||||
alt={`${p.first_name} ${p.last_name}`}
|
||||
objectFit="cover"
|
||||
w="100%"
|
||||
h="240px"
|
||||
/>
|
||||
{typeof p.jersey_number === 'number' && (
|
||||
<Badge position="absolute" top="10px" left="10px" colorScheme="blue" fontSize="0.85rem" px={3} py={1} borderRadius="md" boxShadow="sm">#{p.jersey_number}</Badge>
|
||||
)}
|
||||
</Box>
|
||||
<Text fontWeight="bold" fontSize="lg">{p.first_name} {p.last_name}</Text>
|
||||
<Text color={textSecondary}>{p.position}</Text>
|
||||
{p.jersey_number ? (
|
||||
<Text color="brand.primary" fontWeight="600">#{p.jersey_number}</Text>
|
||||
{p.nationality ? (
|
||||
<HStack spacing={2} color={textSecondary}>
|
||||
<Text as="span" fontSize="lg">{getCountryFlag(p.nationality)}</Text>
|
||||
<Text>{translateNationality(p.nationality)}</Text>
|
||||
</HStack>
|
||||
) : null}
|
||||
</Stack>
|
||||
))}
|
||||
@@ -76,9 +83,6 @@ const PlayersPage: React.FC = () => {
|
||||
|
||||
{/* Newsletter CTA */}
|
||||
<NewsletterCTA />
|
||||
|
||||
{/* Sponsors Section */}
|
||||
<SponsorsSection />
|
||||
</Box>
|
||||
</MainLayout>
|
||||
);
|
||||
|
||||
@@ -31,7 +31,6 @@ import { useSearchParams, useNavigate, Link as RouterLink } from 'react-router-d
|
||||
import MainLayout from '../components/layout/MainLayout';
|
||||
import { searchAll, SearchResults } from '../services/search';
|
||||
import NewsletterCTA from '../components/common/NewsletterCTA';
|
||||
import SponsorsSection from '../components/common/SponsorsSection';
|
||||
import { assetUrl } from '../utils/url';
|
||||
|
||||
const SearchPage: React.FC = () => {
|
||||
@@ -372,6 +371,23 @@ const SearchPage: React.FC = () => {
|
||||
{m.date && <Text fontSize="xs" color="gray.500">{m.date} {m.time}</Text>}
|
||||
</Flex>
|
||||
))}
|
||||
{results.matches.length < 3 && results.matchesPast.slice(0, 3 - results.matches.length).map((m) => (
|
||||
<Flex key={m.id} p={3} bg={bgColor} borderWidth="1px" borderRadius="md" justify="space-between" flexWrap="wrap" gap={2}>
|
||||
<HStack gap={2}>
|
||||
{m.metadata?.home_logo_url && <Image src={m.metadata.home_logo_url} alt="" boxSize="24px" objectFit="contain" />}
|
||||
<Text fontSize="sm" fontWeight="medium">{highlight(m.title, q)}</Text>
|
||||
{m.metadata?.away_logo_url && <Image src={m.metadata.away_logo_url} alt="" boxSize="24px" objectFit="contain" />}
|
||||
</HStack>
|
||||
<HStack gap={2}>
|
||||
{(typeof m.metadata?.result_home === 'number' && typeof m.metadata?.result_away === 'number') ? (
|
||||
<Badge colorScheme="purple" fontSize="xs">{m.metadata.result_home}:{m.metadata.result_away}</Badge>
|
||||
) : (m.metadata?.result ? (
|
||||
<Badge colorScheme="purple" fontSize="xs">{m.metadata.result}</Badge>
|
||||
) : null)}
|
||||
{m.date && <Text fontSize="xs" color="gray.500">{m.date} {m.time}</Text>}
|
||||
</HStack>
|
||||
</Flex>
|
||||
))}
|
||||
</VStack>
|
||||
{(results.matches.length + results.matchesPast.length) > 3 && (
|
||||
<Button mt={3} size="sm" onClick={() => setActiveTab('matches')}>
|
||||
@@ -506,6 +522,11 @@ const SearchPage: React.FC = () => {
|
||||
{m.metadata?.away_logo_url && <Image src={m.metadata.away_logo_url} alt="" boxSize="32px" objectFit="contain" />}
|
||||
</HStack>
|
||||
<HStack gap={2}>
|
||||
{(typeof m.metadata?.result_home === 'number' && typeof m.metadata?.result_away === 'number') ? (
|
||||
<Badge colorScheme="purple" fontSize="sm">{m.metadata.result_home}:{m.metadata.result_away}</Badge>
|
||||
) : (m.metadata?.result ? (
|
||||
<Badge colorScheme="purple" fontSize="sm">{m.metadata.result}</Badge>
|
||||
) : null)}
|
||||
{m.date && <Text fontSize="sm" color="gray.500">{m.date} {m.time}</Text>}
|
||||
{m.subtitle && <Badge>{highlight(m.subtitle, q)}</Badge>}
|
||||
</HStack>
|
||||
@@ -765,9 +786,6 @@ const SearchPage: React.FC = () => {
|
||||
|
||||
{/* Newsletter CTA */}
|
||||
<NewsletterCTA />
|
||||
|
||||
{/* Sponsors Section */}
|
||||
<SponsorsSection />
|
||||
</Container>
|
||||
</MainLayout>
|
||||
);
|
||||
|
||||
@@ -899,6 +899,7 @@ const SetupPage: React.FC = () => {
|
||||
<FormControl>
|
||||
<FormLabel>JWT tajemství</FormLabel>
|
||||
<Input value={jwtSecret} onChange={(e) => setJwtSecret(e.target.value)} placeholder="Ponechte prázdné pro stávající hodnotu" />
|
||||
<FormHelperText>Tajný klíč pro přihlášení (JWT). Nechte prázdné pro ponechání stávající hodnoty.</FormHelperText>
|
||||
<Button mt={2} size="sm" onClick={() => setJwtSecret(generateJwtSecret())}>Vygenerovat bezpečné tajemství</Button>
|
||||
</FormControl>
|
||||
<Box>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { getSponsors, sendContact } from '../services/public';
|
||||
import MainLayout from '../components/layout/MainLayout';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { assetUrl } from '../utils/url';
|
||||
|
||||
type ContactFormData = { name: string; email: string; subject: string; message: string; source?: string };
|
||||
|
||||
@@ -41,9 +42,15 @@ const SponsorsPage: React.FC = () => {
|
||||
return <Text color="red.500">{errMsg}</Text>;
|
||||
}
|
||||
|
||||
// Group sponsors by tier
|
||||
const generalPartners = data?.filter((s) => s.tier === 'general') || [];
|
||||
const standardSponsors = data?.filter((s) => s.tier !== 'general') || [];
|
||||
// Group sponsors by tier and sort within groups
|
||||
const sorter = (a: any, b: any) => {
|
||||
const ao = (a?.display_order ?? 9999);
|
||||
const bo = (b?.display_order ?? 9999);
|
||||
if (ao !== bo) return ao - bo;
|
||||
return String(a?.name || '').localeCompare(String(b?.name || ''));
|
||||
};
|
||||
const generalPartners = (data?.filter((s) => s.tier === 'general') || []).slice().sort(sorter);
|
||||
const standardSponsors = (data?.filter((s) => s.tier !== 'general') || []).slice().sort(sorter);
|
||||
|
||||
return (
|
||||
<MainLayout>
|
||||
@@ -60,7 +67,7 @@ const SponsorsPage: React.FC = () => {
|
||||
{generalPartners.map((s) => (
|
||||
<Stack key={s.id} align="center" bg={cardBg} p={6} borderRadius="lg" borderWidth="2px" borderColor={borderColor} boxShadow="md">
|
||||
<Link href={s.website_url || '#'} isExternal>
|
||||
<Image src={s.logo_url || '/logo192.png'} alt={s.name} height="100px" objectFit="contain" />
|
||||
<Image src={assetUrl(s.logo_url) || '/logo192.png'} alt={s.name} height="100px" objectFit="contain" />
|
||||
</Link>
|
||||
<Text fontWeight="semibold" fontSize="lg">{s.name}</Text>
|
||||
</Stack>
|
||||
@@ -77,7 +84,7 @@ const SponsorsPage: React.FC = () => {
|
||||
{standardSponsors.map((s) => (
|
||||
<Stack key={s.id} align="center" bg={cardBg} p={4} borderRadius="md" borderWidth="1px" borderColor={borderColor}>
|
||||
<Link href={s.website_url || '#'} isExternal>
|
||||
<Image src={s.logo_url || '/logo192.png'} alt={s.name} height="60px" objectFit="contain" />
|
||||
<Image src={assetUrl(s.logo_url) || '/logo192.png'} alt={s.name} height="60px" objectFit="contain" />
|
||||
</Link>
|
||||
<Text fontSize="sm">{s.name}</Text>
|
||||
</Stack>
|
||||
|
||||
@@ -2,7 +2,6 @@ import React, { useEffect, useMemo, useState } from 'react';
|
||||
import MainLayout from '../components/layout/MainLayout';
|
||||
import { Box, Container, Heading, Text, Tabs, TabList, TabPanels, Tab, TabPanel, Table, Thead, Tbody, Tr, Th, Td, Flex, Spinner, Badge, Link, useColorModeValue } from '@chakra-ui/react';
|
||||
import { getCompetitionAliasesPublic, CompetitionAlias } from '../services/competitionAliases';
|
||||
import SponsorsSection from '../components/common/SponsorsSection';
|
||||
import NewsletterCTA from '../components/common/NewsletterCTA';
|
||||
import ClubModal from '../components/home/ClubModal';
|
||||
import { usePublicSettings } from '../hooks/usePublicSettings';
|
||||
@@ -237,9 +236,6 @@ const TablesPage: React.FC = () => {
|
||||
|
||||
{/* Newsletter CTA */}
|
||||
<NewsletterCTA />
|
||||
|
||||
{/* Sponsors Section */}
|
||||
<SponsorsSection />
|
||||
|
||||
<ClubModal
|
||||
isOpen={isModalOpen}
|
||||
|
||||
@@ -26,8 +26,8 @@ import { useClubTheme } from '../contexts/ClubThemeContext';
|
||||
import { usePublicSettings } from '../hooks/usePublicSettings';
|
||||
import { getCachedYouTube, YouTubeVideo } from '../services/youtube';
|
||||
import { FaPlay, FaExternalLinkAlt, FaYoutube } from 'react-icons/fa';
|
||||
import SponsorsSection from '../components/common/SponsorsSection';
|
||||
import NewsletterCTA from '../components/common/NewsletterCTA';
|
||||
import CommentsSection from '../components/comments/CommentsSection';
|
||||
|
||||
type RenderItem = {
|
||||
key: string;
|
||||
@@ -179,6 +179,9 @@ const VideosPage: React.FC = () => {
|
||||
alt={item.title}
|
||||
width="100%"
|
||||
height="100%"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
referrerPolicy="origin-when-cross-origin"
|
||||
style={{ objectFit: 'cover' }}
|
||||
/>
|
||||
) : (
|
||||
@@ -330,6 +333,7 @@ const VideosPage: React.FC = () => {
|
||||
title={selectedVideo.title}
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowFullScreen
|
||||
loading="lazy"
|
||||
referrerPolicy="strict-origin-when-cross-origin"
|
||||
style={{ borderRadius: '8px' }}
|
||||
/>
|
||||
@@ -362,6 +366,11 @@ const VideosPage: React.FC = () => {
|
||||
</Link>
|
||||
)}
|
||||
</HStack>
|
||||
{selectedVideo.videoId && (
|
||||
<Box mt={4}>
|
||||
<CommentsSection targetType="youtube_video" targetId={selectedVideo.videoId} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
@@ -371,9 +380,6 @@ const VideosPage: React.FC = () => {
|
||||
|
||||
{/* Newsletter CTA */}
|
||||
<NewsletterCTA />
|
||||
|
||||
{/* Sponsors Section */}
|
||||
<SponsorsSection />
|
||||
</MainLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -103,14 +103,31 @@ const AdminActivitiesPage: React.FC = () => {
|
||||
onSave: async (data) => {
|
||||
// If event has ID, update it
|
||||
if (data.id) {
|
||||
return await updateEvent(data.id, data);
|
||||
try {
|
||||
return await updateEvent(data.id, data);
|
||||
} catch (e: any) {
|
||||
const status = e?.response?.status;
|
||||
if (status === 404) {
|
||||
if (data.title?.trim() && data.start_time) {
|
||||
const created = await createEvent(data);
|
||||
if (created?.id) {
|
||||
setEditing(prev => ({ ...prev, id: created.id } as any));
|
||||
setDraftKey(`draft-activity-${created.id}`);
|
||||
try { localStorage.removeItem('draft-activity-new'); } catch {}
|
||||
}
|
||||
return created;
|
||||
}
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
// If no ID and has title, create as draft
|
||||
// If no ID and has minimal required fields, create as draft
|
||||
if (data.title?.trim() && data.start_time) {
|
||||
const created = await createEvent(data);
|
||||
// Update editing state with new ID
|
||||
if (created?.id) {
|
||||
setEditing(prev => ({ ...prev, id: created.id } as any));
|
||||
setDraftKey(`draft-activity-${created.id}`);
|
||||
try { localStorage.removeItem('draft-activity-new'); } catch {}
|
||||
}
|
||||
return created;
|
||||
}
|
||||
@@ -258,11 +275,16 @@ const AdminActivitiesPage: React.FC = () => {
|
||||
const handleRecoverDraft = () => {
|
||||
const draft = loadDraft<Partial<Event>>(draftKey);
|
||||
if (draft) {
|
||||
setEditing(draft);
|
||||
const isNewDraft = draftKey === 'draft-activity-new';
|
||||
const restored: any = { ...draft };
|
||||
if (isNewDraft && restored.id) {
|
||||
delete restored.id;
|
||||
}
|
||||
setEditing(restored);
|
||||
// Restore location if present
|
||||
if ((draft as any)?.latitude && (draft as any)?.longitude) {
|
||||
setLocationLat((draft as any).latitude);
|
||||
setLocationLng((draft as any).longitude);
|
||||
if ((restored as any)?.latitude && (restored as any)?.longitude) {
|
||||
setLocationLat((restored as any).latitude);
|
||||
setLocationLng((restored as any).longitude);
|
||||
}
|
||||
onOpen();
|
||||
}
|
||||
@@ -334,12 +356,15 @@ const AdminActivitiesPage: React.FC = () => {
|
||||
try {
|
||||
setAiLoading(true);
|
||||
const e = editing || {};
|
||||
// Build a helpful Czech prompt including known fields
|
||||
const stripHtml = (s: string) => String(s || '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
const lines: string[] = [];
|
||||
const clubName = String(settingsQ?.data?.club_name || '').trim();
|
||||
if (clubName) lines.push(`Klub: ${clubName}`);
|
||||
if (e.type) lines.push(`Typ: ${e.type}`);
|
||||
if (e.description) lines.push(`Poznámky: ${e.description}`);
|
||||
if (!aiOverwrite && e.description) {
|
||||
const plain = stripHtml(e.description as any);
|
||||
if (plain) lines.push(`Stávající text (pro kontext): ${plain}`);
|
||||
}
|
||||
const base = lines.join('\n');
|
||||
const toneText = aiTone === 'informative'
|
||||
? 'neutrálním, věcným a stručným stylem (bez nadsázky)'
|
||||
@@ -347,12 +372,12 @@ const AdminActivitiesPage: React.FC = () => {
|
||||
? 'formálním a profesionálním stylem (bez příkras)'
|
||||
: 'přátelským, ale věcným a stručným stylem (bez nadsázky)';
|
||||
const safeUserPrompt = (aiPrompt || 'Napiš krátkou neutrální pozvánku na klubovou aktivitu.').trim();
|
||||
const constraints = 'Nevkládej datum ani místo (lokalitu) do textu. Neuváděj konkrétní čas nebo adresu. Vyhýbej se superlativům, hyperbolám a marketingovým frázím. Nepoužívej slova jako „neopakovatelný“, „epický“, „úchvatný“ apod. Preferuj 1–2 krátké odstavce nebo stručné odrážky. Dbej na věcný a střízlivý tón.';
|
||||
const prompt = `${safeUserPrompt}\n\nPiš ${toneText}, česky, s důrazem na jasnost a pozvánku k účasti. ${constraints}\nDetaily:\n${base}`.trim();
|
||||
const 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 2–3 krátké odstavce NEBO stručný seznam s odrážkami. Používej HTML značky ul/li pro odrážky a strong pro zvýraznění. Bez nadpisů (nepoužívej H1/H2). 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}\nCílová délka: 80–120 slov.\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: 60,
|
||||
min_words: 100,
|
||||
});
|
||||
|
||||
// Handle potential JSON string response from AI (defensive parsing)
|
||||
@@ -535,7 +560,7 @@ const AdminActivitiesPage: React.FC = () => {
|
||||
<Tr><Td colSpan={8}>Načítání…</Td></Tr>
|
||||
)}
|
||||
{!isLoading && events.map(ev => (
|
||||
<Tr key={ev.id}>
|
||||
<Tr key={ev.id} opacity={ev.is_public ? 1 : 0.6}>
|
||||
<Td>
|
||||
{(ev as any).image_url ? (
|
||||
<ThumbnailPreview
|
||||
|
||||
@@ -26,7 +26,7 @@ import PollLinker from '../../components/admin/PollLinker';
|
||||
import ThumbnailPreview from '../../components/common/ThumbnailPreview';
|
||||
import FilePreview from '../../components/common/FilePreview';
|
||||
import SaveStatusIndicator from '../../components/common/SaveStatusIndicator';
|
||||
import DraftRecoveryModal from '../../components/common/DraftRecoveryModal';
|
||||
|
||||
import { useAutoSave, loadDraft, getDraftMetadata } from '../../hooks/useAutoSave';
|
||||
|
||||
import { getCachedYouTube, YouTubeVideo } from '../../services/youtube';
|
||||
@@ -91,15 +91,16 @@ const MatchLinkBadge: React.FC<{ articleId: number }> = ({ articleId }) => {
|
||||
const label = m
|
||||
? `${String(m.home || m.home_team || '')} ${String(scoreText)} ${String(m.away || m.away_team || '')}`
|
||||
: `ID: ${String(mid)}`;
|
||||
const linkHref = (m && (m.facr_link || m.report_url)) ? String(m.facr_link || m.report_url) : '';
|
||||
return (
|
||||
<HStack spacing={2}>
|
||||
<Badge colorScheme={color as any} title={m?.competitionName ? String(m.competitionName) : undefined}>Zápas: {label}</Badge>
|
||||
{m?.report_url ? (
|
||||
{linkHref ? (
|
||||
<IconButton
|
||||
aria-label="Otevřít FACR"
|
||||
aria-label="Otevřít zápas na fotbal.cz"
|
||||
size="xs"
|
||||
as="a"
|
||||
href={String(m.report_url)}
|
||||
href={linkHref}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
icon={<FiExternalLink />}
|
||||
@@ -180,6 +181,7 @@ const ArticlesAdminPage = () => {
|
||||
const [editing, setEditing] = useState<EditingArticle | null>(null);
|
||||
const [showDraftRecovery, setShowDraftRecovery] = useState(false);
|
||||
const [draftKey, setDraftKey] = useState<string>('');
|
||||
const [localDraft, setLocalDraft] = useState<EditingArticle | null>(null);
|
||||
|
||||
|
||||
|
||||
@@ -268,7 +270,33 @@ const ArticlesAdminPage = () => {
|
||||
onSave: async (data) => {
|
||||
// If article has ID, update it as draft
|
||||
if (data.id) {
|
||||
return await updateArticle(data.id, { ...data as any, published: false });
|
||||
try {
|
||||
return await updateArticle(data.id, { ...data as any, published: false });
|
||||
} catch (e: any) {
|
||||
const status = e?.response?.status;
|
||||
if (status === 404 && data.title?.trim()) {
|
||||
const payload: CreateArticlePayload = {
|
||||
title: data.title || 'Koncept článku',
|
||||
content: data.content || '',
|
||||
image_url: data.image_url || '',
|
||||
category_name: data.category_name,
|
||||
published: false,
|
||||
slug: data.slug || '',
|
||||
seo_title: data.seo_title || '',
|
||||
seo_description: data.seo_description || '',
|
||||
og_image_url: data.og_image_url || '',
|
||||
featured: data.featured || false,
|
||||
};
|
||||
const created = await createArticle(payload);
|
||||
if (created?.id) {
|
||||
setEditing(prev => ({ ...prev, id: created.id } as any));
|
||||
setDraftKey(`draft-article-${created.id}`);
|
||||
try { localStorage.removeItem('draft-article-new'); } catch {}
|
||||
}
|
||||
return created;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
// If no ID, create as draft
|
||||
if (data.title?.trim()) {
|
||||
@@ -277,7 +305,7 @@ const ArticlesAdminPage = () => {
|
||||
content: data.content || '',
|
||||
image_url: data.image_url || '',
|
||||
category_name: data.category_name,
|
||||
published: false, // Always save as draft
|
||||
published: false,
|
||||
slug: data.slug || '',
|
||||
seo_title: data.seo_title || '',
|
||||
seo_description: data.seo_description || '',
|
||||
@@ -285,9 +313,10 @@ const ArticlesAdminPage = () => {
|
||||
featured: data.featured || false,
|
||||
};
|
||||
const created = await createArticle(payload);
|
||||
// Update editing state with new ID
|
||||
if (created?.id) {
|
||||
setEditing(prev => ({ ...prev, id: created.id } as any));
|
||||
setDraftKey(`draft-article-${created.id}`);
|
||||
try { localStorage.removeItem('draft-article-new'); } catch {}
|
||||
}
|
||||
return created;
|
||||
}
|
||||
@@ -298,16 +327,28 @@ const ArticlesAdminPage = () => {
|
||||
enabled: isOpen && editing !== null,
|
||||
});
|
||||
|
||||
// Check for draft on component mount
|
||||
React.useEffect(() => {
|
||||
const key = 'draft-article-new';
|
||||
setDraftKey(key);
|
||||
const metadata = getDraftMetadata(key);
|
||||
if (metadata && metadata.age < 1440) { // Less than 24 hours old
|
||||
setShowDraftRecovery(true);
|
||||
}
|
||||
// Load local new-draft and expose in list (no popup)
|
||||
const refreshLocalDraft = React.useCallback(() => {
|
||||
try {
|
||||
const key = 'draft-article-new';
|
||||
const metadata = getDraftMetadata(key);
|
||||
if (metadata && metadata.age < 1440) {
|
||||
const d = loadDraft<EditingArticle>(key);
|
||||
if (d) {
|
||||
const restored: any = { ...d };
|
||||
if (restored.id) delete restored.id;
|
||||
setLocalDraft(restored);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
setLocalDraft(null);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
refreshLocalDraft();
|
||||
}, [refreshLocalDraft]);
|
||||
|
||||
// Fetch cached Zonerama gallery from prefetch
|
||||
const fetchCachedGallery = useCallback(async () => {
|
||||
try {
|
||||
@@ -661,7 +702,7 @@ const ArticlesAdminPage = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const { seoTitle, seoDescription } = generateSeoMetadata(aiTitle);
|
||||
const { seoTitle, seoDescription } = generateSeoMetadata(aiTitle, aiHtml);
|
||||
setEditing((prev) => ({
|
||||
...(prev || {}),
|
||||
title: aiTitle,
|
||||
@@ -685,20 +726,17 @@ const ArticlesAdminPage = () => {
|
||||
});
|
||||
|
||||
const openCreate = () => {
|
||||
// Check for existing draft
|
||||
const key = 'draft-article-new';
|
||||
setDraftKey(key);
|
||||
const metadata = getDraftMetadata(key);
|
||||
if (metadata && metadata.age < 1440) {
|
||||
// Show recovery modal
|
||||
setShowDraftRecovery(true);
|
||||
if (localDraft) {
|
||||
setEditing(localDraft);
|
||||
setActiveTabIndex(1);
|
||||
} else {
|
||||
// No draft, start fresh
|
||||
setEditing({ title: '', content: '', featured: false, published: false } as any);
|
||||
setActiveTabIndex(0); // Start on AI tab for new articles
|
||||
setAiPrompt(''); // Clear AI prompt
|
||||
onOpen();
|
||||
setActiveTabIndex(0);
|
||||
}
|
||||
setAiPrompt('');
|
||||
onOpen();
|
||||
};
|
||||
|
||||
const openEdit = (a: Article) => {
|
||||
@@ -733,14 +771,20 @@ const ArticlesAdminPage = () => {
|
||||
setMatchIdInput('');
|
||||
setEditing(null);
|
||||
onClose();
|
||||
refreshLocalDraft();
|
||||
};
|
||||
|
||||
// Draft recovery handlers
|
||||
const handleRecoverDraft = () => {
|
||||
const draft = loadDraft<EditingArticle>(draftKey);
|
||||
if (draft) {
|
||||
setEditing(draft);
|
||||
setActiveTabIndex(1); // Go to Základní tab
|
||||
const isNewDraft = draftKey === 'draft-article-new';
|
||||
const restored: any = { ...draft };
|
||||
if (isNewDraft && restored.id) {
|
||||
delete restored.id;
|
||||
}
|
||||
setEditing(restored);
|
||||
setActiveTabIndex(1);
|
||||
onOpen();
|
||||
}
|
||||
setShowDraftRecovery(false);
|
||||
@@ -859,15 +903,45 @@ const ArticlesAdminPage = () => {
|
||||
[deleteMut, toast]
|
||||
);
|
||||
|
||||
const generateSeoMetadata = (title: string) => {
|
||||
const baseTitle = title ? `${title} | ${process.env.REACT_APP_SITE_NAME || 'Fotbalový klub'}` : (process.env.REACT_APP_SITE_NAME || 'Fotbalový klub');
|
||||
const description = title
|
||||
? `Přečtěte si více o ${title.toLowerCase()}. Aktuální informace, novinky a zajímavosti z našeho fotbalového klubu.`
|
||||
: 'Oficiální stránky našeho fotbalového klubu. Aktuality, zápasy, výsledky a další informace.';
|
||||
|
||||
const generateSeoMetadata = (title: string, content?: string) => {
|
||||
const clubName = String(settingsQ.data?.club_name || process.env.REACT_APP_SITE_NAME || 'Fotbalový klub');
|
||||
const baseTitle = title ? `${title} | ${clubName}` : clubName;
|
||||
|
||||
const toPlain = (html?: string): string => {
|
||||
try {
|
||||
const div = document.createElement('div');
|
||||
div.innerHTML = String(html || '');
|
||||
return (div.textContent || div.innerText || '').replace(/\s+/g, ' ').trim();
|
||||
} catch {
|
||||
return String(html || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
};
|
||||
|
||||
const makeExcerpt = (text: string, limit = 28): string => {
|
||||
if (!text) return '';
|
||||
const words = text.split(' ').filter(Boolean);
|
||||
const excerpt = words.slice(0, limit).join(' ');
|
||||
return words.length > limit ? `${excerpt}...` : excerpt;
|
||||
};
|
||||
|
||||
let description = '';
|
||||
const src = toPlain(content);
|
||||
if (src) {
|
||||
description = makeExcerpt(src, 28);
|
||||
}
|
||||
|
||||
if (!description) {
|
||||
const t = (title || '').trim();
|
||||
description = t ? `Přečtěte si více: ${t}.` : `Aktuální informace z klubu ${clubName}.`;
|
||||
}
|
||||
|
||||
if (description.length > 160) {
|
||||
description = description.slice(0, 157).replace(/\s+\S*$/, '') + '...';
|
||||
}
|
||||
|
||||
return {
|
||||
seoTitle: baseTitle,
|
||||
seoDescription: description.length > 160 ? description.substring(0, 157) + '...' : description
|
||||
seoDescription: description
|
||||
};
|
||||
};
|
||||
|
||||
@@ -897,7 +971,7 @@ const ArticlesAdminPage = () => {
|
||||
|
||||
const handleTitleChange = (title: string) => {
|
||||
if (!editing) return;
|
||||
const { seoTitle, seoDescription } = generateSeoMetadata(title);
|
||||
const { seoTitle, seoDescription } = generateSeoMetadata(title, (editing as any)?.content);
|
||||
setEditing(prev => ({
|
||||
...(prev as any),
|
||||
title,
|
||||
@@ -1232,8 +1306,49 @@ const ArticlesAdminPage = () => {
|
||||
{isLoading && (
|
||||
<Tr><Td colSpan={6}><Spinner size="sm" /></Td></Tr>
|
||||
)}
|
||||
{!isLoading && localDraft && (
|
||||
<Tr key="local-draft" opacity={0.6}>
|
||||
<Td>
|
||||
<ThumbnailPreview
|
||||
src={assetUrl((localDraft as any).image_url) || '/dist/img/logo-club-empty.svg'}
|
||||
alt={(localDraft as any).title || 'Koncept'}
|
||||
size="48px"
|
||||
previewSize="350px"
|
||||
/>
|
||||
</Td>
|
||||
<Td>
|
||||
<VStack align="start" spacing={0}>
|
||||
<Text fontWeight="medium">{(localDraft as any).title || 'Bez názvu (koncept)'}</Text>
|
||||
<Text fontSize="xs" color="gray.500">Koncept (lokálně uložený)</Text>
|
||||
</VStack>
|
||||
</Td>
|
||||
<Td>
|
||||
<Badge colorScheme="gray" fontSize="xs">
|
||||
{(localDraft as any).category_name || 'Bez kategorie'}
|
||||
</Badge>
|
||||
</Td>
|
||||
<Td>
|
||||
<Switch size="sm" isChecked={!!(localDraft as any).featured} isDisabled />
|
||||
</Td>
|
||||
<Td>
|
||||
<Badge colorScheme="gray">Koncept</Badge>
|
||||
</Td>
|
||||
<Td isNumeric>
|
||||
<HStack spacing={1} justify="flex-end">
|
||||
<IconButton aria-label="Upravit koncept" size="sm" icon={<FiEdit2 />} onClick={openCreate} />
|
||||
<IconButton
|
||||
aria-label="Smazat koncept"
|
||||
size="sm"
|
||||
colorScheme="red"
|
||||
icon={<FiTrash2 />}
|
||||
onClick={() => { try { localStorage.removeItem('draft-article-new'); } catch {} setLocalDraft(null); toast({ title: 'Koncept odstraněn', status: 'success', duration: 2000 }); }}
|
||||
/>
|
||||
</HStack>
|
||||
</Td>
|
||||
</Tr>
|
||||
)}
|
||||
{!isLoading && articles.map((a) => (
|
||||
<Tr key={a.id}>
|
||||
<Tr key={a.id} opacity={a.published ? 1 : 0.6}>
|
||||
<Td>
|
||||
<ThumbnailPreview
|
||||
src={assetUrl(a.image_url) || '/dist/img/logo-club-empty.svg'}
|
||||
@@ -1769,20 +1884,7 @@ const ArticlesAdminPage = () => {
|
||||
</VStack>
|
||||
</Box>
|
||||
|
||||
{/* OG Image for Social Sharing */}
|
||||
<FormControl>
|
||||
<FormLabel>OG obrázek pro sdílení (volitelné)</FormLabel>
|
||||
<FormHelperText mb={2}>
|
||||
Speciální obrázek pro sdílení na sociálních sítích. Pokud není nastaveno, použije se titulní obrázek.
|
||||
</FormHelperText>
|
||||
<HStack>
|
||||
<Image src={assetUrl((editing as any)?.og_image_url) || assetUrl(editing?.image_url) || '/dist/img/logo-club-empty.svg'} alt="og" boxSize="80px" objectFit="cover" borderRadius="md" />
|
||||
<Button as="label" leftIcon={<FiUpload />} variant="outline">
|
||||
Nahrát OG obrázek
|
||||
<Input type="file" display="none" accept="image/*" onChange={(e) => onUploadOg(e.target.files?.[0])} />
|
||||
</Button>
|
||||
</HStack>
|
||||
</FormControl>
|
||||
|
||||
|
||||
{/* File Attachments */}
|
||||
<FormControl>
|
||||
@@ -2009,6 +2111,19 @@ const ArticlesAdminPage = () => {
|
||||
/>
|
||||
<FormHelperText fontSize="xs">Automaticky generováno z obsahu článku</FormHelperText>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>OG obrázek pro sdílení (volitelné)</FormLabel>
|
||||
<FormHelperText mb={2}>
|
||||
Speciální obrázek pro sdílení na sociálních sítích. Pokud není nastaveno, použije se titulní obrázek.
|
||||
</FormHelperText>
|
||||
<HStack>
|
||||
<Image src={assetUrl((editing as any)?.og_image_url) || assetUrl(editing?.image_url) || '/dist/img/logo-club-empty.svg'} alt="og" boxSize="80px" objectFit="cover" borderRadius="md" />
|
||||
<Button as="label" leftIcon={<FiUpload />} variant="outline">
|
||||
Nahrát OG obrázek
|
||||
<Input type="file" display="none" accept="image/*" onChange={(e) => onUploadOg(e.target.files?.[0])} />
|
||||
</Button>
|
||||
</HStack>
|
||||
</FormControl>
|
||||
</VStack>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
@@ -2223,17 +2338,6 @@ const ArticlesAdminPage = () => {
|
||||
</Modal>
|
||||
|
||||
|
||||
|
||||
{/* Draft Recovery Modal */}
|
||||
<DraftRecoveryModal
|
||||
isOpen={showDraftRecovery}
|
||||
onClose={() => setShowDraftRecovery(false)}
|
||||
onRecover={handleRecoverDraft}
|
||||
onDiscard={handleDiscardDraft}
|
||||
onDeleteOnly={handleDeleteOnly}
|
||||
draftAge={getDraftMetadata(draftKey)?.age || null}
|
||||
entityType="článek"
|
||||
/>
|
||||
</AdminLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Box, Button, FormControl, FormLabel, Heading, HStack, IconButton, Image, Input, Modal, ModalBody, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalOverlay, Spinner, Table, Tbody, Td, Th, Thead, Tr, useColorModeValue, useDisclosure, useToast, VStack, Select, Text, Switch, Badge, Alert, AlertIcon, AlertTitle, AlertDescription, Divider, Grid, GridItem } from '@chakra-ui/react';
|
||||
import { FiPlus, FiEdit2, FiTrash2, FiUpload, FiAlertCircle, FiCheckCircle } from 'react-icons/fi';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import AdminLayout from '../../layouts/AdminLayout';
|
||||
import { Banner as AdminBanner, getBanners, createBanner, updateBanner, deleteBanner } from '../../services/banners';
|
||||
import { uploadFile } from '../../services/articles';
|
||||
import { uploadFile, getArticles } from '../../services/articles';
|
||||
import { assetUrl } from '../../utils/url';
|
||||
|
||||
// Banner placement presets with dimensions and descriptions
|
||||
@@ -19,15 +19,6 @@ type BannerPreset = {
|
||||
};
|
||||
|
||||
const BANNER_PRESETS: BannerPreset[] = [
|
||||
{
|
||||
value: 'homepage_top',
|
||||
label: 'Hlavní banner (Homepage - vrchol)',
|
||||
description: 'Hlavní reklamní plocha nahoře, zobrazena všem návštěvníkům',
|
||||
width: 1200,
|
||||
height: 200,
|
||||
aspectRatio: 6,
|
||||
position: 'top'
|
||||
},
|
||||
{
|
||||
value: 'homepage_middle',
|
||||
label: 'Střední banner (Homepage - střed)',
|
||||
@@ -39,8 +30,8 @@ const BANNER_PRESETS: BannerPreset[] = [
|
||||
},
|
||||
{
|
||||
value: 'homepage_sidebar',
|
||||
label: 'Postranní banner (Homepage - sidebar)',
|
||||
description: 'Menší banner v pravém postranním panelu',
|
||||
label: 'Postranní banner (Homepage - okraj obrazovky)',
|
||||
description: 'Menší banner ukotvený u levého/pravého okraje obrazovky (nastavitelné v editoru: Sidebar varianta vlevo/vpravo)',
|
||||
width: 300,
|
||||
height: 250,
|
||||
aspectRatio: 1.2,
|
||||
@@ -86,6 +77,7 @@ const BannersAdminPage: React.FC = () => {
|
||||
const [imageResolution, setImageResolution] = useState<{ width: number; height: number } | null>(null);
|
||||
const [recommendedPlacements, setRecommendedPlacements] = useState<BannerPreset[]>([]);
|
||||
const [uploadingImage, setUploadingImage] = useState(false);
|
||||
const [hasArticles, setHasArticles] = useState<boolean>(true);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
|
||||
@@ -142,6 +134,18 @@ const BannersAdminPage: React.FC = () => {
|
||||
onClose();
|
||||
};
|
||||
|
||||
// Determine if at least one published article exists to allow "Banner v článcích"
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const resp = await getArticles({ page: 1, page_size: 1, published: true });
|
||||
setHasArticles(((resp?.total ?? 0) > 0) || ((resp?.data?.length ?? 0) > 0));
|
||||
} catch {
|
||||
setHasArticles(true); // fail-open so UI is not unnecessarily blocked
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: (payload: any) => createBanner(payload),
|
||||
onSuccess: () => { toast({ title: 'Banner vytvořen', status: 'success' }); qc.invalidateQueries({ queryKey: ['admin-banners'] }); closeModal(); },
|
||||
@@ -277,7 +281,7 @@ const BannersAdminPage: React.FC = () => {
|
||||
{!isLoading && banners.map((b: AdminBanner) => {
|
||||
const preset = getPreset((b as any).placement);
|
||||
return (
|
||||
<Tr key={b.id}>
|
||||
<Tr key={b.id} opacity={b.is_active ? 1 : 0.6}>
|
||||
<Td>
|
||||
<Image src={assetUrl((b as any).image_url) || '/logo192.png'} alt={b.name} boxSize="56px" objectFit="contain" bg={inputBg} borderRadius="md" />
|
||||
</Td>
|
||||
@@ -402,11 +406,23 @@ const BannersAdminPage: React.FC = () => {
|
||||
}}
|
||||
>
|
||||
<option value="">— vyberte umístění —</option>
|
||||
{BANNER_PRESETS.map(preset => (
|
||||
<option key={preset.value} value={preset.value}>
|
||||
{preset.label} ({preset.width}×{preset.height})
|
||||
</option>
|
||||
))}
|
||||
{BANNER_PRESETS.map(preset => {
|
||||
const isArticleInline = preset.value === 'article_inline';
|
||||
const disabled = isArticleInline && !hasArticles;
|
||||
const label = isArticleInline && !hasArticles
|
||||
? `${preset.label} — nelze použít (na webu zatím není žádný článek)`
|
||||
: `${preset.label} (${preset.width}×${preset.height})`;
|
||||
return (
|
||||
<option
|
||||
key={preset.value}
|
||||
value={preset.value}
|
||||
disabled={disabled}
|
||||
title={isArticleInline && !hasArticles ? 'Tuto pozici lze použít až když existuje alespoň 1 publikovaný článek.' : preset.description}
|
||||
>
|
||||
{label}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
{editing?.placement && (() => {
|
||||
const preset = getPreset((editing as any).placement);
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import React from 'react';
|
||||
import AdminLayout from '../../layouts/AdminLayout';
|
||||
import { Box, Heading, HStack, VStack, Button, Select, Input, Table, Thead, Tbody, Tr, Th, Td, Text, Badge, IconButton, useToast, Modal, ModalOverlay, ModalContent, ModalHeader, ModalBody, ModalFooter, ModalCloseButton, useDisclosure, FormControl, FormLabel, NumberInput, NumberInputField } from '@chakra-ui/react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { adminListComments, adminUpdateCommentStatus, adminBanUser, adminListUnbanRequests, adminResolveUnban } from '../../services/admin/comments';
|
||||
import { deleteComment } from '../../services/comments';
|
||||
import { FiTrash2 } from 'react-icons/fi';
|
||||
|
||||
const CommentsAdminPage: React.FC = () => {
|
||||
const [status, setStatus] = React.useState<string>('');
|
||||
const [targetType, setTargetType] = React.useState<string>('');
|
||||
const [targetId, setTargetId] = React.useState<string>('');
|
||||
const [userId, setUserId] = React.useState<string>('');
|
||||
const [page, setPage] = React.useState<number>(1);
|
||||
const toast = useToast();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const listQ = useQuery({
|
||||
queryKey: ['admin-comments', { status, targetType, targetId, userId, page }],
|
||||
queryFn: () => adminListComments({ status: status as any, target_type: targetType, target_id: targetId, user_id: userId, page, page_size: 50 }),
|
||||
keepPreviousData: true,
|
||||
});
|
||||
|
||||
const unbanQ = useQuery({
|
||||
queryKey: ['admin-unban-requests'],
|
||||
queryFn: adminListUnbanRequests,
|
||||
});
|
||||
|
||||
const updateStatusMut = useMutation({
|
||||
mutationFn: (args: { id: number; s: 'visible'|'hidden' }) => adminUpdateCommentStatus(args.id, args.s),
|
||||
onSuccess: async () => { await qc.invalidateQueries({ queryKey: ['admin-comments'] }); },
|
||||
});
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: (id: number) => deleteComment(id),
|
||||
onSuccess: async () => { await qc.invalidateQueries({ queryKey: ['admin-comments'] }); toast({ status: 'success', title: 'Smazáno' }); },
|
||||
});
|
||||
|
||||
const [banUserId, setBanUserId] = React.useState<number | null>(null);
|
||||
const banModal = useDisclosure();
|
||||
const [banReason, setBanReason] = React.useState<string>('Porušení pravidel diskuse');
|
||||
const [banHours, setBanHours] = React.useState<number>(0);
|
||||
const banMut = useMutation({
|
||||
mutationFn: () => adminBanUser(banUserId || 0, banReason, banHours),
|
||||
onSuccess: async () => { banModal.onClose(); setBanUserId(null); toast({ status: 'success', title: 'Uživatel zablokován' }); },
|
||||
});
|
||||
|
||||
const resolveUnbanMut = useMutation({
|
||||
mutationFn: (args: { id: number; action: 'approve'|'reject' }) => adminResolveUnban(args.id, args.action),
|
||||
onSuccess: async () => { await qc.invalidateQueries({ queryKey: ['admin-unban-requests'] }); toast({ status: 'success', title: 'Vyřízeno' }); },
|
||||
});
|
||||
|
||||
const items = listQ.data?.items || [];
|
||||
|
||||
return (
|
||||
<AdminLayout>
|
||||
<Box>
|
||||
<Heading size="md" mb={4}>Komentáře (moderace)</Heading>
|
||||
<VStack align="stretch" spacing={3} mb={4}>
|
||||
<HStack>
|
||||
<Select placeholder="Status" value={status} onChange={(e) => { setStatus(e.target.value); setPage(1); }} maxW="200px">
|
||||
<option value="visible">Viditelné</option>
|
||||
<option value="hidden">Skryté</option>
|
||||
</Select>
|
||||
<Select placeholder="Typ cíle" value={targetType} onChange={(e) => { setTargetType(e.target.value); setPage(1); }} maxW="220px">
|
||||
<option value="article">Článek</option>
|
||||
<option value="event">Aktivita</option>
|
||||
<option value="gallery_album">Galerie</option>
|
||||
<option value="youtube_video">YouTube video</option>
|
||||
</Select>
|
||||
<Input placeholder="Target ID" value={targetId} onChange={(e) => { setTargetId(e.target.value); setPage(1); }} maxW="200px" />
|
||||
<Input placeholder="User ID" value={userId} onChange={(e) => { setUserId(e.target.value); setPage(1); }} maxW="200px" />
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
<Box borderWidth="1px" borderRadius="md" overflowX="auto">
|
||||
<Table size="sm">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>ID</Th>
|
||||
<Th>Uživatel</Th>
|
||||
<Th>Cíl</Th>
|
||||
<Th>Obsah</Th>
|
||||
<Th>Spam</Th>
|
||||
<Th>Status</Th>
|
||||
<Th>Akce</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{items.map((c) => (
|
||||
<Tr key={c.id}>
|
||||
<Td>#{c.id}</Td>
|
||||
<Td>#{c.user?.id} {c.user?.first_name} {c.user?.last_name}</Td>
|
||||
<Td><Badge>{c.target_type}</Badge> <Text as="span">{c.target_id}</Text></Td>
|
||||
<Td maxW="420px"><Text noOfLines={2}>{c.content}</Text></Td>
|
||||
<Td>{(c as any).spam_score ? <Badge colorScheme={(c as any).spam_score > 0.5 ? 'orange' : 'green'}>{(c as any).spam_score.toFixed(2)}</Badge> : '-'}</Td>
|
||||
<Td>
|
||||
<HStack>
|
||||
<Button size="xs" variant={c.status === 'visible' ? 'solid' : 'outline'} onClick={() => updateStatusMut.mutate({ id: c.id, s: 'visible' })}>Viditelné</Button>
|
||||
<Button size="xs" variant={c.status === 'hidden' ? 'solid' : 'outline'} onClick={() => updateStatusMut.mutate({ id: c.id, s: 'hidden' })}>Skryté</Button>
|
||||
</HStack>
|
||||
</Td>
|
||||
<Td>
|
||||
<HStack>
|
||||
<IconButton aria-label="Smazat" size="xs" icon={<FiTrash2 />} onClick={() => deleteMut.mutate(c.id)} />
|
||||
<Button size="xs" variant="outline" onClick={() => { setBanUserId(c.user?.id as any); banModal.onOpen(); }}>Ban</Button>
|
||||
</HStack>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
|
||||
<Heading size="sm" mt={6} mb={2}>Žádosti o odblokování</Heading>
|
||||
<Box borderWidth="1px" borderRadius="md" overflowX="auto">
|
||||
<Table size="sm">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>ID</Th>
|
||||
<Th>Uživatel</Th>
|
||||
<Th>Text</Th>
|
||||
<Th>Status</Th>
|
||||
<Th>Akce</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{(unbanQ.data?.items || []).map((r) => (
|
||||
<Tr key={r.id}>
|
||||
<Td>#{r.id}</Td>
|
||||
<Td>#{r.user_id}</Td>
|
||||
<Td maxW="480px"><Text noOfLines={2}>{r.message}</Text></Td>
|
||||
<Td><Badge>{r.status}</Badge></Td>
|
||||
<Td>
|
||||
<HStack>
|
||||
<Button size="xs" colorScheme="green" variant="outline" onClick={() => resolveUnbanMut.mutate({ id: r.id, action: 'approve' })}>Povolit</Button>
|
||||
<Button size="xs" colorScheme="red" variant="outline" onClick={() => resolveUnbanMut.mutate({ id: r.id, action: 'reject' })}>Zamítnout</Button>
|
||||
</HStack>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
|
||||
{/* Ban modal */}
|
||||
<Modal isOpen={banModal.isOpen} onClose={banModal.onClose} isCentered>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>Zablokovat uživatele #{banUserId}</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<VStack align="stretch" spacing={3}>
|
||||
<FormControl>
|
||||
<FormLabel>Důvod</FormLabel>
|
||||
<Input value={banReason} onChange={(e) => setBanReason(e.target.value)} />
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Doba (hodiny) – 0 = trvale</FormLabel>
|
||||
<NumberInput min={0} value={banHours} onChange={(v) => setBanHours(Number(v) || 0)}>
|
||||
<NumberInputField />
|
||||
</NumberInput>
|
||||
</FormControl>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<HStack>
|
||||
<Button onClick={banModal.onClose}>Zrušit</Button>
|
||||
<Button colorScheme="red" isLoading={banMut.isPending} onClick={() => banMut.mutate()}>Zablokovat</Button>
|
||||
</HStack>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</Box>
|
||||
</AdminLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommentsAdminPage;
|
||||
@@ -0,0 +1,209 @@
|
||||
import React from 'react';
|
||||
import AdminLayout from '../../layouts/AdminLayout';
|
||||
import {
|
||||
Box,
|
||||
Heading,
|
||||
HStack,
|
||||
VStack,
|
||||
Button,
|
||||
Input,
|
||||
Select,
|
||||
Table,
|
||||
Thead,
|
||||
Tbody,
|
||||
Tr,
|
||||
Th,
|
||||
Td,
|
||||
Text,
|
||||
Badge,
|
||||
IconButton,
|
||||
useToast,
|
||||
Switch,
|
||||
NumberInput,
|
||||
NumberInputField,
|
||||
Image,
|
||||
Divider,
|
||||
} from '@chakra-ui/react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
adminListRewards,
|
||||
adminCreateReward,
|
||||
adminUpdateReward,
|
||||
adminDeleteReward,
|
||||
adminListRedemptions,
|
||||
adminUpdateRedemptionStatus,
|
||||
AdminRewardItem,
|
||||
AdminRedemption,
|
||||
} from '../../services/admin/engagement';
|
||||
import { FiTrash2 } from 'react-icons/fi';
|
||||
|
||||
const EngagementAdminPage: React.FC = () => {
|
||||
const toast = useToast();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const rewardsQ = useQuery({
|
||||
queryKey: ['admin-engagement-rewards'],
|
||||
queryFn: () => adminListRewards(),
|
||||
});
|
||||
const redemptionsQ = useQuery({
|
||||
queryKey: ['admin-engagement-redemptions'],
|
||||
queryFn: () => adminListRedemptions(),
|
||||
});
|
||||
|
||||
const [form, setForm] = React.useState({
|
||||
name: '',
|
||||
type: 'avatar_static',
|
||||
cost_points: 50,
|
||||
image_url: '',
|
||||
stock: 0,
|
||||
active: true,
|
||||
});
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: () => adminCreateReward(form),
|
||||
onSuccess: async () => {
|
||||
setForm({ name: '', type: 'avatar_static', cost_points: 50, image_url: '', stock: 0, active: true });
|
||||
await qc.invalidateQueries({ queryKey: ['admin-engagement-rewards'] });
|
||||
toast({ status: 'success', title: 'Odměna vytvořena' });
|
||||
},
|
||||
onError: (e: any) => toast({ status: 'error', title: e?.response?.data?.error || 'Chyba při vytváření odměny' }),
|
||||
});
|
||||
|
||||
const updateMut = useMutation({
|
||||
mutationFn: (args: { id: number; body: Partial<AdminRewardItem> }) => adminUpdateReward(args.id, args.body as any),
|
||||
onSuccess: async () => { await qc.invalidateQueries({ queryKey: ['admin-engagement-rewards'] }); toast({ status: 'success', title: 'Aktualizováno' }); },
|
||||
});
|
||||
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: (id: number) => adminDeleteReward(id),
|
||||
onSuccess: async () => { await qc.invalidateQueries({ queryKey: ['admin-engagement-rewards'] }); toast({ status: 'success', title: 'Smazáno' }); },
|
||||
});
|
||||
|
||||
const redStatusMut = useMutation({
|
||||
mutationFn: (args: { id: number; action: 'approve'|'reject'|'fulfill' }) => adminUpdateRedemptionStatus(args.id, args.action),
|
||||
onSuccess: async () => { await qc.invalidateQueries({ queryKey: ['admin-engagement-redemptions'] }); toast({ status: 'success', title: 'Status aktualizován' }); },
|
||||
});
|
||||
|
||||
const rewards = rewardsQ.data || [];
|
||||
const redemptions = redemptionsQ.data || [];
|
||||
|
||||
return (
|
||||
<AdminLayout>
|
||||
<Box>
|
||||
<Heading size="md" mb={4}>Odměny & Úspěchy</Heading>
|
||||
<VStack align="stretch" spacing={4}>
|
||||
<Box>
|
||||
<Heading size="sm" mb={2}>Vytvořit novou odměnu</Heading>
|
||||
<VStack align="stretch" spacing={3} borderWidth="1px" borderRadius="md" p={3}>
|
||||
<HStack>
|
||||
<Input placeholder="Název" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} maxW="280px" />
|
||||
<Select value={form.type} onChange={(e) => setForm({ ...form, type: e.target.value })} maxW="220px">
|
||||
<option value="avatar_static">Avatar (statický)</option>
|
||||
<option value="avatar_animated">Avatar (animovaný)</option>
|
||||
<option value="merch_coupon">Merch kupon</option>
|
||||
<option value="custom">Vlastní</option>
|
||||
</Select>
|
||||
<NumberInput value={form.cost_points} min={0} maxW="180px" onChange={(v) => setForm({ ...form, cost_points: Number(v) || 0 })}>
|
||||
<NumberInputField placeholder="Body" />
|
||||
</NumberInput>
|
||||
<NumberInput value={form.stock} min={0} maxW="160px" onChange={(v) => setForm({ ...form, stock: Number(v) || 0 })}>
|
||||
<NumberInputField placeholder="Sklad" />
|
||||
</NumberInput>
|
||||
<Input placeholder="Obrázek URL" value={form.image_url} onChange={(e) => setForm({ ...form, image_url: e.target.value })} />
|
||||
<HStack>
|
||||
<Text>Aktivní</Text>
|
||||
<Switch isChecked={form.active} onChange={(e) => setForm({ ...form, active: e.target.checked })} />
|
||||
</HStack>
|
||||
<Button colorScheme="blue" onClick={() => createMut.mutate()} isLoading={createMut.isPending} isDisabled={!form.name.trim()}>Vytvořit</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box>
|
||||
<Heading size="sm" mb={2}>Odměny</Heading>
|
||||
<Box borderWidth="1px" borderRadius="md" overflowX="auto">
|
||||
<Table size="sm">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>ID</Th>
|
||||
<Th>Název</Th>
|
||||
<Th>Typ</Th>
|
||||
<Th>Body</Th>
|
||||
<Th>Sklad</Th>
|
||||
<Th>Obrázek</Th>
|
||||
<Th>Aktivní</Th>
|
||||
<Th>Akce</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{rewards.map((r: AdminRewardItem) => (
|
||||
<Tr key={r.id}>
|
||||
<Td>#{r.id}</Td>
|
||||
<Td>{r.name}</Td>
|
||||
<Td><Badge>{r.type}</Badge></Td>
|
||||
<Td>
|
||||
<NumberInput size="sm" value={r.cost_points} min={0} maxW="120px" onChange={(v) => updateMut.mutate({ id: r.id, body: { cost_points: Number(v) || 0 } })}>
|
||||
<NumberInputField />
|
||||
</NumberInput>
|
||||
</Td>
|
||||
<Td>
|
||||
<NumberInput size="sm" value={r.stock || 0} min={0} maxW="100px" onChange={(v) => updateMut.mutate({ id: r.id, body: { stock: Number(v) || 0 } })}>
|
||||
<NumberInputField />
|
||||
</NumberInput>
|
||||
</Td>
|
||||
<Td>{r.image_url ? <Image src={r.image_url} alt={r.name} boxSize="40px" objectFit="cover" borderRadius="md" /> : '-'}</Td>
|
||||
<Td>
|
||||
<Switch isChecked={!!r.active} onChange={(e) => updateMut.mutate({ id: r.id, body: { active: e.target.checked } })} />
|
||||
</Td>
|
||||
<Td>
|
||||
<IconButton aria-label="Smazat" size="xs" icon={<FiTrash2 />} onClick={() => deleteMut.mutate(r.id)} />
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Heading size="sm" mt={6} mb={2}>Uplatnění odměn</Heading>
|
||||
<Box borderWidth="1px" borderRadius="md" overflowX="auto">
|
||||
<Table size="sm">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>ID</Th>
|
||||
<Th>Uživatel</Th>
|
||||
<Th>Odměna</Th>
|
||||
<Th>Status</Th>
|
||||
<Th>Akce</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{redemptions.map((d: AdminRedemption) => (
|
||||
<Tr key={d.id}>
|
||||
<Td>#{d.id}</Td>
|
||||
<Td>#{d.user_id}</Td>
|
||||
<Td>#{d.reward_id}</Td>
|
||||
<Td><Badge>{d.status}</Badge></Td>
|
||||
<Td>
|
||||
<HStack>
|
||||
<Button size="xs" variant="outline" onClick={() => redStatusMut.mutate({ id: d.id, action: 'approve' })}>Schválit</Button>
|
||||
<Button size="xs" variant="outline" colorScheme="red" onClick={() => redStatusMut.mutate({ id: d.id, action: 'reject' })}>Zamítnout</Button>
|
||||
<Button size="xs" variant="outline" colorScheme="green" onClick={() => redStatusMut.mutate({ id: d.id, action: 'fulfill' })}>Vydat</Button>
|
||||
</HStack>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
</Box>
|
||||
</VStack>
|
||||
</Box>
|
||||
</AdminLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default EngagementAdminPage;
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
Divider,
|
||||
Code,
|
||||
Icon,
|
||||
Progress,
|
||||
} from '@chakra-ui/react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
@@ -62,6 +63,7 @@ import {
|
||||
refreshFileTracking,
|
||||
formatFileSize,
|
||||
getFileIcon,
|
||||
getStorageUsage,
|
||||
} from '../../services/files';
|
||||
import { API_URL } from '../../services/api';
|
||||
import { assetUrl } from '../../utils/url';
|
||||
@@ -103,6 +105,12 @@ const FilesAdminPage: React.FC = () => {
|
||||
queryFn: getDuplicateFiles,
|
||||
});
|
||||
|
||||
// Storage usage
|
||||
const { data: storageUsage } = useQuery({
|
||||
queryKey: ['admin-files-usage'],
|
||||
queryFn: getStorageUsage,
|
||||
});
|
||||
|
||||
// Delete mutation
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: ({ id, force }: { id: number; force: boolean }) => deleteFile(id, force),
|
||||
@@ -111,6 +119,7 @@ const FilesAdminPage: React.FC = () => {
|
||||
qc.invalidateQueries({ queryKey: ['admin-files'] });
|
||||
qc.invalidateQueries({ queryKey: ['admin-files-unused'] });
|
||||
qc.invalidateQueries({ queryKey: ['admin-files-duplicates'] });
|
||||
qc.invalidateQueries({ queryKey: ['admin-files-usage'] });
|
||||
onDeleteClose();
|
||||
setDeleteTarget(null);
|
||||
setForceDelete(false);
|
||||
@@ -144,6 +153,7 @@ const FilesAdminPage: React.FC = () => {
|
||||
qc.invalidateQueries({ queryKey: ['admin-files'] });
|
||||
qc.invalidateQueries({ queryKey: ['admin-files-unused'] });
|
||||
qc.invalidateQueries({ queryKey: ['admin-files-duplicates'] });
|
||||
qc.invalidateQueries({ queryKey: ['admin-files-usage'] });
|
||||
},
|
||||
onError: () => {
|
||||
toast({ title: 'Chyba při skenování', status: 'error' });
|
||||
@@ -307,6 +317,37 @@ const FilesAdminPage: React.FC = () => {
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
{storageUsage && (
|
||||
<VStack align="stretch" spacing={2}>
|
||||
{(storageUsage.status === 'warn' || storageUsage.status === 'critical') && (
|
||||
<Alert status={storageUsage.status === 'critical' ? 'error' : 'warning'} borderRadius="md">
|
||||
<AlertIcon />
|
||||
<Box>
|
||||
<AlertTitle>
|
||||
{storageUsage.status === 'critical' ? 'Úložiště téměř plné' : 'Dochází místo v úložišti'}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
Využito {storageUsage.percent.toFixed(1)}% ({formatFileSize(storageUsage.used_bytes)} z {formatFileSize(storageUsage.quota_bytes)}).
|
||||
</AlertDescription>
|
||||
</Box>
|
||||
</Alert>
|
||||
)}
|
||||
<HStack>
|
||||
<Text fontWeight="medium">Využití úložiště</Text>
|
||||
<Spacer />
|
||||
<Text fontSize="sm" color="gray.500">
|
||||
{formatFileSize(storageUsage.used_bytes)} / {formatFileSize(storageUsage.quota_bytes)} ({storageUsage.percent.toFixed(1)}%)
|
||||
</Text>
|
||||
</HStack>
|
||||
<Progress
|
||||
value={Math.min(100, storageUsage.percent)}
|
||||
colorScheme={storageUsage.status === 'critical' ? 'red' : storageUsage.status === 'warn' ? 'orange' : 'blue'}
|
||||
height="10px"
|
||||
borderRadius="md"
|
||||
/>
|
||||
</VStack>
|
||||
)}
|
||||
|
||||
<Tabs colorScheme="blue" variant="enclosed">
|
||||
<TabList>
|
||||
<Tab>Všechny soubory ({allFiles.length})</Tab>
|
||||
|
||||
@@ -25,12 +25,7 @@ import {
|
||||
FormLabel,
|
||||
Input,
|
||||
Stack,
|
||||
InputGroup,
|
||||
InputRightElement,
|
||||
List,
|
||||
ListItem,
|
||||
FormErrorMessage,
|
||||
Image,
|
||||
useBreakpointValue,
|
||||
Wrap,
|
||||
WrapItem,
|
||||
@@ -39,15 +34,15 @@ import {
|
||||
} from '@chakra-ui/react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import AdminLayout from '../../layouts/AdminLayout';
|
||||
import { putMatchOverride, fetchTeamLogoOverrides } from '../../services/adminMatches';
|
||||
import { putMatchOverride } from '../../services/adminMatches';
|
||||
import { getPublicSettings } from '../../services/settings';
|
||||
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { parse, format } from 'date-fns';
|
||||
import { assetUrl } from '../../utils/url';
|
||||
import { batchFetchLogosFromSportLogosAPI } from '../../utils/sportLogosAPI';
|
||||
import { API_URL } from '../../services/api';
|
||||
import TeamLogo from '../../components/common/TeamLogo';
|
||||
import { getCompetitionAliasesPublic } from '../../services/competitionAliases';
|
||||
|
||||
const MatchesAdminPage = () => {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -57,16 +52,8 @@ const MatchesAdminPage = () => {
|
||||
const [form, setForm] = useState({
|
||||
venue_override: '',
|
||||
date_time_edit: '',
|
||||
notes: '',
|
||||
});
|
||||
|
||||
const { data: overrides = {} } = useQuery({
|
||||
queryKey: ['teamLogoOverrides'],
|
||||
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 || '');
|
||||
out = out
|
||||
@@ -91,64 +78,8 @@ const MatchesAdminPage = () => {
|
||||
out = out.replace(/\s+/g, ' ').trim();
|
||||
return out;
|
||||
};
|
||||
|
||||
const byName: Record<string, string> = (overrides as any)?.by_name || {};
|
||||
const byNameNormalized = useMemo(() => {
|
||||
const idx: Record<string, string> = {};
|
||||
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';
|
||||
};
|
||||
|
||||
|
||||
// Team name/logo editing removed
|
||||
|
||||
const { data: matches = [], isLoading, error } = useQuery<any[], Error>({
|
||||
@@ -201,23 +132,7 @@ const MatchesAdminPage = () => {
|
||||
}));
|
||||
},
|
||||
});
|
||||
useEffect(() => {
|
||||
if (!Array.isArray(matches) || matches.length === 0) return;
|
||||
const ids = new Set<string>();
|
||||
for (const m of matches as any[]) {
|
||||
if (m.home_id) ids.add(String(m.home_id));
|
||||
if (m.away_id) ids.add(String(m.away_id));
|
||||
}
|
||||
if (ids.size === 0) return;
|
||||
(async () => {
|
||||
try {
|
||||
const map = await batchFetchLogosFromSportLogosAPI(Array.from(ids));
|
||||
setSportLogosMap(map);
|
||||
} catch (e) {
|
||||
console.warn('Failed to batch fetch logos:', e);
|
||||
}
|
||||
})();
|
||||
}, [matches]);
|
||||
|
||||
|
||||
// Filters
|
||||
const [teamFilter, setTeamFilter] = useState('');
|
||||
@@ -273,13 +188,37 @@ const MatchesAdminPage = () => {
|
||||
const ts = stripPrefixes(team);
|
||||
return !!clubNorm && (t.includes(clubNorm) || ts.includes(clubStrip) || t.endsWith(clubStrip) || clubStrip.endsWith(ts));
|
||||
};
|
||||
// Load competition aliases (ordered by display_order in backend)
|
||||
const { data: compAliases = [] } = useQuery({
|
||||
queryKey: ['competition-aliases-public'],
|
||||
queryFn: getCompetitionAliasesPublic,
|
||||
});
|
||||
const competitionOptions = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
for (const m of matches) {
|
||||
if (m.competitionName) set.add(String(m.competitionName));
|
||||
}
|
||||
return Array.from(set).sort((a, b) => a.localeCompare(b));
|
||||
}, [matches]);
|
||||
const arr = Array.from(set);
|
||||
const getOrder = (name: string): number => {
|
||||
if (!Array.isArray(compAliases) || compAliases.length === 0) return Number.MAX_SAFE_INTEGER;
|
||||
const n = normalizeName(name);
|
||||
for (let i = 0; i < compAliases.length; i++) {
|
||||
const al: any = compAliases[i] as any;
|
||||
const a1 = normalizeName(String(al.alias || ''));
|
||||
const a2 = normalizeName(String(al.original_name || ''));
|
||||
if ((a1 && (n.includes(a1) || a1.includes(n))) || (a2 && (n.includes(a2) || a2.includes(n)))) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return Number.MAX_SAFE_INTEGER;
|
||||
};
|
||||
return arr.sort((a, b) => {
|
||||
const oa = getOrder(a);
|
||||
const ob = getOrder(b);
|
||||
if (oa !== ob) return oa - ob;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
}, [matches, compAliases]);
|
||||
const filteredMatches = matches.filter((m: any) => {
|
||||
// team filter
|
||||
const teamOk = normalizedTeam
|
||||
@@ -440,7 +379,6 @@ const MatchesAdminPage = () => {
|
||||
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;
|
||||
@@ -481,7 +419,6 @@ const MatchesAdminPage = () => {
|
||||
setForm({
|
||||
venue_override: m.venue || '',
|
||||
date_time_edit: localStr,
|
||||
notes: '',
|
||||
});
|
||||
setIsOpen(true);
|
||||
};
|
||||
@@ -902,11 +839,12 @@ const MatchesAdminPage = () => {
|
||||
</Td>
|
||||
<Td>
|
||||
<HStack spacing={2}>
|
||||
<Image
|
||||
src={getLogo(m.home || m.home_team || '', m.home_id, m.home_logo_url)}
|
||||
<TeamLogo
|
||||
teamId={m.home_id ? String(m.home_id) : undefined}
|
||||
teamName={m.home || m.home_team || ''}
|
||||
facrLogo={m.home_logo_url}
|
||||
size="small"
|
||||
alt={m.home || m.home_team || ''}
|
||||
boxSize="24px"
|
||||
objectFit="contain"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
@@ -922,11 +860,12 @@ const MatchesAdminPage = () => {
|
||||
</Td>
|
||||
<Td>
|
||||
<HStack spacing={2}>
|
||||
<Image
|
||||
src={getLogo(m.away || m.away_team || '', m.away_id, m.away_logo_url)}
|
||||
<TeamLogo
|
||||
teamId={m.away_id ? String(m.away_id) : undefined}
|
||||
teamName={m.away || m.away_team || ''}
|
||||
facrLogo={m.away_logo_url}
|
||||
size="small"
|
||||
alt={m.away || m.away_team || ''}
|
||||
boxSize="24px"
|
||||
objectFit="contain"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
@@ -992,14 +931,7 @@ const MatchesAdminPage = () => {
|
||||
|
||||
{/* Team name/logo editing removed */}
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>Poznámka</FormLabel>
|
||||
<Input
|
||||
placeholder="Libovolná poznámka (interní)"
|
||||
value={form.notes}
|
||||
onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
</Stack>
|
||||
)}
|
||||
</DrawerBody>
|
||||
|
||||
@@ -40,7 +40,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import AdminLayout from '../../layouts/AdminLayout';
|
||||
import { Player, getPlayers, createPlayer, updatePlayer, deletePlayer } from '../../services/players';
|
||||
import { uploadFile } from '../../services/articles';
|
||||
import { translateNationality } from '../../utils/nationality';
|
||||
import { translateNationality, getCountryFlag } from '../../utils/nationality';
|
||||
import ThumbnailPreview from '../../components/common/ThumbnailPreview';
|
||||
import { assetUrl } from '../../utils/url';
|
||||
|
||||
@@ -376,7 +376,7 @@ const PlayersAdminPage: React.FC = () => {
|
||||
<Tbody>
|
||||
{isLoading && (<Tr><Td colSpan={7}>Načítám...</Td></Tr>)}
|
||||
{!isLoading && (data || []).map((p) => (
|
||||
<Tr key={p.id}>
|
||||
<Tr key={p.id} opacity={p.is_active ? 1 : 0.6}>
|
||||
<Td>
|
||||
<ThumbnailPreview
|
||||
src={assetUrl(p.image_url) || '/logo192.png'}
|
||||
@@ -388,7 +388,14 @@ const PlayersAdminPage: React.FC = () => {
|
||||
</Td>
|
||||
<Td>{p.first_name} {p.last_name}</Td>
|
||||
<Td>{p.position || '-'}</Td>
|
||||
<Td>{p.nationality ? translateNationality(p.nationality) : '-'}</Td>
|
||||
<Td>
|
||||
{p.nationality ? (
|
||||
<HStack spacing={2}>
|
||||
<span>{getCountryFlag(p.nationality)}</span>
|
||||
<span>{translateNationality(p.nationality)}</span>
|
||||
</HStack>
|
||||
) : '-'}
|
||||
</Td>
|
||||
<Td>{p.jersey_number ?? '-'}</Td>
|
||||
<Td><Switch isChecked={!!p.is_active} onChange={() => { if (p.id != null) updateMut.mutate({ id: p.id, payload: { is_active: !p.is_active } }); }} /></Td>
|
||||
<Td>
|
||||
|
||||
@@ -66,12 +66,18 @@ import {
|
||||
updatePoll,
|
||||
deletePoll,
|
||||
getPollStats,
|
||||
getPollVotes,
|
||||
Poll,
|
||||
CreatePollRequest,
|
||||
UpdatePollRequest,
|
||||
PollStats,
|
||||
PollVote,
|
||||
} from '../../services/polls';
|
||||
import { getCachedYouTube, YouTubeVideo } from '../../services/youtube';
|
||||
import { Doughnut, Line, Bar } from 'react-chartjs-2';
|
||||
import { Chart as ChartJS, ArcElement, Tooltip, Legend, CategoryScale, LinearScale, PointElement, LineElement, BarElement } from 'chart.js';
|
||||
|
||||
ChartJS.register(ArcElement, Tooltip, Legend, CategoryScale, LinearScale, PointElement, LineElement, BarElement);
|
||||
|
||||
const PollsAdminPage: React.FC = () => {
|
||||
const toast = useToast();
|
||||
@@ -187,6 +193,40 @@ const PollsAdminPage: React.FC = () => {
|
||||
enabled: !!selectedPollStats?.poll?.id,
|
||||
});
|
||||
|
||||
// Votes list (admin details)
|
||||
const { data: votesData, isLoading: isLoadingVotes } = useQuery<PollVote[]>({
|
||||
queryKey: ['poll-votes', selectedPollStats?.poll?.id],
|
||||
queryFn: () => getPollVotes(selectedPollStats!.poll.id),
|
||||
enabled: !!selectedPollStats?.poll?.id,
|
||||
});
|
||||
|
||||
const exportVotesCSV = () => {
|
||||
if (!votesData) return;
|
||||
const header = ['id','poll_id','option_id','option_text','user_id','user_email','user_first_name','user_last_name','voter_name','voter_email','session_token','created_at'];
|
||||
const rows = votesData.map(v => [
|
||||
v.id,
|
||||
v.poll_id,
|
||||
v.option_id,
|
||||
JSON.stringify(v.option_text || ''),
|
||||
v.user_id ?? '',
|
||||
v.user_email || '',
|
||||
v.user_first_name || '',
|
||||
v.user_last_name || '',
|
||||
v.voter_name || '',
|
||||
v.voter_email || '',
|
||||
v.session_token || '',
|
||||
v.created_at,
|
||||
]);
|
||||
const csv = [header.join(','), ...rows.map(r => r.join(','))].join('\n');
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `poll_${selectedPollStats?.poll?.id || ''}_votes.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
title: '',
|
||||
@@ -514,7 +554,7 @@ const PollsAdminPage: React.FC = () => {
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{polls?.map((poll) => (
|
||||
<Tr key={poll.id}>
|
||||
<Tr key={poll.id} opacity={poll.status === 'draft' ? 0.6 : 1}>
|
||||
<Td>
|
||||
<VStack align="start" spacing={0}>
|
||||
<Text fontWeight="bold">{poll.title}</Text>
|
||||
@@ -1010,16 +1050,123 @@ const PollsAdminPage: React.FC = () => {
|
||||
<Heading size="sm" mb={4}>
|
||||
Hlasy podle dnů
|
||||
</Heading>
|
||||
<VStack spacing={2} align="stretch">
|
||||
{(statsData.votes_by_day || []).map((day) => (
|
||||
<HStack key={day.date} justify="space-between">
|
||||
<Text>{new Date(day.date).toLocaleDateString('cs-CZ')}</Text>
|
||||
<Badge>{day.count} hlasů</Badge>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={6}>
|
||||
<Box>
|
||||
<VStack spacing={2} align="stretch">
|
||||
{(statsData.votes_by_day || []).map((day) => (
|
||||
<HStack key={day.date} justify="space-between">
|
||||
<Text>{new Date(day.date).toLocaleDateString('cs-CZ')}</Text>
|
||||
<Badge>{day.count} hlasů</Badge>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
</Box>
|
||||
<Box>
|
||||
<Line
|
||||
data={{
|
||||
labels: (statsData.votes_by_day || []).map(d => new Date(d.date).toLocaleDateString('cs-CZ')),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Hlasy',
|
||||
data: (statsData.votes_by_day || []).map(d => d.count),
|
||||
borderColor: '#3182ce',
|
||||
backgroundColor: 'rgba(49,130,206,0.2)',
|
||||
tension: 0.3,
|
||||
},
|
||||
],
|
||||
}}
|
||||
options={{ responsive: true, plugins: { legend: { display: false } } }}
|
||||
/>
|
||||
</Box>
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={6}>
|
||||
<Box>
|
||||
<Heading size="sm" mb={3}>Složení hlasujících</Heading>
|
||||
<Doughnut
|
||||
data={{
|
||||
labels: ['Přihlášení', 'Hosté'],
|
||||
datasets: [
|
||||
{
|
||||
data: [statsData.authenticated_votes, statsData.guest_votes],
|
||||
backgroundColor: ['#2f855a', '#718096'],
|
||||
},
|
||||
],
|
||||
}}
|
||||
options={{ plugins: { legend: { position: 'bottom' } } }}
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
<Heading size="sm" mb={3}>Rozdělení hlasů podle možností</Heading>
|
||||
<Bar
|
||||
data={{
|
||||
labels: (statsData.poll.options || []).map(o => o.text),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Hlasy',
|
||||
data: (statsData.poll.options || []).map(o => o.vote_count),
|
||||
backgroundColor: '#3182ce',
|
||||
},
|
||||
],
|
||||
}}
|
||||
options={{
|
||||
responsive: true,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: { y: { beginAtZero: true, ticks: { precision: 0 } } },
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</SimpleGrid>
|
||||
|
||||
<Box>
|
||||
<HStack justify="space-between" mb={3}>
|
||||
<Heading size="sm">Hlasující</Heading>
|
||||
<Button size="sm" onClick={exportVotesCSV} isDisabled={!votesData || votesData.length === 0}>Export CSV</Button>
|
||||
</HStack>
|
||||
{isLoadingVotes ? (
|
||||
<HStack><Spinner size="sm" /><Text>Načítání hlasů...</Text></HStack>
|
||||
) : (votesData && votesData.length > 0) ? (
|
||||
<Box overflowX="auto">
|
||||
<Table size="sm" variant="simple">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Datum</Th>
|
||||
<Th>Jméno</Th>
|
||||
<Th>E-mail</Th>
|
||||
<Th>Typ</Th>
|
||||
<Th>Možnost</Th>
|
||||
<Th>Session</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{votesData.slice(0, 100).map((v) => {
|
||||
const name = v.voter_name || ((v.user_first_name || '') + ' ' + (v.user_last_name || '')).trim();
|
||||
const email = v.voter_email || v.user_email || '';
|
||||
const type = v.user_id ? 'Přihlášený' : 'Host';
|
||||
const session = (v.session_token || '').slice(-8);
|
||||
return (
|
||||
<Tr key={v.id}>
|
||||
<Td>{new Date(v.created_at).toLocaleString('cs-CZ')}</Td>
|
||||
<Td>{name || '-'}</Td>
|
||||
<Td>{email || '-'}</Td>
|
||||
<Td><Badge colorScheme={v.user_id ? 'green' : 'gray'}>{type}</Badge></Td>
|
||||
<Td>{v.option_text}</Td>
|
||||
<Td>{session}</Td>
|
||||
</Tr>
|
||||
);
|
||||
})}
|
||||
</Tbody>
|
||||
</Table>
|
||||
{votesData.length > 100 && (
|
||||
<Text fontSize="xs" color="gray.500" mt={2}>Zobrazeno 100 z {votesData.length} hlasů</Text>
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
<Text fontSize="sm" color="gray.500">Žádné hlasy k zobrazení.</Text>
|
||||
)}
|
||||
</Box>
|
||||
</VStack>
|
||||
) : null}
|
||||
</ModalBody>
|
||||
|
||||
@@ -184,7 +184,7 @@ const SponsorsAdminPage: React.FC = () => {
|
||||
<Tbody>
|
||||
{isLoading && (<Tr><Td colSpan={7}>Načítám...</Td></Tr>)}
|
||||
{!isLoading && (data || []).map((s) => (
|
||||
<Tr key={s.id}>
|
||||
<Tr key={s.id} opacity={s.is_active ? 1 : 0.6}>
|
||||
<Td>
|
||||
<Image src={normalizeImageUrl(s.logo_url)} alt={s.name} boxSize="48px" objectFit="contain" />
|
||||
</Td>
|
||||
|
||||
@@ -193,6 +193,9 @@ const TeamsAdminPage = () => {
|
||||
}
|
||||
return idx;
|
||||
}, [byName]);
|
||||
const byNamePairs = useMemo(() => {
|
||||
return Object.keys(byName || {}).map((k) => ({ keyNorm: normalize(k), url: byName[k] }));
|
||||
}, [byName]);
|
||||
|
||||
const getLogo = (teamName?: string, teamId?: string, original?: string) => {
|
||||
if (!teamName) return assetUrl('/dist/img/logo-club-empty.svg') as string;
|
||||
@@ -204,9 +207,27 @@ const TeamsAdminPage = () => {
|
||||
}
|
||||
// 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);
|
||||
const norm = normalize(teamName);
|
||||
let hit = overridesNameIndex[norm];
|
||||
if (!hit) {
|
||||
// Suffix/containment match: allow sponsor words before/after core name
|
||||
for (const [keyNorm, val] of Object.entries(overridesNameIndex)) {
|
||||
if (!keyNorm) continue;
|
||||
if (norm.endsWith(keyNorm) || keyNorm.endsWith(norm)) { hit = val as any; break; }
|
||||
}
|
||||
}
|
||||
if (!hit) {
|
||||
const norm2 = normalize(teamName);
|
||||
const t1 = norm2.split(' ')[0];
|
||||
if (t1 && t1.length >= 5) {
|
||||
for (const [keyNorm, val] of Object.entries(overridesNameIndex)) {
|
||||
const k1 = String(keyNorm).split(' ')[0];
|
||||
if (k1 === t1) { hit = val as any; break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (hit && (hit as any).logo_url) {
|
||||
const u = String((hit as any).logo_url);
|
||||
if (u.startsWith('/')) return assetUrl(u) as string;
|
||||
return u;
|
||||
}
|
||||
@@ -217,6 +238,14 @@ const TeamsAdminPage = () => {
|
||||
const norm = normalize(teamName);
|
||||
overrideUrl = byNameNormalized[norm];
|
||||
}
|
||||
if (!overrideUrl) {
|
||||
// Suffix/containment against normalized keys
|
||||
const norm = normalize(teamName);
|
||||
for (const { keyNorm, url } of byNamePairs) {
|
||||
if (!keyNorm) continue;
|
||||
if (norm.endsWith(keyNorm) || keyNorm.endsWith(norm)) { overrideUrl = url; break; }
|
||||
}
|
||||
}
|
||||
if (overrideUrl) {
|
||||
if (typeof overrideUrl === 'string' && overrideUrl.startsWith('/')) {
|
||||
return assetUrl(overrideUrl) as string;
|
||||
@@ -245,9 +274,25 @@ const TeamsAdminPage = () => {
|
||||
// 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;
|
||||
const norm = normalize(teamName);
|
||||
let hit = overridesNameIndex[norm];
|
||||
if (!hit) {
|
||||
for (const [keyNorm, val] of Object.entries(overridesNameIndex)) {
|
||||
if (!keyNorm) continue;
|
||||
if (norm.endsWith(keyNorm) || keyNorm.endsWith(norm)) { hit = val as any; break; }
|
||||
}
|
||||
}
|
||||
if (!hit) {
|
||||
const t1 = norm.split(' ')[0];
|
||||
if (t1 && t1.length >= 5) {
|
||||
for (const [keyNorm, val] of Object.entries(overridesNameIndex)) {
|
||||
const k1 = String(keyNorm).split(' ')[0];
|
||||
if (k1 === t1) { hit = val as any; break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (hit && (hit as any).name) {
|
||||
return (hit as any).name as string;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
@@ -407,6 +452,7 @@ const TeamsAdminPage = () => {
|
||||
} catch {}
|
||||
|
||||
if (logoUrl) {
|
||||
let uploadAttempted = false;
|
||||
let shouldUpload = Boolean(uploadedFile);
|
||||
try {
|
||||
const abs = logoUrl.startsWith('/') ? new URL(logoUrl, backendOrigin).toString() : logoUrl;
|
||||
@@ -426,6 +472,7 @@ const TeamsAdminPage = () => {
|
||||
} catch {}
|
||||
|
||||
if (shouldUpload) {
|
||||
uploadAttempted = true;
|
||||
setExternalUploadStatus('uploading');
|
||||
setExternalUploadError(null);
|
||||
try {
|
||||
@@ -447,6 +494,17 @@ const TeamsAdminPage = () => {
|
||||
if (logaResult.url) {
|
||||
logoUrl = logaResult.url;
|
||||
}
|
||||
try {
|
||||
let confirmedUrl: string | null = null;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
confirmedUrl = await fetchLogoFromLogoAPI(form.external_team_id, primaryName);
|
||||
if (confirmedUrl) break;
|
||||
await new Promise((r) => setTimeout(r, 700));
|
||||
}
|
||||
if (confirmedUrl) {
|
||||
logoUrl = confirmedUrl;
|
||||
}
|
||||
} catch {}
|
||||
} else {
|
||||
setExternalUploadStatus('error');
|
||||
setExternalUploadError(logaResult.error || 'Nepodařilo se nahrát logo');
|
||||
@@ -460,6 +518,18 @@ const TeamsAdminPage = () => {
|
||||
setExternalUploadError(error?.message || 'Upload failed');
|
||||
}
|
||||
}
|
||||
|
||||
if (uploadAttempted) {
|
||||
try {
|
||||
const abs = logoUrl.startsWith('/') ? new URL(logoUrl, backendOrigin).toString() : logoUrl;
|
||||
const host = new URL(abs).hostname.toLowerCase();
|
||||
if (host !== 'logoapi.sportcreative.eu') {
|
||||
throw new Error('Externí upload loga ještě není dostupný. Zkuste uložit znovu za chvíli.');
|
||||
}
|
||||
} catch (e: any) {
|
||||
throw new Error(e?.message || 'Externí upload loga selhal');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await putTeamLogoOverride(form.external_team_id, primaryName, logoUrl);
|
||||
|
||||
Reference in New Issue
Block a user