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
All checks were successful
CI - Build and Push / Build and Push Docker Image (push) Successful in 19s
This commit is contained in:
88
src/contexts/BlogIndexContext.tsx
Normal file
88
src/contexts/BlogIndexContext.tsx
Normal 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;
|
||||
}
|
||||
@@ -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,6 +26,7 @@ createRoot(document.getElementById('root')!).render(
|
||||
<AuthProvider>
|
||||
<StatsProvider>
|
||||
<ServerProvider>
|
||||
<BlogIndexProvider>
|
||||
<Router>
|
||||
<ScrollToTop />
|
||||
<Routes>
|
||||
@@ -40,6 +42,7 @@ createRoot(document.getElementById('root')!).render(
|
||||
<Route path="/editor" element={<EditorDemo />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
</BlogIndexProvider>
|
||||
</ServerProvider>
|
||||
</StatsProvider>
|
||||
</AuthProvider>
|
||||
|
||||
@@ -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<BlogTag[]>([]);
|
||||
const [isLoadingTags, setIsLoadingTags] = useState(false);
|
||||
const [tagsError, setTagsError] = useState<string | null>(null);
|
||||
const { initialPosts, initialTags, initialTotalCount, isLoading: isPreloading, error: preloadError, hasInitialData } = useBlogIndex();
|
||||
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
|
||||
@@ -35,49 +34,33 @@ function Blog() {
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [isLoadingPosts, setIsLoadingPosts] = useState(false);
|
||||
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(() => {
|
||||
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]);
|
||||
|
||||
Reference in New Issue
Block a user