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,216 @@
|
||||
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 } 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(
|
||||
['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;
|
||||
Reference in New Issue
Block a user