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
All checks were successful
CI - Build and Push / Build and Push Docker Image (push) Successful in 18s
This commit is contained in:
105
src/blog/BlogContentViewer.tsx
Normal file
105
src/blog/BlogContentViewer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
268
src/pages/BlogPost.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user