Files
MyClub/frontend/src/pages/BlogPage.tsx
T
2025-10-24 18:15:36 +02:00

223 lines
7.1 KiB
TypeScript

import React from 'react';
import { Box, Container, Heading, VStack, Image, Text, Skeleton, LinkBox, HStack, Select, Badge, useColorModeValue } from '@chakra-ui/react';
import { useInfiniteQuery } from '@tanstack/react-query';
import { getArticles, Article, Paginated } from '../services/articles';
import { Link as RouterLink } from 'react-router-dom';
import { assetUrl } from '../utils/url';
import MainLayout from '../components/layout/MainLayout';
import { getCategories, CategoryItem } from '../services/categories';
import SponsorsSection from '../components/common/SponsorsSection';
import NewsletterCTA from '../components/common/NewsletterCTA';
import { Eye, Clock } from 'lucide-react';
const BlogTile: React.FC<{ article: Article }> = ({ article }) => {
const link = article.slug ? `/news/${article.slug}` : `/articles/${article.id}`;
const readTime = article.read_time || article.estimated_read_minutes;
const viewCount = article.view_count;
const bgColor = useColorModeValue('white', 'gray.800');
return (
<LinkBox
as={RouterLink}
to={link}
borderRadius="md"
overflow="hidden"
bg={bgColor}
borderWidth="0"
_hover={{ boxShadow: 'xl', transform: 'translateY(-3px)' }}
transition="all 0.25s ease"
>
<Box position="relative">
<Image src={assetUrl(article.image_url) || '/stadium-placeholder.jpg'} alt={article.title} w="100%" h={{ base: '200px', md: '220px' }} objectFit="cover" />
<Box position="absolute" inset={0} bgGradient="linear(to-t, rgba(0,0,0,0.55), rgba(0,0,0,0.15))" />
{/* Stats badges at top */}
{(readTime || (viewCount && viewCount > 0)) && (
<HStack position="absolute" top={2} right={2} spacing={1}>
{readTime && (
<Badge
display="flex"
alignItems="center"
gap={1}
bg="rgba(0,0,0,0.7)"
color="white"
fontSize="xs"
px={2}
py={1}
borderRadius="md"
>
<Clock size={12} />
{readTime} min
</Badge>
)}
{viewCount && viewCount > 0 && (
<Badge
display="flex"
alignItems="center"
gap={1}
bg="rgba(0,0,0,0.7)"
color="white"
fontSize="xs"
px={2}
py={1}
borderRadius="md"
>
<Eye size={12} />
{viewCount}
</Badge>
)}
</HStack>
)}
<Heading
as="h3"
fontSize={{ base: 'lg', md: 'xl' }}
fontWeight="800"
letterSpacing="0.3px"
textTransform="uppercase"
position="absolute"
bottom={3}
left={4}
right={4}
color="white"
noOfLines={2}
>
{article.title}
</Heading>
</Box>
</LinkBox>
);
};
const BlogPage: React.FC = () => {
const pageSize = 18;
const [categories, setCategories] = React.useState<CategoryItem[]>([]);
const [categoryId, setCategoryId] = React.useState<number | ''>('');
const borderColor = useColorModeValue('gray.200', 'gray.700');
const textColor = useColorModeValue('gray.500', 'gray.400');
React.useEffect(() => {
(async () => {
try {
const list = await getCategories();
setCategories(list || []);
} catch {}
})();
}, []);
const {
data,
isLoading,
isFetchingNextPage,
hasNextPage,
fetchNextPage,
} = useInfiniteQuery<Paginated<Article>>(
['articles-public', { page_size: pageSize, published: true, category_id: categoryId || undefined }],
({ pageParam = 1 }) =>
getArticles({
page: pageParam,
page_size: pageSize,
published: true,
...(categoryId ? { category_id: Number(categoryId) } : {}),
}),
{
getNextPageParam: (lastPage, allPages) => {
const loaded = allPages.reduce((sum, p) => sum + (p?.data?.length || 0), 0);
if (!lastPage) return undefined;
if (loaded < (lastPage.total || 0)) return allPages.length + 1;
return undefined;
},
}
);
const articles = data?.pages?.flatMap((p) => p?.data || []) || [];
// Infinite scroll via intersection observer
const sentinelRef = React.useRef<HTMLDivElement | null>(null);
React.useEffect(() => {
if (!hasNextPage || !sentinelRef.current) return;
const el = sentinelRef.current;
const io = new IntersectionObserver((entries) => {
const first = entries[0];
if (first.isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, { rootMargin: '400px' });
io.observe(el);
return () => io.disconnect();
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
return (
<MainLayout>
<Box>
{/* Header like blog.html */}
<Box bg="transparent" color="inherit" py={{ base: 8, md: 10 }} mb={4} borderBottom="1px" borderColor={borderColor}>
<Container maxW="7xl">
<HStack justify="space-between" align="center">
<Heading as="h1" size={{ base: 'xl', md: '2xl' }}>Blog</Heading>
{!!categories.length && (
<Select
maxW={{ base: '52%', md: '320px' }}
placeholder="Všechny kategorie"
value={categoryId}
onChange={(e) => setCategoryId(e.target.value ? Number(e.target.value) : '')}
>
{categories.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</Select>
)}
</HStack>
</Container>
</Box>
<Container maxW="7xl">
{/* Masonry using CSS columns */}
<Box
sx={{
columnCount: { base: 1, sm: 2, lg: 3 } as any,
columnGap: '28px',
}}
>
{isLoading && Array.from({ length: 9 }).map((_, i) => (
<Skeleton key={i} h={{ base: '220px', md: '260px' }} borderRadius="md" mb={7} />
))}
{!isLoading && articles.map((a) => (
<Box
key={a.id}
mb={7}
sx={{
breakInside: 'avoid',
WebkitColumnBreakInside: 'avoid',
pageBreakInside: 'avoid',
}}
>
<BlogTile article={a} />
</Box>
))}
</Box>
{!isLoading && !articles.length && (
<VStack py={16}>
<Text color={textColor}>Žádné články k zobrazení.</Text>
</VStack>
)}
{/* Infinite scroll sentinel */}
<Box ref={sentinelRef} h="1px" />
{isFetchingNextPage && (
<VStack py={6}>
<Text color={textColor}>Načítání</Text>
</VStack>
)}
</Container>
{/* Newsletter CTA */}
<NewsletterCTA />
{/* Sponsors Section */}
<SponsorsSection />
</Box>
</MainLayout>
);
};
export default BlogPage;