mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 02:32:57 +00:00
223 lines
7.1 KiB
TypeScript
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;
|