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