From 085e48ff69e519deda3d40ad6fc58c720d76c949 Mon Sep 17 00:00:00 2001 From: cialloo Date: Sun, 26 Oct 2025 17:35:19 +0800 Subject: [PATCH] feat: add BlogPost page with content fetching and loading states --- src/blog/BlogContentViewer.tsx | 105 +++++++++++++ src/main.tsx | 2 + src/pages/BlogPost.tsx | 268 +++++++++++++++++++++++++++++++++ 3 files changed, 375 insertions(+) create mode 100644 src/blog/BlogContentViewer.tsx create mode 100644 src/pages/BlogPost.tsx diff --git a/src/blog/BlogContentViewer.tsx b/src/blog/BlogContentViewer.tsx new file mode 100644 index 0000000..b03da53 --- /dev/null +++ b/src/blog/BlogContentViewer.tsx @@ -0,0 +1,105 @@ +import { useEffect } from 'react'; +import { LexicalComposer } from '@lexical/react/LexicalComposer'; +import type { InitialConfigType } from '@lexical/react/LexicalComposer'; +import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'; +import { ContentEditable } from '@lexical/react/LexicalContentEditable'; +import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary'; +import { HeadingNode, QuoteNode } from '@lexical/rich-text'; +import { CodeNode, CodeHighlightNode } from '@lexical/code'; +import { ListItemNode, ListNode } from '@lexical/list'; +import { HorizontalRuleNode } from '@lexical/react/LexicalHorizontalRuleNode'; +import { TableNode, TableRowNode, TableCellNode } from '@lexical/table'; +import { LinkNode, AutoLinkNode } from '@lexical/link'; +import { HashtagNode } from '@lexical/hashtag'; +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; + +import editorTheme from './themes/EditorTheme'; +import { ImageNode } from './nodes/ImageNode'; +import { MentionNode } from './nodes/MentionNode'; +import './styles/editor.css'; + +interface BlogContentViewerProps { + content: string; +} + +function ReadOnlyContentPlugin({ content }: { content: string }) { + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + editor.setEditable(false); + }, [editor]); + + useEffect(() => { + if (!content) { + return; + } + + try { + const editorState = editor.parseEditorState(content); + editor.setEditorState(editorState); + } catch (error) { + console.error('Failed to parse blog content', error); + } + }, [editor, content]); + + return null; +} + +const initialConfig: InitialConfigType = { + namespace: 'BlogContentViewer', + theme: editorTheme, + editable: false, + nodes: [ + HeadingNode, + QuoteNode, + CodeNode, + CodeHighlightNode, + ListNode, + ListItemNode, + HorizontalRuleNode, + TableNode, + TableRowNode, + TableCellNode, + LinkNode, + AutoLinkNode, + ImageNode, + HashtagNode, + MentionNode, + ], + onError(error: Error) { + console.error(error); + }, +}; + +export default function BlogContentViewer({ content }: BlogContentViewerProps) { + return ( + +
+ + } + placeholder={
} + ErrorBoundary={LexicalErrorBoundary} + /> + +
+ + ); +} diff --git a/src/main.tsx b/src/main.tsx index cc7f2df..6fca237 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -16,6 +16,7 @@ import Servers from './pages/Servers.tsx' import Forum from './pages/Forum.tsx' import AuthCallback from './pages/AuthCallback.tsx' import EditorDemo from './pages/EditorDemo.tsx' +import BlogPost from './pages/BlogPost.tsx' createRoot(document.getElementById('root')!).render( @@ -29,6 +30,7 @@ createRoot(document.getElementById('root')!).render( } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/pages/BlogPost.tsx b/src/pages/BlogPost.tsx new file mode 100644 index 0000000..1c4ee4c --- /dev/null +++ b/src/pages/BlogPost.tsx @@ -0,0 +1,268 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Link, useNavigate, useParams } from 'react-router-dom'; +import Layout from '../components/Layout'; +import BlogContentViewer from '../blog/BlogContentViewer'; +import { getBlogPost } from '../blog/api'; +import type { BlogPost as BlogPostType } from '../blog/types'; +import '../App.css'; + +function formatDate(timestamp: number | undefined): string { + if (!timestamp) { + return ''; + } + + const millis = timestamp < 1_000_000_000_000 ? timestamp * 1000 : timestamp; + return new Date(millis).toLocaleDateString(undefined, { + year: 'numeric', + month: 'long', + day: 'numeric', + }); +} + +function LoadingSkeleton() { + return ( +
+ {Array.from({ length: 6 }).map((_, index) => ( +
+ ))} +
+ ); +} + +export default function BlogPost() { + const { postId } = useParams<{ postId: string }>(); + const navigate = useNavigate(); + + const [post, setPost] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchPost = useCallback(async (id: string) => { + setIsLoading(true); + setError(null); + setPost(null); + + try { + const response = await getBlogPost(id); + setPost(response); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to load post'; + setError(message); + console.error('Failed to load blog post:', err); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + if (!postId) { + setError('Post not found'); + setIsLoading(false); + return; + } + + fetchPost(postId); + }, [postId, fetchPost]); + + const heroBackground = useMemo(() => { + if (post?.coverImageUrl) { + return `linear-gradient(180deg, rgba(0,0,0,0.55), rgba(0,0,0,0.82)), url(${post.coverImageUrl})`; + } + + return 'linear-gradient(135deg, rgba(58, 99, 233, 0.7), rgba(32, 46, 120, 0.9))'; + }, [post]); + + const handleBack = () => { + navigate('/blog'); + }; + + const handleRetry = () => { + if (postId) { + fetchPost(postId); + } + }; + + return ( + +
+
+
+
+
+
+ +
+

+ {post?.title || (isLoading ? 'Loading article...' : 'Article unavailable')} +

+
+ {post?.createdAt && Published {formatDate(post.createdAt)}} + {post?.updatedAt && Updated {formatDate(post.updatedAt)}} +
+
+
+ +
+
+ {error && ( +
+ {error} + +
+ )} + + {isLoading && !error && } + + {!isLoading && !error && post && } + + {!isLoading && !error && !post && ( +
+ This article is not available. +
+ )} + +
+ { + event.currentTarget.style.transform = 'translateY(-2px)'; + }} + onMouseLeave={(event) => { + event.currentTarget.style.transform = 'translateY(0)'; + }} + > + {'< Back to blog overview'} + +
+
+
+
+
+ ); +}