feat: enhance blog functionality with pagination, tag filtering, and improved API integration
All checks were successful
CI - Build and Push / Build and Push Docker Image (push) Successful in 17s
All checks were successful
CI - Build and Push / Build and Push Docker Image (push) Successful in 17s
This commit is contained in:
@@ -1,500 +1,522 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Link } from 'react-router-dom'
|
||||
import Layout from '../components/Layout'
|
||||
import '../App.css'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import Layout from '../components/Layout';
|
||||
import { listBlogPosts, listBlogTags } from '../blog/api';
|
||||
import type { BlogPostSummary, BlogTag } from '../blog/types';
|
||||
import '../App.css';
|
||||
|
||||
interface BlogPost {
|
||||
id: number
|
||||
title: string
|
||||
excerpt: string
|
||||
content: string
|
||||
author: string
|
||||
date: string
|
||||
readTime: string
|
||||
category: string
|
||||
tags: string[]
|
||||
image?: string
|
||||
const PAGE_SIZE = 6;
|
||||
|
||||
function formatDate(timestamp: number): string {
|
||||
if (!timestamp) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const millis = timestamp < 1_000_000_000_000 ? timestamp * 1000 : timestamp;
|
||||
return new Date(millis).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
function Blog() {
|
||||
const { t } = useTranslation()
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Mock blog posts data
|
||||
const blogPosts: BlogPost[] = [
|
||||
{
|
||||
id: 1,
|
||||
title: "Mastering Counter-Strike: Advanced Tactics and Strategies",
|
||||
excerpt: "Learn the advanced tactics that separate professional players from casual gamers. From positioning to communication, discover the secrets of competitive CS.",
|
||||
content: "Full article content here...",
|
||||
author: "ProGamer99",
|
||||
date: "2025-10-01",
|
||||
readTime: "8 min read",
|
||||
category: "Strategy",
|
||||
tags: ["Counter-Strike", "Tactics", "Professional"],
|
||||
image: "🎯"
|
||||
const [tags, setTags] = useState<BlogTag[]>([]);
|
||||
const [isLoadingTags, setIsLoadingTags] = useState(false);
|
||||
const [tagsError, setTagsError] = useState<string | null>(null);
|
||||
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
|
||||
const [posts, setPosts] = useState<BlogPostSummary[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [isLoadingPosts, setIsLoadingPosts] = useState(false);
|
||||
const [postError, setPostError] = useState<string | null>(null);
|
||||
|
||||
const isInitialLoading = isLoadingPosts && posts.length === 0;
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
(async () => {
|
||||
setIsLoadingTags(true);
|
||||
setTagsError(null);
|
||||
try {
|
||||
const response = await listBlogTags();
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
setTags(response.tags ?? []);
|
||||
} catch (error) {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
const message = error instanceof Error ? error.message : 'Failed to load tags';
|
||||
setTagsError(message);
|
||||
console.error('Failed to load blog tags:', error);
|
||||
} finally {
|
||||
if (active) {
|
||||
setIsLoadingTags(false);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const fetchPosts = useCallback(
|
||||
async (pageToLoad: number, replace = false) => {
|
||||
setIsLoadingPosts(true);
|
||||
setPostError(null);
|
||||
|
||||
if (replace) {
|
||||
setPosts([]);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await listBlogPosts({
|
||||
page: pageToLoad,
|
||||
pageSize: PAGE_SIZE,
|
||||
tagIds: selectedTags,
|
||||
});
|
||||
|
||||
const total = response.totalCount ?? response.posts.length;
|
||||
setPage(pageToLoad);
|
||||
|
||||
setPosts((previous) => {
|
||||
const updated = replace ? response.posts : [...previous, ...response.posts];
|
||||
setHasMore(updated.length < total);
|
||||
return updated;
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to load posts';
|
||||
setPostError(message);
|
||||
console.error('Failed to load blog posts:', error);
|
||||
} finally {
|
||||
setIsLoadingPosts(false);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: "The Evolution of Esports: From LAN Parties to Global Tournaments",
|
||||
excerpt: "Explore how esports has grown from small local tournaments to billion-dollar industry with global audiences and professional athletes.",
|
||||
content: "Full article content here...",
|
||||
author: "EsportsAnalyst",
|
||||
date: "2025-09-28",
|
||||
readTime: "6 min read",
|
||||
category: "Esports",
|
||||
tags: ["Esports", "History", "Tournaments"],
|
||||
image: "🏆"
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: "Building the Perfect Gaming Setup: Hardware Guide 2025",
|
||||
excerpt: "A comprehensive guide to building the ultimate gaming setup for Counter-Strike and other competitive games. From monitors to peripherals.",
|
||||
content: "Full article content here...",
|
||||
author: "TechReviewer",
|
||||
date: "2025-09-25",
|
||||
readTime: "12 min read",
|
||||
category: "Hardware",
|
||||
tags: ["Gaming Setup", "Hardware", "PC Building"],
|
||||
image: "🖥️"
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: "Community Spotlight: Rising Stars in CS Competitive Scene",
|
||||
excerpt: "Meet the up-and-coming players who are making waves in the Counter-Strike competitive scene. Their stories, strategies, and paths to success.",
|
||||
content: "Full article content here...",
|
||||
author: "CommunityManager",
|
||||
date: "2025-09-22",
|
||||
readTime: "10 min read",
|
||||
category: "Community",
|
||||
tags: ["Players", "Community", "Rising Stars"],
|
||||
image: "⭐"
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: "Mental Health in Competitive Gaming: Staying Sharp",
|
||||
excerpt: "The importance of mental health in competitive gaming. Tips and strategies for maintaining focus, managing stress, and performing at your best.",
|
||||
content: "Full article content here...",
|
||||
author: "SportsPsychologist",
|
||||
date: "2025-09-20",
|
||||
readTime: "7 min read",
|
||||
category: "Wellness",
|
||||
tags: ["Mental Health", "Gaming", "Performance"],
|
||||
image: "🧠"
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
title: "Server Administration: Running Your Own CS Game Server",
|
||||
excerpt: "A complete guide to setting up and managing your own Counter-Strike game server. From basic setup to advanced configuration and maintenance.",
|
||||
content: "Full article content here...",
|
||||
author: "ServerAdmin",
|
||||
date: "2025-09-18",
|
||||
readTime: "15 min read",
|
||||
category: "Technical",
|
||||
tags: ["Server", "Administration", "Technical"],
|
||||
image: "🖧"
|
||||
[selectedTags]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPosts(1, true);
|
||||
}, [fetchPosts]);
|
||||
|
||||
const featuredPost = useMemo(() => (posts.length > 0 ? posts[0] : null), [posts]);
|
||||
const otherPosts = useMemo(() => (featuredPost ? posts.slice(1) : posts), [posts, featuredPost]);
|
||||
|
||||
const handleLoadMore = () => {
|
||||
if (isLoadingPosts || !hasMore) {
|
||||
return;
|
||||
}
|
||||
]
|
||||
fetchPosts(page + 1, false);
|
||||
};
|
||||
|
||||
const categories = [...new Set(blogPosts.map(post => post.category))]
|
||||
const featuredPost = blogPosts[0]
|
||||
const recentPosts = blogPosts.slice(1, 4)
|
||||
const toggleTag = (tagId: string) => {
|
||||
setSelectedTags((current) => {
|
||||
if (current.includes(tagId)) {
|
||||
return current.filter((id) => id !== tagId);
|
||||
}
|
||||
return [...current, tagId];
|
||||
});
|
||||
};
|
||||
|
||||
const clearTags = () => {
|
||||
setSelectedTags([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout currentPage="blog">
|
||||
{/* Blog Page Header */}
|
||||
<section className="blog-header" style={{
|
||||
padding: '120px 2rem 60px',
|
||||
background: 'linear-gradient(135deg, var(--bg-primary) 0%, var(--bg-secondary) 100%)',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div style={{ maxWidth: '800px', margin: '0 auto' }}>
|
||||
<h1 style={{
|
||||
fontSize: '3.5rem',
|
||||
fontWeight: 'bold',
|
||||
color: 'var(--text-primary)',
|
||||
marginBottom: '1rem'
|
||||
}}>
|
||||
{t('blog.title')}
|
||||
</h1>
|
||||
<p style={{
|
||||
fontSize: '1.3rem',
|
||||
color: 'var(--text-secondary)',
|
||||
lineHeight: '1.6',
|
||||
marginBottom: '2rem'
|
||||
}}>
|
||||
{t('blog.subtitle')}
|
||||
</p>
|
||||
{/* Create Post Button */}
|
||||
<Link
|
||||
to="/blog/create"
|
||||
<section
|
||||
style={{
|
||||
padding: '120px 2rem 60px',
|
||||
background: 'linear-gradient(135deg, var(--bg-primary) 0%, var(--bg-secondary) 100%)',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<div style={{ maxWidth: '860px', margin: '0 auto' }}>
|
||||
<h1
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
background: 'var(--accent-color)',
|
||||
color: 'white',
|
||||
padding: '1rem 2rem',
|
||||
borderRadius: '8px',
|
||||
textDecoration: 'none',
|
||||
fontSize: '3.25rem',
|
||||
fontWeight: 'bold',
|
||||
fontSize: '1.1rem',
|
||||
transition: 'transform 0.3s ease, box-shadow 0.3s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-2px)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px var(--accent-shadow)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
color: 'var(--text-primary)',
|
||||
marginBottom: '1rem',
|
||||
}}
|
||||
>
|
||||
✍️ Create New Post
|
||||
</Link>
|
||||
{t('blog.title')}
|
||||
</h1>
|
||||
<p
|
||||
style={{
|
||||
fontSize: '1.2rem',
|
||||
color: 'var(--text-secondary)',
|
||||
lineHeight: 1.6,
|
||||
marginBottom: '2.5rem',
|
||||
}}
|
||||
>
|
||||
{t('blog.subtitle')}
|
||||
</p>
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: '1rem', flexWrap: 'wrap' }}>
|
||||
<Link
|
||||
to="/blog/create"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
background: 'var(--accent-color)',
|
||||
color: 'white',
|
||||
padding: '0.9rem 1.8rem',
|
||||
borderRadius: '999px',
|
||||
fontWeight: 600,
|
||||
textDecoration: 'none',
|
||||
transition: 'transform 0.3s ease, box-shadow 0.3s ease',
|
||||
}}
|
||||
onMouseEnter={(event) => {
|
||||
event.currentTarget.style.transform = 'translateY(-2px)';
|
||||
event.currentTarget.style.boxShadow = '0 6px 18px var(--accent-shadow)';
|
||||
}}
|
||||
onMouseLeave={(event) => {
|
||||
event.currentTarget.style.transform = 'translateY(0)';
|
||||
event.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
>
|
||||
{t('blog.readMore')}
|
||||
</Link>
|
||||
{selectedTags.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearTags}
|
||||
style={{
|
||||
padding: '0.9rem 1.8rem',
|
||||
borderRadius: '999px',
|
||||
border: '1px solid var(--border-color)',
|
||||
background: 'var(--bg-card)',
|
||||
color: 'var(--text-secondary)',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Featured Post */}
|
||||
<section className="featured-post" style={{ padding: '0 2rem 60px' }}>
|
||||
<section style={{ padding: '40px 2rem 20px' }}>
|
||||
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
|
||||
<h2 style={{
|
||||
fontSize: '2rem',
|
||||
color: 'var(--text-primary)',
|
||||
marginBottom: '2rem',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
{t('blog.featuredPost')}
|
||||
</h2>
|
||||
<div style={{
|
||||
background: 'var(--bg-card)',
|
||||
border: '1px solid var(--border-color)',
|
||||
borderRadius: '12px',
|
||||
overflow: 'hidden',
|
||||
boxShadow: '0 4px 20px var(--shadow)',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr',
|
||||
gap: '2rem',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<div style={{ padding: '3rem' }}>
|
||||
<div style={{
|
||||
display: 'inline-block',
|
||||
background: 'var(--accent-color)',
|
||||
color: 'white',
|
||||
padding: '0.5rem 1rem',
|
||||
borderRadius: '20px',
|
||||
fontSize: '0.9rem',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '1rem'
|
||||
}}>
|
||||
{featuredPost.category}
|
||||
</div>
|
||||
<h3 style={{
|
||||
fontSize: '2rem',
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}>
|
||||
<h2
|
||||
style={{
|
||||
fontSize: '1.5rem',
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-primary)',
|
||||
marginBottom: '1rem',
|
||||
lineHeight: '1.3'
|
||||
}}>
|
||||
{featuredPost.title}
|
||||
</h3>
|
||||
<p style={{
|
||||
color: 'var(--text-secondary)',
|
||||
fontSize: '1.1rem',
|
||||
lineHeight: '1.6',
|
||||
marginBottom: '1.5rem'
|
||||
}}>
|
||||
{featuredPost.excerpt}
|
||||
</p>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '1rem',
|
||||
marginBottom: '1.5rem'
|
||||
}}>
|
||||
<span style={{ color: 'var(--text-secondary)', fontSize: '0.9rem' }}>
|
||||
{t('blog.by')} {featuredPost.author}
|
||||
</span>
|
||||
<span style={{ color: 'var(--text-secondary)', fontSize: '0.9rem' }}>
|
||||
{featuredPost.date}
|
||||
</span>
|
||||
<span style={{ color: 'var(--text-secondary)', fontSize: '0.9rem' }}>
|
||||
{featuredPost.readTime}
|
||||
</span>
|
||||
</div>
|
||||
<Link
|
||||
to={`/blog/${featuredPost.id}`}
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
background: 'var(--accent-color)',
|
||||
color: 'white',
|
||||
padding: '1rem 2rem',
|
||||
borderRadius: '8px',
|
||||
textDecoration: 'none',
|
||||
fontWeight: 'bold',
|
||||
fontSize: '1rem',
|
||||
transition: 'transform 0.3s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-2px)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)'
|
||||
}}
|
||||
>
|
||||
{t('blog.readMore')}
|
||||
</Link>
|
||||
</div>
|
||||
<div style={{
|
||||
background: 'var(--bg-secondary)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '8rem',
|
||||
height: '300px'
|
||||
}}>
|
||||
{featuredPost.image}
|
||||
gap: '0.6rem',
|
||||
}}
|
||||
>
|
||||
Tags
|
||||
</h2>
|
||||
{isLoadingTags && <span style={{ color: 'var(--text-secondary)', fontSize: '0.95rem' }}>Loading...</span>}
|
||||
</div>
|
||||
{tagsError && (
|
||||
<div
|
||||
style={{
|
||||
background: 'rgba(244, 67, 54, 0.1)',
|
||||
border: '1px solid rgba(244, 67, 54, 0.3)',
|
||||
padding: '1rem 1.25rem',
|
||||
borderRadius: '10px',
|
||||
color: '#F44336',
|
||||
marginBottom: '1.5rem',
|
||||
}}
|
||||
>
|
||||
{tagsError}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.75rem' }}>
|
||||
{tags.map((tag) => {
|
||||
const isSelected = selectedTags.includes(tag.tagId);
|
||||
return (
|
||||
<button
|
||||
key={tag.tagId}
|
||||
type="button"
|
||||
onClick={() => toggleTag(tag.tagId)}
|
||||
style={{
|
||||
padding: '0.55rem 1.25rem',
|
||||
borderRadius: '999px',
|
||||
border: isSelected ? 'none' : '1px solid var(--border-color)',
|
||||
background: isSelected ? 'var(--accent-color)' : 'var(--bg-card)',
|
||||
boxShadow: isSelected ? '0 8px 18px rgba(76, 175, 80, 0.25)' : 'none',
|
||||
color: isSelected ? '#fff' : 'var(--text-secondary)',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.6rem',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
>
|
||||
<span>{tag.tagName}</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '0.8rem',
|
||||
padding: '0.15rem 0.45rem',
|
||||
borderRadius: '999px',
|
||||
background: isSelected ? 'rgba(255, 255, 255, 0.25)' : 'var(--bg-secondary)',
|
||||
color: isSelected ? '#fff' : 'var(--text-secondary)',
|
||||
}}
|
||||
>
|
||||
{tag.postCount}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{!isLoadingTags && tags.length === 0 && !tagsError && (
|
||||
<span style={{ color: 'var(--text-secondary)' }}>No tags available yet.</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Blog Content */}
|
||||
<section className="blog-content" style={{ padding: '0 2rem 80px' }}>
|
||||
<div style={{ maxWidth: '1200px', margin: '0 auto', display: 'grid', gridTemplateColumns: '1fr 300px', gap: '3rem' }}>
|
||||
<section style={{ padding: '20px 2rem 80px' }}>
|
||||
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
|
||||
{postError && (
|
||||
<div
|
||||
style={{
|
||||
background: 'rgba(244, 67, 54, 0.1)',
|
||||
border: '1px solid rgba(244, 67, 54, 0.3)',
|
||||
padding: '1rem 1.25rem',
|
||||
borderRadius: '10px',
|
||||
color: '#F44336',
|
||||
marginBottom: '2rem',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: '1rem',
|
||||
}}
|
||||
>
|
||||
<span>{postError}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fetchPosts(page || 1, posts.length === 0)}
|
||||
style={{
|
||||
padding: '0.5rem 1rem',
|
||||
borderRadius: '8px',
|
||||
border: 'none',
|
||||
background: 'var(--accent-color)',
|
||||
color: '#fff',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Content */}
|
||||
<div>
|
||||
<h2 style={{
|
||||
fontSize: '2rem',
|
||||
color: 'var(--text-primary)',
|
||||
marginBottom: '2rem'
|
||||
}}>
|
||||
{t('blog.recentPosts')}
|
||||
</h2>
|
||||
{featuredPost && (
|
||||
<article
|
||||
style={{
|
||||
background: 'var(--bg-card)',
|
||||
border: '1px solid var(--border-color)',
|
||||
borderRadius: '16px',
|
||||
overflow: 'hidden',
|
||||
marginBottom: '3rem',
|
||||
boxShadow: '0 12px 32px rgba(0, 0, 0, 0.12)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0', alignItems: 'stretch' }}>
|
||||
<div style={{ padding: '3rem 3rem 3rem 3.5rem' }}>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.4rem',
|
||||
background: 'rgba(76, 175, 80, 0.14)',
|
||||
color: 'var(--accent-color)',
|
||||
padding: '0.4rem 0.9rem',
|
||||
borderRadius: '999px',
|
||||
fontSize: '0.85rem',
|
||||
fontWeight: 600,
|
||||
marginBottom: '1rem',
|
||||
}}
|
||||
>
|
||||
Featured
|
||||
</span>
|
||||
<h2
|
||||
style={{
|
||||
fontSize: '2.2rem',
|
||||
fontWeight: 700,
|
||||
color: 'var(--text-primary)',
|
||||
lineHeight: 1.3,
|
||||
marginBottom: '1.4rem',
|
||||
}}
|
||||
>
|
||||
{featuredPost.title}
|
||||
</h2>
|
||||
<div style={{ display: 'flex', gap: '1.2rem', color: 'var(--text-secondary)', fontSize: '0.95rem', marginBottom: '1.5rem' }}>
|
||||
<span>{formatDate(featuredPost.createdAt)}</span>
|
||||
<span>Updated {formatDate(featuredPost.updatedAt)}</span>
|
||||
</div>
|
||||
<Link
|
||||
to={`/blog/${featuredPost.postId}`}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
padding: '0.85rem 1.6rem',
|
||||
background: 'var(--accent-color)',
|
||||
color: '#fff',
|
||||
borderRadius: '10px',
|
||||
fontWeight: 600,
|
||||
textDecoration: 'none',
|
||||
transition: 'transform 0.3s ease',
|
||||
}}
|
||||
onMouseEnter={(event) => {
|
||||
event.currentTarget.style.transform = 'translateY(-2px)';
|
||||
}}
|
||||
onMouseLeave={(event) => {
|
||||
event.currentTarget.style.transform = 'translateY(0)';
|
||||
}}
|
||||
>
|
||||
{'Read article ->'}
|
||||
</Link>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
minHeight: '320px',
|
||||
background: featuredPost.coverImageUrl
|
||||
? `linear-gradient(180deg, rgba(0,0,0,0.2), rgba(0,0,0,0.55)), url(${featuredPost.coverImageUrl})`
|
||||
: 'linear-gradient(135deg, rgba(90, 140, 255, 0.25), rgba(90, 140, 255, 0.07))',
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'grid', gap: '2rem' }}>
|
||||
{recentPosts.map(post => (
|
||||
<article key={post.id} style={{
|
||||
{!isInitialLoading && posts.length === 0 && !postError && (
|
||||
<div
|
||||
style={{
|
||||
padding: '4rem 2rem',
|
||||
textAlign: 'center',
|
||||
border: '1px dashed var(--border-color)',
|
||||
borderRadius: '16px',
|
||||
background: 'var(--bg-card)',
|
||||
color: 'var(--text-secondary)',
|
||||
}}
|
||||
>
|
||||
No posts yet. Be the first to share something with the community!
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'grid', gap: '2.5rem', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))' }}>
|
||||
{otherPosts.map((post) => (
|
||||
<article
|
||||
key={post.postId}
|
||||
style={{
|
||||
background: 'var(--bg-card)',
|
||||
border: '1px solid var(--border-color)',
|
||||
borderRadius: '12px',
|
||||
borderRadius: '14px',
|
||||
overflow: 'hidden',
|
||||
transition: 'transform 0.3s ease, box-shadow 0.3s ease',
|
||||
cursor: 'pointer'
|
||||
boxShadow: '0 6px 20px rgba(0, 0, 0, 0.12)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-5px)'
|
||||
e.currentTarget.style.boxShadow = '0 10px 30px var(--accent-shadow)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)'
|
||||
e.currentTarget.style.boxShadow = 'none'
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '200px 1fr', gap: '0' }}>
|
||||
<div style={{
|
||||
background: 'var(--bg-secondary)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '4rem'
|
||||
}}>
|
||||
{post.image}
|
||||
</div>
|
||||
<div style={{ padding: '2rem' }}>
|
||||
<div style={{
|
||||
display: 'inline-block',
|
||||
background: 'var(--accent-color)',
|
||||
color: 'white',
|
||||
padding: '0.3rem 0.8rem',
|
||||
borderRadius: '15px',
|
||||
fontSize: '0.8rem',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '1rem'
|
||||
}}>
|
||||
{post.category}
|
||||
</div>
|
||||
<h3 style={{
|
||||
fontSize: '1.5rem',
|
||||
color: 'var(--text-primary)',
|
||||
marginBottom: '1rem',
|
||||
lineHeight: '1.3'
|
||||
}}>
|
||||
{post.title}
|
||||
</h3>
|
||||
<p style={{
|
||||
color: 'var(--text-secondary)',
|
||||
fontSize: '1rem',
|
||||
lineHeight: '1.6',
|
||||
marginBottom: '1rem'
|
||||
}}>
|
||||
{post.excerpt}
|
||||
</p>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '1rem',
|
||||
marginBottom: '1rem'
|
||||
}}>
|
||||
<span style={{ color: 'var(--text-secondary)', fontSize: '0.9rem' }}>
|
||||
{t('blog.by')} {post.author}
|
||||
</span>
|
||||
<span style={{ color: 'var(--text-secondary)', fontSize: '0.9rem' }}>
|
||||
{post.date}
|
||||
</span>
|
||||
<span style={{ color: 'var(--text-secondary)', fontSize: '0.9rem' }}>
|
||||
{post.readTime}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
{post.tags.map(tag => (
|
||||
<span key={tag} style={{
|
||||
background: 'var(--bg-secondary)',
|
||||
color: 'var(--text-secondary)',
|
||||
padding: '0.2rem 0.6rem',
|
||||
borderRadius: '12px',
|
||||
fontSize: '0.8rem'
|
||||
}}>
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Load More Button */}
|
||||
<div style={{ textAlign: 'center', marginTop: '3rem' }}>
|
||||
<button style={{
|
||||
background: 'var(--accent-color)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
padding: '1rem 2rem',
|
||||
borderRadius: '8px',
|
||||
fontSize: '1rem',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
transition: 'transform 0.3s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-2px)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)'
|
||||
}}
|
||||
>
|
||||
{t('blog.loadMore')}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
height: '180px',
|
||||
background: post.coverImageUrl
|
||||
? `linear-gradient(180deg, rgba(0,0,0,0.15), rgba(0,0,0,0.45)), url(${post.coverImageUrl})`
|
||||
: 'linear-gradient(135deg, rgba(76, 175, 80, 0.18), rgba(76, 175, 80, 0.05))',
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
}}
|
||||
/>
|
||||
<div style={{ padding: '1.75rem', display: 'flex', flexDirection: 'column', flex: 1 }}>
|
||||
<h3
|
||||
style={{
|
||||
fontSize: '1.3rem',
|
||||
fontWeight: 700,
|
||||
color: 'var(--text-primary)',
|
||||
marginBottom: '0.9rem',
|
||||
}}
|
||||
>
|
||||
{post.title}
|
||||
</h3>
|
||||
<div style={{ color: 'var(--text-secondary)', fontSize: '0.95rem', marginBottom: '1.4rem' }}>
|
||||
Published {formatDate(post.createdAt)}
|
||||
</div>
|
||||
<div style={{ marginTop: 'auto' }}>
|
||||
<Link
|
||||
to={`/blog/${post.postId}`}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.4rem',
|
||||
color: 'var(--accent-color)',
|
||||
fontWeight: 600,
|
||||
textDecoration: 'none',
|
||||
}}
|
||||
>
|
||||
{'Read more ->'}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside>
|
||||
{/* Categories */}
|
||||
<div style={{
|
||||
background: 'var(--bg-card)',
|
||||
border: '1px solid var(--border-color)',
|
||||
borderRadius: '12px',
|
||||
padding: '2rem',
|
||||
marginBottom: '2rem'
|
||||
}}>
|
||||
<h3 style={{
|
||||
color: 'var(--text-primary)',
|
||||
marginBottom: '1rem',
|
||||
fontSize: '1.3rem'
|
||||
}}>
|
||||
{t('blog.categories')}
|
||||
</h3>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||
{categories.map(category => (
|
||||
<button key={category} style={{
|
||||
background: 'var(--bg-secondary)',
|
||||
color: 'var(--text-primary)',
|
||||
border: 'none',
|
||||
padding: '0.75rem 1rem',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
transition: 'background 0.3s ease'
|
||||
{isInitialLoading && (
|
||||
<div style={{ display: 'grid', gap: '2rem', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))' }}>
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
height: '240px',
|
||||
borderRadius: '14px',
|
||||
background: 'linear-gradient(135deg, rgba(255,255,255,0.04), rgba(255,255,255,0.08))',
|
||||
border: '1px solid var(--border-color)',
|
||||
animation: 'pulse 1.5s ease-in-out infinite',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'var(--accent-color)'
|
||||
e.currentTarget.style.color = 'white'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'var(--bg-secondary)'
|
||||
e.currentTarget.style.color = 'var(--text-primary)'
|
||||
}}
|
||||
>
|
||||
{category}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Newsletter Signup */}
|
||||
<div style={{
|
||||
background: 'var(--bg-card)',
|
||||
border: '1px solid var(--border-color)',
|
||||
borderRadius: '12px',
|
||||
padding: '2rem',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<h3 style={{
|
||||
color: 'var(--text-primary)',
|
||||
marginBottom: '1rem',
|
||||
fontSize: '1.3rem'
|
||||
}}>
|
||||
{t('blog.subscribe')}
|
||||
</h3>
|
||||
<p style={{
|
||||
color: 'var(--text-secondary)',
|
||||
marginBottom: '1.5rem',
|
||||
fontSize: '0.9rem'
|
||||
}}>
|
||||
{t('blog.subscribeDesc')}
|
||||
</p>
|
||||
<input
|
||||
type="email"
|
||||
placeholder={t('blog.emailPlaceholder')}
|
||||
{hasMore && (
|
||||
<div style={{ textAlign: 'center', marginTop: '3rem' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLoadMore}
|
||||
disabled={isLoadingPosts}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '0.75rem',
|
||||
border: '1px solid var(--border-color)',
|
||||
borderRadius: '6px',
|
||||
background: 'var(--bg-secondary)',
|
||||
color: 'var(--text-primary)',
|
||||
marginBottom: '1rem',
|
||||
fontSize: '0.9rem'
|
||||
padding: '1rem 2.5rem',
|
||||
borderRadius: '999px',
|
||||
border: 'none',
|
||||
background: isLoadingPosts ? 'var(--bg-secondary)' : 'var(--accent-color)',
|
||||
color: '#fff',
|
||||
fontWeight: 600,
|
||||
cursor: isLoadingPosts ? 'wait' : 'pointer',
|
||||
transition: 'transform 0.2s ease',
|
||||
}}
|
||||
/>
|
||||
<button style={{
|
||||
width: '100%',
|
||||
background: 'var(--accent-color)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
padding: '0.75rem',
|
||||
borderRadius: '6px',
|
||||
fontSize: '0.9rem',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
transition: 'transform 0.3s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-2px)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)'
|
||||
}}
|
||||
>
|
||||
{t('blog.subscribeBtn')}
|
||||
{isLoadingPosts ? 'Loading...' : t('blog.loadMore')}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</Layout>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export default Blog
|
||||
export default Blog;
|
||||
|
||||
Reference in New Issue
Block a user