From 1f6bb776918eb48e639b53a0157a7936c84cac3d Mon Sep 17 00:00:00 2001 From: cialloo Date: Sun, 26 Oct 2025 18:21:45 +0800 Subject: [PATCH] feat: implement EditPost page with functionality to update existing blog posts --- src/blog/BlogEditor.tsx | 35 +++- src/blog/api.ts | 15 ++ src/blog/types.ts | 12 ++ src/main.tsx | 2 + src/pages/Blog.tsx | 25 --- src/pages/BlogPost.tsx | 89 +++++--- src/pages/CreatePost.tsx | 8 +- src/pages/EditPost.tsx | 436 +++++++++++++++++++++++++++++++++++++++ 8 files changed, 566 insertions(+), 56 deletions(-) create mode 100644 src/pages/EditPost.tsx diff --git a/src/blog/BlogEditor.tsx b/src/blog/BlogEditor.tsx index 58eea01..987a8fe 100644 --- a/src/blog/BlogEditor.tsx +++ b/src/blog/BlogEditor.tsx @@ -1,4 +1,5 @@ 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 { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin'; @@ -15,7 +16,7 @@ import { LinkNode, AutoLinkNode } from '@lexical/link'; import { LinkPlugin } from '@lexical/react/LexicalLinkPlugin'; import { AutoLinkPlugin } from '@lexical/react/LexicalAutoLinkPlugin'; import { HashtagNode } from '@lexical/hashtag'; -import { forwardRef, useImperativeHandle, useEffect } from 'react'; +import { forwardRef, useImperativeHandle, useEffect, useRef } from 'react'; import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; import { ImageNode } from './nodes/ImageNode'; @@ -66,7 +67,7 @@ const MATCHERS = [ }, ]; -const editorConfig = { +const editorConfig: InitialConfigType = { namespace: 'BlogEditor', theme: editorTheme, onError(error: Error) { @@ -95,6 +96,10 @@ export interface BlogEditorRef { getEditorState: () => string; } +interface BlogEditorProps { + initialContent?: string; +} + // Plugin to expose editor instance via ref function EditorRefPlugin({ editorRef }: { editorRef: React.Ref }) { const [editor] = useLexicalComposerContext(); @@ -108,7 +113,30 @@ function EditorRefPlugin({ editorRef }: { editorRef: React.Ref }) return null; } -const BlogEditor = forwardRef((_, ref) => { +function InitialContentPlugin({ initialContent }: { initialContent?: string }) { + const [editor] = useLexicalComposerContext(); + const appliedContent = useRef(undefined); + + useEffect(() => { + if (initialContent === undefined || initialContent === appliedContent.current) { + return; + } + + try { + const editorState = initialContent + ? editor.parseEditorState(initialContent) + : editor.parseEditorState('{"root":{"children":[],"direction":null,"format":"","indent":0,"type":"root","version":1}}'); + editor.setEditorState(editorState); + appliedContent.current = initialContent; + } catch (error) { + console.error('Failed to set initial editor content:', error); + } + }, [editor, initialContent]); + + return null; +} + +const BlogEditor = forwardRef(({ initialContent }, ref) => { const { toasts, removeToast, success, error } = useToast(); // Setup toast handler for drag/drop plugin @@ -150,6 +178,7 @@ const BlogEditor = forwardRef((_, ref) => { + diff --git a/src/blog/api.ts b/src/blog/api.ts index bfd7445..021f8b7 100644 --- a/src/blog/api.ts +++ b/src/blog/api.ts @@ -11,6 +11,8 @@ import type { ListPostsResponse, ListTagsResponse, GetPostResponse, + UpdatePostPayload, + UpdatePostResponse, } from './types'; const API_BASE = '/api/blog'; @@ -113,6 +115,19 @@ export async function createBlogPost( return response.json(); } +/** + * Update an existing blog post + */ +export async function updateBlogPost(payload: UpdatePostPayload): Promise { + const response = await apiPost(`${API_BASE}/post/update`, payload); + + if (!response.ok) { + throw new Error(`Failed to update post: ${response.statusText}`); + } + + return response.json(); +} + /** * Fetch a paginated list of blog posts */ diff --git a/src/blog/types.ts b/src/blog/types.ts index 6f74c06..7e02f58 100644 --- a/src/blog/types.ts +++ b/src/blog/types.ts @@ -31,6 +31,7 @@ export interface BlogPostSummary { postId: string; title: string; coverImageUrl?: string; + coverImageKey?: string; createdAt: number; updatedAt: number; } @@ -39,6 +40,17 @@ export interface BlogPost extends BlogPostSummary { content: string; } +export interface UpdatePostPayload { + postId: string; + title: string; + content: string; + coverImageKey?: string; +} + +export interface UpdatePostResponse { + postId: string; +} + export interface ListPostsRequest { page: number; pageSize: number; diff --git a/src/main.tsx b/src/main.tsx index 6fca237..51c67d7 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -17,6 +17,7 @@ import Forum from './pages/Forum.tsx' import AuthCallback from './pages/AuthCallback.tsx' import EditorDemo from './pages/EditorDemo.tsx' import BlogPost from './pages/BlogPost.tsx' +import EditPost from './pages/EditPost.tsx' createRoot(document.getElementById('root')!).render( @@ -31,6 +32,7 @@ createRoot(document.getElementById('root')!).render( } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/pages/Blog.tsx b/src/pages/Blog.tsx index 6807a07..3931aa4 100644 --- a/src/pages/Blog.tsx +++ b/src/pages/Blog.tsx @@ -162,31 +162,6 @@ function Blog() { {t('blog.subtitle')}

- { - event.currentTarget.style.transform = 'translateY(-2px)'; - event.currentTarget.style.boxShadow = '0 6px 18px var(--accent-shadow)'; - }} - onMouseLeave={(event) => { - event.currentTarget.style.transform = 'translateY(0)'; - event.currentTarget.style.boxShadow = 'none'; - }} - > - {t('blog.readMore')} - {selectedTags.length > 0 && ( +
+
+ +
+ {isAuthenticated && post && postId && ( + + )}

(null); const { toasts, removeToast, success, error } = useToast(); + const { isAuthenticated } = useAuth(); const [title, setTitle] = useState(''); const [coverImage, setCoverImage] = useState(null); const [coverImageKey, setCoverImageKey] = useState(''); @@ -99,6 +101,10 @@ function CreatePost() { } }; + if (!isAuthenticated) { + return ; + } + return ( diff --git a/src/pages/EditPost.tsx b/src/pages/EditPost.tsx new file mode 100644 index 0000000..9e14aee --- /dev/null +++ b/src/pages/EditPost.tsx @@ -0,0 +1,436 @@ +import { useEffect, useRef, useState } from 'react'; +import { Navigate, useNavigate, useParams } from 'react-router-dom'; +import Layout from '../components/Layout'; +import BlogEditor, { type BlogEditorRef } from '../blog/BlogEditor'; +import { getBlogPost, updateBlogPost, uploadImage } from '../blog/api'; +import { Toast } from '../components/Toast'; +import { useToast } from '../hooks/useToast'; +import { useAuth } from '../contexts/AuthContext'; +import '../App.css'; + +function EditPost() { + const { postId } = useParams<{ postId: string }>(); + const navigate = useNavigate(); + const editorRef = useRef(null); + const fileInputRef = useRef(null); + const { toasts, removeToast, success, error } = useToast(); + const { isAuthenticated } = useAuth(); + + const [isLoading, setIsLoading] = useState(true); + const [loadError, setLoadError] = useState(null); + + const [title, setTitle] = useState(''); + const [initialContent, setInitialContent] = useState(undefined); + const [coverImage, setCoverImage] = useState(null); + const [coverImageKey, setCoverImageKey] = useState(''); + + const [uploadProgress, setUploadProgress] = useState(0); + const [isUploading, setIsUploading] = useState(false); + const [uploadError, setUploadError] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + + useEffect(() => { + if (!postId) { + setLoadError('Post not found.'); + setIsLoading(false); + return; + } + + (async () => { + try { + const fetched = await getBlogPost(postId); + setTitle(fetched.title); + setInitialContent(fetched.content); + setCoverImage(fetched.coverImageUrl ?? null); + setCoverImageKey(fetched.coverImageKey ?? ''); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to load post.'; + setLoadError(message); + console.error('Failed to load blog post for editing:', err); + } finally { + setIsLoading(false); + } + })(); + }, [postId]); + + const handleCoverImageChange = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) { + return; + } + + if (!file.type.startsWith('image/')) { + setUploadError('Please select an image file'); + return; + } + + const reader = new FileReader(); + reader.onload = (e) => { + setCoverImage(e.target?.result as string); + }; + reader.readAsDataURL(file); + + setIsUploading(true); + setUploadError(''); + setUploadProgress(0); + + try { + const { fileKey, url } = await uploadImage(file, (progress) => { + setUploadProgress(progress); + }); + + setCoverImageKey(fileKey); + setCoverImage(url); + setUploadProgress(100); + success('Cover image uploaded successfully!', 2000); + } catch (err) { + const message = err instanceof Error ? err.message : 'Upload failed'; + setUploadError(message); + error(`Cover image upload failed: ${message}`); + } finally { + setIsUploading(false); + } + }; + + const handleSubmit = async () => { + if (!postId) { + error('Invalid post identifier.'); + return; + } + + if (!title.trim()) { + error('Please enter a title'); + return; + } + + if (!editorRef.current) { + error('Editor not initialized'); + return; + } + + const content = editorRef.current.getEditorState(); + + if (!coverImageKey) { + error('Please upload a cover image'); + return; + } + + setIsSubmitting(true); + + try { + await updateBlogPost({ + postId, + title: title.trim(), + content, + coverImageKey, + }); + + success('Blog post updated successfully!', 2000); + + setTimeout(() => { + navigate(`/blog/${postId}`); + }, 1500); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to update blog post'; + error(`Failed to update post: ${message}`); + } finally { + setIsSubmitting(false); + } + }; + + if (!isAuthenticated) { + return ; + } + + return ( + + +
+ {isLoading ? ( +
Loading post...
+ ) : loadError ? ( +
+ {loadError} +
+ ) : ( + <> +
+

+ Edit Post +

+

+ Update your article content and cover image +

+
+ +
+ + setTitle(e.target.value)} + placeholder="Enter your blog post title..." + style={{ + width: '100%', + padding: '1rem', + fontSize: '1.2rem', + border: '2px solid var(--border-color)', + borderRadius: '8px', + background: 'var(--bg-card)', + color: 'var(--text-primary)', + outline: 'none', + transition: 'border-color 0.3s ease', + }} + onFocus={(e) => { + e.currentTarget.style.borderColor = 'var(--accent-color)'; + }} + onBlur={(e) => { + e.currentTarget.style.borderColor = 'var(--border-color)'; + }} + /> +
+ +
+ + +
fileInputRef.current?.click()} + onMouseEnter={(e) => { + e.currentTarget.style.borderColor = 'var(--accent-color)'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.borderColor = 'var(--border-color)'; + }} + > + {coverImage ? ( +
+ Cover + {isUploading && ( +
+
+
+
+
+ Uploading... {Math.round(uploadProgress)}% +
+
+ )} +
+ ) : ( +
+
📷
+

+ Click to upload cover image +

+

+ PNG, JPG, GIF up to 10MB +

+
+ )} +
+ + {uploadError && ( +
+ ❌ {uploadError} +
+ )} + + +
+ +
+ +
+ +
+
+ +
+ + +
+ + )} +
+ + ); +} + +export default EditPost;