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 { 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,6 +26,7 @@ createRoot(document.getElementById('root')!).render(
|
|||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<StatsProvider>
|
<StatsProvider>
|
||||||
<ServerProvider>
|
<ServerProvider>
|
||||||
|
<BlogIndexProvider>
|
||||||
<Router>
|
<Router>
|
||||||
<ScrollToTop />
|
<ScrollToTop />
|
||||||
<Routes>
|
<Routes>
|
||||||
@@ -40,6 +42,7 @@ createRoot(document.getElementById('root')!).render(
|
|||||||
<Route path="/editor" element={<EditorDemo />} />
|
<Route path="/editor" element={<EditorDemo />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Router>
|
</Router>
|
||||||
|
</BlogIndexProvider>
|
||||||
</ServerProvider>
|
</ServerProvider>
|
||||||
</StatsProvider>
|
</StatsProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
Reference in New Issue
Block a user