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

This commit is contained in:
2025-10-26 16:55:11 +08:00
parent 38ca76e2fb
commit 47d6f93e97
3 changed files with 557 additions and 459 deletions

View File

@@ -2,14 +2,19 @@
* Blog API functions * Blog API functions
*/ */
import { apiRequest } from '../utils/api'; import { apiRequest, apiPost } from '../utils/api';
import { getS3Url } from './s3Config'; import { getS3Url } from './s3Config';
import type { import type {
UploadPresignedURLResponse, UploadPresignedURLResponse,
CreatePostResponse, CreatePostResponse,
ListPostsRequest,
ListPostsResponse,
ListTagsResponse,
GetPostResponse,
} from './types'; } from './types';
const API_BASE = '/api/blog'; const API_BASE = '/api/blog';
const BLOG_VIEW_BASE = `${API_BASE}/view`;
/** /**
* Get presigned URL for file upload * Get presigned URL for file upload
@@ -95,13 +100,10 @@ export async function createBlogPost(
content: string, content: string,
coverImageKey: string coverImageKey: string
): Promise<CreatePostResponse> { ): Promise<CreatePostResponse> {
const response = await apiRequest(`${API_BASE}/post/create`, { const response = await apiPost(`${API_BASE}/post/create`, {
method: 'POST',
body: JSON.stringify({
title, title,
content, content,
coverImageKey, coverImageKey,
}),
}); });
if (!response.ok) { if (!response.ok) {
@@ -110,3 +112,42 @@ export async function createBlogPost(
return response.json(); return response.json();
} }
/**
* Fetch a paginated list of blog posts
*/
export async function listBlogPosts(params: ListPostsRequest): Promise<ListPostsResponse> {
const response = await apiPost(`${BLOG_VIEW_BASE}/posts`, params);
if (!response.ok) {
throw new Error(`Failed to load posts: ${response.statusText}`);
}
return response.json();
}
/**
* Fetch detailed blog post by id
*/
export async function getBlogPost(postId: string): Promise<GetPostResponse> {
const response = await apiPost(`${BLOG_VIEW_BASE}/post`, { postId });
if (!response.ok) {
throw new Error(`Failed to load post: ${response.statusText}`);
}
return response.json();
}
/**
* Fetch available blog tags
*/
export async function listBlogTags(): Promise<ListTagsResponse> {
const response = await apiPost(`${BLOG_VIEW_BASE}/tags`, {});
if (!response.ok) {
throw new Error(`Failed to load tags: ${response.statusText}`);
}
return response.json();
}

View File

@@ -26,3 +26,38 @@ export interface ImageUploadStatus {
fileKey?: string; fileKey?: string;
url?: string; url?: string;
} }
export interface BlogPostSummary {
postId: string;
title: string;
coverImageUrl?: string;
createdAt: number;
updatedAt: number;
}
export interface BlogPost extends BlogPostSummary {
content: string;
}
export interface ListPostsRequest {
page: number;
pageSize: number;
tagIds: string[];
}
export interface ListPostsResponse {
posts: BlogPostSummary[];
totalCount: number;
}
export interface BlogTag {
tagId: string;
tagName: string;
postCount: number;
}
export interface ListTagsResponse {
tags: BlogTag[];
}
export interface GetPostResponse extends BlogPost {}

View File

