feat: implement blog post and tag fetching with pagination
All checks were successful
CI - Build and Push / Build and Push Docker Image (push) Successful in 22s
All checks were successful
CI - Build and Push / Build and Push Docker Image (push) Successful in 22s
This commit is contained in:
@@ -87,6 +87,79 @@ export async function uploadImage(
|
|||||||
return { fileKey, url };
|
return { fileKey, url };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a list of blog posts with pagination
|
||||||
|
*/
|
||||||
|
export async function listBlogPosts(
|
||||||
|
page: number,
|
||||||
|
pageSize: number,
|
||||||
|
tagIds: string[] = []
|
||||||
|
): Promise<{
|
||||||
|
posts: Array<{
|
||||||
|
postId: string;
|
||||||
|
title: string;
|
||||||
|
coverImageUrl: string;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
}>;
|
||||||
|
totalCount: number;
|
||||||
|
}> {
|
||||||
|
const response = await apiRequest('/api/blog/view/posts', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ page, pageSize, tagIds }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to list posts: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single blog post by ID
|
||||||
|
*/
|
||||||
|
export async function getBlogPost(postId: string): Promise<{
|
||||||
|
postId: string;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
coverImageUrl: string;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
}> {
|
||||||
|
const response = await apiRequest('/api/blog/view/post', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ postId }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to get post: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a list of blog tags
|
||||||
|
*/
|
||||||
|
export async function listBlogTags(): Promise<{
|
||||||
|
tags: Array<{
|
||||||
|
tagId: string;
|
||||||
|
tagName: string;
|
||||||
|
postCount: number;
|
||||||
|
}>;
|
||||||
|
}> {
|
||||||
|
const response = await apiRequest('/api/blog/view/tags', {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to list tags: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new blog post
|
* Create a new blog post
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,107 +1,115 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom';
|
||||||
import Layout from '../components/Layout'
|
import { useState, useEffect } from 'react';
|
||||||
import '../App.css'
|
import Layout from '../components/Layout';
|
||||||
|
import { listBlogPosts, listBlogTags } from '../blog/api';
|
||||||
|
import { getS3Url } from '../blog/s3Config';
|
||||||
|
import { Toast } from '../components/Toast';
|
||||||
|
import { useToast } from '../hooks/useToast';
|
||||||
|
import '../App.css';
|
||||||
|
|
||||||
interface BlogPost {
|
interface BlogPost {
|
||||||
id: number
|
postId: string;
|
||||||
title: string
|
title: string;
|
||||||
excerpt: string
|
coverImageUrl: string;
|
||||||
content: string
|
createdAt: number;
|
||||||
author: string
|
updatedAt: number;
|
||||||
date: string
|
}
|
||||||
readTime: string
|
|
||||||
category: string
|
interface BlogTag {
|
||||||
tags: string[]
|
tagId: string;
|
||||||
image?: string
|
tagName: string;
|
||||||
|
postCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Blog() {
|
function Blog() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation();
|
||||||
|
const { toasts, removeToast, error } = useToast();
|
||||||
|
|
||||||
|
const [posts, setPosts] = useState<BlogPost[]>([]);
|
||||||
|
const [tags, setTags] = useState<BlogTag[]>([]);
|
||||||
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||||
|
|
||||||
|
const pageSize = 6;
|
||||||
|
|
||||||
// Mock blog posts data
|
// Fetch tags on mount
|
||||||
const blogPosts: BlogPost[] = [
|
useEffect(() => {
|
||||||
{
|
const fetchTags = async () => {
|
||||||
id: 1,
|
try {
|
||||||
title: "Mastering Counter-Strike: Advanced Tactics and Strategies",
|
const response = await listBlogTags();
|
||||||
excerpt: "Learn the advanced tactics that separate professional players from casual gamers. From positioning to communication, discover the secrets of competitive CS.",
|
setTags(response.tags);
|
||||||
content: "Full article content here...",
|
} catch (err) {
|
||||||
author: "ProGamer99",
|
error('Failed to load tags');
|
||||||
date: "2025-10-01",
|
console.error('Failed to fetch tags:', err);
|
||||||
readTime: "8 min read",
|
}
|
||||||
category: "Strategy",
|
};
|
||||||
tags: ["Counter-Strike", "Tactics", "Professional"],
|
fetchTags();
|
||||||
image: "🎯"
|
}, [error]);
|
||||||
},
|
|
||||||
{
|
// Fetch posts when page or tags change
|
||||||
id: 2,
|
useEffect(() => {
|
||||||
title: "The Evolution of Esports: From LAN Parties to Global Tournaments",
|
const fetchPosts = async () => {
|
||||||
excerpt: "Explore how esports has grown from small local tournaments to billion-dollar industry with global audiences and professional athletes.",
|
try {
|
||||||
content: "Full article content here...",
|
setIsLoading(true);
|
||||||
author: "EsportsAnalyst",
|
const response = await listBlogPosts(currentPage, pageSize, selectedTags);
|
||||||
date: "2025-09-28",
|
setPosts(response.posts);
|
||||||
readTime: "6 min read",
|
setTotalCount(response.totalCount);
|
||||||
category: "Esports",
|
} catch (err) {
|
||||||
tags: ["Esports", "History", "Tournaments"],
|
error('Failed to load blog posts');
|
||||||
image: "🏆"
|
console.error('Failed to fetch posts:', err);
|
||||||
},
|
} finally {
|
||||||
{
|
setIsLoading(false);
|
||||||
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.",
|
fetchPosts();
|
||||||
content: "Full article content here...",
|
}, [currentPage, selectedTags, error]);
|
||||||
author: "TechReviewer",
|
|
||||||
date: "2025-09-25",
|
const handleLoadMore = async () => {
|
||||||
readTime: "12 min read",
|
try {
|
||||||
category: "Hardware",
|
setIsLoadingMore(true);
|
||||||
tags: ["Gaming Setup", "Hardware", "PC Building"],
|
const nextPage = currentPage + 1;
|
||||||
image: "🖥️"
|
const response = await listBlogPosts(nextPage, pageSize, selectedTags);
|
||||||
},
|
setPosts([...posts, ...response.posts]);
|
||||||
{
|
setCurrentPage(nextPage);
|
||||||
id: 4,
|
} catch (err) {
|
||||||
title: "Community Spotlight: Rising Stars in CS Competitive Scene",
|
error('Failed to load more posts');
|
||||||
excerpt: "Meet the up-and-coming players who are making waves in the Counter-Strike competitive scene. Their stories, strategies, and paths to success.",
|
console.error('Failed to load more posts:', err);
|
||||||
content: "Full article content here...",
|
} finally {
|
||||||
author: "CommunityManager",
|
setIsLoadingMore(false);
|
||||||
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: "🖧"
|
|
||||||
}
|
}
|
||||||
]
|
};
|
||||||
|
|
||||||
const categories = [...new Set(blogPosts.map(post => post.category))]
|
const handleTagClick = (tagId: string) => {
|
||||||
const featuredPost = blogPosts[0]
|
setSelectedTags((prev) => {
|
||||||
const recentPosts = blogPosts.slice(1, 4)
|
if (prev.includes(tagId)) {
|
||||||
|
return prev.filter((id) => id !== tagId);
|
||||||
|
}
|
||||||
|
return [...prev, tagId];
|
||||||
|
});
|
||||||
|
setCurrentPage(1); // Reset to first page when filtering
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (timestamp: number) => {
|
||||||
|
return new Date(timestamp * 1000).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasMorePosts = posts.length < totalCount;
|
||||||
|
const featuredPost = posts[0];
|
||||||
|
const recentPosts = posts.slice(1);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout currentPage="blog">
|
<Layout currentPage="blog">
|
||||||
{/* Blog Page Header */}
|
<Toast toasts={toasts} onRemove={removeToast} />
|
||||||
|
|
||||||
|
{/* Header Section */}
|
||||||
<section className="blog-header" style={{
|
<section className="blog-header" 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%)',
|
||||||
@@ -124,377 +132,332 @@ function Blog() {
|
|||||||
}}>
|
}}>
|
||||||
{t('blog.subtitle')}
|
{t('blog.subtitle')}
|
||||||
</p>
|
</p>
|
||||||
{/* Create Post Button */}
|
<Link to="/blog/create" style={{
|
||||||
<Link
|
display: 'inline-block',
|
||||||
to="/blog/create"
|
background: 'var(--accent-color)',
|
||||||
style={{
|
color: 'white',
|
||||||
display: 'inline-block',
|
padding: '1rem 2rem',
|
||||||
background: 'var(--accent-color)',
|
borderRadius: '8px',
|
||||||
color: 'white',
|
textDecoration: 'none',
|
||||||
padding: '1rem 2rem',
|
fontWeight: 'bold',
|
||||||
borderRadius: '8px',
|
fontSize: '1.1rem',
|
||||||
textDecoration: 'none',
|
transition: 'transform 0.3s ease'
|
||||||
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
|
✍️ Create New Post
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Featured Post */}
|
{/* Loading State */}
|
||||||
<section className="featured-post" style={{ padding: '0 2rem 60px' }}>
|
{isLoading && (
|
||||||
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
|
<section style={{ padding: '60px 2rem', textAlign: 'center' }}>
|
||||||
|
<div style={{
|
||||||
|
fontSize: '3rem',
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
marginBottom: '1rem'
|
||||||
|
}}>
|
||||||
|
⏳
|
||||||
|
</div>
|
||||||
|
<p style={{
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
fontSize: '1.2rem'
|
||||||
|
}}>
|
||||||
|
Loading posts...
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty State */}
|
||||||
|
{!isLoading && posts.length === 0 && (
|
||||||
|
<section style={{ padding: '60px 2rem', textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: '5rem', marginBottom: '1rem' }}>📝</div>
|
||||||
<h2 style={{
|
<h2 style={{
|
||||||
fontSize: '2rem',
|
fontSize: '2rem',
|
||||||
color: 'var(--text-primary)',
|
color: 'var(--text-primary)',
|
||||||
marginBottom: '2rem',
|
marginBottom: '1rem'
|
||||||
textAlign: 'center'
|
|
||||||
}}>
|
}}>
|
||||||
{t('blog.featuredPost')}
|
No Posts Yet
|
||||||
</h2>
|
</h2>
|
||||||
<div style={{
|
<p style={{
|
||||||
background: 'var(--bg-card)',
|
color: 'var(--text-secondary)',
|
||||||
border: '1px solid var(--border-color)',
|
fontSize: '1.2rem',
|
||||||
borderRadius: '12px',
|
marginBottom: '2rem'
|
||||||
overflow: 'hidden',
|
|
||||||
boxShadow: '0 4px 20px var(--shadow)',
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: '1fr 1fr',
|
|
||||||
gap: '2rem',
|
|
||||||
alignItems: 'center'
|
|
||||||
}}>
|
}}>
|
||||||
<div style={{ padding: '3rem' }}>
|
Be the first to create a blog post!
|
||||||
<div style={{
|
</p>
|
||||||
display: 'inline-block',
|
<Link to="/blog/create" style={{
|
||||||
background: 'var(--accent-color)',
|
display: 'inline-block',
|
||||||
color: 'white',
|
background: 'var(--accent-color)',
|
||||||
padding: '0.5rem 1rem',
|
color: 'white',
|
||||||
borderRadius: '20px',
|
padding: '1rem 2rem',
|
||||||
fontSize: '0.9rem',
|
borderRadius: '8px',
|
||||||
fontWeight: 'bold',
|
textDecoration: 'none',
|
||||||
marginBottom: '1rem'
|
fontWeight: 'bold',
|
||||||
}}>
|
fontSize: '1.1rem'
|
||||||
{featuredPost.category}
|
}}>
|
||||||
</div>
|
✍️ Create First Post
|
||||||
<h3 style={{
|
</Link>
|
||||||
fontSize: '2rem',
|
</section>
|
||||||
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}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Blog Content */}
|
{/* Featured Post */}
|
||||||
<section className="blog-content" style={{ padding: '0 2rem 80px' }}>
|
{!isLoading && featuredPost && (
|
||||||
<div style={{ maxWidth: '1200px', margin: '0 auto', display: 'grid', gridTemplateColumns: '1fr 300px', gap: '3rem' }}>
|
<section style={{ padding: '0 2rem 60px' }}>
|
||||||
|
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
|
||||||
{/* Main Content */}
|
|
||||||
<div>
|
|
||||||
<h2 style={{
|
<h2 style={{
|
||||||
fontSize: '2rem',
|
fontSize: '2rem',
|
||||||
color: 'var(--text-primary)',
|
color: 'var(--text-primary)',
|
||||||
marginBottom: '2rem'
|
marginBottom: '2rem',
|
||||||
|
textAlign: 'center'
|
||||||
}}>
|
}}>
|
||||||
{t('blog.recentPosts')}
|
{t('blog.featuredPost')}
|
||||||
</h2>
|
</h2>
|
||||||
|
<Link to={`/blog/${featuredPost.postId}`} style={{ textDecoration: 'none', display: 'block' }}>
|
||||||
|
<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' }}>
|
||||||
|
<h3 style={{
|
||||||
|
fontSize: '2rem',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
marginBottom: '1rem',
|
||||||
|
lineHeight: '1.3'
|
||||||
|
}}>
|
||||||
|
{featuredPost.title}
|
||||||
|
</h3>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '1rem',
|
||||||
|
marginTop: '1.5rem'
|
||||||
|
}}>
|
||||||
|
<span style={{ color: 'var(--text-secondary)', fontSize: '0.9rem' }}>
|
||||||
|
📅 {formatDate(featuredPost.createdAt)}
|
||||||
|
</span>
|
||||||
|
<span style={{ color: 'var(--text-secondary)', fontSize: '0.9rem' }}>
|
||||||
|
🔄 {formatDate(featuredPost.updatedAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
background: `url(${getS3Url(featuredPost.coverImageUrl)}) center/cover`,
|
||||||
|
height: '300px',
|
||||||
|
borderRadius: '0 12px 12px 0'
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
<div style={{ display: 'grid', gap: '2rem' }}>
|
{/* Recent Posts & Sidebar */}
|
||||||
{recentPosts.map(post => (
|
{!isLoading && posts.length > 0 && (
|
||||||
<article key={post.id} style={{
|
<section style={{ padding: '0 2rem 80px' }}>
|
||||||
|
<div style={{
|
||||||
|
maxWidth: '1200px',
|
||||||
|
margin: '0 auto',
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '1fr 300px',
|
||||||
|
gap: '3rem'
|
||||||
|
}}>
|
||||||
|
{/* Posts Grid */}
|
||||||
|
<div>
|
||||||
|
<h2 style={{
|
||||||
|
fontSize: '2rem',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
marginBottom: '2rem'
|
||||||
|
}}>
|
||||||
|
{t('blog.recentPosts')}
|
||||||
|
</h2>
|
||||||
|
<div style={{ display: 'grid', gap: '2rem' }}>
|
||||||
|
{recentPosts.map(post => (
|
||||||
|
<Link key={post.postId} to={`/blog/${post.postId}`} style={{ textDecoration: 'none' }}>
|
||||||
|
<article style={{
|
||||||
|
background: 'var(--bg-card)',
|
||||||
|
border: '1px solid var(--border-color)',
|
||||||
|
borderRadius: '12px',
|
||||||
|
overflow: 'hidden'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '200px 1fr',
|
||||||
|
gap: '0'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
background: `url(${getS3Url(post.coverImageUrl)}) center/cover`,
|
||||||
|
height: '200px'
|
||||||
|
}} />
|
||||||
|
<div style={{ padding: '2rem' }}>
|
||||||
|
<h3 style={{
|
||||||
|
fontSize: '1.5rem',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
marginBottom: '1rem',
|
||||||
|
lineHeight: '1.3'
|
||||||
|
}}>
|
||||||
|
{post.title}
|
||||||
|
</h3>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '1rem'
|
||||||
|
}}>
|
||||||
|
<span style={{ color: 'var(--text-secondary)', fontSize: '0.9rem' }}>
|
||||||
|
📅 {formatDate(post.createdAt)}
|
||||||
|
</span>
|
||||||
|
<span style={{ color: 'var(--text-secondary)', fontSize: '0.9rem' }}>
|
||||||
|
🔄 {formatDate(post.updatedAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Load More Button */}
|
||||||
|
{hasMorePosts && (
|
||||||
|
<div style={{ textAlign: 'center', marginTop: '3rem' }}>
|
||||||
|
<button
|
||||||
|
onClick={handleLoadMore}
|
||||||
|
disabled={isLoadingMore}
|
||||||
|
style={{
|
||||||
|
background: isLoadingMore ? 'var(--bg-secondary)' : 'var(--accent-color)',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
padding: '1rem 2rem',
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontSize: '1rem',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
cursor: isLoadingMore ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: isLoadingMore ? 0.6 : 1
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isLoadingMore ? 'Loading...' : t('blog.loadMore')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<aside>
|
||||||
|
{/* Tags */}
|
||||||
|
{tags.length > 0 && (
|
||||||
|
<div style={{
|
||||||
background: 'var(--bg-card)',
|
background: 'var(--bg-card)',
|
||||||
border: '1px solid var(--border-color)',
|
border: '1px solid var(--border-color)',
|
||||||
borderRadius: '12px',
|
borderRadius: '12px',
|
||||||
overflow: 'hidden',
|
padding: '2rem',
|
||||||
transition: 'transform 0.3s ease, box-shadow 0.3s ease',
|
marginBottom: '2rem'
|
||||||
cursor: 'pointer'
|
}}>
|
||||||
}}
|
<h3 style={{
|
||||||
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>
|
|
||||||
|
|
||||||
{/* 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)',
|
color: 'var(--text-primary)',
|
||||||
border: 'none',
|
marginBottom: '1rem',
|
||||||
padding: '0.75rem 1rem',
|
fontSize: '1.3rem'
|
||||||
borderRadius: '8px',
|
}}>
|
||||||
cursor: 'pointer',
|
{t('blog.categories')}
|
||||||
textAlign: 'left',
|
</h3>
|
||||||
transition: 'background 0.3s ease'
|
<div style={{
|
||||||
}}
|
display: 'flex',
|
||||||
onMouseEnter={(e) => {
|
flexDirection: 'column',
|
||||||
e.currentTarget.style.background = 'var(--accent-color)'
|
gap: '0.5rem'
|
||||||
e.currentTarget.style.color = 'white'
|
}}>
|
||||||
}}
|
{tags.map(tag => (
|
||||||
onMouseLeave={(e) => {
|
<button
|
||||||
e.currentTarget.style.background = 'var(--bg-secondary)'
|
key={tag.tagId}
|
||||||
e.currentTarget.style.color = 'var(--text-primary)'
|
onClick={() => handleTagClick(tag.tagId)}
|
||||||
}}
|
style={{
|
||||||
>
|
background: selectedTags.includes(tag.tagId) ? 'var(--accent-color)' : 'var(--bg-secondary)',
|
||||||
{category}
|
color: selectedTags.includes(tag.tagId) ? 'white' : 'var(--text-primary)',
|
||||||
</button>
|
border: 'none',
|
||||||
))}
|
padding: '0.75rem 1rem',
|
||||||
</div>
|
borderRadius: '8px',
|
||||||
</div>
|
cursor: 'pointer',
|
||||||
|
textAlign: 'left',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{tag.tagName}</span>
|
||||||
|
<span style={{
|
||||||
|
background: 'rgba(255, 255, 255, 0.2)',
|
||||||
|
padding: '0.2rem 0.6rem',
|
||||||
|
borderRadius: '12px',
|
||||||
|
fontSize: '0.8rem'
|
||||||
|
}}>
|
||||||
|
{tag.postCount}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Newsletter Signup */}
|
{/* Newsletter */}
|
||||||
<div style={{
|
<div style={{
|
||||||
background: 'var(--bg-card)',
|
background: 'var(--bg-card)',
|
||||||
border: '1px solid var(--border-color)',
|
border: '1px solid var(--border-color)',
|
||||||
borderRadius: '12px',
|
borderRadius: '12px',
|
||||||
padding: '2rem',
|
padding: '2rem',
|
||||||
textAlign: 'center'
|
textAlign: 'center'
|
||||||
}}>
|
|
||||||
<h3 style={{
|
|
||||||
color: 'var(--text-primary)',
|
|
||||||
marginBottom: '1rem',
|
|
||||||
fontSize: '1.3rem'
|
|
||||||
}}>
|
}}>
|
||||||
{t('blog.subscribe')}
|
<h3 style={{
|
||||||
</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={{
|
|
||||||
width: '100%',
|
|
||||||
padding: '0.75rem',
|
|
||||||
border: '1px solid var(--border-color)',
|
|
||||||
borderRadius: '6px',
|
|
||||||
background: 'var(--bg-secondary)',
|
|
||||||
color: 'var(--text-primary)',
|
color: 'var(--text-primary)',
|
||||||
marginBottom: '1rem',
|
marginBottom: '1rem',
|
||||||
|
fontSize: '1.3rem'
|
||||||
|
}}>
|
||||||
|
{t('blog.subscribe')}
|
||||||
|
</h3>
|
||||||
|
<p style={{
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
marginBottom: '1.5rem',
|
||||||
fontSize: '0.9rem'
|
fontSize: '0.9rem'
|
||||||
}}
|
}}>
|
||||||
/>
|
{t('blog.subscribeDesc')}
|
||||||
<button style={{
|
</p>
|
||||||
width: '100%',
|
<input
|
||||||
background: 'var(--accent-color)',
|
type="email"
|
||||||
color: 'white',
|
placeholder={t('blog.emailPlaceholder')}
|
||||||
border: 'none',
|
style={{
|
||||||
padding: '0.75rem',
|
width: '100%',
|
||||||
borderRadius: '6px',
|
padding: '0.75rem',
|
||||||
fontSize: '0.9rem',
|
border: '1px solid var(--border-color)',
|
||||||
fontWeight: 'bold',
|
borderRadius: '6px',
|
||||||
cursor: 'pointer',
|
background: 'var(--bg-secondary)',
|
||||||
transition: 'transform 0.3s ease'
|
color: 'var(--text-primary)',
|
||||||
}}
|
marginBottom: '1rem',
|
||||||
onMouseEnter={(e) => {
|
fontSize: '0.9rem'
|
||||||
e.currentTarget.style.transform = 'translateY(-2px)'
|
}}
|
||||||
}}
|
/>
|
||||||
onMouseLeave={(e) => {
|
<button style={{
|
||||||
e.currentTarget.style.transform = 'translateY(0)'
|
width: '100%',
|
||||||
}}
|
background: 'var(--accent-color)',
|
||||||
>
|
color: 'white',
|
||||||
{t('blog.subscribeBtn')}
|
border: 'none',
|
||||||
</button>
|
padding: '0.75rem',
|
||||||
</div>
|
borderRadius: '6px',
|
||||||
</aside>
|
fontSize: '0.9rem',
|
||||||
</div>
|
fontWeight: 'bold',
|
||||||
</section>
|
cursor: 'pointer'
|
||||||
|
}}>
|
||||||
|
{t('blog.subscribeBtn')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
</Layout>
|
</Layout>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Blog
|
export default Blog;
|
||||||
|
|||||||
Reference in New Issue
Block a user