feat: add BlogPost page with content fetching and loading states
All checks were successful
CI - Build and Push / Build and Push Docker Image (push) Successful in 18s

This commit is contained in:
2025-10-26 17:35:19 +08:00
parent 47d6f93e97
commit 085e48ff69
3 changed files with 375 additions and 0 deletions

View File

@@ -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 (
<LexicalComposer initialConfig={initialConfig}>
<div
style={{
borderRadius: '16px',
border: '1px solid var(--border-color)',
background: 'var(--bg-card)',
padding: '2.5rem',
boxShadow: '0 10px 30px rgba(0, 0, 0, 0.12)',
}}
>
<RichTextPlugin
contentEditable={
<ContentEditable
className="blog-post-content"
style={{
outline: 'none',
fontSize: '1.05rem',
lineHeight: 1.8,
color: 'var(--text-primary)',
}}
/>
}
placeholder={<div />}
ErrorBoundary={LexicalErrorBoundary}
/>
<ReadOnlyContentPlugin content={content} />
</div>
</LexicalComposer>
);
}

View File

@@ -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(
<StrictMode>
@@ -29,6 +30,7 @@ createRoot(document.getElementById('root')!).render(
<Route path="/" element={<App />} />
<Route path="/friends" element={<Friends />} />
<Route path="/blog" element={<Blog />} />
<Route path="/blog/:postId" element={<BlogPost />} />
<Route path="/blog/create" element={<CreatePost />} />
<Route path="/servers" element={<Servers />} />
<Route path="/forum" element={<Forum />} />

268
src/pages/BlogPost.tsx Normal file
View File

@@ -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 (
<div style={{ display: 'grid', gap: '1.5rem' }}>
{Array.from({ length: 6 }).map((_, index) => (
<div
key={index}
style={{
height: index % 3 === 0 ? '28px' : '18px',
width: index % 3 === 0 ? '70%' : index % 3 === 1 ? '55%' : '95%',
background: 'linear-gradient(135deg, rgba(255,255,255,0.08), rgba(255,255,255,0.18))',
borderRadius: '8px',
animation: 'loading-pulse 1.6s ease-in-out infinite',
}}
/>
))}
</div>
);
}
export default function BlogPost() {
const { postId } = useParams<{ postId: string }>();
const navigate = useNavigate();
const [post, setPost] = useState<BlogPostType | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<Layout currentPage="blog">
<article>
<section
style={{
position: 'relative',
padding: '180px 2rem 120px',
color: '#fff',
textAlign: 'center',
overflow: 'hidden',
}}
>
<div
style={{
position: 'absolute',
inset: 0,
background: heroBackground,
backgroundSize: 'cover',
backgroundPosition: 'center',
filter: 'brightness(0.9)',
zIndex: 0,
transform: 'scale(1.05)',
}}
/>
<div
style={{
position: 'absolute',
inset: 0,
background: 'linear-gradient(180deg, rgba(12, 16, 37, 0.65), rgba(12, 16, 37, 0.92))',
zIndex: 1,
}}
/>
<div style={{ position: 'relative', zIndex: 2, maxWidth: '900px', margin: '0 auto' }}>
<div style={{ marginBottom: '2rem', display: 'flex', justifyContent: 'center' }}>
<button
type="button"
onClick={handleBack}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '0.5rem',
padding: '0.7rem 1.4rem',
borderRadius: '999px',
border: '1px solid rgba(255, 255, 255, 0.35)',
background: 'rgba(0, 0, 0, 0.25)',
color: '#fff',
fontWeight: 600,
cursor: 'pointer',
transition: 'all 0.2s ease',
backdropFilter: 'blur(6px)',
}}
onMouseEnter={(event) => {
event.currentTarget.style.background = 'rgba(0, 0, 0, 0.45)';
}}
onMouseLeave={(event) => {
event.currentTarget.style.background = 'rgba(0, 0, 0, 0.25)';
}}
>
{'< Back to blog'}
</button>
</div>
<h1
style={{
fontSize: '3.1rem',
fontWeight: 800,
marginBottom: '1.5rem',
lineHeight: 1.15,
textShadow: '0 12px 32px rgba(0, 0, 0, 0.45)',
}}
>
{post?.title || (isLoading ? 'Loading article...' : 'Article unavailable')}
</h1>
<div
style={{
display: 'flex',
justifyContent: 'center',
gap: '1.5rem',
fontSize: '1rem',
opacity: 0.85,
flexWrap: 'wrap',
}}
>
{post?.createdAt && <span>Published {formatDate(post.createdAt)}</span>}
{post?.updatedAt && <span>Updated {formatDate(post.updatedAt)}</span>}
</div>
</div>
</section>
<section style={{ padding: '80px 2rem 140px', background: 'var(--bg-primary)' }}>
<div style={{ maxWidth: '960px', margin: '0 auto' }}>
{error && (
<div
style={{
marginBottom: '2.5rem',
padding: '1.5rem 1.75rem',
borderRadius: '12px',
border: '1px solid rgba(244, 67, 54, 0.35)',
background: 'rgba(244, 67, 54, 0.12)',
color: '#ff7770',
display: 'flex',
justifyContent: 'space-between',
gap: '1.25rem',
alignItems: 'center',
}}
>
<span>{error}</span>
<button
type="button"
onClick={handleRetry}
style={{
padding: '0.6rem 1.4rem',
borderRadius: '8px',
border: 'none',
background: 'var(--accent-color)',
color: '#fff',
fontWeight: 600,
cursor: 'pointer',
}}
>
Retry
</button>
</div>
)}
{isLoading && !error && <LoadingSkeleton />}
{!isLoading && !error && post && <BlogContentViewer content={post.content} />}
{!isLoading && !error && !post && (
<div
style={{
padding: '3rem',
borderRadius: '12px',
border: '1px dashed var(--border-color)',
background: 'var(--bg-card)',
color: 'var(--text-secondary)',
textAlign: 'center',
}}
>
This article is not available.
</div>
)}
<div style={{ marginTop: '3rem', display: 'flex', justifyContent: 'center' }}>
<Link
to="/blog"
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '0.5rem',
padding: '0.9rem 1.8rem',
borderRadius: '999px',
border: '1px solid var(--border-color)',
background: 'var(--bg-card)',
color: 'var(--text-primary)',
fontWeight: 600,
textDecoration: 'none',
transition: 'transform 0.2s ease',
}}
onMouseEnter={(event) => {
event.currentTarget.style.transform = 'translateY(-2px)';
}}
onMouseLeave={(event) => {
event.currentTarget.style.transform = 'translateY(0)';
}}
>
{'< Back to blog overview'}
</Link>
</div>
</div>
</section>
</article>
</Layout>
);
}