From 1b9ee67545c80076ad73074fcb42770201ce55bc Mon Sep 17 00:00:00 2001 From: cialloo Date: Sun, 26 Oct 2025 16:12:27 +0800 Subject: [PATCH] feat: implement blog post and tag fetching with pagination --- src/blog/api.ts | 73 ++++ src/pages/Blog.tsx | 837 ++++++++++++++++++++++----------------------- 2 files changed, 473 insertions(+), 437 deletions(-) diff --git a/src/blog/api.ts b/src/blog/api.ts index ca10bb1..21d5438 100644 --- a/src/blog/api.ts +++ b/src/blog/api.ts @@ -87,6 +87,79 @@ export async function uploadImage( 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 */ diff --git a/src/pages/Blog.tsx b/src/pages/Blog.tsx index df90d87..2dbcf88 100644 --- a/src/pages/Blog.tsx +++ b/src/pages/Blog.tsx @@ -1,107 +1,115 @@ -import { useTranslation } from 'react-i18next' -import { Link } from 'react-router-dom' -import Layout from '../components/Layout' -import '../App.css' +import { useTranslation } from 'react-i18next'; +import { Link } from 'react-router-dom'; +import { useState, useEffect } from 'react'; +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 { - id: number - title: string - excerpt: string - content: string - author: string - date: string - readTime: string - category: string - tags: string[] - image?: string + postId: string; + title: string; + coverImageUrl: string; + createdAt: number; + updatedAt: number; +} + +interface BlogTag { + tagId: string; + tagName: string; + postCount: number; } function Blog() { - const { t } = useTranslation() + const { t } = useTranslation(); + const { toasts, removeToast, error } = useToast(); + + const [posts, setPosts] = useState([]); + const [tags, setTags] = useState([]); + const [totalCount, setTotalCount] = useState(0); + const [currentPage, setCurrentPage] = useState(1); + const [selectedTags, setSelectedTags] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [isLoadingMore, setIsLoadingMore] = useState(false); + + const pageSize = 6; - // 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: "🎯" - }, - { - 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: "🖧" + // Fetch tags on mount + useEffect(() => { + const fetchTags = async () => { + try { + const response = await listBlogTags(); + setTags(response.tags); + } catch (err) { + error('Failed to load tags'); + console.error('Failed to fetch tags:', err); + } + }; + fetchTags(); + }, [error]); + + // Fetch posts when page or tags change + useEffect(() => { + const fetchPosts = async () => { + try { + setIsLoading(true); + const response = await listBlogPosts(currentPage, pageSize, selectedTags); + setPosts(response.posts); + setTotalCount(response.totalCount); + } catch (err) { + error('Failed to load blog posts'); + console.error('Failed to fetch posts:', err); + } finally { + setIsLoading(false); + } + }; + fetchPosts(); + }, [currentPage, selectedTags, error]); + + const handleLoadMore = async () => { + try { + setIsLoadingMore(true); + const nextPage = currentPage + 1; + const response = await listBlogPosts(nextPage, pageSize, selectedTags); + setPosts([...posts, ...response.posts]); + setCurrentPage(nextPage); + } catch (err) { + error('Failed to load more posts'); + console.error('Failed to load more posts:', err); + } finally { + setIsLoadingMore(false); } - ] + }; - const categories = [...new Set(blogPosts.map(post => post.category))] - const featuredPost = blogPosts[0] - const recentPosts = blogPosts.slice(1, 4) + const handleTagClick = (tagId: string) => { + setSelectedTags((prev) => { + 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 ( - {/* Blog Page Header */} + + + {/* Header Section */}
{t('blog.subtitle')}

- {/* Create Post Button */} - { - 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
- {/* Featured Post */} -
-
+ {/* Loading State */} + {isLoading && ( +
+
+ ⏳ +
+

+ Loading posts... +

+
+ )} + + {/* Empty State */} + {!isLoading && posts.length === 0 && ( +
+
📝

- {t('blog.featuredPost')} + No Posts Yet

-
-
-
- {featuredPost.category} -
-

- {featuredPost.title} -

-

- {featuredPost.excerpt} -

-
- - {t('blog.by')} {featuredPost.author} - - - {featuredPost.date} - - - {featuredPost.readTime} - -
- { - e.currentTarget.style.transform = 'translateY(-2px)' - }} - onMouseLeave={(e) => { - e.currentTarget.style.transform = 'translateY(0)' - }} - > - {t('blog.readMore')} - -
-
- {featuredPost.image} -
-
-
-
+ Be the first to create a blog post! +

+ + ✍️ Create First Post + + + )} - {/* Blog Content */} -
-
- - {/* Main Content */} -
+ {/* Featured Post */} + {!isLoading && featuredPost && ( +
+

- {t('blog.recentPosts')} + {t('blog.featuredPost')}

+ +
+
+

+ {featuredPost.title} +

+
+ + 📅 {formatDate(featuredPost.createdAt)} + + + 🔄 {formatDate(featuredPost.updatedAt)} + +
+
+
+
+ +
+
+ )} -
- {recentPosts.map(post => ( -
0 && ( +
+
+ {/* Posts Grid */} +
+

+ {t('blog.recentPosts')} +

+
+ {recentPosts.map(post => ( + +
+
+
+
+

+ {post.title} +

+
+ + 📅 {formatDate(post.createdAt)} + + + 🔄 {formatDate(post.updatedAt)} + +
+
+
+
+ + ))} +
+ + {/* Load More Button */} + {hasMorePosts && ( +
+ +
+ )} +
+ + {/* Sidebar */} +
- ))} -
- - {/* Load More Button */} -
- -
-
- - {/* Sidebar */} -
+ )} - {/* Newsletter Signup */} -
-

- {t('blog.subscribe')} -

-

- {t('blog.subscribeDesc')} -

- + {t('blog.subscribe')} + +

- -

- - -
+ }}> + {t('blog.subscribeDesc')} +

+ + + + + + + )}
- ) + ); } -export default Blog \ No newline at end of file +export default Blog;