From 47d6f93e97e43caf9ca887addc8da56487369a9f Mon Sep 17 00:00:00 2001 From: cialloo Date: Sun, 26 Oct 2025 16:55:11 +0800 Subject: [PATCH] feat: enhance blog functionality with pagination, tag filtering, and improved API integration --- src/blog/api.ts | 57 ++- src/blog/types.ts | 35 ++ src/pages/Blog.tsx | 924 +++++++++++++++++++++++---------------------- 3 files changed, 557 insertions(+), 459 deletions(-) diff --git a/src/blog/api.ts b/src/blog/api.ts index ca10bb1..bfd7445 100644 --- a/src/blog/api.ts +++ b/src/blog/api.ts @@ -2,14 +2,19 @@ * Blog API functions */ -import { apiRequest } from '../utils/api'; +import { apiRequest, apiPost } from '../utils/api'; import { getS3Url } from './s3Config'; import type { UploadPresignedURLResponse, CreatePostResponse, + ListPostsRequest, + ListPostsResponse, + ListTagsResponse, + GetPostResponse, } from './types'; const API_BASE = '/api/blog'; +const BLOG_VIEW_BASE = `${API_BASE}/view`; /** * Get presigned URL for file upload @@ -95,13 +100,10 @@ export async function createBlogPost( content: string, coverImageKey: string ): Promise { - const response = await apiRequest(`${API_BASE}/post/create`, { - method: 'POST', - body: JSON.stringify({ - title, - content, - coverImageKey, - }), + const response = await apiPost(`${API_BASE}/post/create`, { + title, + content, + coverImageKey, }); if (!response.ok) { @@ -110,3 +112,42 @@ export async function createBlogPost( return response.json(); } + +/** + * Fetch a paginated list of blog posts + */ +export async function listBlogPosts(params: ListPostsRequest): Promise { + 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 { + 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 { + const response = await apiPost(`${BLOG_VIEW_BASE}/tags`, {}); + + if (!response.ok) { + throw new Error(`Failed to load tags: ${response.statusText}`); + } + + return response.json(); +} diff --git a/src/blog/types.ts b/src/blog/types.ts index 8530db4..6f74c06 100644 --- a/src/blog/types.ts +++ b/src/blog/types.ts @@ -26,3 +26,38 @@ export interface ImageUploadStatus { fileKey?: 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 {} diff --git a/src/pages/Blog.tsx b/src/pages/Blog.tsx index df90d87..6807a07 100644 --- a/src/pages/Blog.tsx +++ b/src/pages/Blog.tsx @@ -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([]); + const [isLoadingTags, setIsLoadingTags] = useState(false); + const [tagsError, setTagsError] = useState(null); + + const [selectedTags, setSelectedTags] = useState([]); + + const [posts, setPosts] = useState([]); + const [page, setPage] = useState(1); + const [hasMore, setHasMore] = useState(false); + const [isLoadingPosts, setIsLoadingPosts] = useState(false); + const [postError, setPostError] = useState(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 ( - {/* Blog Page Header */} -
-
-

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

-

- {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'; + color: 'var(--text-primary)', + marginBottom: '1rem', }} > - ✍️ Create New Post - + {t('blog.title')} +

+

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

+
+ { + 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')} + + {selectedTags.length > 0 && ( + + )} +
- {/* Featured Post */} -
+
-

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

-
-
-
- {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} + gap: '0.6rem', + }} + > + Tags + + {isLoadingTags && Loading...} +
+ {tagsError && ( +
+ {tagsError}
+ )} +
+ {tags.map((tag) => { + const isSelected = selectedTags.includes(tag.tagId); + return ( + + ); + })} + {!isLoadingTags && tags.length === 0 && !tagsError && ( + No tags available yet. + )}
- {/* Blog Content */} -
-
+
+
+ {postError && ( +
+ {postError} + +
+ )} - {/* Main Content */} -
-

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

+ {featuredPost && ( +
+
+
+ + Featured + +

+ {featuredPost.title} +

+
+ {formatDate(featuredPost.createdAt)} + Updated {formatDate(featuredPost.updatedAt)} +
+ { + event.currentTarget.style.transform = 'translateY(-2px)'; + }} + onMouseLeave={(event) => { + event.currentTarget.style.transform = 'translateY(0)'; + }} + > + {'Read article ->'} + +
+
+
+
+ )} -
- {recentPosts.map(post => ( -
+ No posts yet. Be the first to share something with the community! +
+ )} + +
+ {otherPosts.map((post) => ( +
{ - 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' - }} - > -
-
- {post.image} -
-
-
- {post.category} -
-

- {post.title} -

-

- {post.excerpt} -

-
- - {t('blog.by')} {post.author} - - - {post.date} - - - {post.readTime} - -
-
- {post.tags.map(tag => ( - - #{tag} - - ))} -
-
-
-
- ))} -
- - {/* Load More Button */} -
- -
+
+
+

+ {post.title} +

+
+ Published {formatDate(post.createdAt)} +
+
+ + {'Read more ->'} + +
+
+ + ))}
- {/* Sidebar */} - + )}
- ) + ); } -export default Blog \ No newline at end of file +export default Blog;