@@ -1,500 +1,522 @@
import { useTranslation } from 'react-i18next' import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom' import { useTranslation } from 'react-i18next';
import Layout from '../components/Layout' import { Link } from 'react-router-dom';
import '../App.css' import Layout from '../components/Layout';
import { listBlogPosts, listBlogTags } from '../blog/api';
import type { BlogPostSummary, BlogTag } from '../blog/types';
import '../App.css';
interface BlogPost { const PAGE_SIZE = 6;
id: number
title: string function formatDate(timestamp: number): string {
excerpt: string if (!timestamp) {
content: string return '';
author: string }
date: string
readTime: string const millis = timestamp < 1_000_000_000_000 ? timestamp * 1000 : timestamp;
category: string return new Date(millis).toLocaleDateString(undefined, {
tags: string[] year: 'numeric',
image?: string month: 'short',
day: 'numeric',
});
} }
function Blog() { function Blog() {
const { t } = useTranslation() const { t } = useTranslation();
// Mock blog posts data const [tags, setTags] = useState<BlogTag[]>([]);
const blogPosts: BlogPost[] = [ const [isLoadingTags, setIsLoadingTags] = useState(false);
{ const [tagsError, setTagsError] = useState<string | null>(null);
id: 1,
title: "Mastering Counter-Strike: Advanced Tactics and Strategies", const [selectedTags, setSelectedTags] = useState<string[]>([]);
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...", const [posts, setPosts] = useState<BlogPostSummary[]>([]);
author: "ProGamer99", const [page, setPage] = useState(1);
date: "2025-10-01", const [hasMore, setHasMore] = useState(false);
readTime: "8 min read", const [isLoadingPosts, setIsLoadingPosts] = useState(false);
category: "Strategy", const [postError, setPostError] = useState<string | null>(null);
tags: ["Counter-Strike", "Tactics", "Professional"],
image: "🎯" const isInitialLoading = isLoadingPosts && posts.length === 0;
},
{ useEffect(() => {
id: 2, let active = true;
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.", (async () => {
content: "Full article content here...", setIsLoadingTags(true);
author: "EsportsAnalyst", setTagsError(null);
date: "2025-09-28", try {
readTime: "6 min read", const response = await listBlogTags();
category: "Esports", if (!active) {
tags: ["Esports", "History", "Tournaments"], return;
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: "🖧"
} }
] 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);
}
}
})();
const categories = [...new Set(blogPosts.map(post => post.category))] return () => {
const featuredPost = blogPosts[0] active = false;
const recentPosts = blogPosts.slice(1, 4) };
}, []);
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);
}
},
[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 toggleTag = (tagId: string) => {
setSelectedTags((current) => {
if (current.includes(tagId)) {
return current.filter((id) => id !== tagId);
}
return [...current, tagId];
});
};
const clearTags = () => {
setSelectedTags([]);
};
return ( return (
<Layout currentPage="blog"> <Layout currentPage="blog">
{/* Blog Page Header */} <section
<section className="blog-header" style={{ style={{
padding: '120px 2rem 60px', padding: '120px 2rem 60px',
background: 'linear-gradient(135deg, var(--bg-primary) 0%, var(--bg-secondary) 100%)', background: 'linear-gradient(135deg, var(--bg-primary) 0%, var(--bg-secondary) 100%)',
textAlign: 'center' textAlign: 'center',
}}> }}
<div style={{ maxWidth: '800px', margin: '0 auto' }}> >
<h1 style={{ <div style={{ maxWidth: '860px', margin: '0 auto' }}>
fontSize: '3.5rem', <h1
style={{
fontSize: '3.25rem',
fontWeight: 'bold', fontWeight: 'bold',
color: 'var(--text-primary)', color: 'var(--text-primary)',
marginBottom: '1rem' marginBottom: '1rem',
}}> }}
>
{t('blog.title')} {t('blog.title')}
</h1> </h1>
<p style={{ <p
fontSize: '1.3rem', style={{
fontSize: '1.2rem',
color: 'var(--text-secondary)', color: 'var(--text-secondary)',
lineHeight: '1.6', lineHeight: 1.6,
marginBottom: '2rem' marginBottom: '2.5rem',
}}> }}
>
{t('blog.subtitle')} {t('blog.subtitle')}
</p> </p>
{/* Create Post Button */} <div style={{ display: 'flex', justifyContent: 'center', gap: '1rem', flexWrap: 'wrap' }}>
<Link <Link
to="/blog/create" to="/blog/create"
style={{ style={{
display: 'inline-block', display: 'inline-flex',
background: 'var(--accent-color)',
color: 'white',
padding: '1rem 2rem',
borderRadius: '8px',
textDecoration: 'none',
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';
}}
>
Create New Post
</Link>
</div>
</section>
{/* Featured Post */}
<section className="featured-post" style={{ padding: '0 2rem 60px' }}>
<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',
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', alignItems: 'center',
gap: '1rem', gap: '0.5rem',
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)', background: 'var(--accent-color)',
color: 'white', color: 'white',
padding: '1rem 2rem', padding: '0.9rem 1.8rem',
borderRadius: '8px', borderRadius: '999px',
fontWeight: 600,
textDecoration: 'none', textDecoration: 'none',
fontWeight: 'bold', transition: 'transform 0.3s ease, box-shadow 0.3s ease',
fontSize: '1rem',
transition: 'transform 0.3s ease'
}} }}
onMouseEnter={(e) => { onMouseEnter={(event) => {
e.currentTarget.style.transform = 'translateY(-2px)' event.currentTarget.style.transform = 'translateY(-2px)';
event.currentTarget.style.boxShadow = '0 6px 18px var(--accent-shadow)';
}} }}
onMouseLeave={(e) => { onMouseLeave={(event) => {
e.currentTarget.style.transform = 'translateY(0)' event.currentTarget.style.transform = 'translateY(0)';
event.currentTarget.style.boxShadow = 'none';
}} }}
> >
{t('blog.readMore')} {t('blog.readMore')}
</Link> </Link>
</div> {selectedTags.length > 0 && (
<div style={{ <button
background: 'var(--bg-secondary)', type="button"
display: 'flex', onClick={clearTags}
alignItems: 'center', style={{
justifyContent: 'center', padding: '0.9rem 1.8rem',
fontSize: '8rem', borderRadius: '999px',
height: '300px' border: '1px solid var(--border-color)',
}}> background: 'var(--bg-card)',
{featuredPost.image} color: 'var(--text-secondary)',
</div> fontWeight: 600,
cursor: 'pointer',
}}
>
Clear filters
</button>
)}
</div> </div>
</div> </div>
</section> </section>
{/* Blog Content */} <section style={{ padding: '40px 2rem 20px' }}>
<section className="blog-content" style={{ padding: '0 2rem 80px' }}> <div style={{ maxWidth: '1200px', margin: '0 auto' }}>
<div style={{ maxWidth: '1200px', margin: '0 auto', display: 'grid', gridTemplateColumns: '1fr 300px', gap: '3rem' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}>
<h2
{/* Main Content */} style={{
<div> fontSize: '1.5rem',
<h2 style={{ fontWeight: 600,
fontSize: '2rem',
color: 'var(--text-primary)', color: 'var(--text-primary)',
marginBottom: '2rem' display: 'flex',
}}> alignItems: 'center',
{t('blog.recentPosts')} gap: '0.6rem',
</h2>
<div style={{ display: 'grid', gap: '2rem' }}>
{recentPosts.map(post => (
<article key={post.id} style={{
background: 'var(--bg-card)',
border: '1px solid var(--border-color)',
borderRadius: '12px',
overflow: 'hidden',
transition: 'transform 0.3s ease, box-shadow 0.3s ease',
cursor: 'pointer'
}}
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' }}> Tags
<div style={{ </h2>
background: 'var(--bg-secondary)', {isLoadingTags && <span style={{ color: 'var(--text-secondary)', fontSize: '0.95rem' }}>Loading...</span>}
display: 'flex', </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', alignItems: 'center',
justifyContent: 'center', gap: '0.6rem',
fontSize: '4rem' transition: 'all 0.2s ease',
}}> }}
{post.image} >
</div> <span>{tag.tagName}</span>
<div style={{ padding: '2rem' }}> <span
<div style={{ style={{
display: 'inline-block',
background: 'var(--accent-color)',
color: 'white',
padding: '0.3rem 0.8rem',
borderRadius: '15px',
fontSize: '0.8rem', fontSize: '0.8rem',
fontWeight: 'bold', padding: '0.15rem 0.45rem',
marginBottom: '1rem' borderRadius: '999px',
}}> background: isSelected ? 'rgba(255, 255, 255, 0.25)' : 'var(--bg-secondary)',
{post.category} 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>
<h3 style={{ </div>
fontSize: '1.5rem', </section>
color: 'var(--text-primary)',
marginBottom: '1rem', <section style={{ padding: '20px 2rem 80px' }}>
lineHeight: '1.3' <div style={{ maxWidth: '1200px', margin: '0 auto' }}>
}}> {postError && (
{post.title} <div
</h3> style={{
<p style={{ background: 'rgba(244, 67, 54, 0.1)',
color: 'var(--text-secondary)', border: '1px solid rgba(244, 67, 54, 0.3)',
fontSize: '1rem', padding: '1rem 1.25rem',
lineHeight: '1.6', borderRadius: '10px',
marginBottom: '1rem' color: '#F44336',
}}> marginBottom: '2rem',
{post.excerpt}
</p>
<div style={{
display: 'flex', display: 'flex',
justifyContent: 'space-between',
alignItems: 'center', alignItems: 'center',
gap: '1rem', gap: '1rem',
marginBottom: '1rem' }}
}}> >
<span style={{ color: 'var(--text-secondary)', fontSize: '0.9rem' }}> <span>{postError}</span>
{t('blog.by')} {post.author} <button
</span> type="button"
<span style={{ color: 'var(--text-secondary)', fontSize: '0.9rem' }}> onClick={() => fetchPosts(page || 1, posts.length === 0)}
{post.date} style={{
</span> padding: '0.5rem 1rem',
<span style={{ color: 'var(--text-secondary)', fontSize: '0.9rem' }}> borderRadius: '8px',
{post.readTime} border: 'none',
</span> background: 'var(--accent-color)',
color: '#fff',
fontWeight: 600,
cursor: 'pointer',
}}
>
Retry
</button>
</div> </div>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}> )}
{post.tags.map(tag => (
<span key={tag} style={{ {featuredPost && (
background: 'var(--bg-secondary)', <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>
)}
{!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)', color: 'var(--text-secondary)',
padding: '0.2rem 0.6rem', }}
borderRadius: '12px', >
fontSize: '0.8rem' No posts yet. Be the first to share something with the community!
}}>
#{tag}
</span>
))}
</div> </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: '14px',
overflow: 'hidden',
boxShadow: '0 6px 20px rgba(0, 0, 0, 0.12)',
display: 'flex',
flexDirection: 'column',
}}
>
<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>
</div> </div>
</article> </article>
))} ))}
</div> </div>
{/* Load More Button */} {isInitialLoading && (
<div style={{ textAlign: 'center', marginTop: '3rem' }}> <div style={{ display: 'grid', gap: '2rem', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))' }}>
<button style={{ {Array.from({ length: 3 }).map((_, index) => (
background: 'var(--accent-color)', <div
color: 'white', key={index}
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>
{/* 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'
}}
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')}
style={{ style={{
width: '100%', height: '240px',
padding: '0.75rem', borderRadius: '14px',
background: 'linear-gradient(135deg, rgba(255,255,255,0.04), rgba(255,255,255,0.08))',
border: '1px solid var(--border-color)', border: '1px solid var(--border-color)',
borderRadius: '6px', animation: 'pulse 1.5s ease-in-out infinite',
background: 'var(--bg-secondary)',
color: 'var(--text-primary)',
marginBottom: '1rem',
fontSize: '0.9rem'
}} }}
/> />
<button style={{ ))}
width: '100%', </div>
background: 'var(--accent-color)', )}
color: 'white',
{hasMore && (
<div style={{ textAlign: 'center', marginTop: '3rem' }}>
<button
type="button"
onClick={handleLoadMore}
disabled={isLoadingPosts}
style={{
padding: '1rem 2.5rem',
borderRadius: '999px',
border: 'none', border: 'none',
padding: '0.75rem', background: isLoadingPosts ? 'var(--bg-secondary)' : 'var(--accent-color)',
borderRadius: '6px', color: '#fff',
fontSize: '0.9rem', fontWeight: 600,
fontWeight: 'bold', cursor: isLoadingPosts ? 'wait' : 'pointer',
cursor: 'pointer', transition: 'transform 0.2s ease',
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> </button>
</div> </div>
</aside> )}
</div> </div>
</section> </section>
</Layout> </Layout>
) );
} }
export default Blog export default Blog;