mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 10:42:57 +00:00
upload
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
import React from 'react';
|
||||
import { Box, Image, Heading, Text, VStack, HStack, Badge, Skeleton, useColorModeValue, Button } from '@chakra-ui/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getArticles, Article } from '../../services/articles';
|
||||
import HorizontalScroller from '../ui/HorizontalScroller';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import { useClubTheme } from '../../contexts/ClubThemeContext';
|
||||
import { assetUrl } from '../../utils/url';
|
||||
import { Eye, Clock } from 'lucide-react';
|
||||
|
||||
const Card: React.FC<{ a: Article }> = ({ a }) => {
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const border = useColorModeValue('gray.200', 'whiteAlpha.300');
|
||||
const theme = useClubTheme();
|
||||
const link = a.slug ? `/news/${a.slug}` : `/articles/${a.id}`;
|
||||
const categoryBadgeColor = useColorModeValue('gray.100', 'whiteAlpha.200');
|
||||
const categoryName = (a as any)?.category?.name || '';
|
||||
|
||||
return (
|
||||
<Box
|
||||
as={RouterLink}
|
||||
to={link}
|
||||
minW={{ base: '85%', md: '60%', lg: '33%' }}
|
||||
scrollSnapAlign="start"
|
||||
bg={cardBg}
|
||||
borderRadius="xl"
|
||||
overflow="hidden"
|
||||
boxShadow="lg"
|
||||
borderWidth="1px"
|
||||
borderColor={border}
|
||||
_hover={{ transform: 'translateY(-4px)', boxShadow: '2xl' }}
|
||||
transition="all 0.3s cubic-bezier(0.4, 0, 0.2, 1)"
|
||||
position="relative"
|
||||
>
|
||||
<Box position="relative" overflow="hidden">
|
||||
<Image
|
||||
src={assetUrl(a.image_url) || '/stadium-placeholder.jpg'}
|
||||
alt={a.title}
|
||||
w="100%"
|
||||
h={{ base: '200px', md: '240px' }}
|
||||
objectFit="cover"
|
||||
transition="transform 0.3s ease"
|
||||
_groupHover={{ transform: 'scale(1.05)' }}
|
||||
/>
|
||||
{categoryName && (
|
||||
<Badge
|
||||
position="absolute"
|
||||
top={3}
|
||||
left={3}
|
||||
colorScheme="blue"
|
||||
fontSize="xs"
|
||||
px={3}
|
||||
py={1}
|
||||
borderRadius="full"
|
||||
textTransform="uppercase"
|
||||
fontWeight="bold"
|
||||
>
|
||||
{categoryName}
|
||||
</Badge>
|
||||
)}
|
||||
</Box>
|
||||
<VStack align="stretch" spacing={3} p={5}>
|
||||
<Heading size="md" noOfLines={2} lineHeight="1.3">{a.title}</Heading>
|
||||
{a.content && (
|
||||
<Text fontSize="sm" color="gray.600" noOfLines={3} lineHeight="1.5">
|
||||
{a.content.replace(/<[^>]*>/g, '').trim()}
|
||||
</Text>
|
||||
)}
|
||||
<HStack spacing={3} pt={2} borderTopWidth="1px" borderColor={border} flexWrap="wrap">
|
||||
{(a.read_time || a.estimated_read_minutes) && (
|
||||
<HStack spacing={1}>
|
||||
<Clock size={14} color="gray" />
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
{a.read_time || a.estimated_read_minutes} min
|
||||
</Text>
|
||||
</HStack>
|
||||
)}
|
||||
{a.view_count !== undefined && a.view_count > 0 && (
|
||||
<HStack spacing={1}>
|
||||
<Eye size={14} color="gray" />
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
{a.view_count}
|
||||
</Text>
|
||||
</HStack>
|
||||
)}
|
||||
{a.published_at && (
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
{new Date(a.published_at).toLocaleDateString('cs-CZ')}
|
||||
</Text>
|
||||
)}
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const BlogCardsScroller: React.FC = () => {
|
||||
const theme = useClubTheme();
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['articles', { page: 1, page_size: 12, published: true }],
|
||||
queryFn: () => getArticles({ page: 1, page_size: 12, published: true }),
|
||||
});
|
||||
|
||||
const list: Article[] = data?.data || [];
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<HorizontalScroller
|
||||
title="Novinky"
|
||||
rightAction={<Button as={RouterLink} to="/blog" variant="link" color="brand.primary">Více</Button>}
|
||||
>
|
||||
{isLoading && Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} minW={{ base: '85%', md: '60%', lg: '33%' }} h={{ base: '260px', md: '300px' }} borderRadius="xl" />
|
||||
))}
|
||||
{!isLoading && list.map((a) => (
|
||||
<Card key={a.id} a={a} />
|
||||
))}
|
||||
</HorizontalScroller>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default BlogCardsScroller;
|
||||
@@ -0,0 +1,103 @@
|
||||
import { Box, Heading, SimpleGrid, Image, Text, VStack, HStack, Button, Skeleton, Badge, useColorModeValue } from '@chakra-ui/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getArticles, Article } from '../../services/articles';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import { assetUrl } from '../../utils/url';
|
||||
|
||||
const BlogCard: React.FC<{ article: Article }> = ({ article }) => {
|
||||
const link = article.slug ? `/news/${article.slug}` : `/articles/${article.id}`;
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const border = useColorModeValue('gray.200', 'whiteAlpha.300');
|
||||
const categoryName = (article as any)?.category?.name || '';
|
||||
|
||||
return (
|
||||
<VStack
|
||||
as={RouterLink}
|
||||
to={link}
|
||||
align="stretch"
|
||||
spacing={0}
|
||||
borderWidth="1px"
|
||||
borderRadius="xl"
|
||||
bg={cardBg}
|
||||
overflow="hidden"
|
||||
boxShadow="lg"
|
||||
borderColor={border}
|
||||
_hover={{ boxShadow: '2xl', transform: 'translateY(-4px)' }}
|
||||
transition="all 0.3s cubic-bezier(0.4, 0, 0.2, 1)"
|
||||
>
|
||||
<Box position="relative" overflow="hidden">
|
||||
<Image
|
||||
src={assetUrl(article.image_url) || '/logo192.png'}
|
||||
alt={article.title}
|
||||
objectFit="cover"
|
||||
w="100%"
|
||||
h="200px"
|
||||
transition="transform 0.3s ease"
|
||||
_groupHover={{ transform: 'scale(1.05)' }}
|
||||
/>
|
||||
{categoryName && (
|
||||
<Badge
|
||||
position="absolute"
|
||||
top={3}
|
||||
left={3}
|
||||
colorScheme="blue"
|
||||
fontSize="xs"
|
||||
px={3}
|
||||
py={1}
|
||||
borderRadius="full"
|
||||
textTransform="uppercase"
|
||||
fontWeight="bold"
|
||||
>
|
||||
{categoryName}
|
||||
</Badge>
|
||||
)}
|
||||
</Box>
|
||||
<VStack align="stretch" spacing={3} p={5}>
|
||||
<Heading size="md" noOfLines={2} lineHeight="1.3">{article.title}</Heading>
|
||||
<Text noOfLines={3} color="gray.600" fontSize="sm" lineHeight="1.5">
|
||||
{article.content?.replace(/<[^>]*>/g, '').slice(0, 160)}
|
||||
</Text>
|
||||
<HStack spacing={2} pt={2} borderTopWidth="1px" borderColor={border}>
|
||||
{article.estimated_read_minutes && (
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
{article.estimated_read_minutes} min čtení
|
||||
</Text>
|
||||
)}
|
||||
{article.published_at && (
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
• {new Date(article.published_at).toLocaleDateString('cs-CZ')}
|
||||
</Text>
|
||||
)}
|
||||
</HStack>
|
||||
</VStack>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
const BlogGrid: React.FC = () => {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['articles', { page: 1, page_size: 10, published: true }],
|
||||
queryFn: () => getArticles({ page: 1, page_size: 10, published: true }),
|
||||
});
|
||||
|
||||
const articles = data?.data || [];
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<HStack justify="space-between" mb={4}>
|
||||
<Heading size="lg">Aktuality</Heading>
|
||||
<Button as={RouterLink} to="/blog" size="sm" variant="link">Zobrazit všechny</Button>
|
||||
</HStack>
|
||||
<SimpleGrid columns={{ base: 1, sm: 2, md: 3 }} spacing={6}>
|
||||
{isLoading && Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} height="240px" />
|
||||
))}
|
||||
{!isLoading && articles.map((a) => (
|
||||
<BlogCard key={a.id} article={a} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default BlogGrid;
|
||||
@@ -0,0 +1,302 @@
|
||||
import React, { useRef, useState, useCallback } from 'react';
|
||||
import { Box, Image, Heading, Text, VStack, HStack, Skeleton, Button, IconButton, Flex, useBreakpointValue, Container } from '@chakra-ui/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getArticles, getFeaturedArticles, Article } from '../../services/articles';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import { assetUrl } from '../../utils/url';
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from '@chakra-ui/icons';
|
||||
import { useClubTheme } from '../../contexts/ClubThemeContext';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { wrap } from 'popmotion';
|
||||
|
||||
const MotionBox = motion(Box);
|
||||
const MotionImage = motion(Image);
|
||||
|
||||
const variants = {
|
||||
enter: (direction: number) => ({
|
||||
x: direction > 0 ? 1000 : -1000,
|
||||
opacity: 0
|
||||
}),
|
||||
center: {
|
||||
zIndex: 1,
|
||||
x: 0,
|
||||
opacity: 1
|
||||
},
|
||||
exit: (direction: number) => ({
|
||||
zIndex: 0,
|
||||
x: direction < 0 ? 1000 : -1000,
|
||||
opacity: 0
|
||||
})
|
||||
};
|
||||
|
||||
const swipeConfidenceThreshold = 10000;
|
||||
const swipePower = (offset: number, velocity: number) => {
|
||||
return Math.abs(offset) * velocity;
|
||||
};
|
||||
|
||||
const HeroSlide: React.FC<{ article: Article }> = ({ article }) => {
|
||||
const theme = useClubTheme();
|
||||
const excerpt = (article.content || '').replace(/<[^>]*>/g, '').slice(0, 200) + '...';
|
||||
const link = article.slug ? `/news/${article.slug}` : `/articles/${article.id}`;
|
||||
|
||||
return (
|
||||
<Box
|
||||
position="relative"
|
||||
w="100%"
|
||||
h={{ base: '500px', md: '600px' }}
|
||||
overflow="hidden"
|
||||
borderRadius={{ base: 'none', md: 'xl' }}
|
||||
boxShadow="lg"
|
||||
>
|
||||
<MotionImage
|
||||
src={assetUrl(article.image_url) || '/stadium-placeholder.jpg'}
|
||||
alt={article.title}
|
||||
w="100%"
|
||||
h="100%"
|
||||
objectFit="cover"
|
||||
initial={{ opacity: 0.7 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
/>
|
||||
<Box
|
||||
position="absolute"
|
||||
bottom={0}
|
||||
left={0}
|
||||
right={0}
|
||||
p={{ base: 6, md: 10 }}
|
||||
bgGradient="linear(to-t, blackAlpha.900, blackAlpha.700, transparent)"
|
||||
color="white"
|
||||
>
|
||||
<Container maxW="7xl" px={{ base: 4, md: 6 }}>
|
||||
{/* Top-left BLOG link badge */}
|
||||
<HStack spacing={3} mb={4}>
|
||||
<Button
|
||||
as={RouterLink}
|
||||
to="/blog"
|
||||
size="sm"
|
||||
px={3}
|
||||
height="28px"
|
||||
borderRadius="full"
|
||||
bg={theme.primary}
|
||||
color="white"
|
||||
_hover={{ bg: theme.accent }}
|
||||
>
|
||||
BLOG
|
||||
</Button>
|
||||
<Text fontSize={{ base: 'xs', md: 'sm' }} opacity={0.85}>•</Text>
|
||||
<Text fontSize={{ base: 'xs', md: 'sm' }} opacity={0.85}>Klubové aktuality</Text>
|
||||
</HStack>
|
||||
|
||||
<Box maxW={{ base: '100%', md: '70%', lg: '55%' }}>
|
||||
<Text
|
||||
fontSize={{ base: 'sm', md: 'md' }}
|
||||
fontWeight="bold"
|
||||
color={theme.accent}
|
||||
textTransform="uppercase"
|
||||
letterSpacing="0.1em"
|
||||
mb={2}
|
||||
>
|
||||
Nejnovější aktualita
|
||||
</Text>
|
||||
<Heading
|
||||
as="h2"
|
||||
size={{ base: 'xl', md: '2xl', lg: '3xl' }}
|
||||
mb={4}
|
||||
lineHeight="1.2"
|
||||
textShadow="0 2px 4px rgba(0,0,0,0.5)"
|
||||
>
|
||||
{article.title}
|
||||
</Heading>
|
||||
<Text
|
||||
fontSize={{ base: 'sm', md: 'md' }}
|
||||
noOfLines={3}
|
||||
mb={6}
|
||||
textShadow="0 1px 2px rgba(0,0,0,0.5)"
|
||||
>
|
||||
{excerpt}
|
||||
</Text>
|
||||
<HStack spacing={4}>
|
||||
<Button
|
||||
as={RouterLink}
|
||||
to={link}
|
||||
size="lg"
|
||||
bg={theme.primary}
|
||||
color="white"
|
||||
rightIcon={<ChevronRightIcon />}
|
||||
_hover={{
|
||||
bg: theme.accent,
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: 'lg',
|
||||
}}
|
||||
>
|
||||
Číst více
|
||||
</Button>
|
||||
<Button
|
||||
as={RouterLink}
|
||||
to="/blog"
|
||||
size="lg"
|
||||
variant="outline"
|
||||
borderColor="whiteAlpha.700"
|
||||
color="white"
|
||||
_hover={{ bg: 'whiteAlpha.200' }}
|
||||
>
|
||||
Všechny články
|
||||
</Button>
|
||||
</HStack>
|
||||
</Box>
|
||||
</Container>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const BlogSwiper: React.FC = () => {
|
||||
const [page, setPage] = useState(0);
|
||||
const [[slideIndex, direction], setSlideIndex] = useState([0, 0]);
|
||||
const { data: featuredData, isLoading: loadingFeatured } = useQuery({
|
||||
queryKey: ['featured-articles', { page: 1, page_size: 5 }],
|
||||
queryFn: () => getFeaturedArticles({ page: 1, page_size: 5 }),
|
||||
});
|
||||
// Fallback to latest published if no featured are available
|
||||
const { data: latestData } = useQuery({
|
||||
queryKey: ['latest-articles', { page: 1, page_size: 5, published: true }],
|
||||
queryFn: () => getArticles({ page: 1, page_size: 5, published: true }),
|
||||
enabled: Boolean(!loadingFeatured && !(featuredData?.data?.length)),
|
||||
});
|
||||
|
||||
const articles = (featuredData?.data?.length ? featuredData.data : (latestData?.data || []));
|
||||
const articleIndex = wrap(0, articles.length, slideIndex);
|
||||
const paginate = useCallback(
|
||||
(newDirection: number) => {
|
||||
setSlideIndex([slideIndex + newDirection, newDirection]);
|
||||
},
|
||||
[slideIndex]
|
||||
);
|
||||
|
||||
// Auto-advance slides
|
||||
React.useEffect(() => {
|
||||
if (articles.length <= 1) return;
|
||||
|
||||
const timer = setInterval(() => {
|
||||
paginate(1);
|
||||
}, 8000);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [articles.length, paginate]);
|
||||
|
||||
if (loadingFeatured) {
|
||||
return (
|
||||
<Skeleton
|
||||
w="100%"
|
||||
h={{ base: '500px', md: '600px' }}
|
||||
borderRadius={{ base: 'none', md: 'xl' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!articles.length) return null;
|
||||
|
||||
const currentArticle = articles[articleIndex];
|
||||
if (!currentArticle) return null;
|
||||
|
||||
return (
|
||||
<Box position="relative" w="100%" overflow="hidden">
|
||||
<AnimatePresence initial={false} custom={direction}>
|
||||
<MotionBox
|
||||
key={slideIndex}
|
||||
custom={direction}
|
||||
variants={variants}
|
||||
initial="enter"
|
||||
animate="center"
|
||||
exit="exit"
|
||||
transition={{
|
||||
x: { type: 'spring', stiffness: 300, damping: 30 },
|
||||
opacity: { duration: 0.2 }
|
||||
}}
|
||||
drag="x"
|
||||
dragConstraints={{ left: 0, right: 0 }}
|
||||
dragElastic={1}
|
||||
onDragEnd={(e, { offset, velocity }) => {
|
||||
const swipe = swipePower(offset.x, velocity.x);
|
||||
if (swipe < -swipeConfidenceThreshold) {
|
||||
paginate(1);
|
||||
} else if (swipe > swipeConfidenceThreshold) {
|
||||
paginate(-1);
|
||||
}
|
||||
}}
|
||||
position="relative"
|
||||
w="100%"
|
||||
h="100%"
|
||||
>
|
||||
<HeroSlide article={currentArticle} />
|
||||
</MotionBox>
|
||||
</AnimatePresence>
|
||||
|
||||
{articles.length > 1 && (
|
||||
<>
|
||||
<IconButton
|
||||
aria-label="Předchozí slide"
|
||||
icon={<ChevronLeftIcon />}
|
||||
position="absolute"
|
||||
left={4}
|
||||
top="50%"
|
||||
transform="translateY(-50%)"
|
||||
zIndex={2}
|
||||
borderRadius="full"
|
||||
colorScheme="blackAlpha"
|
||||
onClick={() => paginate(-1)}
|
||||
size="lg"
|
||||
/>
|
||||
<IconButton
|
||||
aria-label="Další slide"
|
||||
icon={<ChevronRightIcon />}
|
||||
position="absolute"
|
||||
right={4}
|
||||
top="50%"
|
||||
transform="translateY(-50%)"
|
||||
zIndex={2}
|
||||
borderRadius="full"
|
||||
colorScheme="blackAlpha"
|
||||
onClick={() => paginate(1)}
|
||||
size="lg"
|
||||
/>
|
||||
<Flex
|
||||
position="absolute"
|
||||
bottom={8}
|
||||
left="50%"
|
||||
transform="translateX(-50%)"
|
||||
zIndex={2}
|
||||
gap={2}
|
||||
>
|
||||
{articles.map((_, index) => (
|
||||
<Box
|
||||
key={index}
|
||||
as="button"
|
||||
px={2}
|
||||
h="20px"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
fontSize="xs"
|
||||
fontWeight="700"
|
||||
color={index === articleIndex ? 'black' : 'white'}
|
||||
bg={index === articleIndex ? 'white' : 'whiteAlpha.500'}
|
||||
borderRadius="sm"
|
||||
onClick={() => setSlideIndex([index, index > articleIndex ? 1 : -1])}
|
||||
transition="all 0.3s"
|
||||
_hover={{
|
||||
bg: 'white',
|
||||
color: 'black',
|
||||
}}
|
||||
>
|
||||
{String(index + 1).padStart(2, '0')}
|
||||
</Box>
|
||||
))}
|
||||
</Flex>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default BlogSwiper;
|
||||
@@ -0,0 +1,85 @@
|
||||
import React from 'react';
|
||||
import { Box, HStack, Image, Skeleton, useBreakpointValue, Tooltip } from '@chakra-ui/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getArticles, Article } from '../../services/articles';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import { assetUrl } from '../../utils/url';
|
||||
|
||||
const modulo = (n: number, m: number) => ((n % m) + m) % m;
|
||||
|
||||
const BlogThumbStrip: React.FC = () => {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['thumb-articles', { page: 1, page_size: 12, published: true }],
|
||||
queryFn: () => getArticles({ page: 1, page_size: 12, published: true }),
|
||||
});
|
||||
|
||||
const articles = data?.data?.filter(a => !!a.image_url) || [];
|
||||
const visible = useBreakpointValue({ base: 2, md: 3, lg: 5 }) || 5;
|
||||
|
||||
const [index, setIndex] = React.useState(0);
|
||||
const [paused, setPaused] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (articles.length <= visible) return;
|
||||
const id = setInterval(() => {
|
||||
if (!paused) setIndex((i) => i + 1);
|
||||
}, 3000);
|
||||
return () => clearInterval(id);
|
||||
}, [articles.length, visible, paused]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<HStack spacing={3}>
|
||||
{Array.from({ length: visible }).map((_, i) => (
|
||||
<Skeleton key={i} w={{ base: '50%', md: `${100/visible}%` }} h={{ base: '90px', md: '120px', lg: '140px' }} borderRadius="md" />
|
||||
))}
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
|
||||
if (!articles.length) return null;
|
||||
|
||||
const items = Array.from({ length: visible }).map((_, i) => {
|
||||
const idx = modulo(index + i, articles.length);
|
||||
return articles[idx];
|
||||
});
|
||||
|
||||
return (
|
||||
<Box
|
||||
onMouseEnter={() => setPaused(true)}
|
||||
onMouseLeave={() => setPaused(false)}
|
||||
>
|
||||
<HStack spacing={3}>
|
||||
{items.map((a: Article) => (
|
||||
<Box
|
||||
key={a.id}
|
||||
as={RouterLink}
|
||||
to={a.slug ? `/news/${a.slug}` : `/articles/${a.id}`}
|
||||
flex={`0 0 ${100/visible}%`}
|
||||
position="relative"
|
||||
borderRadius="md"
|
||||
overflow="hidden"
|
||||
_hover={{ transform: 'translateY(-2px)', boxShadow: 'lg' }}
|
||||
transition="all 0.25s ease"
|
||||
>
|
||||
<Image
|
||||
src={assetUrl(a.image_url) || '/stadium-placeholder.jpg'}
|
||||
alt={a.title}
|
||||
w="100%"
|
||||
h={{ base: '90px', md: '120px', lg: '140px' }}
|
||||
objectFit="cover"
|
||||
/>
|
||||
<Box position="absolute" bottom={0} left={0} right={0} h="36px" bgGradient="linear(to-t, blackAlpha.700, transparent)" />
|
||||
<Tooltip label={a.title} openDelay={300}>
|
||||
<Box position="absolute" bottom={1} left={2} right={2} color="white" fontSize="xs" noOfLines={1}>
|
||||
{a.title}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
))}
|
||||
</HStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default BlogThumbStrip;
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Box, Flex, Heading, Image, HStack, Button, Text } from '@chakra-ui/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { facrApi } from '../../services/facr/facrApi';
|
||||
import { FACR_CLUB_ID, FACR_CLUB_TYPE } from '../../config/facr';
|
||||
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
||||
|
||||
const ClubHeader: React.FC = () => {
|
||||
const { data: settings } = usePublicSettings();
|
||||
const clubId = settings?.club_id || FACR_CLUB_ID;
|
||||
const clubType = settings?.club_type || FACR_CLUB_TYPE;
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['facr-club', clubId, clubType],
|
||||
queryFn: () => facrApi.getClub(clubId, clubType),
|
||||
enabled: Boolean(clubId),
|
||||
});
|
||||
|
||||
return (
|
||||
<Flex align="center" justify="space-between" bg="white" borderWidth="1px" borderRadius="lg" p={4}>
|
||||
<HStack spacing={4}>
|
||||
<Image src={data?.logo_url || '/logo192.png'} alt={data?.name || 'Club'} boxSize="64px" objectFit="contain" />
|
||||
<Box>
|
||||
<Heading size="lg">{data?.name || 'Club Name'}</Heading>
|
||||
<Text color="gray.600" fontSize="sm">
|
||||
{data?.address || (!clubId ? 'Nastavte klub v Nastavení (Admin) nebo REACT_APP_FACR_CLUB_ID' : '')}
|
||||
</Text>
|
||||
</Box>
|
||||
</HStack>
|
||||
<HStack spacing={2}>
|
||||
<Button as="a" href="https://facebook.com" target="_blank" size="sm" variant="ghost">FB</Button>
|
||||
<Button as="a" href="https://instagram.com" target="_blank" size="sm" variant="ghost">IG</Button>
|
||||
<Button as="a" href="https://youtube.com" target="_blank" size="sm" variant="ghost">YT</Button>
|
||||
{data?.url && (
|
||||
<Button as="a" href={data.url} target="_blank" size="sm" colorScheme="blue">FAČR profil</Button>
|
||||
)}
|
||||
</HStack>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClubHeader;
|
||||
@@ -0,0 +1,211 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalFooter,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
Button,
|
||||
Box,
|
||||
Text,
|
||||
VStack,
|
||||
HStack,
|
||||
Badge,
|
||||
Flex,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { TeamLogo } from '../common/TeamLogo';
|
||||
|
||||
interface ClubModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
club: {
|
||||
team: string;
|
||||
team_id?: string;
|
||||
team_logo_url?: string;
|
||||
rank?: string | number;
|
||||
played?: string | number;
|
||||
wins?: string | number;
|
||||
draws?: string | number;
|
||||
losses?: string | number;
|
||||
score?: string;
|
||||
points?: string | number;
|
||||
// Additional fields from FACR
|
||||
goals_scored?: string | number;
|
||||
goals_conceded?: string | number;
|
||||
goal_difference?: string | number;
|
||||
form?: string; // Last 5 matches form (e.g., "WWDWL")
|
||||
position_change?: number; // +/- change in position
|
||||
} | null;
|
||||
clubType?: 'football' | 'futsal';
|
||||
}
|
||||
|
||||
const ClubModal: React.FC<ClubModalProps> = ({ isOpen, onClose, club, clubType = 'football' }) => {
|
||||
if (!club) return null;
|
||||
|
||||
// Theme-aware colors
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'whiteAlpha.300');
|
||||
const fallbackBg = useColorModeValue('gray.100', 'gray.700');
|
||||
const fallbackText = useColorModeValue('gray.600', 'gray.300');
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="lg" isCentered>
|
||||
<ModalOverlay bg="blackAlpha.600" backdropFilter="blur(4px)" />
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
<Flex align="center" gap={3}>
|
||||
<TeamLogo
|
||||
teamId={club.team_id}
|
||||
teamName={club.team}
|
||||
facrLogo={club.team_logo_url}
|
||||
size="large"
|
||||
alt={club.team}
|
||||
borderRadius="full"
|
||||
bg={cardBg}
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
fallbackIcon={
|
||||
<Box
|
||||
w="48px"
|
||||
h="48px"
|
||||
bg={fallbackBg}
|
||||
borderRadius="full"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
color={fallbackText}
|
||||
fontSize="lg"
|
||||
fontWeight="bold"
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
>
|
||||
{club.team.substring(0, 2).toUpperCase()}
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
<Box>
|
||||
<Text fontSize="xl" fontWeight="bold">
|
||||
{club.team}
|
||||
</Text>
|
||||
{club.rank && (
|
||||
<Badge colorScheme="blue" fontSize="sm">
|
||||
{club.rank}. místo
|
||||
</Badge>
|
||||
)}
|
||||
</Box>
|
||||
</Flex>
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<VStack spacing={4} align="stretch">
|
||||
{/* Statistics */}
|
||||
<Box
|
||||
borderWidth="1px"
|
||||
borderRadius="lg"
|
||||
p={4}
|
||||
bg={useColorModeValue('gray.50', 'gray.700')}
|
||||
borderColor={borderColor}
|
||||
>
|
||||
<Text fontSize="md" fontWeight="semibold" mb={3} color={useColorModeValue('gray.700', 'gray.200')}>
|
||||
Statistiky
|
||||
</Text>
|
||||
<VStack spacing={2} align="stretch">
|
||||
<HStack justify="space-between">
|
||||
<Text color={useColorModeValue('gray.600', 'gray.300')}>Odehráno zápasů:</Text>
|
||||
<Text fontWeight="bold" color={useColorModeValue('gray.800', 'gray.100')}>{club.played || 0}</Text>
|
||||
</HStack>
|
||||
<HStack justify="space-between">
|
||||
<Text color={useColorModeValue('gray.600', 'gray.300')}>Výhry:</Text>
|
||||
<Text fontWeight="bold" color="green.600">
|
||||
{club.wins || 0}
|
||||
</Text>
|
||||
</HStack>
|
||||
<HStack justify="space-between">
|
||||
<Text color={useColorModeValue('gray.600', 'gray.300')}>Remízy:</Text>
|
||||
<Text fontWeight="bold" color={useColorModeValue('gray.600', 'gray.400')}>
|
||||
{club.draws || 0}
|
||||
</Text>
|
||||
</HStack>
|
||||
<HStack justify="space-between">
|
||||
<Text color={useColorModeValue('gray.600', 'gray.300')}>Prohry:</Text>
|
||||
<Text fontWeight="bold" color="red.600">
|
||||
{club.losses || 0}
|
||||
</Text>
|
||||
</HStack>
|
||||
<HStack justify="space-between">
|
||||
<Text color={useColorModeValue('gray.600', 'gray.300')}>Skóre:</Text>
|
||||
<Text fontWeight="bold" color={useColorModeValue('gray.800', 'gray.100')}>{club.score || '0:0'}</Text>
|
||||
</HStack>
|
||||
{(club.goals_scored !== undefined || club.goals_conceded !== undefined) && (
|
||||
<>
|
||||
<HStack justify="space-between">
|
||||
<Text color={useColorModeValue('gray.600', 'gray.300')}>Vstřelené góly:</Text>
|
||||
<Text fontWeight="bold" color="green.500">{club.goals_scored || 0}</Text>
|
||||
</HStack>
|
||||
<HStack justify="space-between">
|
||||
<Text color={useColorModeValue('gray.600', 'gray.300')}>Obdržené góly:</Text>
|
||||
<Text fontWeight="bold" color="red.500">{club.goals_conceded || 0}</Text>
|
||||
</HStack>
|
||||
</>
|
||||
)}
|
||||
{club.goal_difference !== undefined && (
|
||||
<HStack justify="space-between">
|
||||
<Text color={useColorModeValue('gray.600', 'gray.300')}>Skóre rozdíl:</Text>
|
||||
<Text fontWeight="bold" color={Number(club.goal_difference) >= 0 ? 'green.600' : 'red.600'}>
|
||||
{Number(club.goal_difference) > 0 ? '+' : ''}{club.goal_difference}
|
||||
</Text>
|
||||
</HStack>
|
||||
)}
|
||||
<HStack justify="space-between" pt={2} borderTopWidth="1px" borderColor={borderColor}>
|
||||
<Text color={useColorModeValue('gray.700', 'gray.200')} fontWeight="semibold">Body:</Text>
|
||||
<Badge colorScheme="blue" fontSize="lg" px={3} py={1}>
|
||||
{club.points || 0}
|
||||
</Badge>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
|
||||
{/* Form (Last 5 matches) */}
|
||||
{club.form && (
|
||||
<Box
|
||||
borderWidth="1px"
|
||||
borderRadius="lg"
|
||||
p={4}
|
||||
bg={useColorModeValue('gray.50', 'gray.700')}
|
||||
borderColor={borderColor}
|
||||
>
|
||||
<Text fontSize="md" fontWeight="semibold" mb={3} color={useColorModeValue('gray.700', 'gray.200')}>
|
||||
Forma (posledních 5 zápasů)
|
||||
</Text>
|
||||
<HStack spacing={2} justify="center">
|
||||
{club.form.split('').map((result, idx) => (
|
||||
<Badge
|
||||
key={idx}
|
||||
colorScheme={result === 'W' ? 'green' : result === 'D' ? 'yellow' : 'red'}
|
||||
fontSize="md"
|
||||
px={3}
|
||||
py={1}
|
||||
borderRadius="md"
|
||||
>
|
||||
{result === 'W' ? 'V' : result === 'D' ? 'R' : 'P'}
|
||||
</Badge>
|
||||
))}
|
||||
</HStack>
|
||||
</Box>
|
||||
)}
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button colorScheme="gray" onClick={onClose}>
|
||||
Zavřít
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClubModal;
|
||||
@@ -0,0 +1,151 @@
|
||||
import { Box, Tabs, TabList, TabPanels, Tab, TabPanel, VStack, HStack, Image, Text, Skeleton, Badge } from '@chakra-ui/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import React from 'react';
|
||||
import { facrApi } from '../../services/facr/facrApi';
|
||||
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
||||
import { getCompetitionAliasesPublic, CompetitionAlias } from '../../services/competitionAliases';
|
||||
import { TeamLogo } from '../common/TeamLogo';
|
||||
import { sortCategoriesWithOrder } from '../../utils/categorySort';
|
||||
import '../../styles/logos.css';
|
||||
|
||||
const Row: React.FC<{ d: string; h: string; hid?: string; hl?: string; a: string; aid?: string; al?: string; s?: string; clubName?: string }> = ({ d, h, hid, hl, a, aid, al, s, clubName }) => (
|
||||
<HStack justify="space-between" borderRadius="lg" p={3} bg="white" boxShadow="sm">
|
||||
<Text w="140px" fontSize="sm" color="gray.600">{d}</Text>
|
||||
<HStack flex={1} justify="flex-end" spacing={4}>
|
||||
<HStack minW="40%" justify="flex-end" spacing={2}>
|
||||
<Text noOfLines={1} textAlign="right" flex={1}>{h}</Text>
|
||||
<Box className="logo-container" w="28px" h="28px">
|
||||
<TeamLogo
|
||||
teamId={hid}
|
||||
teamName={h}
|
||||
facrLogo={hl}
|
||||
size="custom"
|
||||
boxSize="28px"
|
||||
/>
|
||||
</Box>
|
||||
</HStack>
|
||||
<HStack minW="60px" justify="center" spacing={2}>
|
||||
<Text fontWeight="bold" textAlign="center">{s || '-:-'}</Text>
|
||||
{(() => {
|
||||
if (!s || !clubName) return null;
|
||||
const m = s.match(/^(\d+)\s*[:\-]\s*(\d+)$/);
|
||||
if (!m) return null;
|
||||
const hG = parseInt(m[1], 10), aG = parseInt(m[2], 10);
|
||||
const norm = (x: string) => String(x||'').normalize('NFD').replace(/[\u0300-\u036f]/g,'').replace(/\s+/g,' ').trim().toLowerCase();
|
||||
const strip = (x: string) => norm(x).replace(/\b(mestsky|m\.?f\.?k\.?|mfk|tj|sk|sokol|fotbalovy|fotbalový|fotbalovy\s+klub|fotbalovy\s+klub)\b/g,'').replace(/\s+/g,' ').trim();
|
||||
const ourHome = (() => { const A = strip(h); const B = strip(clubName); return A && B && (A===B || A.endsWith(B) || B.endsWith(A)); })();
|
||||
const ourAway = (() => { const A = strip(a); const B = strip(clubName); return A && B && (A===B || A.endsWith(B) || B.endsWith(A)); })();
|
||||
if (!ourHome && !ourAway) return null;
|
||||
if (hG === aG) return <Badge colorScheme="blue" variant="subtle">Remíza</Badge>;
|
||||
const our = ourHome ? hG : aG; const opp = ourHome ? aG : hG;
|
||||
return our > opp ? <Badge colorScheme="green" variant="subtle">Výhra</Badge> : <Badge colorScheme="red" variant="subtle">Prohra</Badge>;
|
||||
})()}
|
||||
</HStack>
|
||||
<HStack minW="40%" spacing={2}>
|
||||
<Box className="logo-container" w="28px" h="28px">
|
||||
<TeamLogo
|
||||
teamId={aid}
|
||||
teamName={a}
|
||||
facrLogo={al}
|
||||
size="custom"
|
||||
boxSize="28px"
|
||||
/>
|
||||
</Box>
|
||||
<Text noOfLines={1} flex={1}>{a}</Text>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</HStack>
|
||||
);
|
||||
|
||||
const CompetitionMatches: React.FC = () => {
|
||||
const { data: settings } = usePublicSettings();
|
||||
const clubId = settings?.club_id;
|
||||
const clubType = settings?.club_type || 'football';
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['facr-club', clubId, clubType],
|
||||
queryFn: () => facrApi.getClub(clubId!, clubType as any),
|
||||
enabled: !!clubId,
|
||||
});
|
||||
// Load competition aliases
|
||||
const [aliases, setAliases] = React.useState<Record<string, { alias: string; original_name?: string; display_order?: number }>>({});
|
||||
React.useEffect(() => {
|
||||
let mounted = true;
|
||||
(async () => {
|
||||
try {
|
||||
const list: CompetitionAlias[] = await getCompetitionAliasesPublic();
|
||||
if (!mounted) return;
|
||||
const map: Record<string, { alias: string; original_name?: string; display_order?: number }> = {};
|
||||
(list || []).forEach((a) => { if (a?.code && a?.alias) map[a.code] = { alias: a.alias, original_name: a.original_name, display_order: a.display_order }; });
|
||||
setAliases(map);
|
||||
} catch {}
|
||||
})();
|
||||
return () => { mounted = false; };
|
||||
}, []);
|
||||
|
||||
// Precompute sorted competitions safely (must be before any early returns to keep hooks order stable)
|
||||
const competitions = data?.competitions ?? [];
|
||||
const sortedCompetitions = React.useMemo(() => {
|
||||
const arr = Array.isArray(competitions) ? competitions : [];
|
||||
return sortCategoriesWithOrder(
|
||||
arr.map(c => ({
|
||||
...c,
|
||||
name: aliases[c.code]?.alias || aliases[c.id]?.alias || c.name,
|
||||
alias: aliases[c.code]?.alias || aliases[c.id]?.alias,
|
||||
display_order: (aliases[c.code]?.display_order) ?? (aliases[c.id]?.display_order),
|
||||
}))
|
||||
);
|
||||
}, [competitions, aliases]);
|
||||
|
||||
if (isLoading) return <Skeleton height="200px" />;
|
||||
|
||||
if (!clubId) {
|
||||
return (
|
||||
<Box p={4} bg="yellow.50" borderRadius="md" borderWidth="1px" borderColor="yellow.200">
|
||||
<Text color="gray.700">
|
||||
Pro zobrazení zápasů je potřeba nastavit klub v administraci (Nastavení → Základní údaje).
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data || !data.competitions || data.competitions.length === 0) {
|
||||
return (
|
||||
<Box p={4} bg="gray.50" borderRadius="md" borderWidth="1px" borderColor="gray.200">
|
||||
<Text color="gray.600">
|
||||
Žádné soutěže ani zápasy nejsou k dispozici pro vybraný klub.
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Sort competitions by age (Muži first, then U19, U17, etc.) and respect custom order (computed above)
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Tabs variant="soft-rounded" colorScheme="blue" isFitted>
|
||||
<TabList>
|
||||
{sortedCompetitions.map((c) => {
|
||||
const label = c.alias || c.name;
|
||||
return <Tab key={c.id}>{label}</Tab>;
|
||||
})}
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
{sortedCompetitions.map((c) => (
|
||||
<TabPanel key={c.id} px={0}>
|
||||
<VStack align="stretch" spacing={3}>
|
||||
{(c.matches || []).slice(0, 6).map((m, idx) => (
|
||||
<Row key={m.match_id || idx} d={m.date_time} h={m.home} hid={m.home_id} hl={m.home_logo_url} a={m.away} aid={m.away_id} al={m.away_logo_url} s={m.score} clubName={data.name} />
|
||||
))}
|
||||
{(c.matches || []).length === 0 && (
|
||||
<Text color="gray.500">Žádné zápasy k dispozici.</Text>
|
||||
)}
|
||||
</VStack>
|
||||
</TabPanel>
|
||||
))}
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default CompetitionMatches;
|
||||
@@ -0,0 +1,328 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { Box } from '@chakra-ui/react';
|
||||
|
||||
// Dynamically load Leaflet
|
||||
let L: any = null;
|
||||
|
||||
interface ContactMapProps {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
zoom?: number;
|
||||
address?: string;
|
||||
clubName?: string;
|
||||
mapStyle?: string;
|
||||
height?: number;
|
||||
clubPrimaryColor?: string;
|
||||
clubSecondaryColor?: string;
|
||||
}
|
||||
|
||||
// Available map styles
|
||||
export const MAP_STYLES = {
|
||||
// Clean & Minimal
|
||||
'positron': {
|
||||
name: 'Positron (Light)',
|
||||
url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',
|
||||
attribution: '© OpenStreetMap © CartoDB',
|
||||
description: 'Clean light map, perfect for overlays'
|
||||
},
|
||||
'positron-no-labels': {
|
||||
name: 'Positron No Labels',
|
||||
url: 'https://{s}.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}{r}.png',
|
||||
attribution: '© OpenStreetMap © CartoDB',
|
||||
description: 'Minimal light map without labels'
|
||||
},
|
||||
|
||||
// Dark Themes
|
||||
'dark': {
|
||||
name: 'Dark Matter',
|
||||
url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
|
||||
attribution: '© OpenStreetMap © CartoDB',
|
||||
description: 'Dark theme, great for night mode'
|
||||
},
|
||||
'dark-no-labels': {
|
||||
name: 'Dark No Labels',
|
||||
url: 'https://{s}.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png',
|
||||
attribution: '© OpenStreetMap © CartoDB',
|
||||
description: 'Dark map without labels'
|
||||
},
|
||||
|
||||
// Black & White
|
||||
'toner': {
|
||||
name: 'Toner (B&W)',
|
||||
url: 'https://tiles.stadiamaps.com/tiles/stamen_toner/{z}/{x}/{y}{r}.png',
|
||||
attribution: '© Stamen Design © OpenStreetMap',
|
||||
description: 'High contrast black and white'
|
||||
},
|
||||
'toner-lite': {
|
||||
name: 'Toner Lite (B&W)',
|
||||
url: 'https://tiles.stadiamaps.com/tiles/stamen_toner_lite/{z}/{x}/{y}{r}.png',
|
||||
attribution: '© Stamen Design © OpenStreetMap',
|
||||
description: 'Subtle black and white'
|
||||
},
|
||||
|
||||
// Colorful Options
|
||||
'voyager': {
|
||||
name: 'Voyager',
|
||||
url: 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png',
|
||||
attribution: '© OpenStreetMap © CartoDB',
|
||||
description: 'Balanced colors, good readability'
|
||||
},
|
||||
'terrain': {
|
||||
name: 'Terrain',
|
||||
url: 'https://tiles.stadiamaps.com/tiles/stamen_terrain/{z}/{x}/{y}{r}.jpg',
|
||||
attribution: '© Stamen Design © OpenStreetMap',
|
||||
description: 'Natural terrain visualization'
|
||||
},
|
||||
'watercolor': {
|
||||
name: 'Watercolor',
|
||||
url: 'https://tiles.stadiamaps.com/tiles/stamen_watercolor/{z}/{x}/{y}.jpg',
|
||||
attribution: '© Stamen Design © OpenStreetMap',
|
||||
description: 'Artistic watercolor style'
|
||||
},
|
||||
|
||||
// Default
|
||||
'default': {
|
||||
name: 'OpenStreetMap',
|
||||
url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
attribution: '© OpenStreetMap contributors',
|
||||
description: 'Standard OpenStreetMap'
|
||||
},
|
||||
|
||||
// Satellite
|
||||
'satellite': {
|
||||
name: 'Satellite',
|
||||
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
||||
attribution: '© Esri',
|
||||
description: 'Satellite imagery'
|
||||
},
|
||||
};
|
||||
|
||||
const ContactMap: React.FC<ContactMapProps> = ({
|
||||
latitude,
|
||||
longitude,
|
||||
zoom = 15,
|
||||
address,
|
||||
clubName,
|
||||
mapStyle = 'default',
|
||||
height = 400,
|
||||
clubPrimaryColor,
|
||||
clubSecondaryColor,
|
||||
}) => {
|
||||
const mapRef = useRef<HTMLDivElement>(null);
|
||||
const mapInstanceRef = useRef<any>(null);
|
||||
const [isLoaded, setIsLoaded] = React.useState(false);
|
||||
const [loadError, setLoadError] = React.useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Load Leaflet CSS and JS dynamically
|
||||
const loadLeaflet = async () => {
|
||||
try {
|
||||
// Check if already loaded
|
||||
if ((window as any).L) {
|
||||
L = (window as any).L;
|
||||
setIsLoaded(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Load CSS
|
||||
if (!document.getElementById('leaflet-css')) {
|
||||
const link = document.createElement('link');
|
||||
link.id = 'leaflet-css';
|
||||
link.rel = 'stylesheet';
|
||||
link.href = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css';
|
||||
link.integrity = 'sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=';
|
||||
link.crossOrigin = '';
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
|
||||
// Load JS
|
||||
if (!document.getElementById('leaflet-js')) {
|
||||
const script = document.createElement('script');
|
||||
script.id = 'leaflet-js';
|
||||
script.src = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js';
|
||||
script.integrity = 'sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=';
|
||||
script.crossOrigin = '';
|
||||
|
||||
script.onload = () => {
|
||||
L = (window as any).L;
|
||||
setIsLoaded(true);
|
||||
};
|
||||
|
||||
script.onerror = () => {
|
||||
setLoadError('Failed to load map library');
|
||||
};
|
||||
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
} catch (error) {
|
||||
setLoadError('Error loading map');
|
||||
}
|
||||
};
|
||||
|
||||
loadLeaflet();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoaded || !L || !mapRef.current || mapInstanceRef.current) return;
|
||||
|
||||
try {
|
||||
// Initialize map
|
||||
const map = L.map(mapRef.current, {
|
||||
center: [latitude, longitude],
|
||||
zoom: zoom,
|
||||
scrollWheelZoom: false, // Disable scroll zoom for better UX
|
||||
});
|
||||
|
||||
mapInstanceRef.current = map;
|
||||
|
||||
// Get tile layer URL based on style
|
||||
let tileUrl = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png';
|
||||
let attribution = '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors';
|
||||
|
||||
// Use predefined styles or custom URL
|
||||
if (mapStyle && MAP_STYLES[mapStyle as keyof typeof MAP_STYLES]) {
|
||||
const style = MAP_STYLES[mapStyle as keyof typeof MAP_STYLES];
|
||||
tileUrl = style.url;
|
||||
attribution = style.attribution;
|
||||
} else if (mapStyle && mapStyle.startsWith('http')) {
|
||||
// Custom tile URL
|
||||
tileUrl = mapStyle;
|
||||
}
|
||||
|
||||
// Add tile layer
|
||||
const tileLayer = L.tileLayer(tileUrl, {
|
||||
attribution: attribution,
|
||||
maxZoom: 19,
|
||||
}).addTo(map);
|
||||
|
||||
// Apply club color overlay if provided
|
||||
if (clubPrimaryColor && clubPrimaryColor !== '') {
|
||||
const colorFilter = createColorFilter(clubPrimaryColor);
|
||||
if (colorFilter) {
|
||||
const pane = map.createPane('colorOverlay');
|
||||
pane.style.zIndex = '400';
|
||||
pane.style.pointerEvents = 'none';
|
||||
pane.style.mixBlendMode = 'multiply';
|
||||
pane.style.backgroundColor = colorFilter;
|
||||
pane.style.opacity = '0.15';
|
||||
}
|
||||
}
|
||||
|
||||
// Create custom marker icon with club colors
|
||||
const markerColor = clubPrimaryColor || '#3388ff';
|
||||
const customIcon = createCustomMarkerIcon(markerColor, L);
|
||||
|
||||
// Add marker
|
||||
const marker = L.marker([latitude, longitude], { icon: customIcon }).addTo(map);
|
||||
|
||||
// Add popup if address is provided
|
||||
if (clubName || address) {
|
||||
let popupContent = '';
|
||||
if (clubName) popupContent += `<b>${clubName}</b><br>`;
|
||||
if (address) popupContent += address;
|
||||
marker.bindPopup(popupContent);
|
||||
}
|
||||
|
||||
// Enable scroll zoom on click
|
||||
map.on('click', () => {
|
||||
map.scrollWheelZoom.enable();
|
||||
});
|
||||
|
||||
// Disable scroll zoom on mouseout
|
||||
map.on('mouseout', () => {
|
||||
map.scrollWheelZoom.disable();
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error initializing map:', error);
|
||||
setLoadError('Failed to initialize map');
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
if (mapInstanceRef.current) {
|
||||
mapInstanceRef.current.remove();
|
||||
mapInstanceRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [isLoaded, latitude, longitude, zoom, address, clubName, mapStyle, clubPrimaryColor, clubSecondaryColor]);
|
||||
|
||||
// Helper function to create color filter
|
||||
function createColorFilter(color: string): string | null {
|
||||
try {
|
||||
// Validate and normalize color
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.style.color = color;
|
||||
document.body.appendChild(tempDiv);
|
||||
const computedColor = window.getComputedStyle(tempDiv).color;
|
||||
document.body.removeChild(tempDiv);
|
||||
return computedColor;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to create custom marker with club colors
|
||||
function createCustomMarkerIcon(color: string, leaflet: any) {
|
||||
// Create SVG marker with custom color
|
||||
const svgIcon = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 36" width="36" height="54">
|
||||
<defs>
|
||||
<filter id="shadow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur in="SourceAlpha" stdDeviation="2"/>
|
||||
<feOffset dx="0" dy="2" result="offsetblur"/>
|
||||
<feComponentTransfer>
|
||||
<feFuncA type="linear" slope="0.3"/>
|
||||
</feComponentTransfer>
|
||||
<feMerge>
|
||||
<feMergeNode/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
<path fill="${color}" stroke="#fff" stroke-width="1.5" filter="url(#shadow)"
|
||||
d="M12 0C7.03 0 3 4.03 3 9c0 7.5 9 18 9 18s9-10.5 9-18c0-4.97-4.03-9-9-9z"/>
|
||||
<circle cx="12" cy="9" r="3" fill="#fff"/>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
const iconUrl = 'data:image/svg+xml;base64,' + btoa(svgIcon);
|
||||
|
||||
return leaflet.icon({
|
||||
iconUrl: iconUrl,
|
||||
iconSize: [36, 54],
|
||||
iconAnchor: [18, 54],
|
||||
popupAnchor: [0, -54],
|
||||
});
|
||||
}
|
||||
|
||||
if (loadError) {
|
||||
return (
|
||||
<Box
|
||||
ref={mapRef}
|
||||
w="100%"
|
||||
h={`${height}px`}
|
||||
bg="gray.100"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
borderRadius="md"
|
||||
>
|
||||
{loadError}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={mapRef}
|
||||
w="100%"
|
||||
h={`${height}px`}
|
||||
borderRadius="md"
|
||||
overflow="hidden"
|
||||
boxShadow="md"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContactMap;
|
||||
@@ -0,0 +1,334 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Heading,
|
||||
Text,
|
||||
SimpleGrid,
|
||||
VStack,
|
||||
HStack,
|
||||
Avatar,
|
||||
Badge,
|
||||
Link,
|
||||
Icon,
|
||||
useColorModeValue,
|
||||
Divider,
|
||||
Accordion,
|
||||
AccordionItem,
|
||||
AccordionButton,
|
||||
AccordionPanel,
|
||||
AccordionIcon,
|
||||
} from '@chakra-ui/react';
|
||||
import { FiMail, FiPhone, FiMapPin } from 'react-icons/fi';
|
||||
import { getPublicContacts, GroupedContacts } from '../../services/contactInfo';
|
||||
import { getPublicSettings } from '../../services/settings';
|
||||
import ContactMap from './ContactMap';
|
||||
import { getImageUrl } from '../../utils/imageUtils';
|
||||
|
||||
const ContactsSection: React.FC = () => {
|
||||
const [contactsData, setContactsData] = useState<GroupedContacts | null>(null);
|
||||
const [settings, setSettings] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const bgColor = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [contacts, settingsData] = await Promise.all([
|
||||
getPublicContacts(),
|
||||
getPublicSettings(),
|
||||
]);
|
||||
setContactsData(contacts);
|
||||
setSettings(settingsData);
|
||||
} catch (error) {
|
||||
console.error('Failed to load contacts:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading || !contactsData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if there's any data to display
|
||||
const hasContacts = Object.keys(contactsData.categories).length > 0 || contactsData.uncategorized.length > 0;
|
||||
const hasLocation = settings?.location_latitude && settings?.location_longitude;
|
||||
const hasContactInfo = settings?.contact_address || settings?.contact_phone || settings?.contact_email;
|
||||
|
||||
if (!hasContacts && !hasLocation && !hasContactInfo) {
|
||||
return null; // Don't render if no data
|
||||
}
|
||||
|
||||
return (
|
||||
<Box py={16} bg={useColorModeValue('gray.50', 'gray.900')}>
|
||||
<Container maxW="container.xl">
|
||||
<VStack spacing={8} align="stretch">
|
||||
<Box textAlign="center">
|
||||
<Heading size="xl" mb={4}>Kontakt</Heading>
|
||||
<Text fontSize="lg" color="gray.600">
|
||||
Spojte se s námi
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Map and Address Section */}
|
||||
{(hasLocation || hasContactInfo) && (
|
||||
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={8}>
|
||||
{/* Map - always show if coordinates are set */}
|
||||
{hasLocation && (
|
||||
<Box>
|
||||
<ContactMap
|
||||
latitude={settings.location_latitude}
|
||||
longitude={settings.location_longitude}
|
||||
zoom={settings.map_zoom_level || 15}
|
||||
address={settings.contact_address}
|
||||
clubName={settings.club_name || settings.site_title}
|
||||
mapStyle={settings.map_style || 'default'}
|
||||
clubPrimaryColor={settings.primary_color}
|
||||
clubSecondaryColor={settings.accent_color}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Contact Information */}
|
||||
{hasContactInfo && (
|
||||
<Box
|
||||
bg={bgColor}
|
||||
p={6}
|
||||
borderRadius="lg"
|
||||
boxShadow="md"
|
||||
border="1px"
|
||||
borderColor={borderColor}
|
||||
>
|
||||
<VStack align="stretch" spacing={4}>
|
||||
<Heading size="md">Naše adresa</Heading>
|
||||
|
||||
{settings.contact_address && (
|
||||
<HStack align="start">
|
||||
<Icon as={FiMapPin} boxSize={5} color="blue.500" mt={1} />
|
||||
<VStack align="start" spacing={0}>
|
||||
<Text fontWeight="bold">Adresa</Text>
|
||||
<Text>{settings.contact_address}</Text>
|
||||
{settings.contact_city && (
|
||||
<Text>
|
||||
{settings.contact_zip && `${settings.contact_zip} `}
|
||||
{settings.contact_city}
|
||||
</Text>
|
||||
)}
|
||||
{settings.contact_country && <Text>{settings.contact_country}</Text>}
|
||||
</VStack>
|
||||
</HStack>
|
||||
)}
|
||||
|
||||
{settings.contact_phone && (
|
||||
<HStack align="start">
|
||||
<Icon as={FiPhone} boxSize={5} color="blue.500" mt={1} />
|
||||
<VStack align="start" spacing={0}>
|
||||
<Text fontWeight="bold">Telefon</Text>
|
||||
<Link href={`tel:${settings.contact_phone}`} color="blue.500">
|
||||
{settings.contact_phone}
|
||||
</Link>
|
||||
</VStack>
|
||||
</HStack>
|
||||
)}
|
||||
|
||||
{settings.contact_email && (
|
||||
<HStack align="start">
|
||||
<Icon as={FiMail} boxSize={5} color="blue.500" mt={1} />
|
||||
<VStack align="start" spacing={0}>
|
||||
<Text fontWeight="bold">Email</Text>
|
||||
<Link href={`mailto:${settings.contact_email}`} color="blue.500">
|
||||
{settings.contact_email}
|
||||
</Link>
|
||||
</VStack>
|
||||
</HStack>
|
||||
)}
|
||||
</VStack>
|
||||
</Box>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
|
||||
{/* Contacts by Category */}
|
||||
{hasContacts && (
|
||||
<Box>
|
||||
<Divider my={8} />
|
||||
|
||||
<Accordion allowMultiple defaultIndex={[0]}>
|
||||
{Object.entries(contactsData.categories).map(([categoryName, contacts]) => (
|
||||
<AccordionItem key={categoryName} border="none" mb={4}>
|
||||
<AccordionButton
|
||||
bg={bgColor}
|
||||
borderRadius="lg"
|
||||
p={4}
|
||||
_hover={{ bg: useColorModeValue('gray.100', 'gray.700') }}
|
||||
boxShadow="sm"
|
||||
>
|
||||
<Box flex="1" textAlign="left">
|
||||
<Heading size="md">{categoryName}</Heading>
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
{contacts.length} {contacts.length === 1 ? 'kontakt' : 'kontaktů'}
|
||||
</Text>
|
||||
</Box>
|
||||
<AccordionIcon />
|
||||
</AccordionButton>
|
||||
<AccordionPanel pb={4} pt={4}>
|
||||
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={6}>
|
||||
{contacts.map((contact) => (
|
||||
<Box
|
||||
key={contact.id}
|
||||
bg={bgColor}
|
||||
p={6}
|
||||
borderRadius="lg"
|
||||
boxShadow="md"
|
||||
border="1px"
|
||||
borderColor={borderColor}
|
||||
transition="transform 0.2s"
|
||||
_hover={{ transform: 'translateY(-4px)', boxShadow: 'lg' }}
|
||||
>
|
||||
<VStack spacing={4} align="start">
|
||||
{contact.image_url && (
|
||||
<Avatar
|
||||
src={getImageUrl(contact.image_url)}
|
||||
name={contact.name}
|
||||
size="xl"
|
||||
alignSelf="center"
|
||||
/>
|
||||
)}
|
||||
<Box textAlign={contact.image_url ? 'center' : 'left'} w="100%">
|
||||
<Heading size="sm" mb={1}>
|
||||
{contact.name}
|
||||
</Heading>
|
||||
{contact.position && (
|
||||
<Badge colorScheme="blue" mb={2}>
|
||||
{contact.position}
|
||||
</Badge>
|
||||
)}
|
||||
</Box>
|
||||
{contact.description && (
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
{contact.description}
|
||||
</Text>
|
||||
)}
|
||||
<VStack align="start" spacing={2} w="100%">
|
||||
{contact.email && (
|
||||
<HStack spacing={2}>
|
||||
<Icon as={FiMail} color="blue.500" />
|
||||
<Link href={`mailto:${contact.email}`} fontSize="sm" color="blue.500">
|
||||
{contact.email}
|
||||
</Link>
|
||||
</HStack>
|
||||
)}
|
||||
{contact.phone && (
|
||||
<HStack spacing={2}>
|
||||
<Icon as={FiPhone} color="blue.500" />
|
||||
<Link href={`tel:${contact.phone}`} fontSize="sm" color="blue.500">
|
||||
{contact.phone}
|
||||
</Link>
|
||||
</HStack>
|
||||
)}
|
||||
</VStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
))}
|
||||
|
||||
{/* Uncategorized contacts */}
|
||||
{contactsData.uncategorized.length > 0 && (
|
||||
<AccordionItem border="none" mb={4}>
|
||||
<AccordionButton
|
||||
bg={bgColor}
|
||||
borderRadius="lg"
|
||||
p={4}
|
||||
_hover={{ bg: useColorModeValue('gray.100', 'gray.700') }}
|
||||
boxShadow="sm"
|
||||
>
|
||||
<Box flex="1" textAlign="left">
|
||||
<Heading size="md">Ostatní kontakty</Heading>
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
{contactsData.uncategorized.length} {contactsData.uncategorized.length === 1 ? 'kontakt' : 'kontaktů'}
|
||||
</Text>
|
||||
</Box>
|
||||
<AccordionIcon />
|
||||
</AccordionButton>
|
||||
<AccordionPanel pb={4} pt={4}>
|
||||
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={6}>
|
||||
{contactsData.uncategorized.map((contact) => (
|
||||
<Box
|
||||
key={contact.id}
|
||||
bg={bgColor}
|
||||
p={6}
|
||||
borderRadius="lg"
|
||||
boxShadow="md"
|
||||
border="1px"
|
||||
borderColor={borderColor}
|
||||
transition="transform 0.2s"
|
||||
_hover={{ transform: 'translateY(-4px)', boxShadow: 'lg' }}
|
||||
>
|
||||
<VStack spacing={4} align="start">
|
||||
{contact.image_url && (
|
||||
<Avatar
|
||||
src={getImageUrl(contact.image_url)}
|
||||
name={contact.name}
|
||||
size="xl"
|
||||
alignSelf="center"
|
||||
/>
|
||||
)}
|
||||
<Box textAlign={contact.image_url ? 'center' : 'left'} w="100%">
|
||||
<Heading size="sm" mb={1}>
|
||||
{contact.name}
|
||||
</Heading>
|
||||
{contact.position && (
|
||||
<Badge colorScheme="blue" mb={2}>
|
||||
{contact.position}
|
||||
</Badge>
|
||||
)}
|
||||
</Box>
|
||||
{contact.description && (
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
{contact.description}
|
||||
</Text>
|
||||
)}
|
||||
<VStack align="start" spacing={2} w="100%">
|
||||
{contact.email && (
|
||||
<HStack spacing={2}>
|
||||
<Icon as={FiMail} color="blue.500" />
|
||||
<Link href={`mailto:${contact.email}`} fontSize="sm" color="blue.500">
|
||||
{contact.email}
|
||||
</Link>
|
||||
</HStack>
|
||||
)}
|
||||
{contact.phone && (
|
||||
<HStack spacing={2}>
|
||||
<Icon as={FiPhone} color="blue.500" />
|
||||
<Link href={`tel:${contact.phone}`} fontSize="sm" color="blue.500">
|
||||
{contact.phone}
|
||||
</Link>
|
||||
</HStack>
|
||||
)}
|
||||
</VStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
)}
|
||||
</Accordion>
|
||||
</Box>
|
||||
)}
|
||||
</VStack>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContactsSection;
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Box, Grid, GridItem, Heading, Image, Text, VStack, HStack, Button, Skeleton, Badge } from '@chakra-ui/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getArticles, Article } from '../../services/articles';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import { assetUrl } from '../../utils/url';
|
||||
import { useClubTheme } from '../../contexts/ClubThemeContext';
|
||||
import { Eye, Clock } from 'lucide-react';
|
||||
|
||||
const FeaturedBlog: React.FC = () => {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['articles', { page: 1, page_size: 3, published: true }],
|
||||
queryFn: () => getArticles({ page: 1, page_size: 3, published: true }),
|
||||
});
|
||||
const theme = useClubTheme();
|
||||
const articles = data?.data || [];
|
||||
|
||||
if (isLoading) return <Skeleton height="320px" />;
|
||||
|
||||
const [main, side1, side2] = [articles[0], articles[1], articles[2]];
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<HStack justify="space-between" mb={3}>
|
||||
<Heading size="lg">Aktuality</Heading>
|
||||
<Button as={RouterLink} to="/blog" size="sm" variant="link">Všechny články</Button>
|
||||
</HStack>
|
||||
<Grid templateColumns={{ base: '1fr', md: '2fr 1fr' }} gap={4}>
|
||||
<GridItem>
|
||||
{main && (
|
||||
<Box as={RouterLink} to={main.slug ? `/news/${main.slug}` : `/articles/${main.id}`} position="relative" overflow="hidden" borderRadius="xl">
|
||||
<Image src={assetUrl(main.image_url) || '/logo192.png'} alt={main.title} w="100%" h={{ base: '220px', md: '320px' }} objectFit="cover" />
|
||||
<Box position="absolute" inset={0} bgGradient="linear(to-t, rgba(0,0,0,0.7), rgba(0,0,0,0.1))" />
|
||||
{/* Stats badges */}
|
||||
{((main.read_time || main.estimated_read_minutes) || (main.view_count && main.view_count > 0)) && (
|
||||
<HStack position="absolute" top={3} right={3} spacing={2}>
|
||||
{(main.read_time || main.estimated_read_minutes) && (
|
||||
<Badge display="flex" alignItems="center" gap={1} bg="rgba(0,0,0,0.7)" color="white" fontSize="xs" px={2} py={1}>
|
||||
<Clock size={12} />
|
||||
{main.read_time || main.estimated_read_minutes} min
|
||||
</Badge>
|
||||
)}
|
||||
{main.view_count && main.view_count > 0 && (
|
||||
<Badge display="flex" alignItems="center" gap={1} bg="rgba(0,0,0,0.7)" color="white" fontSize="xs" px={2} py={1}>
|
||||
<Eye size={12} />
|
||||
{main.view_count}
|
||||
</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
)}
|
||||
<VStack align="stretch" spacing={2} position="absolute" bottom={0} p={4} color="white">
|
||||
<Text fontSize="xs" bg={theme.secondary} color="black" px={2} py={0.5} borderRadius="md" w="fit-content">Novinka</Text>
|
||||
<Heading size="md">{main.title}</Heading>
|
||||
</VStack>
|
||||
</Box>
|
||||
)}
|
||||
</GridItem>
|
||||
<GridItem>
|
||||
<VStack spacing={4} align="stretch">
|
||||
{[side1, side2].filter(Boolean).map((a) => (
|
||||
<HStack key={(a as Article).id} align="stretch" spacing={3} as={RouterLink} to={(a as Article).slug ? `/news/${(a as Article).slug}` : `/articles/${(a as Article).id}`}>
|
||||
<Image src={assetUrl((a as Article).image_url) || '/logo192.png'} alt={(a as Article).title} w="40%" h="120px" objectFit="cover" borderRadius="lg" />
|
||||
<VStack align="stretch" spacing={2} flex={1}>
|
||||
<HStack spacing={2} flexWrap="wrap">
|
||||
{((a as Article).read_time || (a as Article).estimated_read_minutes) && (
|
||||
<HStack spacing={1}>
|
||||
<Clock size={12} color="gray" />
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
{(a as Article).read_time || (a as Article).estimated_read_minutes} min
|
||||
</Text>
|
||||
</HStack>
|
||||
)}
|
||||
{(a as Article).view_count && (a as Article).view_count! > 0 && (
|
||||
<HStack spacing={1}>
|
||||
<Eye size={12} color="gray" />
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
{(a as Article).view_count}
|
||||
</Text>
|
||||
</HStack>
|
||||
)}
|
||||
</HStack>
|
||||
<Heading size="sm" noOfLines={3}>{(a as Article).title}</Heading>
|
||||
</VStack>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
</GridItem>
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturedBlog;
|
||||
@@ -0,0 +1,287 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import {
|
||||
Box,
|
||||
Heading,
|
||||
SimpleGrid,
|
||||
Image,
|
||||
Text,
|
||||
VStack,
|
||||
HStack,
|
||||
Button,
|
||||
Skeleton,
|
||||
Badge,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { Calendar, Image as ImageIcon, ExternalLink, ArrowRight } from 'lucide-react';
|
||||
|
||||
interface Album {
|
||||
id: string;
|
||||
title: string;
|
||||
url: string;
|
||||
date: string;
|
||||
photos_count: number;
|
||||
views_count?: number;
|
||||
photos: Array<{
|
||||
id: string;
|
||||
page_url: string;
|
||||
image_1500: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
const resolveBackendUrl = (path: string) => {
|
||||
try {
|
||||
if (/^https?:\/\//i.test(path)) return path;
|
||||
if (path.startsWith('/cache') || path.startsWith('/uploads') || path.startsWith('/api/')) {
|
||||
const base = process.env.REACT_APP_API_BASE_URL || process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1';
|
||||
const b = new URL(base);
|
||||
const abs = new URL(path, `${b.protocol}//${b.host}`);
|
||||
return abs.toString();
|
||||
}
|
||||
return path;
|
||||
} catch {
|
||||
return path;
|
||||
}
|
||||
};
|
||||
|
||||
const GallerySection: React.FC = () => {
|
||||
const [albums, setAlbums] = useState<Album[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Dark mode colors
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const headingColor = useColorModeValue('gray.800', 'gray.100');
|
||||
const textColor = useColorModeValue('gray.600', 'gray.300');
|
||||
const infoBg = useColorModeValue('blue.50', 'blue.900');
|
||||
const infoBorder = useColorModeValue('blue.200', 'blue.700');
|
||||
const infoText = useColorModeValue('blue.700', 'blue.200');
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAlbums = async () => {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// Load from both sources and combine
|
||||
const [profileRes, albumsRes] = await Promise.allSettled([
|
||||
fetch(resolveBackendUrl('/cache/prefetch/zonerama_profile.json'), { cache: 'no-cache' }),
|
||||
fetch(resolveBackendUrl('/cache/prefetch/zonerama_albums.json'), { cache: 'no-cache' })
|
||||
]);
|
||||
|
||||
let combinedAlbums: Album[] = [];
|
||||
|
||||
// Get profile albums (newest/main source)
|
||||
if (profileRes.status === 'fulfilled' && profileRes.value.ok) {
|
||||
const profileData = await profileRes.value.json();
|
||||
combinedAlbums = [...(profileData.albums || [])];
|
||||
}
|
||||
|
||||
// Get blog-related albums (additional source)
|
||||
if (albumsRes.status === 'fulfilled' && albumsRes.value.ok) {
|
||||
const albumsData = await albumsRes.value.json();
|
||||
const blogAlbums = Array.isArray(albumsData) ? albumsData : [];
|
||||
|
||||
// Filter out albums with empty/invalid data and avoid duplicates
|
||||
const validBlogAlbums = blogAlbums.filter((album: any) =>
|
||||
album.id &&
|
||||
album.title &&
|
||||
!combinedAlbums.some(existing => existing.id === album.id)
|
||||
);
|
||||
|
||||
combinedAlbums = [...combinedAlbums, ...validBlogAlbums];
|
||||
}
|
||||
|
||||
// Sort by date (newest first)
|
||||
combinedAlbums.sort((a, b) => {
|
||||
const parseDate = (dateStr: string) => {
|
||||
if (!dateStr) return new Date(0);
|
||||
const parts = dateStr.split(/[.\s]+/).filter(Boolean);
|
||||
if (parts.length === 3) {
|
||||
const [day, month, year] = parts;
|
||||
return new Date(`${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`);
|
||||
}
|
||||
return new Date(dateStr);
|
||||
};
|
||||
|
||||
return parseDate(b.date).getTime() - parseDate(a.date).getTime();
|
||||
});
|
||||
|
||||
// Get the 3 most recent albums
|
||||
const recentAlbums = combinedAlbums.slice(0, 3);
|
||||
setAlbums(recentAlbums);
|
||||
} catch (err) {
|
||||
console.error('Error loading albums:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchAlbums();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box py={12}>
|
||||
<VStack spacing={6} align="stretch">
|
||||
<Heading size="xl">Galerie</Heading>
|
||||
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={6}>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} height="300px" borderRadius="lg" />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (albums.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box py={12}>
|
||||
<VStack spacing={6} align="stretch">
|
||||
{/* Header */}
|
||||
<HStack justify="space-between" align="center" flexWrap="wrap">
|
||||
<VStack align="start" spacing={1}>
|
||||
<Heading size="xl" color={headingColor}>
|
||||
Fotogalerie
|
||||
</Heading>
|
||||
<Text color={textColor} fontSize="sm">
|
||||
Nejnovější alba z našich akcí
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
<Button
|
||||
as={RouterLink}
|
||||
to="/galerie"
|
||||
rightIcon={<ArrowRight size={18} />}
|
||||
colorScheme="blue"
|
||||
variant="outline"
|
||||
size="md"
|
||||
>
|
||||
Zobrazit vše
|
||||
</Button>
|
||||
</HStack>
|
||||
|
||||
{/* Zonerama Attribution */}
|
||||
<Box
|
||||
bg={infoBg}
|
||||
borderWidth="1px"
|
||||
borderColor={infoBorder}
|
||||
borderRadius="md"
|
||||
px={4}
|
||||
py={2}
|
||||
>
|
||||
<Text fontSize="xs" color={infoText}>
|
||||
📸 Všechny fotografie jsou z platformy{' '}
|
||||
<Text
|
||||
as="a"
|
||||
href="https://zonerama.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
fontWeight="600"
|
||||
color="blue.600"
|
||||
_hover={{ textDecoration: 'underline' }}
|
||||
>
|
||||
Zonerama
|
||||
</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Albums Grid */}
|
||||
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={6}>
|
||||
{albums.map((album) => {
|
||||
const coverPhoto = album.photos && album.photos.length > 0
|
||||
? album.photos[0]
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={album.id}
|
||||
as={RouterLink}
|
||||
to={`/galerie/album/${album.id}`}
|
||||
bg={cardBg}
|
||||
borderRadius="lg"
|
||||
overflow="hidden"
|
||||
boxShadow="md"
|
||||
transition="all 0.3s"
|
||||
borderWidth="1px"
|
||||
borderColor={useColorModeValue('gray.200', 'gray.700')}
|
||||
_hover={{
|
||||
transform: 'translateY(-8px)',
|
||||
boxShadow: '2xl',
|
||||
borderColor: useColorModeValue('gray.300', 'gray.600'),
|
||||
}}
|
||||
cursor="pointer"
|
||||
>
|
||||
{/* Cover Image */}
|
||||
{coverPhoto ? (
|
||||
<Image
|
||||
src={coverPhoto.image_1500}
|
||||
alt={album.title}
|
||||
w="100%"
|
||||
h="200px"
|
||||
objectFit="cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<Box
|
||||
w="100%"
|
||||
h="200px"
|
||||
bg="gray.200"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<ImageIcon size={48} color="gray" />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Album Info */}
|
||||
<VStack align="stretch" p={4} spacing={2}>
|
||||
<Heading size="sm" color={headingColor} noOfLines={2} minH="40px">
|
||||
{album.title}
|
||||
</Heading>
|
||||
|
||||
<VStack spacing={2} fontSize="xs" color={textColor} align="stretch">
|
||||
{album.date && (
|
||||
<HStack spacing={1}>
|
||||
<Calendar size={14} />
|
||||
<Text>{album.date}</Text>
|
||||
</HStack>
|
||||
)}
|
||||
<HStack spacing={1}>
|
||||
<ImageIcon size={14} />
|
||||
<Text>{album.photos_count} foto</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
{album.views_count !== undefined && album.views_count > 0 && (
|
||||
<Badge colorScheme="purple" fontSize="2xs" alignSelf="flex-start">
|
||||
{album.views_count} zhlédnutí
|
||||
</Badge>
|
||||
)}
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
|
||||
{/* Bottom CTA */}
|
||||
<Box textAlign="center" pt={4}>
|
||||
<Button
|
||||
as={RouterLink}
|
||||
to="/galerie"
|
||||
rightIcon={<ArrowRight size={18} />}
|
||||
colorScheme="blue"
|
||||
size="lg"
|
||||
>
|
||||
Zobrazit všechna alba
|
||||
</Button>
|
||||
</Box>
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default GallerySection;
|
||||
@@ -0,0 +1,199 @@
|
||||
import React from 'react';
|
||||
import { Box, Flex, HStack, Image, Text, Container, useColorModeValue } from '@chakra-ui/react';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
|
||||
interface HeaderVariantsProps {
|
||||
variant: 'unified' | 'edge' | 'minimal' | 'modern';
|
||||
clubName?: string;
|
||||
clubLogo?: string;
|
||||
clubId?: string;
|
||||
}
|
||||
|
||||
const HeaderVariants: React.FC<HeaderVariantsProps> = ({
|
||||
variant = 'unified',
|
||||
clubName,
|
||||
clubLogo,
|
||||
clubId,
|
||||
}) => {
|
||||
const displayLogo = clubId
|
||||
? `http://logoapi.sportcreative.eu/logos/${clubId}?format=svg`
|
||||
: clubLogo || '/images/club-logo.png';
|
||||
|
||||
// Unified variant - classic header
|
||||
if (variant === 'unified') {
|
||||
return (
|
||||
<Box
|
||||
bg={useColorModeValue('white', 'gray.800')}
|
||||
borderBottom="1px"
|
||||
borderColor={useColorModeValue('gray.200', 'gray.700')}
|
||||
py={4}
|
||||
>
|
||||
<Container maxW="7xl">
|
||||
<Flex align="center" justify="space-between">
|
||||
<HStack as={RouterLink} to="/" spacing={4}>
|
||||
{displayLogo && (
|
||||
<Image
|
||||
src={displayLogo}
|
||||
alt={clubName || 'Club'}
|
||||
boxSize="48px"
|
||||
objectFit="contain"
|
||||
borderRadius="full"
|
||||
borderWidth="2px"
|
||||
borderColor="brand.primary"
|
||||
style={{
|
||||
padding: displayLogo.includes('logoapi.sportcreative.eu') ? '4px' : '0px',
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Box>
|
||||
<Text fontSize="2xl" fontWeight="bold" color={useColorModeValue('gray.800', 'white')}>
|
||||
{clubName || 'MyClub'}
|
||||
</Text>
|
||||
<Text fontSize="xs" color="gray.500">Official Website</Text>
|
||||
</Box>
|
||||
</HStack>
|
||||
</Flex>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Edge variant - modern with gradient
|
||||
if (variant === 'edge') {
|
||||
return (
|
||||
<Box
|
||||
bgGradient="linear(to-r, brand.primary, brand.secondary)"
|
||||
position="relative"
|
||||
overflow="hidden"
|
||||
>
|
||||
<Box
|
||||
position="absolute"
|
||||
top={0}
|
||||
left={0}
|
||||
right={0}
|
||||
bottom={0}
|
||||
bg="blackAlpha.300"
|
||||
backdropFilter="blur(8px)"
|
||||
/>
|
||||
<Container maxW="7xl" position="relative" py={6}>
|
||||
<Flex align="center" justify="center" direction="column">
|
||||
{displayLogo && (
|
||||
<Image
|
||||
src={displayLogo}
|
||||
alt={clubName || 'Club'}
|
||||
boxSize="80px"
|
||||
objectFit="contain"
|
||||
mb={3}
|
||||
filter="drop-shadow(0 4px 6px rgba(0,0,0,0.3))"
|
||||
style={{
|
||||
padding: displayLogo.includes('logoapi.sportcreative.eu') ? '8px' : '0px',
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Text fontSize="3xl" fontWeight="bold" color="white" textShadow="0 2px 4px rgba(0,0,0,0.3)">
|
||||
{clubName || 'Football Club'}
|
||||
</Text>
|
||||
<Text fontSize="sm" color="whiteAlpha.900" mt={1}>
|
||||
Official Website
|
||||
</Text>
|
||||
</Flex>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Minimal variant - clean and simple
|
||||
if (variant === 'minimal') {
|
||||
return (
|
||||
<Box bg={useColorModeValue('gray.50', 'gray.900')} py={3}>
|
||||
<Container maxW="7xl">
|
||||
<Flex align="center" justify="center">
|
||||
<HStack as={RouterLink} to="/" spacing={3}>
|
||||
{displayLogo && (
|
||||
<Image
|
||||
src={displayLogo}
|
||||
alt={clubName || 'Club'}
|
||||
boxSize="36px"
|
||||
objectFit="contain"
|
||||
style={{
|
||||
padding: displayLogo.includes('logoapi.sportcreative.eu') ? '3px' : '0px',
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Text fontSize="lg" fontWeight="600" color={useColorModeValue('gray.700', 'gray.200')}>
|
||||
{clubName || 'FC'}
|
||||
</Text>
|
||||
</HStack>
|
||||
</Flex>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Modern variant - bold with accent
|
||||
if (variant === 'modern') {
|
||||
return (
|
||||
<Box
|
||||
bg={useColorModeValue('white', 'gray.800')}
|
||||
borderBottom="4px"
|
||||
borderColor="brand.primary"
|
||||
boxShadow="sm"
|
||||
>
|
||||
<Container maxW="7xl" py={5}>
|
||||
<Flex align="center" justify="space-between">
|
||||
<HStack as={RouterLink} to="/" spacing={4}>
|
||||
{displayLogo && (
|
||||
<Box
|
||||
position="relative"
|
||||
_before={{
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: '-4px',
|
||||
left: '-4px',
|
||||
right: '-4px',
|
||||
bottom: '-4px',
|
||||
bg: 'brand.primary',
|
||||
opacity: 0.1,
|
||||
borderRadius: 'full',
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src={displayLogo}
|
||||
alt={clubName || 'Club'}
|
||||
boxSize="56px"
|
||||
objectFit="contain"
|
||||
borderRadius="full"
|
||||
style={{
|
||||
padding: displayLogo.includes('logoapi.sportcreative.eu') ? '6px' : '0px',
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
<Box>
|
||||
<Text
|
||||
fontSize="2xl"
|
||||
fontWeight="900"
|
||||
color="brand.primary"
|
||||
letterSpacing="tight"
|
||||
>
|
||||
{clubName || 'FOOTBALL CLUB'}
|
||||
</Text>
|
||||
<Text fontSize="xs" color="gray.500" fontWeight="600" letterSpacing="wider" textTransform="uppercase">
|
||||
Official Website
|
||||
</Text>
|
||||
</Box>
|
||||
</HStack>
|
||||
</Flex>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default HeaderVariants;
|
||||
@@ -0,0 +1,106 @@
|
||||
import React from 'react';
|
||||
import { Box, Container, Grid, GridItem, VStack, HStack, Image, Heading, Text, Icon, Button } from '@chakra-ui/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import BlogSwiper from './BlogSwiper';
|
||||
import { getArticles, Article } from '../../services/articles';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import { useClubTheme } from '../../contexts/ClubThemeContext';
|
||||
import UpcomingSwitch from './UpcomingSwitch';
|
||||
import { ChevronRightIcon } from '@chakra-ui/icons';
|
||||
|
||||
const RailItem: React.FC<{ a: Article }>=({ a })=>{
|
||||
return (
|
||||
<HStack
|
||||
as={RouterLink}
|
||||
to={`/articles/${a.id}`}
|
||||
spacing={3}
|
||||
align="center"
|
||||
px={3}
|
||||
py={2}
|
||||
borderRadius="md"
|
||||
_hover={{ bg: 'whiteAlpha.200' }}
|
||||
transition="background 0.2s"
|
||||
>
|
||||
<Box position="relative" flexShrink={0}>
|
||||
<Image src={a.image_url || '/stadium-placeholder.jpg'} alt={a.title} boxSize="64px" objectFit="cover" borderRadius="md" />
|
||||
</Box>
|
||||
<VStack spacing={0} align="start" minW={0}>
|
||||
<Text fontSize="xs" color="whiteAlpha.700">{new Date(a.created_at || '').toLocaleDateString()}</Text>
|
||||
<Text fontWeight={600} noOfLines={2} color="white">{a.title}</Text>
|
||||
</VStack>
|
||||
<ChevronRightIcon color="whiteAlpha.700" ml="auto" />
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
|
||||
const HeroWithRail: React.FC = () => {
|
||||
const theme = useClubTheme();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['hero-rail-articles', { page: 1, page_size: 8, published: true }],
|
||||
queryFn: () => getArticles({ page: 1, page_size: 8, published: true }),
|
||||
});
|
||||
|
||||
const articles = data?.data || [];
|
||||
|
||||
return (
|
||||
<Box position="relative">
|
||||
{/* Hero */}
|
||||
<Grid templateColumns={{ base: '1fr', lg: '2fr 1fr' }} gap={6}>
|
||||
<GridItem>
|
||||
<BlogSwiper />
|
||||
</GridItem>
|
||||
{/* Right rail */}
|
||||
<GridItem display={{ base: 'none', lg: 'block' }}>
|
||||
<Box
|
||||
h={{ base: 'auto', lg: '600px' }}
|
||||
bg="blackAlpha.600"
|
||||
borderRadius="xl"
|
||||
overflow="hidden"
|
||||
backdropFilter="auto"
|
||||
backdropBlur="8px"
|
||||
border="1px solid"
|
||||
borderColor="whiteAlpha.200"
|
||||
>
|
||||
<VStack align="stretch" spacing={0} h="100%">
|
||||
<HStack justify="space-between" px={4} py={3} borderBottom="1px solid" borderColor="whiteAlpha.200">
|
||||
<Text fontWeight="700" color="white">Novinky</Text>
|
||||
<Button as={RouterLink} to="/blog" size="sm" variant="ghost" color="whiteAlpha.800" _hover={{ bg: 'whiteAlpha.200' }}>Vše</Button>
|
||||
</HStack>
|
||||
<VStack spacing={1} align="stretch" px={2} py={2} overflowY="auto">
|
||||
{isLoading && Array.from({length:5}).map((_,i)=> (
|
||||
<Box key={i} h="72px" borderRadius="md" bg="whiteAlpha.200" />
|
||||
))}
|
||||
{!isLoading && articles.map((a) => (
|
||||
<RailItem key={a.id} a={a} />
|
||||
))}
|
||||
</VStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
</GridItem>
|
||||
</Grid>
|
||||
|
||||
{/* Glasmorphic upcoming panel */}
|
||||
<Box
|
||||
position="absolute"
|
||||
left="50%"
|
||||
bottom={{ base: 2, md: 4 }}
|
||||
transform="translateX(-50%)"
|
||||
w={{ base: '95%', md: '80%' }}
|
||||
bg="whiteAlpha.200"
|
||||
backdropFilter="auto"
|
||||
backdropBlur="10px"
|
||||
border="1px solid"
|
||||
borderColor="whiteAlpha.300"
|
||||
borderRadius="xl"
|
||||
px={{ base: 3, md: 6 }}
|
||||
py={{ base: 3, md: 4 }}
|
||||
boxShadow="lg"
|
||||
>
|
||||
<UpcomingSwitch />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroWithRail;
|
||||
@@ -0,0 +1,227 @@
|
||||
import { Box, Tabs, TabList, TabPanels, Tab, TabPanel, Table, Thead, Tbody, Tr, Th, Td, Text, Badge, Heading, Flex, Tooltip } from '@chakra-ui/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { facrApi } from '../../services/facr/facrApi';
|
||||
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
||||
import { useClubTheme } from '../../contexts/ClubThemeContext';
|
||||
import { useState } from 'react';
|
||||
import ClubModal from './ClubModal';
|
||||
import { TeamLogo } from '../common/TeamLogo';
|
||||
|
||||
const LeagueTablePro: React.FC = () => {
|
||||
const { data: settings } = usePublicSettings();
|
||||
const clubId = settings?.club_id;
|
||||
const clubType = settings?.club_type || 'football';
|
||||
const theme = useClubTheme();
|
||||
const { data } = useQuery({
|
||||
queryKey: ['facr-table', clubId, clubType],
|
||||
queryFn: () => facrApi.getClubTable(clubId!, clubType as any),
|
||||
enabled: !!clubId,
|
||||
});
|
||||
|
||||
const [selectedClub, setSelectedClub] = useState<any>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
const handleClubClick = (row: any) => {
|
||||
// Transform row data to match ClubModal interface
|
||||
const clubData = {
|
||||
team: row.team || row.team_name || '-',
|
||||
team_id: row.team_id || '',
|
||||
team_logo_url: row.team_logo_url,
|
||||
rank: row.rank,
|
||||
played: row.played,
|
||||
wins: row.wins,
|
||||
draws: row.draws,
|
||||
losses: row.losses,
|
||||
score: row.score,
|
||||
points: row.points,
|
||||
};
|
||||
setSelectedClub(clubData);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
return (
|
||||
<Box borderRadius="lg" overflow="hidden" bg="white" boxShadow="sm" borderWidth="1px" borderColor="gray.100">
|
||||
<Box bg="primary.600" px={4} py={2} borderBottomWidth="1px" borderColor="primary.700">
|
||||
<Heading size="md" color="white">Tabulka</Heading>
|
||||
</Box>
|
||||
|
||||
<Tabs variant="enclosed" colorScheme="gray" size="sm">
|
||||
<TabList px={2} pt={2} overflowX="auto" overflowY="hidden" css={{
|
||||
'&::-webkit-scrollbar': { height: '4px' },
|
||||
'&::-webkit-scrollbar-track': { background: 'transparent' },
|
||||
'&::-webkit-scrollbar-thumb': { background: 'gray.300', borderRadius: '4px' },
|
||||
}}>
|
||||
{data.competitions?.map((c) => (
|
||||
<Tab
|
||||
key={c.id}
|
||||
_selected={{
|
||||
color: 'primary.600',
|
||||
borderBottom: '2px solid',
|
||||
borderColor: 'primary.600',
|
||||
fontWeight: 'bold'
|
||||
}}
|
||||
_focus={{ boxShadow: 'none' }}
|
||||
fontSize="sm"
|
||||
px={3}
|
||||
py={3}
|
||||
>
|
||||
{c.name}
|
||||
</Tab>
|
||||
))}
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
{data.competitions?.map((c) => (
|
||||
<TabPanel key={c.id} px={0}>
|
||||
<Box maxH="420px" overflowY="auto">
|
||||
<Table size="sm" variant="simple">
|
||||
<Thead position="sticky" top={0} zIndex={1} bg="gray.50">
|
||||
<Tr borderBottomWidth="1px" borderColor="gray.200">
|
||||
<Th width="8" px={2} textAlign="center" color="gray.600" fontWeight="bold" fontSize="xs" textTransform="uppercase">#</Th>
|
||||
<Th px={3} color="gray.600" fontWeight="bold" fontSize="xs" textTransform="uppercase">Tým</Th>
|
||||
<Th width="8" px={1} textAlign="center" color="gray.600" fontWeight="bold" fontSize="xs" textTransform="uppercase">Z</Th>
|
||||
<Th width="8" px={1} textAlign="center" color="gray.600" fontWeight="bold" fontSize="xs" textTransform="uppercase">V</Th>
|
||||
<Th width="8" px={1} textAlign="center" color="gray.600" fontWeight="bold" fontSize="xs" textTransform="uppercase">R</Th>
|
||||
<Th width="8" px={1} textAlign="center" color="gray.600" fontWeight="bold" fontSize="xs" textTransform="uppercase">P</Th>
|
||||
<Th width="16" px={1} textAlign="center" color="gray.600" fontWeight="bold" fontSize="xs" textTransform="uppercase">Skóre</Th>
|
||||
<Th width="14" px={1} textAlign="center" color="gray.600" fontWeight="bold" fontSize="xs" textTransform="uppercase">Body</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{c.table?.overall?.map((row, idx) => {
|
||||
const isHighlighted = row.team_id === clubId;
|
||||
return (
|
||||
<Tr
|
||||
key={`${row.team_id}-${idx}`}
|
||||
bg={isHighlighted ? 'primary.50' : idx % 2 === 0 ? 'white' : 'gray.50'}
|
||||
borderLeft={isHighlighted ? '3px solid' : '3px solid transparent'}
|
||||
borderLeftColor={isHighlighted ? 'primary.500' : 'transparent'}
|
||||
_hover={{ bg: isHighlighted ? 'primary.100' : 'gray.100', cursor: 'pointer' }}
|
||||
onClick={() => handleClubClick(row)}
|
||||
>
|
||||
<Td px={2} textAlign="center" color={isHighlighted ? 'primary.700' : 'gray.700'} fontWeight={isHighlighted ? 'bold' : 'normal'}>
|
||||
<Flex align="center" justify="center">
|
||||
{row.rank}
|
||||
{idx < 3 && (
|
||||
<Box as="span" ml={1} color={idx === 0 ? 'yellow.400' : idx === 1 ? 'gray.400' : 'yellow.700'}>
|
||||
{idx === 0 ? '🥇' : idx === 1 ? '🥈' : '🥉'}
|
||||
</Box>
|
||||
)}
|
||||
</Flex>
|
||||
</Td>
|
||||
<Td px={3} py={2}>
|
||||
<Flex align="center" minW="180px">
|
||||
<TeamLogo
|
||||
teamId={row.team_id}
|
||||
teamName={row.team}
|
||||
facrLogo={row.team_logo_url}
|
||||
size="small"
|
||||
alt={row.team}
|
||||
mr={2}
|
||||
fallbackIcon={
|
||||
<Box
|
||||
w="20px"
|
||||
h="20px"
|
||||
bg="gray.200"
|
||||
borderRadius="md"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
color="gray.400"
|
||||
fontSize="xs"
|
||||
fontWeight="bold"
|
||||
>
|
||||
{row.team.substring(0, 2).toUpperCase()}
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
<Text
|
||||
as="span"
|
||||
fontWeight={isHighlighted ? 'bold' : 'normal'}
|
||||
color={isHighlighted ? 'primary.700' : 'gray.800'}
|
||||
isTruncated
|
||||
>
|
||||
{row.team}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Td>
|
||||
<Td isNumeric px={1} textAlign="center" color={isHighlighted ? 'primary.700' : 'gray.700'} fontWeight={isHighlighted ? 'bold' : 'normal'}>
|
||||
{row.played}
|
||||
</Td>
|
||||
<Td isNumeric px={1} textAlign="center" color={isHighlighted ? 'primary.700' : 'gray.700'} fontWeight={isHighlighted ? 'bold' : 'normal'}>
|
||||
{row.wins}
|
||||
</Td>
|
||||
<Td isNumeric px={1} textAlign="center" color={isHighlighted ? 'primary.700' : 'gray.700'} fontWeight={isHighlighted ? 'bold' : 'normal'}>
|
||||
{row.draws}
|
||||
</Td>
|
||||
<Td isNumeric px={1} textAlign="center" color={isHighlighted ? 'primary.700' : 'gray.700'} fontWeight={isHighlighted ? 'bold' : 'normal'}>
|
||||
{row.losses}
|
||||
</Td>
|
||||
<Td isNumeric px={1} textAlign="center" fontFamily="mono" color={isHighlighted ? 'primary.700' : 'gray.700'} fontWeight={isHighlighted ? 'bold' : 'normal'}>
|
||||
{row.score}
|
||||
</Td>
|
||||
<Td
|
||||
isNumeric
|
||||
px={1}
|
||||
textAlign="center"
|
||||
fontWeight="bold"
|
||||
color={isHighlighted ? 'white' : 'gray.800'}
|
||||
bg={isHighlighted ? 'primary.500' : 'gray.100'}
|
||||
borderLeftWidth="1px"
|
||||
borderLeftColor="white"
|
||||
>
|
||||
{row.points}
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
})}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
|
||||
{/* League Info Footer */}
|
||||
<Box px={4} py={3} borderTopWidth="1px" borderColor="gray.100" bg="gray.50">
|
||||
<Flex justify="space-between" fontSize="xs" color="gray.600">
|
||||
<Box>
|
||||
<Text as="span" mr={4} display="inline-flex" alignItems="center">
|
||||
<Box as="span" display="inline-block" w="8px" h="8px" bg="green.500" borderRadius="full" mr={1} />
|
||||
Postup
|
||||
</Text>
|
||||
<Text as="span" mr={4} display="inline-flex" alignItems="center">
|
||||
<Box as="span" display="inline-block" w="8px" h="8px" bg="blue.500" borderRadius="full" mr={1} />
|
||||
Evropské poháry
|
||||
</Text>
|
||||
<Text as="span" display="inline-flex" alignItems="center">
|
||||
<Box as="span" display="inline-block" w="8px" h="8px" bg="red.500" borderRadius="full" mr={1} />
|
||||
Sestup
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text as="span" display="inline-flex" alignItems="center">
|
||||
<Box as="span" fontWeight="bold" mr={1}>Aktualizováno:</Box>
|
||||
{new Date().toLocaleDateString('cs-CZ', {
|
||||
day: '2-digit',
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
})}
|
||||
</Text>
|
||||
</Box>
|
||||
</Flex>
|
||||
</Box>
|
||||
</TabPanel>
|
||||
))}
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
|
||||
<ClubModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
club={selectedClub}
|
||||
clubType={clubType as 'football' | 'futsal'}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default LeagueTablePro;
|
||||
@@ -0,0 +1,193 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalCloseButton,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
Button,
|
||||
HStack,
|
||||
VStack,
|
||||
Image,
|
||||
Text,
|
||||
Badge,
|
||||
Link,
|
||||
Divider,
|
||||
} from '@chakra-ui/react';
|
||||
import { useCountdown } from '../../hooks/useCountdown';
|
||||
import { assetUrl } from '../../utils/url';
|
||||
|
||||
export type FacrMatchLike = {
|
||||
id?: string | number;
|
||||
date?: string; // yyyy-mm-dd
|
||||
time?: string; // HH:MM
|
||||
date_time?: string; // alternative combined format (dd.MM.yyyy HH:mm)
|
||||
home?: string;
|
||||
away?: string;
|
||||
home_logo_url?: string;
|
||||
away_logo_url?: string;
|
||||
competition?: string;
|
||||
competitionName?: string;
|
||||
venue?: string;
|
||||
score?: string | null;
|
||||
facr_link?: string | null;
|
||||
report_url?: string | null;
|
||||
};
|
||||
|
||||
interface MatchModalProps {
|
||||
isOpen: boolean;
|
||||
match: FacrMatchLike | null;
|
||||
onClose: () => void;
|
||||
onTeamClick?: (teamName: string, teamLogoUrl?: string) => void;
|
||||
}
|
||||
|
||||
const formatWhen = (m: FacrMatchLike | null) => {
|
||||
if (!m) return '';
|
||||
try {
|
||||
if (m.date && m.time) {
|
||||
const d = new Date(`${m.date}T${(m.time || '00:00')}:00`);
|
||||
if (!isNaN(d.getTime())) return d.toLocaleString();
|
||||
}
|
||||
if (m.date_time) {
|
||||
// Try to parse dd.MM.yyyy HH:mm quickly by reordering
|
||||
const dt = String(m.date_time);
|
||||
const [dPart, tPart] = dt.split(' ');
|
||||
const [dd, MM, yyyy] = (dPart || '').split('.');
|
||||
if (dd && MM && yyyy) {
|
||||
const iso = `${yyyy}-${MM.padStart(2, '0')}-${dd.padStart(2, '0')}T${(tPart || '00:00')}:00`;
|
||||
const d = new Date(iso);
|
||||
if (!isNaN(d.getTime())) return d.toLocaleString();
|
||||
}
|
||||
return m.date_time;
|
||||
}
|
||||
} catch {}
|
||||
return '';
|
||||
};
|
||||
|
||||
export const MatchModal: React.FC<MatchModalProps> = ({ isOpen, match, onClose, onTeamClick }) => {
|
||||
const kickoffIso = useMemo(() => {
|
||||
if (!match) return null;
|
||||
if (match.date && match.time) return `${match.date}T${(match.time || '00:00')}:00`;
|
||||
if (match.date_time) {
|
||||
const dt = String(match.date_time);
|
||||
const [dPart, tPart] = dt.split(' ');
|
||||
const [dd, MM, yyyy] = (dPart || '').split('.');
|
||||
if (dd && MM && yyyy) return `${yyyy}-${MM.padStart(2, '0')}-${dd.padStart(2, '0')}T${(tPart || '00:00')}:00`;
|
||||
return match.date_time; // fallback
|
||||
}
|
||||
return null;
|
||||
}, [match]);
|
||||
|
||||
const { countdownString, isActive, timeRemaining } = useCountdown(kickoffIso, 1000);
|
||||
const facrLink = match?.facr_link || match?.report_url || null;
|
||||
const when = formatWhen(match);
|
||||
|
||||
// Determine if match has started (countdown finished) but no score yet
|
||||
const matchStarted = kickoffIso ? new Date(kickoffIso).getTime() <= Date.now() : false;
|
||||
const hasScore = match?.score && match.score.trim() !== '';
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} isCentered size={{ base: 'md', md: 'lg' }}>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
{match?.home || 'Domácí'} vs {match?.away || 'Hosté'}
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
{match && (
|
||||
<VStack align="stretch" spacing={4}>
|
||||
<HStack justify="space-between" align="center">
|
||||
<VStack
|
||||
align="center"
|
||||
spacing={2}
|
||||
flex={1}
|
||||
minW={0}
|
||||
cursor={onTeamClick ? 'pointer' : 'default'}
|
||||
onClick={() => onTeamClick && onTeamClick(match.home || '', match.home_logo_url)}
|
||||
_hover={onTeamClick ? { opacity: 0.8, transform: 'scale(1.05)' } : {}}
|
||||
transition="all 0.2s"
|
||||
role={onTeamClick ? 'button' : undefined}
|
||||
tabIndex={onTeamClick ? 0 : undefined}
|
||||
>
|
||||
<Image src={assetUrl(match.home_logo_url) || '/logo192.png'} alt={match.home || 'Domácí'} boxSize="56px" objectFit="contain" />
|
||||
<Text fontWeight="semibold" noOfLines={1} textAlign="center">{match.home || 'Domácí'}</Text>
|
||||
</VStack>
|
||||
<VStack spacing={1} minW="120px">
|
||||
{hasScore ? (
|
||||
<>
|
||||
<Text fontSize="2xl" fontWeight="bold">{match.score}</Text>
|
||||
<Text fontSize="sm" color="gray.600">Skončeno</Text>
|
||||
</>
|
||||
) : matchStarted ? (
|
||||
<>
|
||||
<Text fontSize="2xl" fontWeight="bold">—:—</Text>
|
||||
<Text fontSize="sm" color="green.600">Probíhá</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text fontSize="lg" color="gray.600">Začátek za</Text>
|
||||
<Text fontSize="2xl" fontWeight="bold">{countdownString || '—'}</Text>
|
||||
</>
|
||||
)}
|
||||
{(match.competition || match.competitionName) && (
|
||||
<Badge colorScheme="blue" variant="subtle">{match.competition || match.competitionName}</Badge>
|
||||
)}
|
||||
</VStack>
|
||||
<VStack
|
||||
align="center"
|
||||
spacing={2}
|
||||
flex={1}
|
||||
minW={0}
|
||||
cursor={onTeamClick ? 'pointer' : 'default'}
|
||||
onClick={() => onTeamClick && onTeamClick(match.away || '', match.away_logo_url)}
|
||||
_hover={onTeamClick ? { opacity: 0.8, transform: 'scale(1.05)' } : {}}
|
||||
transition="all 0.2s"
|
||||
role={onTeamClick ? 'button' : undefined}
|
||||
tabIndex={onTeamClick ? 0 : undefined}
|
||||
>
|
||||
<Image src={assetUrl(match.away_logo_url) || '/logo192.png'} alt={match.away || 'Hosté'} boxSize="56px" objectFit="contain" />
|
||||
<Text fontWeight="semibold" noOfLines={1} textAlign="center">{match.away || 'Hosté'}</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
|
||||
<Divider />
|
||||
|
||||
<VStack align="stretch" spacing={1} color="gray.700">
|
||||
{when && <Text><strong>Kdy:</strong> {when}</Text>}
|
||||
{match.venue && <Text><strong>Kde:</strong> {match.venue}</Text>}
|
||||
</VStack>
|
||||
</VStack>
|
||||
)}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
{facrLink && (
|
||||
<Button
|
||||
colorScheme="blue"
|
||||
mr={3}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
// Open in background tab without switching focus
|
||||
const link = document.createElement('a');
|
||||
link.href = facrLink;
|
||||
link.target = '_blank';
|
||||
link.rel = 'noopener noreferrer';
|
||||
link.style.display = 'none';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}}
|
||||
>
|
||||
Detail na FAČR
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={onClose}>Zavřít</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default MatchModal;
|
||||
@@ -0,0 +1,118 @@
|
||||
import { Box, Heading, Tabs, TabList, TabPanels, Tab, TabPanel, VStack, HStack, Image, Text, Link, Skeleton, Badge } from '@chakra-ui/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { facrApi } from '../../services/facr/facrApi';
|
||||
import { FACR_CLUB_ID, FACR_CLUB_TYPE } from '../../config/facr';
|
||||
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
||||
import { TeamLogo } from '../common/TeamLogo';
|
||||
import '../../styles/logos.css';
|
||||
|
||||
const MatchRow: React.FC<{
|
||||
date: string;
|
||||
home: { name: string; logo?: string; id?: string };
|
||||
away: { name: string; logo?: string; id?: string };
|
||||
score?: string;
|
||||
clubName?: string;
|
||||
}> = ({ date, home, away, score, clubName }) => (
|
||||
<HStack justify="space-between" borderWidth="1px" borderRadius="md" p={3} bg="white">
|
||||
<Text w="140px" fontSize="sm" color="gray.600">{date}</Text>
|
||||
<HStack flex={1} justify="flex-end">
|
||||
<HStack minW="40%" justify="flex-end" spacing={2}>
|
||||
<Text noOfLines={1} textAlign="right" flex={1}>{home.name}</Text>
|
||||
<Box className="logo-container" w="28px" h="28px">
|
||||
<TeamLogo
|
||||
teamId={home.id}
|
||||
teamName={home.name}
|
||||
facrLogo={home.logo}
|
||||
size="custom"
|
||||
boxSize="28px"
|
||||
/>
|
||||
</Box>
|
||||
</HStack>
|
||||
<HStack w="auto" minW="60px" justify="center" spacing={2}>
|
||||
<Text fontWeight="bold" textAlign="center">{score || '-:-'}</Text>
|
||||
{(() => {
|
||||
const sent = (() => {
|
||||
if (!score || !clubName) return null;
|
||||
const m = score.match(/^(\d+)\s*[:\-]\s*(\d+)$/);
|
||||
if (!m) return null;
|
||||
const h = parseInt(m[1], 10), a = parseInt(m[2], 10);
|
||||
const norm = (s: string) => String(s||'').normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/\s+/g,' ').trim().toLowerCase();
|
||||
const strip = (s: string) => norm(s).replace(/\b(mestsky|m\.?f\.?k\.?|mfk|tj|sk|sokol|fotbalovy|fotbalový|fotbalovy\s+klub|fotbalovy\s+klub)\b/g,'').replace(/\s+/g,' ').trim();
|
||||
const ourIsHome = (() => { const aName = strip(home.name); const bName = strip(clubName); return aName && bName && (aName===bName || aName.endsWith(bName) || bName.endsWith(aName)); })();
|
||||
const ourIsAway = (() => { const aName = strip(away.name); const bName = strip(clubName); return aName && bName && (aName===bName || aName.endsWith(bName) || bName.endsWith(aName)); })();
|
||||
if (!ourIsHome && !ourIsAway) return null;
|
||||
if (h === a) return { label: 'Remíza', color: 'blue' } as const;
|
||||
const our = ourIsHome ? h : a; const opp = ourIsHome ? a : h;
|
||||
return our > opp ? ({ label: 'Výhra', color: 'green' } as const) : ({ label: 'Prohra', color: 'red' } as const);
|
||||
})();
|
||||
return sent ? <Badge colorScheme={sent.color} variant="subtle">{sent.label}</Badge> : null;
|
||||
})()}
|
||||
</HStack>
|
||||
<HStack minW="40%" spacing={2}>
|
||||
<Box className="logo-container" w="28px" h="28px">
|
||||
<TeamLogo
|
||||
teamId={away.id}
|
||||
teamName={away.name}
|
||||
facrLogo={away.logo}
|
||||
size="custom"
|
||||
boxSize="28px"
|
||||
/>
|
||||
</Box>
|
||||
<Text noOfLines={1} flex={1}>{away.name}</Text>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</HStack>
|
||||
);
|
||||
|
||||
const MatchesSection: React.FC = () => {
|
||||
const { data: settings } = usePublicSettings();
|
||||
const clubId = settings?.club_id || FACR_CLUB_ID;
|
||||
const clubType = settings?.club_type || FACR_CLUB_TYPE;
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['facr-club', clubId, clubType],
|
||||
queryFn: () => facrApi.getClub(clubId, clubType),
|
||||
enabled: Boolean(clubId),
|
||||
});
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Heading size="lg" mb={4}>Zápasy</Heading>
|
||||
{!clubId && (
|
||||
<Text color="orange.500" mb={4}>Nastavte klub v Nastavení (Admin) nebo REACT_APP_FACR_CLUB_ID pro načtení zápasů z FAČR.</Text>
|
||||
)}
|
||||
{isLoading && <Skeleton height="200px" />}
|
||||
{!isLoading && data && (
|
||||
<Tabs variant="enclosed-colored" isFitted>
|
||||
<TabList>
|
||||
{data.competitions?.map((c) => (
|
||||
<Tab key={c.id}>{c.name}</Tab>
|
||||
))}
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
{data.competitions?.map((c) => (
|
||||
<TabPanel key={c.id} px={0}>
|
||||
<VStack align="stretch" spacing={3}>
|
||||
{(c.matches || []).slice(0, 5).map((m, idx) => (
|
||||
<MatchRow
|
||||
key={m.match_id || idx}
|
||||
date={m.date_time}
|
||||
home={{ name: m.home, logo: m.home_logo_url, id: m.home_id }}
|
||||
away={{ name: m.away, logo: m.away_logo_url, id: m.away_id }}
|
||||
score={m.score}
|
||||
clubName={data.name}
|
||||
/>
|
||||
))}
|
||||
{(c.matches || []).length === 0 && (
|
||||
<Text color="gray.500">Žádné zápasy k dispozici.</Text>
|
||||
)}
|
||||
</VStack>
|
||||
</TabPanel>
|
||||
))}
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default MatchesSection;
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Box, SimpleGrid, Heading, Text, useColorModeValue, HStack, Button, Link, Badge } from '@chakra-ui/react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getClothing, ClothingItem } from '../../services/clothing';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
|
||||
const MerchSection: React.FC = () => {
|
||||
const [items, setItems] = useState<ClothingItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
|
||||
useEffect(() => {
|
||||
const fetchItems = async () => {
|
||||
try {
|
||||
const data = await getClothing();
|
||||
// Show only 5 newest items on homepage
|
||||
setItems(data.slice(0, 5));
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch clothing items:', e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchItems();
|
||||
}, []);
|
||||
|
||||
if (loading || items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<HStack justify="space-between" mb={3}>
|
||||
<Heading as="h3" size="md">Oblečení týmu</Heading>
|
||||
<Link as={RouterLink} to="/obleceni">
|
||||
<Button size="sm" variant="outline" colorScheme="blue">Zobrazit vše</Button>
|
||||
</Link>
|
||||
</HStack>
|
||||
<SimpleGrid columns={{ base: 2, md: 3, lg: 5 }} spacing={4}>
|
||||
{items.map((it) => (
|
||||
<a
|
||||
key={it.id}
|
||||
href={it.url || '/obleceni'}
|
||||
target={it.url ? "_blank" : undefined}
|
||||
rel={it.url ? "noreferrer noopener" : undefined}
|
||||
>
|
||||
<Box
|
||||
bg={cardBg}
|
||||
borderRadius="xl"
|
||||
overflow="hidden"
|
||||
boxShadow="sm"
|
||||
borderWidth="1px"
|
||||
transition="all 0.2s"
|
||||
_hover={{ transform: 'translateY(-4px)', boxShadow: 'md' }}
|
||||
>
|
||||
<Box
|
||||
aria-hidden
|
||||
height={{ base: 140, md: 180 }}
|
||||
bgSize="cover"
|
||||
bgPos="center"
|
||||
style={{ backgroundImage: `url(${it.image_url})` }}
|
||||
/>
|
||||
<Box p={3} borderTopWidth="1px">
|
||||
<Text noOfLines={1} fontWeight="semibold" fontSize="sm">{it.title}</Text>
|
||||
{it.price && it.price > 0 && (
|
||||
<Badge colorScheme="blue" mt={1} fontSize="xs">
|
||||
{it.price} {it.currency || 'Kč'}
|
||||
</Badge>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</a>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default MerchSection;
|
||||
@@ -0,0 +1,168 @@
|
||||
import { Box, Grid, GridItem, Heading, Image, Button, HStack, Text, VStack, Badge } from '@chakra-ui/react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import { Calendar, Image as ImageIcon } from 'lucide-react';
|
||||
|
||||
interface Album {
|
||||
id: string;
|
||||
title: string;
|
||||
url: string;
|
||||
date: string;
|
||||
photos_count: number;
|
||||
views_count?: number;
|
||||
photos: Array<{
|
||||
id: string;
|
||||
page_url: string;
|
||||
image_1500: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
// Resolve backend-relative URLs against API origin
|
||||
const resolveBackendUrl = (path: string) => {
|
||||
try {
|
||||
if (/^https?:\/\//i.test(path)) return path;
|
||||
if (path.startsWith('/cache') || path.startsWith('/uploads') || path.startsWith('/api/')) {
|
||||
const base = (process.env.REACT_APP_API_BASE_URL || process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1');
|
||||
const b = new URL(base);
|
||||
const abs = new URL(path, `${b.protocol}//${b.host}`);
|
||||
return abs.toString();
|
||||
}
|
||||
return path;
|
||||
} catch { return path; }
|
||||
};
|
||||
|
||||
const PhotosSection: React.FC<{ zoneramaUrl?: string | null }> = ({ zoneramaUrl }) => {
|
||||
const [albums, setAlbums] = useState<Album[]>([]);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
(async () => {
|
||||
try {
|
||||
const apiUrl = process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1';
|
||||
const response = await fetch(`${apiUrl}/gallery/albums`);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (active) {
|
||||
// Get 5 most recent albums
|
||||
setAlbums((data.albums || []).slice(0, 5));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if (active) setAlbums([]);
|
||||
} finally {
|
||||
if (active) setLoaded(true);
|
||||
}
|
||||
})();
|
||||
return () => { active = false };
|
||||
}, []);
|
||||
|
||||
const showSetupHint = loaded && albums.length === 0 && !zoneramaUrl;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<HStack justify="space-between" mb={3}>
|
||||
<Heading size="lg">Fotogalerie</Heading>
|
||||
<Button as={RouterLink} to="/galerie" size="sm" variant="outline">
|
||||
Zobrazit vše
|
||||
</Button>
|
||||
</HStack>
|
||||
|
||||
{showSetupHint && (
|
||||
<Box bg="yellow.50" borderWidth="1px" borderColor="yellow.200" color="yellow.800" p={3} borderRadius="md" mb={3}>
|
||||
<Text>Žádné fotky nejsou k dispozici. Zadejte prosím odkaz na Zonerama v nastavení (Sociální sítě → Fotogalerie) a my ji budeme automaticky načítat.</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Zonerama Attribution */}
|
||||
{albums.length > 0 && (
|
||||
<Box bg="blue.50" borderWidth="1px" borderColor="blue.200" color="blue.800" p={2} borderRadius="md" mb={3} fontSize="xs">
|
||||
<Text>
|
||||
📸 Fotografie z{' '}
|
||||
<Text
|
||||
as="a"
|
||||
href="https://zonerama.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
fontWeight="600"
|
||||
color="blue.600"
|
||||
_hover={{ textDecoration: 'underline' }}
|
||||
>
|
||||
Zonerama
|
||||
</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Grid templateColumns={{ base: '1fr', md: 'repeat(2, 1fr)', lg: 'repeat(3, 1fr)' }} gap={4}>
|
||||
{albums.map((album) => {
|
||||
const coverPhoto = album.photos && album.photos.length > 0 ? album.photos[0] : null;
|
||||
|
||||
return (
|
||||
<GridItem key={album.id}>
|
||||
<Box
|
||||
as={RouterLink}
|
||||
to={`/galerie/album/${album.id}`}
|
||||
bg="white"
|
||||
borderRadius="md"
|
||||
overflow="hidden"
|
||||
boxShadow="sm"
|
||||
transition="all 0.2s"
|
||||
_hover={{
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: 'md',
|
||||
}}
|
||||
cursor="pointer"
|
||||
display="block"
|
||||
>
|
||||
{/* Cover Image */}
|
||||
{coverPhoto ? (
|
||||
<Image
|
||||
src={resolveBackendUrl(coverPhoto.image_1500)}
|
||||
alt={album.title}
|
||||
h="180px"
|
||||
w="100%"
|
||||
objectFit="cover"
|
||||
/>
|
||||
) : (
|
||||
<Box
|
||||
h="180px"
|
||||
w="100%"
|
||||
bg="gray.200"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<ImageIcon size={32} color="gray" />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Album Info */}
|
||||
<VStack align="stretch" p={3} spacing={2}>
|
||||
<Heading size="sm" noOfLines={2} color="gray.800">
|
||||
{album.title}
|
||||
</Heading>
|
||||
<HStack spacing={3} fontSize="xs" color="gray.600">
|
||||
{album.date && (
|
||||
<HStack spacing={1}>
|
||||
<Calendar size={14} />
|
||||
<Text>{album.date}</Text>
|
||||
</HStack>
|
||||
)}
|
||||
<HStack spacing={1}>
|
||||
<ImageIcon size={14} />
|
||||
<Text>{album.photos_count} foto</Text>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
</GridItem>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default PhotosSection;
|
||||
@@ -0,0 +1,87 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
Heading,
|
||||
Text,
|
||||
Spinner,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getPolls, getPoll } from '../../services/polls';
|
||||
import PollCard from '../polls/PollCard';
|
||||
|
||||
interface PollsWidgetProps {
|
||||
featuredOnly?: boolean;
|
||||
maxPolls?: number;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
const PollsWidget: React.FC<PollsWidgetProps> = ({
|
||||
featuredOnly = true,
|
||||
maxPolls = 1,
|
||||
title = 'Hlasování',
|
||||
}) => {
|
||||
const bgSection = useColorModeValue('gray.50', 'gray.900');
|
||||
|
||||
// Fetch polls list
|
||||
const { data: polls, isLoading } = useQuery({
|
||||
queryKey: ['polls', { featured: featuredOnly }],
|
||||
queryFn: () => getPolls(featuredOnly ? { featured: true } : undefined),
|
||||
staleTime: 2 * 60 * 1000, // 2 minutes
|
||||
});
|
||||
|
||||
// Get full poll data for each featured poll
|
||||
const pollsToDisplay = polls?.slice(0, maxPolls) || [];
|
||||
|
||||
const { data: pollsData, isLoading: isLoadingPolls } = useQuery({
|
||||
queryKey: ['polls-details', pollsToDisplay.map((p) => p.id)],
|
||||
queryFn: async () => {
|
||||
const promises = pollsToDisplay.map((poll) => getPoll(poll.id));
|
||||
return await Promise.all(promises);
|
||||
},
|
||||
enabled: pollsToDisplay.length > 0,
|
||||
});
|
||||
|
||||
if (isLoading || isLoadingPolls) {
|
||||
return (
|
||||
<Box bg={bgSection} py={12} px={4}>
|
||||
<VStack spacing={4}>
|
||||
<Spinner size="lg" />
|
||||
<Text>Načítání ankety...</Text>
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!pollsData || pollsData.length === 0) {
|
||||
return null; // Don't show widget if no polls
|
||||
}
|
||||
|
||||
return (
|
||||
<Box bg={bgSection} py={12} px={4}>
|
||||
<VStack spacing={8} maxW="4xl" mx="auto">
|
||||
<Heading size="lg" textAlign="center">
|
||||
{title}
|
||||
</Heading>
|
||||
|
||||
<VStack spacing={6} w="full">
|
||||
{pollsData.map((pollResponse) => (
|
||||
<Box key={pollResponse.poll.id} w="full" maxW="600px">
|
||||
<PollCard
|
||||
poll={pollResponse.poll}
|
||||
hasVoted={pollResponse.has_voted}
|
||||
isActive={pollResponse.is_active}
|
||||
canShowResults={pollResponse.can_show_results}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</VStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default PollsWidget;
|
||||
@@ -0,0 +1,121 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
||||
|
||||
// Normalizes various social URL formats to a proper https URL
|
||||
const normalizeSocialUrl = (network: 'facebook' | 'instagram' | 'youtube', raw?: string | null): string | null => {
|
||||
let v = String(raw || '').trim();
|
||||
if (!v) return null;
|
||||
// Replace whitespace
|
||||
v = v.replace(/\s+/g, '');
|
||||
// Accept handle like @club
|
||||
if (v.startsWith('@')) {
|
||||
const handle = v.slice(1);
|
||||
if (network === 'facebook') return `https://www.facebook.com/${handle}`;
|
||||
if (network === 'instagram') return `https://www.instagram.com/${handle}`;
|
||||
if (network === 'youtube') return `https://www.youtube.com/@${handle}`;
|
||||
}
|
||||
// If only a username without slashes
|
||||
if (!/^https?:\/\//i.test(v) && !v.includes('/') && !v.includes('.')) {
|
||||
if (network === 'facebook') return `https://www.facebook.com/${v}`;
|
||||
if (network === 'instagram') return `https://www.instagram.com/${v}`;
|
||||
if (network === 'youtube') return `https://www.youtube.com/@${v}`;
|
||||
}
|
||||
// If looks like domain without scheme
|
||||
if (!/^https?:\/\//i.test(v)) {
|
||||
if (/^facebook\.com\//i.test(v)) return `https://www.${v}`;
|
||||
if (/^instagram\.com\//i.test(v)) return `https://www.${v}`;
|
||||
if (/^youtube\.com\//i.test(v)) return `https://www.${v}`;
|
||||
}
|
||||
return v;
|
||||
};
|
||||
|
||||
const SocialEmbeds: React.FC<{ variant?: 'unified' | 'magazine' | 'pro' | 'edge' }>
|
||||
= ({ variant = 'unified' }) => {
|
||||
const { data: settings } = usePublicSettings();
|
||||
|
||||
const facebookHref = useMemo(() => {
|
||||
const raw = (settings as any)?.facebook_url
|
||||
|| (settings as any)?.facebook
|
||||
|| (settings as any)?.facebookPage
|
||||
|| (settings as any)?.facebook_page
|
||||
|| (settings as any)?.facebookPageUrl
|
||||
|| (settings as any)?.facebook_page_url;
|
||||
return normalizeSocialUrl('facebook', raw);
|
||||
}, [settings]);
|
||||
|
||||
const instagramHref = useMemo(() => {
|
||||
const raw = (settings as any)?.instagram_url
|
||||
|| (settings as any)?.instagram
|
||||
|| (settings as any)?.instagramProfile
|
||||
|| (settings as any)?.instagram_profile
|
||||
|| (settings as any)?.ig
|
||||
|| (settings as any)?.ig_url;
|
||||
return normalizeSocialUrl('instagram', raw);
|
||||
}, [settings]);
|
||||
|
||||
const youtubeHref = useMemo(() => {
|
||||
const raw = (settings as any)?.youtube_url
|
||||
|| (settings as any)?.youtube
|
||||
|| (settings as any)?.yt
|
||||
|| (settings as any)?.youtube_channel;
|
||||
return normalizeSocialUrl('youtube', raw);
|
||||
}, [settings]);
|
||||
|
||||
if (!instagramHref && !youtubeHref) return null;
|
||||
|
||||
const outerStyle: React.CSSProperties = {
|
||||
margin: variant === 'pro' ? '16px 0' : '8px 0',
|
||||
};
|
||||
const gridStyle: React.CSSProperties = {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr',
|
||||
gap: 12,
|
||||
};
|
||||
const colStyle: React.CSSProperties = {
|
||||
background: '#fff',
|
||||
borderRadius: 10,
|
||||
border: '1px solid var(--light-gray)',
|
||||
padding: 8,
|
||||
};
|
||||
|
||||
// Instagram profile embed is unofficial; try /embed fallback, else show CTA tile
|
||||
const instagramEmbedSrc = instagramHref ? `${instagramHref.replace(/\/$/, '')}/embed` : null;
|
||||
|
||||
return (
|
||||
<div className={`social-embeds ${variant}`} style={outerStyle}>
|
||||
<div className="section-head" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 8 }}>
|
||||
<h3 style={{ margin: 0 }}>Sledujte nás</h3>
|
||||
<div className="links" style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
{facebookHref && (<a className="btn" href={facebookHref} target="_blank" rel="noreferrer noopener">Facebook</a>)}
|
||||
{instagramHref && (<a className="btn" href={instagramHref} target="_blank" rel="noreferrer noopener">Instagram</a>)}
|
||||
{youtubeHref && (<a className="btn" href={youtubeHref} target="_blank" rel="noreferrer noopener">YouTube</a>)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid" style={gridStyle}>
|
||||
{instagramHref && (
|
||||
<div className="col" style={colStyle}>
|
||||
{instagramEmbedSrc ? (
|
||||
<iframe
|
||||
title="Instagram"
|
||||
src={instagramEmbedSrc}
|
||||
width="100%"
|
||||
height={360}
|
||||
style={{ border: 0, width: '100%' }}
|
||||
frameBorder={0}
|
||||
scrolling="no"
|
||||
/>
|
||||
) : (
|
||||
<div style={{ padding: 16 }}>
|
||||
<p>Sledujte nás na Instagramu.</p>
|
||||
<a className="btn" href={instagramHref} target="_blank" rel="noreferrer noopener">Otevřít Instagram</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SocialEmbeds;
|
||||
@@ -0,0 +1,266 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Box, Heading, Tabs, TabList, TabPanels, Tab, TabPanel, Table, Thead, Tbody, Tr, Th, Td, Skeleton, Text, Badge, HStack, useColorModeValue } from '@chakra-ui/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { facrApi } from '../../services/facr/facrApi';
|
||||
import { FACR_CLUB_ID, FACR_CLUB_TYPE } from '../../config/facr';
|
||||
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
||||
import ClubModal from './ClubModal';
|
||||
import { TeamLogo } from '../common/TeamLogo';
|
||||
|
||||
const TableSection: React.FC = () => {
|
||||
const { data: settings } = usePublicSettings();
|
||||
const clubId = settings?.club_id || FACR_CLUB_ID;
|
||||
const clubType = settings?.club_type || FACR_CLUB_TYPE;
|
||||
// movement map: compKey -> teamKey -> delta (prevRank - currentRank)
|
||||
const [movementMap, setMovementMap] = useState<Record<string, Record<string, number>>>({});
|
||||
const [selectedClub, setSelectedClub] = useState<any>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
const handleClubClick = (row: any) => {
|
||||
// Transform row data to match ClubModal interface
|
||||
const clubData = {
|
||||
team: row.team || row.team_name || '-',
|
||||
team_id: row.team_id || '',
|
||||
team_logo_url: row.team_logo_url,
|
||||
rank: row.rank,
|
||||
played: row.played,
|
||||
wins: row.wins,
|
||||
draws: row.draws,
|
||||
losses: row.losses,
|
||||
score: row.score,
|
||||
points: row.points,
|
||||
};
|
||||
setSelectedClub(clubData);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
// Theme-aware movement colors (softer in dark mode)
|
||||
const upColor = useColorModeValue('green.400', 'green.300');
|
||||
const downColor = useColorModeValue('red.400', 'red.300');
|
||||
const sameColor = useColorModeValue('gray.300', 'gray.600');
|
||||
// Badge/background colors to avoid white-on-white
|
||||
const badgeBg = useColorModeValue('gray.100', 'gray.700');
|
||||
const badgeText = useColorModeValue('gray.800', 'whiteAlpha.900');
|
||||
const rankTopBg = useColorModeValue('green.100', 'green.600');
|
||||
const rankTopText = useColorModeValue('green.800', 'white');
|
||||
const pointsBg = useColorModeValue('blue.600', 'blue.400');
|
||||
const pointsText = 'white';
|
||||
const { data, isLoading, isError, error } = useQuery({
|
||||
queryKey: ['facr-table', clubId, clubType],
|
||||
queryFn: () => facrApi.getClubTable(clubId, clubType),
|
||||
enabled: Boolean(clubId),
|
||||
staleTime: 1000 * 60 * 3, // 3 minutes
|
||||
retry: 2,
|
||||
retryDelay: attempt => Math.min(1000 * 2 ** attempt, 8000),
|
||||
});
|
||||
|
||||
// After data loads, compare with previous snapshot stored in localStorage to compute movement
|
||||
useEffect(() => {
|
||||
try {
|
||||
if (!data?.competitions?.length) return;
|
||||
const storageKey = `facr_table_prev_${clubId || 'unknown'}_${clubType || 'football'}`;
|
||||
const prevRaw = localStorage.getItem(storageKey);
|
||||
const prev = prevRaw ? JSON.parse(prevRaw) : null;
|
||||
const map: Record<string, Record<string, number>> = {};
|
||||
|
||||
data.competitions.forEach((c: any) => {
|
||||
const compKey = String(c.id ?? c.code ?? c.name ?? 'comp');
|
||||
const prevComp = prev?.competitions?.find((pc: any) => String(pc.id ?? pc.code ?? pc.name) === compKey);
|
||||
const prevRanks: Record<string, number> = {};
|
||||
(prevComp?.table?.overall || []).forEach((r: any, i: number) => {
|
||||
const teamKey = String(r.team_id ?? r.team ?? r.team_name ?? i).toLowerCase();
|
||||
const rank = Number(r.rank ?? (i + 1));
|
||||
prevRanks[teamKey] = rank;
|
||||
});
|
||||
const compMov: Record<string, number> = {};
|
||||
(c.table?.overall || []).forEach((r: any, i: number) => {
|
||||
const teamKeyRaw = String(r.team_id ?? r.team ?? r.team_name ?? i);
|
||||
const teamKey = teamKeyRaw.toLowerCase();
|
||||
const currentRank = Number(r.rank ?? (i + 1));
|
||||
const prevRank = prevRanks[teamKey];
|
||||
if (typeof prevRank === 'number') {
|
||||
compMov[teamKeyRaw] = prevRank - currentRank; // positive => moved up
|
||||
}
|
||||
});
|
||||
map[compKey] = compMov;
|
||||
});
|
||||
|
||||
setMovementMap(map);
|
||||
|
||||
// Save current snapshot for next comparison (trim to essentials)
|
||||
const snapshot = {
|
||||
competitions: (data.competitions || []).map((c: any) => ({
|
||||
id: c.id,
|
||||
code: c.code,
|
||||
name: c.name,
|
||||
table: { overall: (c.table?.overall || []).map((r: any, i: number) => ({
|
||||
team_id: r.team_id,
|
||||
team: r.team,
|
||||
team_name: r.team_name,
|
||||
rank: Number(r.rank ?? (i + 1)),
|
||||
})) },
|
||||
})),
|
||||
};
|
||||
localStorage.setItem(storageKey, JSON.stringify(snapshot));
|
||||
} catch {}
|
||||
}, [data, clubId, clubType]);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Heading size="lg" mb={4}>Tabulka soutěží</Heading>
|
||||
{!clubId && (
|
||||
<Text color="orange.500" mb={4}>Nastavte klub v Nastavení (Admin) nebo REACT_APP_FACR_CLUB_ID pro načtení tabulek z FAČR.</Text>
|
||||
)}
|
||||
{isLoading && <Skeleton height="200px" />}
|
||||
{isError && (
|
||||
<Text color="red.500" mb={4}>
|
||||
Nepodařilo se načíst tabulky z FAČR. Zkuste to prosím znovu později.
|
||||
{process.env.NODE_ENV !== 'production' && error instanceof Error ? ` (${error.message})` : ''}
|
||||
</Text>
|
||||
)}
|
||||
{/* Legend for movement */}
|
||||
{!isLoading && !isError && (
|
||||
<HStack spacing={4} mb={2} color="gray.600" fontSize="sm">
|
||||
<HStack spacing={2}>
|
||||
<Box w="10px" h="10px" borderRadius="2px" bg={upColor} />
|
||||
<Text>Lepší pozice</Text>
|
||||
</HStack>
|
||||
<HStack spacing={2}>
|
||||
<Box w="10px" h="10px" borderRadius="2px" bg={sameColor} />
|
||||
<Text>Beze změny</Text>
|
||||
</HStack>
|
||||
<HStack spacing={2}>
|
||||
<Box w="10px" h="10px" borderRadius="2px" bg={downColor} />
|
||||
<Text>Horší pozice</Text>
|
||||
</HStack>
|
||||
</HStack>
|
||||
)}
|
||||
{!isLoading && !isError && data && data.competitions?.length > 0 && (
|
||||
<Tabs variant="enclosed" colorScheme="blue" isFitted>
|
||||
<TabList bg={useColorModeValue('white', 'gray.800')} borderRadius="md" borderWidth="1px" borderColor={useColorModeValue('gray.200', 'gray.700')}>
|
||||
{data.competitions?.map((c) => (
|
||||
<Tab
|
||||
key={c.id}
|
||||
_selected={{ bg: useColorModeValue('blue.50', 'blue.900'), color: useColorModeValue('blue.700', 'blue.200'), borderColor: useColorModeValue('blue.200', 'blue.600') }}
|
||||
color={useColorModeValue('gray.800', 'gray.200')}
|
||||
>
|
||||
{c.name}
|
||||
</Tab>
|
||||
))}
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
{data.competitions?.map((c) => (
|
||||
<TabPanel key={c.id} px={0}>
|
||||
<Box maxH="420px" overflowY="auto" borderWidth="1px" borderRadius="md" bg={useColorModeValue('white', 'gray.800')} color={useColorModeValue('gray.800', 'gray.100')} borderColor={useColorModeValue('gray.200', 'gray.700')}>
|
||||
<Table size="sm" variant="striped" colorScheme="gray">
|
||||
<Thead position="sticky" top={0} zIndex={1} bg="brand.primary">
|
||||
<Tr>
|
||||
<Th color="text.onPrimary">#</Th>
|
||||
<Th color="text.onPrimary">Tým</Th>
|
||||
<Th isNumeric color="text.onPrimary">Z</Th>
|
||||
<Th isNumeric color="text.onPrimary">V</Th>
|
||||
<Th isNumeric color="text.onPrimary">R</Th>
|
||||
<Th isNumeric color="text.onPrimary">P</Th>
|
||||
<Th isNumeric color="text.onPrimary">Skóre</Th>
|
||||
<Th isNumeric color="text.onPrimary">Body</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{c.table?.overall?.map((row, idx) => {
|
||||
const compKey = String(c.id ?? c.code ?? c.name ?? 'comp');
|
||||
const teamKeyRaw = String((row as any).team_id ?? (row as any).team ?? (row as any).team_name ?? idx);
|
||||
const deltaStored = movementMap?.[compKey]?.[teamKeyRaw];
|
||||
const movement: 'up' | 'same' | 'down' = typeof deltaStored === 'number' ? (deltaStored > 0 ? 'up' : (deltaStored < 0 ? 'down' : 'same')) : 'same';
|
||||
const deltaVal = typeof deltaStored === 'number' ? deltaStored : 0;
|
||||
const borderCol = movement === 'up' ? upColor : movement === 'down' ? downColor : sameColor;
|
||||
|
||||
const ourClubId = settings?.club_id;
|
||||
const ourClubName = (settings?.club_name || '').toLowerCase();
|
||||
const isOurClub = (ourClubId && row.team_id === ourClubId) || (!!ourClubName && String(row.team || '').toLowerCase() === ourClubName);
|
||||
|
||||
return (
|
||||
<Tr
|
||||
key={`${row.team_id}-${idx}`}
|
||||
_hover={{ bg: useColorModeValue('gray.50', 'gray.700'), cursor: 'pointer' }}
|
||||
bg={idx % 2 === 0 ? useColorModeValue('white', 'gray.800') : useColorModeValue('gray.50', 'gray.750')}
|
||||
sx={{ borderLeftWidth: '4px', borderLeftStyle: 'solid', borderLeftColor: borderCol }}
|
||||
onClick={() => handleClubClick(row)}
|
||||
>
|
||||
<Td>
|
||||
<Badge
|
||||
variant="subtle"
|
||||
bg={idx <= 2 ? rankTopBg : badgeBg}
|
||||
color={idx <= 2 ? rankTopText : badgeText}
|
||||
borderWidth="1px"
|
||||
borderColor={useColorModeValue('gray.200', 'whiteAlpha.300')}
|
||||
>
|
||||
{row.rank}
|
||||
</Badge>
|
||||
</Td>
|
||||
<Td>
|
||||
<HStack spacing={2} align="center">
|
||||
<TeamLogo
|
||||
teamId={row.team_id}
|
||||
teamName={row.team}
|
||||
facrLogo={row.team_logo_url}
|
||||
size="small"
|
||||
alt={row.team}
|
||||
borderRadius="full"
|
||||
bg="white"
|
||||
borderWidth="1px"
|
||||
borderColor={useColorModeValue('gray.200', 'whiteAlpha.300')}
|
||||
/>
|
||||
<Text as="span" color={isOurClub ? 'brand.primary' : useColorModeValue('gray.800', 'gray.100')} fontWeight={isOurClub ? 'bold' : 'normal'}>
|
||||
{row.team}
|
||||
</Text>
|
||||
<Text as="span" fontSize="xs" color={movement === 'up' ? 'green.500' : movement === 'down' ? 'red.500' : 'gray.500'}>
|
||||
{movement === 'up' ? '▲' : movement === 'down' ? '▼' : '•'}
|
||||
</Text>
|
||||
</HStack>
|
||||
{deltaVal !== 0 && (
|
||||
<Badge
|
||||
ml={2}
|
||||
variant="subtle"
|
||||
bg={movement === 'up' ? 'green.100' : movement === 'down' ? 'red.100' : badgeBg}
|
||||
color={movement === 'up' ? 'green.700' : movement === 'down' ? 'red.700' : badgeText}
|
||||
borderWidth="1px"
|
||||
borderColor={useColorModeValue('green.200', movement === 'down' ? 'red.300' : 'whiteAlpha.300')}
|
||||
>
|
||||
{movement === 'up' ? `+${deltaVal}` : `${deltaVal}`}
|
||||
</Badge>
|
||||
)}
|
||||
</Td>
|
||||
<Td isNumeric>{row.played}</Td>
|
||||
<Td isNumeric>{row.wins}</Td>
|
||||
<Td isNumeric>{row.draws}</Td>
|
||||
<Td isNumeric>{row.losses}</Td>
|
||||
<Td isNumeric>{row.score}</Td>
|
||||
<Td isNumeric>
|
||||
<Badge variant="solid" bg={pointsBg} color={pointsText}>{row.points}</Badge>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
})}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
</TabPanel>
|
||||
))}
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
)}
|
||||
{!isLoading && !isError && data && (!data.competitions || data.competitions.length === 0) && (
|
||||
<Text color="gray.500">Pro tento klub nejsou dostupné tabulky.</Text>
|
||||
)}
|
||||
|
||||
<ClubModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
club={selectedClub}
|
||||
clubType={clubType as 'football' | 'futsal'}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default TableSection;
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Box, Heading, HStack, VStack, Image, Text, useColorModeValue } from '@chakra-ui/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getPlayers, Player } from '../../services/players';
|
||||
|
||||
const TeamScroller: React.FC = () => {
|
||||
const { data } = useQuery({ queryKey: ['players'], queryFn: getPlayers });
|
||||
const players = (data || []).filter(p => p.is_active);
|
||||
if (!players.length) return null;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Heading size="lg" mb={4} textAlign="center">Náš tým</Heading>
|
||||
<HStack spacing={6} overflowX="auto" py={2} className="hide-scrollbar">
|
||||
{players.map((p: Player) => (
|
||||
<VStack key={p.id} minW="160px" spacing={2} bg={useColorModeValue('white', 'gray.800')} borderRadius="xl" p={4} boxShadow="sm" borderWidth="1px" borderColor={useColorModeValue('gray.200', 'gray.700')}>
|
||||
<Image src={p.image_url || '/logo192.png'} alt={p.first_name + ' ' + p.last_name} w="140px" h="140px" objectFit="cover" borderRadius="lg" />
|
||||
<Text fontWeight="bold" textAlign="center">{p.first_name} {p.last_name}</Text>
|
||||
<Text fontSize="sm" color={useColorModeValue('gray.600', 'gray.400')}>{p.position}</Text>
|
||||
</VStack>
|
||||
))}
|
||||
</HStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default TeamScroller;
|
||||
@@ -0,0 +1,72 @@
|
||||
import React from 'react';
|
||||
import ContactMap from './ContactMap';
|
||||
import VectorMap from './VectorMap';
|
||||
|
||||
interface UnifiedMapProps {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
zoom?: number;
|
||||
address?: string;
|
||||
clubName?: string;
|
||||
mapStyle?: string;
|
||||
height?: number;
|
||||
clubPrimaryColor?: string;
|
||||
clubSecondaryColor?: string;
|
||||
useVectorMaps?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified Map Component
|
||||
*
|
||||
* Automatically chooses between raster (Leaflet) and vector (MapLibre GL) maps
|
||||
* based on the useVectorMaps prop or environment configuration.
|
||||
*
|
||||
* Usage:
|
||||
* <UnifiedMap
|
||||
* latitude={50.0755}
|
||||
* longitude={14.4378}
|
||||
* useVectorMaps={true} // or from settings
|
||||
* />
|
||||
*/
|
||||
const UnifiedMap: React.FC<UnifiedMapProps> = ({
|
||||
useVectorMaps = false,
|
||||
...props
|
||||
}) => {
|
||||
// Map style conversion: raster styles to vector equivalents
|
||||
const getVectorStyle = (rasterStyle?: string): 'positron' | 'dark-matter' | 'osm-bright' | 'klokantech-basic' => {
|
||||
const styleMap: Record<string, any> = {
|
||||
'default': 'osm-bright',
|
||||
'positron': 'positron',
|
||||
'positron-no-labels': 'positron',
|
||||
'dark': 'dark-matter',
|
||||
'dark-no-labels': 'dark-matter',
|
||||
'dark-matter': 'dark-matter',
|
||||
'toner': 'klokantech-basic',
|
||||
'toner-lite': 'klokantech-basic',
|
||||
'voyager': 'osm-bright',
|
||||
'osm-bright': 'osm-bright',
|
||||
'klokantech-basic': 'klokantech-basic',
|
||||
};
|
||||
|
||||
return styleMap[rasterStyle || 'default'] || 'positron';
|
||||
};
|
||||
|
||||
if (useVectorMaps) {
|
||||
// Use vector maps (MapLibre GL JS)
|
||||
return (
|
||||
<VectorMap
|
||||
{...props}
|
||||
mapStyle={getVectorStyle(props.mapStyle)}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
// Use raster maps (Leaflet)
|
||||
return (
|
||||
<ContactMap
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default UnifiedMap;
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Box, Flex, Heading, Text, HStack, Image, Button } from '@chakra-ui/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
||||
import { facrApi } from '../../services/facr/facrApi';
|
||||
import { useClubTheme } from '../../contexts/ClubThemeContext';
|
||||
|
||||
function formatCountdown(dt: string) {
|
||||
const target = new Date(dt).getTime();
|
||||
const diff = target - Date.now();
|
||||
if (isNaN(target) || diff <= 0) return '';
|
||||
const d = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
const h = Math.floor((diff / (1000 * 60 * 60)) % 24);
|
||||
const m = Math.floor((diff / (1000 * 60)) % 60);
|
||||
return `${String(d).padStart(2,'0')}d ${String(h).padStart(2,'0')}h ${String(m).padStart(2,'0')}m`;
|
||||
}
|
||||
|
||||
const UpcomingBanner: React.FC = () => {
|
||||
const { data: settings } = usePublicSettings();
|
||||
const clubId = settings?.club_id;
|
||||
const clubType = settings?.club_type || 'football';
|
||||
const theme = useClubTheme();
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ['facr-club', clubId, clubType],
|
||||
queryFn: () => facrApi.getClub(clubId!, clubType as any),
|
||||
enabled: !!clubId,
|
||||
});
|
||||
|
||||
const allMatches = (data?.competitions || []).flatMap(c => c.matches || []);
|
||||
const upcoming = allMatches
|
||||
.map(m => ({ m, t: new Date(m.date_time).getTime() }))
|
||||
.filter(x => !isNaN(x.t) && x.t > Date.now())
|
||||
.sort((a, b) => a.t - b.t)[0]?.m;
|
||||
|
||||
if (!upcoming) return null;
|
||||
|
||||
return (
|
||||
<Box bg={theme.primary} color="white" borderRadius="xl" p={{ base: 4, md: 6 }} shadow="md">
|
||||
<Text fontSize="sm" opacity={0.9} fontWeight="600">Nadcházející zápas</Text>
|
||||
<Flex align="center" justify="space-between" gap={4} mt={2} direction={{ base: 'column', md: 'row' }}>
|
||||
<HStack spacing={4} flex={1} justify="center">
|
||||
<HStack>
|
||||
<Image src={upcoming.home_logo_url} alt={upcoming.home} boxSize={{ base: '36px', md: '48px' }} objectFit="contain" />
|
||||
<Text fontWeight="600">{upcoming.home}</Text>
|
||||
</HStack>
|
||||
<Heading size="md">vs</Heading>
|
||||
<HStack>
|
||||
<Image src={upcoming.away_logo_url} alt={upcoming.away} boxSize={{ base: '36px', md: '48px' }} objectFit="contain" />
|
||||
<Text fontWeight="600">{upcoming.away}</Text>
|
||||
</HStack>
|
||||
</HStack>
|
||||
<HStack spacing={6}>
|
||||
<Box textAlign="center">
|
||||
<Text fontSize="xs" opacity={0.8}>KICKOFF</Text>
|
||||
<Heading size="sm">{new Date(upcoming.date_time).toLocaleString()}</Heading>
|
||||
</Box>
|
||||
<Box textAlign="center">
|
||||
<Text fontSize="xs" opacity={0.8}>ZAČÍNÁ ZA</Text>
|
||||
<Heading size="sm">{formatCountdown(upcoming.date_time)}</Heading>
|
||||
</Box>
|
||||
</HStack>
|
||||
{upcoming.report_url && (
|
||||
<Button as="a" href={upcoming.report_url} target="_blank" colorScheme="red" variant="solid">Detail</Button>
|
||||
)}
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default UpcomingBanner;
|
||||
@@ -0,0 +1,105 @@
|
||||
import React from 'react';
|
||||
import { Box, HStack, VStack, Text, Heading, Tabs, TabList, TabPanels, Tab, TabPanel, Image, Button } from '@chakra-ui/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
||||
import { facrApi } from '../../services/facr/facrApi';
|
||||
import { useClubTheme } from '../../contexts/ClubThemeContext';
|
||||
|
||||
function formatCountdown(dt?: string) {
|
||||
if (!dt) return '';
|
||||
const target = new Date(dt).getTime();
|
||||
const diff = target - Date.now();
|
||||
if (isNaN(target) || diff <= 0) return '';
|
||||
const d = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
const h = Math.floor((diff / (1000 * 60 * 60)) % 24);
|
||||
const m = Math.floor((diff / (1000 * 60)) % 60);
|
||||
return `${String(d).padStart(2,'0')}d ${String(h).padStart(2,'0')}h ${String(m).padStart(2,'0')}m`;
|
||||
}
|
||||
|
||||
const UpcomingSwitch: React.FC = () => {
|
||||
const { data: settings } = usePublicSettings();
|
||||
const clubId = settings?.club_id;
|
||||
const clubType = (settings?.club_type || 'football') as any;
|
||||
const theme = useClubTheme();
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ['facr-club', clubId, clubType],
|
||||
queryFn: () => facrApi.getClub(clubId!, clubType),
|
||||
enabled: !!clubId,
|
||||
});
|
||||
|
||||
const comps = data?.competitions || [];
|
||||
|
||||
if (!comps.length) return null;
|
||||
|
||||
return (
|
||||
<Tabs variant="unstyled" colorScheme="whiteAlpha">
|
||||
<TabList gap={2} flexWrap={{ base: 'wrap', md: 'nowrap' }}>
|
||||
{comps.map((c) => (
|
||||
<Tab
|
||||
key={c.id}
|
||||
px={3}
|
||||
py={2}
|
||||
borderRadius="full"
|
||||
bg="whiteAlpha.200"
|
||||
color="white"
|
||||
_selected={{ bg: 'white', color: 'black' }}
|
||||
>
|
||||
{c.name}
|
||||
</Tab>
|
||||
))}
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
{comps.map((c) => {
|
||||
const upcoming = (c.matches || [])
|
||||
.map((m) => ({ m, t: new Date(m.date_time).getTime() }))
|
||||
.filter((x) => !isNaN(x.t) && x.t > Date.now())
|
||||
.sort((a, b) => a.t - b.t)[0]?.m;
|
||||
|
||||
if (!upcoming) {
|
||||
return (
|
||||
<TabPanel key={c.id} px={0}>
|
||||
<Box py={6} textAlign="center" color="whiteAlpha.800">Žádný nadcházející zápas.</Box>
|
||||
</TabPanel>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TabPanel key={c.id} px={0}>
|
||||
<HStack spacing={6} align="center" justify="space-between" flexWrap={{ base: 'wrap', md: 'nowrap' }}>
|
||||
<HStack spacing={4} flex={1} minW={0} justify="center">
|
||||
<HStack minW={0}>
|
||||
<Image src={upcoming.home_logo_url} alt={upcoming.home} boxSize={{ base: '32px', md: '44px' }} objectFit="contain" />
|
||||
<Text fontWeight={700} color="white" noOfLines={1}>{upcoming.home}</Text>
|
||||
</HStack>
|
||||
<Heading size="sm" color="white">vs</Heading>
|
||||
<HStack minW={0}>
|
||||
<Image src={upcoming.away_logo_url} alt={upcoming.away} boxSize={{ base: '32px', md: '44px' }} objectFit="contain" />
|
||||
<Text fontWeight={700} color="white" noOfLines={1}>{upcoming.away}</Text>
|
||||
</HStack>
|
||||
</HStack>
|
||||
<HStack spacing={6}>
|
||||
<Box textAlign="center" color="white">
|
||||
<Text fontSize="xs" opacity={0.85}>KICKOFF</Text>
|
||||
<Heading size="sm">{new Date(upcoming.date_time).toLocaleString()}</Heading>
|
||||
</Box>
|
||||
<Box textAlign="center" color="white">
|
||||
<Text fontSize="xs" opacity={0.85}>ZAČÍNÁ ZA</Text>
|
||||
<Heading size="sm">{formatCountdown(upcoming.date_time)}</Heading>
|
||||
</Box>
|
||||
</HStack>
|
||||
{upcoming.report_url && (
|
||||
<Button as="a" href={upcoming.report_url} target="_blank" bg={theme.primary} color="white" _hover={{ bg: theme.accent }}>
|
||||
Detail zápasu
|
||||
</Button>
|
||||
)}
|
||||
</HStack>
|
||||
</TabPanel>
|
||||
);
|
||||
})}
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
|
||||
export default UpcomingSwitch;
|
||||
@@ -0,0 +1,323 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Box } from '@chakra-ui/react';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
|
||||
interface VectorMapProps {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
zoom?: number;
|
||||
address?: string;
|
||||
clubName?: string;
|
||||
mapStyle?: 'positron' | 'dark-matter' | 'osm-bright' | 'klokantech-basic';
|
||||
height?: number;
|
||||
clubPrimaryColor?: string;
|
||||
clubSecondaryColor?: string;
|
||||
customStyleUrl?: string;
|
||||
}
|
||||
|
||||
// OpenMapTiles free demo server (for development/testing)
|
||||
// For production, use your own tile server or a commercial provider
|
||||
const MAPTILER_API_KEY = process.env.REACT_APP_MAPTILER_KEY || 'get_your_own_OpIi9ZULNHzrESv6T2vL';
|
||||
|
||||
// Vector tile style definitions
|
||||
export const VECTOR_STYLES = {
|
||||
'positron': {
|
||||
name: 'Positron (Light)',
|
||||
description: 'Clean light style, perfect for data visualization',
|
||||
getStyleUrl: (apiKey: string) => `https://api.maptiler.com/maps/positron/style.json?key=${apiKey}`,
|
||||
},
|
||||
'dark-matter': {
|
||||
name: 'Dark Matter',
|
||||
description: 'Sleek dark theme for modern interfaces',
|
||||
getStyleUrl: (apiKey: string) => `https://api.maptiler.com/maps/darkmatter/style.json?key=${apiKey}`,
|
||||
},
|
||||
'osm-bright': {
|
||||
name: 'OSM Bright',
|
||||
description: 'Colorful OpenStreetMap style',
|
||||
getStyleUrl: (apiKey: string) => `https://api.maptiler.com/maps/bright/style.json?key=${apiKey}`,
|
||||
},
|
||||
'klokantech-basic': {
|
||||
name: 'Basic',
|
||||
description: 'Simple and clean base map',
|
||||
getStyleUrl: (apiKey: string) => `https://api.maptiler.com/maps/basic/style.json?key=${apiKey}`,
|
||||
},
|
||||
};
|
||||
|
||||
// Custom Positron-like style with club colors (self-hosted tiles not required)
|
||||
const createCustomPositronStyle = (primaryColor?: string, secondaryColor?: string): any => {
|
||||
const mainColor = primaryColor || '#e11d48';
|
||||
const accentColor = secondaryColor || '#3b82f6';
|
||||
|
||||
return {
|
||||
version: 8,
|
||||
name: 'Custom Positron',
|
||||
sources: {
|
||||
'openmaptiles': {
|
||||
type: 'vector',
|
||||
url: `https://api.maptiler.com/tiles/v3/tiles.json?key=${MAPTILER_API_KEY}`,
|
||||
},
|
||||
},
|
||||
glyphs: 'https://api.maptiler.com/fonts/{fontstack}/{range}.pbf?key=' + MAPTILER_API_KEY,
|
||||
layers: [
|
||||
// Background
|
||||
{
|
||||
id: 'background',
|
||||
type: 'background',
|
||||
paint: { 'background-color': '#f8f8f8' },
|
||||
},
|
||||
// Water
|
||||
{
|
||||
id: 'water',
|
||||
type: 'fill',
|
||||
source: 'openmaptiles',
|
||||
'source-layer': 'water',
|
||||
paint: { 'fill-color': '#e3e8ed' },
|
||||
},
|
||||
// Parks
|
||||
{
|
||||
id: 'park',
|
||||
type: 'fill',
|
||||
source: 'openmaptiles',
|
||||
'source-layer': 'park',
|
||||
paint: { 'fill-color': '#e8f5e8' },
|
||||
},
|
||||
// Buildings
|
||||
{
|
||||
id: 'building',
|
||||
type: 'fill',
|
||||
source: 'openmaptiles',
|
||||
'source-layer': 'building',
|
||||
paint: {
|
||||
'fill-color': '#ececec',
|
||||
'fill-opacity': 0.6,
|
||||
},
|
||||
},
|
||||
// Roads - major
|
||||
{
|
||||
id: 'road-major',
|
||||
type: 'line',
|
||||
source: 'openmaptiles',
|
||||
'source-layer': 'transportation',
|
||||
filter: ['in', 'class', 'motorway', 'trunk', 'primary'],
|
||||
paint: {
|
||||
'line-color': '#ffffff',
|
||||
'line-width': {
|
||||
base: 1.4,
|
||||
stops: [[6, 0.5], [20, 30]],
|
||||
},
|
||||
},
|
||||
},
|
||||
// Roads - minor
|
||||
{
|
||||
id: 'road-minor',
|
||||
type: 'line',
|
||||
source: 'openmaptiles',
|
||||
'source-layer': 'transportation',
|
||||
filter: ['in', 'class', 'secondary', 'tertiary', 'minor'],
|
||||
paint: {
|
||||
'line-color': '#ffffff',
|
||||
'line-width': {
|
||||
base: 1.4,
|
||||
stops: [[6, 0.25], [20, 20]],
|
||||
},
|
||||
},
|
||||
},
|
||||
// Place labels
|
||||
{
|
||||
id: 'place-label',
|
||||
type: 'symbol',
|
||||
source: 'openmaptiles',
|
||||
'source-layer': 'place',
|
||||
layout: {
|
||||
'text-field': '{name}',
|
||||
'text-font': ['Noto Sans Regular'],
|
||||
'text-size': {
|
||||
base: 1.2,
|
||||
stops: [[7, 11], [15, 14]],
|
||||
},
|
||||
},
|
||||
paint: {
|
||||
'text-color': '#666666',
|
||||
'text-halo-color': '#ffffff',
|
||||
'text-halo-width': 1.5,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const VectorMap: React.FC<VectorMapProps> = ({
|
||||
latitude,
|
||||
longitude,
|
||||
zoom = 15,
|
||||
address,
|
||||
clubName,
|
||||
mapStyle = 'positron',
|
||||
height = 400,
|
||||
clubPrimaryColor,
|
||||
clubSecondaryColor,
|
||||
customStyleUrl,
|
||||
}) => {
|
||||
const mapContainer = useRef<HTMLDivElement>(null);
|
||||
const map = useRef<maplibregl.Map | null>(null);
|
||||
const marker = useRef<maplibregl.Marker | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mapContainer.current || map.current) return;
|
||||
|
||||
try {
|
||||
// Determine style URL
|
||||
let styleUrl: string | any;
|
||||
|
||||
if (customStyleUrl) {
|
||||
styleUrl = customStyleUrl;
|
||||
} else if (mapStyle === 'positron' && clubPrimaryColor) {
|
||||
// Use custom style with club colors
|
||||
styleUrl = createCustomPositronStyle(clubPrimaryColor, clubSecondaryColor);
|
||||
} else {
|
||||
// Use predefined style
|
||||
styleUrl = VECTOR_STYLES[mapStyle]?.getStyleUrl(MAPTILER_API_KEY) ||
|
||||
VECTOR_STYLES.positron.getStyleUrl(MAPTILER_API_KEY);
|
||||
}
|
||||
|
||||
// Initialize map
|
||||
map.current = new maplibregl.Map({
|
||||
container: mapContainer.current,
|
||||
style: styleUrl,
|
||||
center: [longitude, latitude],
|
||||
zoom: zoom,
|
||||
});
|
||||
|
||||
// Add navigation controls
|
||||
map.current.addControl(new maplibregl.NavigationControl(), 'top-right');
|
||||
|
||||
// Create custom marker with club color
|
||||
const markerColor = clubPrimaryColor || '#e11d48';
|
||||
|
||||
// Create marker element
|
||||
const el = document.createElement('div');
|
||||
el.style.width = '36px';
|
||||
el.style.height = '54px';
|
||||
el.innerHTML = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 36" width="36" height="54">
|
||||
<defs>
|
||||
<filter id="marker-shadow-${Date.now()}" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur in="SourceAlpha" stdDeviation="2"/>
|
||||
<feOffset dx="0" dy="2" result="offsetblur"/>
|
||||
<feComponentTransfer>
|
||||
<feFuncA type="linear" slope="0.3"/>
|
||||
</feComponentTransfer>
|
||||
<feMerge>
|
||||
<feMergeNode/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
<path fill="${markerColor}" stroke="#fff" stroke-width="1.5"
|
||||
filter="url(#marker-shadow-${Date.now()})"
|
||||
d="M12 0C7.03 0 3 4.03 3 9c0 7.5 9 18 9 18s9-10.5 9-18c0-4.97-4.03-9-9-9z"/>
|
||||
<circle cx="12" cy="9" r="3" fill="#fff"/>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
// Add marker to map
|
||||
marker.current = new maplibregl.Marker({ element: el })
|
||||
.setLngLat([longitude, latitude])
|
||||
.addTo(map.current);
|
||||
|
||||
// Add popup if there's content
|
||||
if (clubName || address) {
|
||||
let popupContent = '';
|
||||
if (clubName) popupContent += `<strong>${clubName}</strong><br>`;
|
||||
if (address) popupContent += address;
|
||||
|
||||
const popup = new maplibregl.Popup({ offset: 25 })
|
||||
.setHTML(popupContent);
|
||||
|
||||
marker.current.setPopup(popup);
|
||||
}
|
||||
|
||||
// Handle map load event for additional customization
|
||||
map.current.on('load', () => {
|
||||
if (!map.current) return;
|
||||
|
||||
// Apply club color tint to water features if primary color is set
|
||||
if (clubPrimaryColor && map.current.getLayer('water')) {
|
||||
map.current.setPaintProperty('water', 'fill-color',
|
||||
adjustColorBrightness(clubPrimaryColor, 0.9));
|
||||
}
|
||||
});
|
||||
|
||||
} catch (err: any) {
|
||||
console.error('Error initializing map:', err);
|
||||
setError(err?.message || 'Failed to load map');
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
if (marker.current) {
|
||||
marker.current.remove();
|
||||
marker.current = null;
|
||||
}
|
||||
if (map.current) {
|
||||
map.current.remove();
|
||||
map.current = null;
|
||||
}
|
||||
};
|
||||
}, [latitude, longitude, zoom, mapStyle, clubPrimaryColor, clubSecondaryColor, customStyleUrl]);
|
||||
|
||||
// Update marker and center when coordinates change
|
||||
useEffect(() => {
|
||||
if (!map.current || !marker.current) return;
|
||||
|
||||
const newCenter: [number, number] = [longitude, latitude];
|
||||
marker.current.setLngLat(newCenter);
|
||||
map.current.setCenter(newCenter);
|
||||
}, [latitude, longitude]);
|
||||
|
||||
// Helper function to adjust color brightness
|
||||
function adjustColorBrightness(color: string, factor: number): string {
|
||||
try {
|
||||
// Simple RGB adjustment
|
||||
const hex = color.replace('#', '');
|
||||
const r = Math.min(255, Math.floor(parseInt(hex.substring(0, 2), 16) * factor));
|
||||
const g = Math.min(255, Math.floor(parseInt(hex.substring(2, 4), 16) * factor));
|
||||
const b = Math.min(255, Math.floor(parseInt(hex.substring(4, 6), 16) * factor));
|
||||
return `rgb(${r}, ${g}, ${b})`;
|
||||
} catch {
|
||||
return color;
|
||||
}
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Box
|
||||
w="100%"
|
||||
h={`${height}px`}
|
||||
bg="gray.100"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
borderRadius="md"
|
||||
p={4}
|
||||
>
|
||||
{error}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={mapContainer}
|
||||
w="100%"
|
||||
h={`${height}px`}
|
||||
borderRadius="md"
|
||||
overflow="hidden"
|
||||
boxShadow="md"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default VectorMap;
|
||||
@@ -0,0 +1,337 @@
|
||||
import { Box, AspectRatio, Text, useColorModeValue, SimpleGrid, Heading, HStack, Badge, Button, Link, Modal, ModalOverlay, ModalContent, ModalBody, ModalCloseButton, useDisclosure, Icon, VStack } from '@chakra-ui/react';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import { FaYoutube, FaPlay } from 'react-icons/fa';
|
||||
import HorizontalScroller from '../ui/HorizontalScroller';
|
||||
import { useClubTheme } from '../../contexts/ClubThemeContext';
|
||||
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
||||
import { getCachedYouTube, YouTubeVideo } from '../../services/youtube';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
type Props = {
|
||||
// optional manual override
|
||||
videos?: string[];
|
||||
};
|
||||
|
||||
type RenderItem = {
|
||||
key: string;
|
||||
title: string;
|
||||
embedUrl: string;
|
||||
thumbnail?: string;
|
||||
date?: string; // YYYY-MM-DD
|
||||
videoId?: string;
|
||||
};
|
||||
|
||||
const toEmbed = (idOrUrl: string): string => {
|
||||
// If a full URL is passed, try to extract the id; otherwise assume it's already an id
|
||||
// supports https://www.youtube.com/watch?v=ID or youtu.be/ID
|
||||
try {
|
||||
if (idOrUrl.includes('youtube.com') || idOrUrl.includes('youtu.be')) {
|
||||
const u = new URL(idOrUrl);
|
||||
if (u.hostname.includes('youtu.be')) {
|
||||
const id = u.pathname.replace('/', '');
|
||||
return `https://www.youtube.com/embed/${id}`;
|
||||
}
|
||||
const id = u.searchParams.get('v');
|
||||
if (id) return `https://www.youtube.com/embed/${id}`;
|
||||
}
|
||||
} catch {}
|
||||
// otherwise treat as id
|
||||
return `https://www.youtube.com/embed/${idOrUrl}`;
|
||||
};
|
||||
|
||||
const VideosSection: React.FC<Props> = ({ videos }) => {
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const theme = useClubTheme();
|
||||
const { data: settings } = usePublicSettings();
|
||||
const [yt, setYt] = useState<YouTubeVideo[]>([]);
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const [selectedVideo, setSelectedVideo] = useState<RenderItem | null>(null);
|
||||
// If admin explicitly disabled, respect it. Otherwise default to ON when there are manual videos configured
|
||||
// or when a YouTube URL is present for auto mode.
|
||||
const hasManualConfigured = Boolean((settings as any)?.videos_items?.length || (settings as any)?.videos?.length);
|
||||
const hasAutoConfigured = Boolean((settings as any)?.youtube_url || (settings as any)?.social_youtube);
|
||||
// Default enablement: if not explicitly set, enable when manual items exist or when a YouTube URL is configured (auto mode).
|
||||
// This avoids flicker caused by toggling visibility while data is loading.
|
||||
const enabled = (typeof (settings as any)?.videos_module_enabled === 'boolean')
|
||||
? Boolean((settings as any)?.videos_module_enabled)
|
||||
: (hasManualConfigured || ((settings?.videos_source || 'auto') === 'auto' && hasAutoConfigured));
|
||||
const style = settings?.videos_style || 'slider';
|
||||
const source = settings?.videos_source || 'auto';
|
||||
// Default to 6 items on homepage unless overridden by settings (max 12)
|
||||
const limit = Math.max(1, Math.min(12, settings?.videos_limit ?? 6));
|
||||
const youtubeUrl = (settings as any)?.youtube_url || (settings as any)?.social_youtube || null;
|
||||
|
||||
useEffect(() => {
|
||||
let canceled = false;
|
||||
const run = async () => {
|
||||
if (source !== 'auto') return;
|
||||
const payload = await getCachedYouTube();
|
||||
if (!payload) return;
|
||||
// Sort by published_date descending (safety; service should already do this)
|
||||
const vids = (payload.videos || []).slice().sort((a, b) => (Date.parse(b.published_date || '') || 0) - (Date.parse(a.published_date || '') || 0));
|
||||
if (!canceled) setYt(vids);
|
||||
};
|
||||
run();
|
||||
return () => { canceled = true; };
|
||||
}, [source]);
|
||||
|
||||
const extractVideoId = (embedUrl: string): string | undefined => {
|
||||
if (embedUrl?.includes('/embed/')) {
|
||||
return embedUrl.split('/embed/')[1]?.split('?')[0];
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const items: RenderItem[] = useMemo(() => {
|
||||
if (source === 'auto') {
|
||||
return (yt || []).slice(0, limit).map(v => ({
|
||||
key: v.video_id,
|
||||
title: v.title,
|
||||
embedUrl: toEmbed(v.video_id),
|
||||
thumbnail: v.thumbnail_url,
|
||||
date: v.published_date,
|
||||
videoId: v.video_id,
|
||||
}));
|
||||
}
|
||||
// manual fallback from settings or prop
|
||||
const manual = (settings?.videos_items || []).map((it, i) => {
|
||||
const embedUrl = toEmbed(it.url);
|
||||
return {
|
||||
key: `${i}-${it.url}`,
|
||||
title: it.title || `Video ${i+1}`,
|
||||
embedUrl,
|
||||
thumbnail: it.thumbnail_url,
|
||||
date: it.uploaded_at,
|
||||
videoId: extractVideoId(embedUrl),
|
||||
};
|
||||
});
|
||||
const legacy = (videos || settings?.videos || []).map((url, i) => {
|
||||
const embedUrl = toEmbed(url as any);
|
||||
return {
|
||||
key: `${i}-${url}`,
|
||||
title: `Video ${i+1}`,
|
||||
embedUrl,
|
||||
videoId: extractVideoId(embedUrl),
|
||||
};
|
||||
});
|
||||
return (manual.length ? manual : legacy).slice(0, limit);
|
||||
}, [source, yt, settings?.videos_items, settings?.videos, videos, limit]);
|
||||
|
||||
if (!enabled || items.length === 0) return null;
|
||||
|
||||
const handlePlayClick = (it: RenderItem) => {
|
||||
setSelectedVideo(it);
|
||||
onOpen();
|
||||
};
|
||||
|
||||
const Card: React.FC<{ it: RenderItem; idx: number }> = ({ it, idx }) => {
|
||||
const thumb = it.thumbnail || (it.videoId ? `https://i.ytimg.com/vi/${it.videoId}/hqdefault.jpg` : undefined);
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
const placeholderBg = useColorModeValue('gray.100', 'gray.700');
|
||||
const placeholderIcon = useColorModeValue('gray.400', 'gray.500');
|
||||
const videoPrimaryColor = theme.primary;
|
||||
|
||||
return (
|
||||
<Box
|
||||
bg={cardBg}
|
||||
borderRadius="xl"
|
||||
overflow="hidden"
|
||||
boxShadow="sm"
|
||||
borderWidth="2px"
|
||||
borderColor={borderColor}
|
||||
transition="all 0.3s"
|
||||
position="relative"
|
||||
sx={{
|
||||
'&:hover': {
|
||||
transform: 'translateY(-8px)',
|
||||
boxShadow: '0 20px 40px rgba(0,0,0,0.15)',
|
||||
borderColor: 'brand.primary',
|
||||
},
|
||||
'&:hover .play-overlay': {
|
||||
opacity: 1,
|
||||
},
|
||||
'&:hover .play-overlay > div': {
|
||||
transform: 'scale(1.05)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<AspectRatio ratio={16 / 9}>
|
||||
<Box position="relative" cursor="pointer" onClick={() => handlePlayClick(it)}>
|
||||
{/* Thumbnail */}
|
||||
{thumb ? (
|
||||
<Box
|
||||
as="img"
|
||||
src={thumb}
|
||||
alt={it.title}
|
||||
width="100%"
|
||||
height="100%"
|
||||
style={{ objectFit: 'cover' }}
|
||||
/>
|
||||
) : (
|
||||
<Box bg={placeholderBg} display="flex" alignItems="center" justifyContent="center">
|
||||
<Icon as={FaPlay} boxSize={12} color={placeholderIcon} />
|
||||
</Box>
|
||||
)}
|
||||
{/* Play overlay */}
|
||||
<Box
|
||||
className="play-overlay"
|
||||
position="absolute"
|
||||
inset={0}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
bg="blackAlpha.700"
|
||||
opacity={0}
|
||||
transition="opacity 0.3s ease"
|
||||
pointerEvents="none"
|
||||
>
|
||||
<Box
|
||||
bg="white"
|
||||
color="brand.primary"
|
||||
borderRadius="full"
|
||||
px={8}
|
||||
py={4}
|
||||
fontWeight="bold"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
gap={2}
|
||||
transform="scale(0.9)"
|
||||
transition="transform 0.3s cubic-bezier(0.4, 0, 0.2, 1)"
|
||||
boxShadow="0 12px 32px rgba(0,0,0,0.4)"
|
||||
>
|
||||
<Icon as={FaPlay} boxSize={5} />
|
||||
<Text fontSize="lg">Přehrát</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</AspectRatio>
|
||||
<Box p={4} borderTopWidth="2px" borderTopColor={videoPrimaryColor}>
|
||||
<VStack align="start" spacing={2}>
|
||||
<Text fontWeight="bold" fontSize="md" color={videoPrimaryColor} noOfLines={2}>
|
||||
{it.title}
|
||||
</Text>
|
||||
<HStack justify="space-between" width="100%">
|
||||
{it.date && (
|
||||
<Badge colorScheme="gray" fontSize="0.7rem">
|
||||
{new Date(it.date).toLocaleDateString('cs-CZ')}
|
||||
</Badge>
|
||||
)}
|
||||
{it.videoId && (
|
||||
<Link href={`https://www.youtube.com/watch?v=${it.videoId}`} isExternal onClick={(e) => e.stopPropagation()}>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
colorScheme="red"
|
||||
leftIcon={<Icon as={FaYoutube} />}
|
||||
>
|
||||
YouTube
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
if (style === 'slider') {
|
||||
return (
|
||||
<Box>
|
||||
<Box className="section-head" style={{ marginTop: 8, marginBottom: 16 }}>
|
||||
<HStack spacing={3}>
|
||||
<Heading as="h3" size="lg" fontWeight="700">Videa</Heading>
|
||||
</HStack>
|
||||
<Link as={RouterLink} to="/videa">
|
||||
<Button
|
||||
size="md"
|
||||
variant="solid"
|
||||
bg={theme.primary}
|
||||
color="white"
|
||||
rightIcon={<Box as="span">→</Box>}
|
||||
_hover={{ opacity: 0.9, transform: 'translateX(4px)' }}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
Více videí
|
||||
</Button>
|
||||
</Link>
|
||||
</Box>
|
||||
<HorizontalScroller draggable>
|
||||
{items.map((it, idx) => (
|
||||
<Box
|
||||
key={it.key}
|
||||
minW={{ base: '85%', md: '60%', lg: '33%' }}
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
>
|
||||
<Card it={it} idx={idx} />
|
||||
</Box>
|
||||
))}
|
||||
</HorizontalScroller>
|
||||
|
||||
{/* Video Modal */}
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="6xl" isCentered>
|
||||
<ModalOverlay bg="blackAlpha.800" />
|
||||
<ModalContent bg="transparent" boxShadow="none" maxW="90vw">
|
||||
<ModalCloseButton color="white" size="lg" bg="blackAlpha.600" _hover={{ bg: 'blackAlpha.700' }} borderRadius="full" zIndex={2} />
|
||||
<ModalBody p={0}>
|
||||
{selectedVideo && (
|
||||
<AspectRatio ratio={16 / 9} maxH="90vh">
|
||||
<iframe
|
||||
src={`${selectedVideo.embedUrl}?autoplay=1&vq=hd1080&rel=0&modestbranding=1&playsinline=1`}
|
||||
title={selectedVideo.title}
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowFullScreen
|
||||
referrerPolicy="strict-origin-when-cross-origin"
|
||||
style={{ borderRadius: '8px' }}
|
||||
/>
|
||||
</AspectRatio>
|
||||
)}
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const cols = style === 'grid3' ? { base: 1, md: 3 } : { base: 1, md: 2, lg: 3 };
|
||||
return (
|
||||
<Box>
|
||||
<Box className="section-head">
|
||||
<Heading as="h3" size="md">Videa</Heading>
|
||||
<Link as={RouterLink} to="/videa">
|
||||
<Button size="sm" variant="outline" colorScheme="blue">Více videí</Button>
|
||||
</Link>
|
||||
</Box>
|
||||
<SimpleGrid columns={cols} spacing={4}>
|
||||
{items.map((it, idx) => (
|
||||
<Card key={it.key} it={it} idx={idx} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
{/* Video Modal */}
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="6xl" isCentered>
|
||||
<ModalOverlay bg="blackAlpha.800" />
|
||||
<ModalContent bg="transparent" boxShadow="none" maxW="90vw">
|
||||
<ModalCloseButton color="white" size="lg" bg="blackAlpha.600" _hover={{ bg: 'blackAlpha.700' }} borderRadius="full" zIndex={2} />
|
||||
<ModalBody p={0}>
|
||||
{selectedVideo && (
|
||||
<AspectRatio ratio={16 / 9} maxH="90vh">
|
||||
<iframe
|
||||
src={`${selectedVideo.embedUrl}?autoplay=1&vq=hd1080&rel=0&modestbranding=1&playsinline=1`}
|
||||
title={selectedVideo.title}
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowFullScreen
|
||||
referrerPolicy="strict-origin-when-cross-origin"
|
||||
style={{ borderRadius: '8px' }}
|
||||
/>
|
||||
</AspectRatio>
|
||||
)}
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default VideosSection;
|
||||
Reference in New Issue
Block a user