From a547b1f99d42544111bd64562c97c04435efb653 Mon Sep 17 00:00:00 2001 From: cialloo Date: Sun, 26 Oct 2025 22:58:40 +0800 Subject: [PATCH] feat: implement BlogIndex context provider and integrate it into Blog component for improved data handling --- src/contexts/BlogIndexContext.tsx | 88 +++++++++++++++++++++++++++++ src/main.tsx | 33 ++++++----- src/pages/Blog.tsx | 93 +++++++++++++++++-------------- 3 files changed, 156 insertions(+), 58 deletions(-) create mode 100644 src/contexts/BlogIndexContext.tsx diff --git a/src/contexts/BlogIndexContext.tsx b/src/contexts/BlogIndexContext.tsx new file mode 100644 index 0000000..fc8a977 --- /dev/null +++ b/src/contexts/BlogIndexContext.tsx @@ -0,0 +1,88 @@ +import { createContext, useContext, useEffect, useMemo, useState } from 'react'; +import type { ReactNode } from 'react'; +import { listBlogPosts, listBlogTags } from '../blog/api'; +import type { BlogPostSummary, BlogTag } from '../blog/types'; + +export const BLOG_INDEX_PAGE_SIZE = 6; + +interface BlogIndexContextValue { + initialPosts: BlogPostSummary[]; + initialTotalCount: number; + initialTags: BlogTag[]; + isLoading: boolean; + error: string | null; + hasInitialData: boolean; +} + +const BlogIndexContext = createContext(undefined); + +export function BlogIndexProvider({ children }: { children: ReactNode }) { + const [initialPosts, setInitialPosts] = useState([]); + const [initialTotalCount, setInitialTotalCount] = useState(0); + const [initialTags, setInitialTags] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [hasInitialData, setHasInitialData] = useState(false); + + useEffect(() => { + let active = true; + + async function fetchInitialData() { + setIsLoading(true); + setError(null); + + try { + const [postsResponse, tagsResponse] = await Promise.all([ + listBlogPosts({ page: 1, pageSize: BLOG_INDEX_PAGE_SIZE, tagIds: [] }), + listBlogTags(), + ]); + + if (!active) { + return; + } + + setInitialPosts(postsResponse.posts ?? []); + setInitialTotalCount(postsResponse.totalCount ?? postsResponse.posts.length ?? 0); + setInitialTags(tagsResponse.tags ?? []); + setHasInitialData(true); + } catch (err) { + if (!active) { + return; + } + + const message = err instanceof Error ? err.message : 'Failed to load blog data'; + setError(message); + setInitialPosts([]); + setInitialTotalCount(0); + setInitialTags([]); + setHasInitialData(false); + console.error('Failed to preload blog data:', err); + } finally { + if (active) { + setIsLoading(false); + } + } + } + + void fetchInitialData(); + + return () => { + active = false; + }; + }, []); + + const value = useMemo( + () => ({ initialPosts, initialTotalCount, initialTags, isLoading, error, hasInitialData }), + [initialPosts, initialTotalCount, initialTags, isLoading, error, hasInitialData], + ); + + return {children}; +} + +export function useBlogIndex(): BlogIndexContextValue { + const context = useContext(BlogIndexContext); + if (context === undefined) { + throw new Error('useBlogIndex must be used within a BlogIndexProvider'); + } + return context; +} diff --git a/src/main.tsx b/src/main.tsx index 51c67d7..6c9b52c 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -7,6 +7,7 @@ import { ThemeProvider } from './contexts/ThemeContext' import { AuthProvider } from './contexts/AuthContext' import { ServerProvider } from './contexts/ServerContext' import { StatsProvider } from './contexts/StatsContext' +import { BlogIndexProvider } from './contexts/BlogIndexContext' import ScrollToTop from './components/ScrollToTop' import App from './App.tsx' import Friends from './pages/Friends.tsx' @@ -25,21 +26,23 @@ createRoot(document.getElementById('root')!).render( - - - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - + + + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + diff --git a/src/pages/Blog.tsx b/src/pages/Blog.tsx index 3931aa4..9f58f75 100644 --- a/src/pages/Blog.tsx +++ b/src/pages/Blog.tsx @@ -2,11 +2,12 @@ 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 { listBlogPosts } from '../blog/api'; import type { BlogPostSummary, BlogTag } from '../blog/types'; +import { useBlogIndex, BLOG_INDEX_PAGE_SIZE } from '../contexts/BlogIndexContext'; import '../App.css'; -const PAGE_SIZE = 6; +const PAGE_SIZE = BLOG_INDEX_PAGE_SIZE; function formatDate(timestamp: number): string { if (!timestamp) { @@ -24,9 +25,7 @@ function formatDate(timestamp: number): string { function Blog() { const { t } = useTranslation(); - const [tags, setTags] = useState([]); - const [isLoadingTags, setIsLoadingTags] = useState(false); - const [tagsError, setTagsError] = useState(null); + const { initialPosts, initialTags, initialTotalCount, isLoading: isPreloading, error: preloadError, hasInitialData } = useBlogIndex(); const [selectedTags, setSelectedTags] = useState([]); @@ -35,49 +34,33 @@ function Blog() { const [hasMore, setHasMore] = useState(false); const [isLoadingPosts, setIsLoadingPosts] = useState(false); const [postError, setPostError] = useState(null); + const [didInit, setDidInit] = useState(false); - const isInitialLoading = isLoadingPosts && posts.length === 0; + const tags: BlogTag[] = initialTags; + const isLoadingTags = isPreloading && !hasInitialData; + const tagsError = !hasInitialData ? preloadError : null; - 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 isInitialLoading = (!didInit && (isPreloading || isLoadingPosts)) || (isLoadingPosts && posts.length === 0); const fetchPosts = useCallback( async (pageToLoad: number, replace = false) => { + const isDefaultSelection = selectedTags.length === 0; + if (isDefaultSelection && hasInitialData && pageToLoad === 1) { + if (replace) { + setPosts(initialPosts); + } else { + setPosts((previous) => (previous.length === 0 ? initialPosts : previous)); + } + setHasMore(initialPosts.length < initialTotalCount); + setPage(1); + setIsLoadingPosts(false); + setPostError(null); + return; + } + setIsLoadingPosts(true); setPostError(null); - if (replace) { - setPosts([]); - } - try { const response = await listBlogPosts({ page: pageToLoad, @@ -101,12 +84,36 @@ function Blog() { setIsLoadingPosts(false); } }, - [selectedTags] + [selectedTags, hasInitialData, initialPosts, initialTotalCount] ); useEffect(() => { - fetchPosts(1, true); - }, [fetchPosts]); + if (didInit) { + return; + } + + if (hasInitialData) { + setPosts(initialPosts); + setHasMore(initialPosts.length < initialTotalCount); + setPage(1); + setPostError(null); + setDidInit(true); + return; + } + + if (!isPreloading) { + void fetchPosts(1, true); + setDidInit(true); + } + }, [didInit, fetchPosts, hasInitialData, initialPosts, initialTotalCount, isPreloading]); + + useEffect(() => { + if (!didInit) { + return; + } + + void fetchPosts(1, true); + }, [selectedTags, fetchPosts, didInit]); const featuredPost = useMemo(() => (posts.length > 0 ? posts[0] : null), [posts]); const otherPosts = useMemo(() => (featuredPost ? posts.slice(1) : posts), [posts, featuredPost]);