feat: implement BlogIndex context provider and integrate it into Blog component for improved data handling
All checks were successful
CI - Build and Push / Build and Push Docker Image (push) Successful in 19s

This commit is contained in:
2025-10-26 22:58:40 +08:00
parent 181fcae739
commit a547b1f99d
3 changed files with 156 additions and 58 deletions

View File

@@ -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<BlogIndexContextValue | undefined>(undefined);
export function BlogIndexProvider({ children }: { children: ReactNode }) {
const [initialPosts, setInitialPosts] = useState<BlogPostSummary[]>([]);
const [initialTotalCount, setInitialTotalCount] = useState(0);
const [initialTags, setInitialTags] = useState<BlogTag[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(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<BlogIndexContextValue>(
() => ({ initialPosts, initialTotalCount, initialTags, isLoading, error, hasInitialData }),
[initialPosts, initialTotalCount, initialTags, isLoading, error, hasInitialData],
);
return <BlogIndexContext.Provider value={value}>{children}</BlogIndexContext.Provider>;
}
export function useBlogIndex(): BlogIndexContextValue {
const context = useContext(BlogIndexContext);
if (context === undefined) {
throw new Error('useBlogIndex must be used within a BlogIndexProvider');
}
return context;
}

View File

@@ -7,6 +7,7 @@ import { ThemeProvider } from './contexts/ThemeContext'
import { AuthProvider } from './contexts/AuthContext' import { AuthProvider } from './contexts/AuthContext'
import { ServerProvider } from './contexts/ServerContext' import { ServerProvider } from './contexts/ServerContext'
import { StatsProvider } from './contexts/StatsContext' import { StatsProvider } from './contexts/StatsContext'
import { BlogIndexProvider } from './contexts/BlogIndexContext'
import ScrollToTop from './components/ScrollToTop' import ScrollToTop from './components/ScrollToTop'
import App from './App.tsx' import App from './App.tsx'
import Friends from './pages/Friends.tsx' import Friends from './pages/Friends.tsx'
@@ -25,21 +26,23 @@ createRoot(document.getElementById('root')!).render(
<AuthProvider> <AuthProvider>
<StatsProvider> <StatsProvider>
<ServerProvider> <ServerProvider>
<Router> <BlogIndexProvider>
<ScrollToTop /> <Router>
<Routes> <ScrollToTop />
<Route path="/" element={<App />} /> <Routes>
<Route path="/friends" element={<Friends />} /> <Route path="/" element={<App />} />
<Route path="/blog" element={<Blog />} /> <Route path="/friends" element={<Friends />} />
<Route path="/blog/:postId" element={<BlogPost />} /> <Route path="/blog" element={<Blog />} />
<Route path="/blog/:postId/edit" element={<EditPost />} /> <Route path="/blog/:postId" element={<BlogPost />} />
<Route path="/blog/create" element={<CreatePost />} /> <Route path="/blog/:postId/edit" element={<EditPost />} />
<Route path="/servers" element={<Servers />} /> <Route path="/blog/create" element={<CreatePost />} />
<Route path="/forum" element={<Forum />} /> <Route path="/servers" element={<Servers />} />
<Route path="/auth/callback" element={<AuthCallback />} /> <Route path="/forum" element={<Forum />} />
<Route path="/editor" element={<EditorDemo />} /> <Route path="/auth/callback" element={<AuthCallback />} />
</Routes> <Route path="/editor" element={<EditorDemo />} />
</Router> </Routes>
</Router>
</BlogIndexProvider>
</ServerProvider> </ServerProvider>
</StatsProvider> </StatsProvider>
</AuthProvider> </AuthProvider>

View File

@@ -2,11 +2,12 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
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 Layout from '../components/Layout';
import { listBlogPosts, listBlogTags } from '../blog/api'; import { listBlogPosts } from '../blog/api';
import type { BlogPostSummary, BlogTag } from '../blog/types'; import type { BlogPostSummary, BlogTag } from '../blog/types';
import { useBlogIndex, BLOG_INDEX_PAGE_SIZE } from '../contexts/BlogIndexContext';
import '../App.css'; import '../App.css';
const PAGE_SIZE = 6; const PAGE_SIZE = BLOG_INDEX_PAGE_SIZE;
function formatDate(timestamp: number): string { function formatDate(timestamp: number): string {
if (!timestamp) { if (!timestamp) {
@@ -24,9 +25,7 @@ function formatDate(timestamp: number): string {
function Blog() { function Blog() {
const { t } = useTranslation(); const { t } = useTranslation();
const [tags, setTags] = useState<BlogTag[]>([]); const { initialPosts, initialTags, initialTotalCount, isLoading: isPreloading, error: preloadError, hasInitialData } = useBlogIndex();
const [isLoadingTags, setIsLoadingTags] = useState(false);
const [tagsError, setTagsError] = useState<string | null>(null);
const [selectedTags, setSelectedTags] = useState<string[]>([]); const [selectedTags, setSelectedTags] = useState<string[]>([]);
@@ -35,49 +34,33 @@ function Blog() {
const [hasMore, setHasMore] = useState(false); const [hasMore, setHasMore] = useState(false);
const [isLoadingPosts, setIsLoadingPosts] = useState(false); const [isLoadingPosts, setIsLoadingPosts] = useState(false);
const [postError, setPostError] = useState<string | null>(null); const [postError, setPostError] = useState<string | null>(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(() => { const isInitialLoading = (!didInit && (isPreloading || isLoadingPosts)) || (isLoadingPosts && posts.length === 0);
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( const fetchPosts = useCallback(
async (pageToLoad: number, replace = false) => { 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); setIsLoadingPosts(true);
setPostError(null); setPostError(null);
if (replace) {
setPosts([]);
}
try { try {
const response = await listBlogPosts({ const response = await listBlogPosts({
page: pageToLoad, page: pageToLoad,
@@ -101,12 +84,36 @@ function Blog() {
setIsLoadingPosts(false); setIsLoadingPosts(false);
} }
}, },
[selectedTags] [selectedTags, hasInitialData, initialPosts, initialTotalCount]
); );
useEffect(() => { useEffect(() => {
fetchPosts(1, true); if (didInit) {
}, [fetchPosts]); 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 featuredPost = useMemo(() => (posts.length > 0 ? posts[0] : null), [posts]);
const otherPosts = useMemo(() => (featuredPost ? posts.slice(1) : posts), [posts, featuredPost]); const otherPosts = useMemo(() => (featuredPost ? posts.slice(1) : posts), [posts, featuredPost]);