feat: enhance EditPost component with improved styling and structured subcomponents for title, cover image, and content editing
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 21:13:18 +08:00
parent 7b83d00f85
commit 2909daa41e

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import Layout from '../components/Layout';
import BlogEditor, { type BlogEditorRef } from '../blog/BlogEditor';
@@ -7,6 +7,307 @@ import { Toast } from '../components/Toast';
import { useToast } from '../hooks/useToast';
import '../App.css';
const styles = {
page: {
maxWidth: '900px',
margin: '0 auto',
padding: '120px 2rem 80px',
},
header: {
marginBottom: '3rem',
},
heading: {
fontSize: '2.5rem',
fontWeight: 'bold',
color: 'var(--text-primary)',
marginBottom: '1rem',
},
subHeading: {
fontSize: '1.1rem',
color: 'var(--text-secondary)',
},
fieldWrapper: {
marginBottom: '2rem',
},
label: {
display: 'block',
fontSize: '1rem',
fontWeight: 600,
color: 'var(--text-primary)',
marginBottom: '0.5rem',
},
textInput: {
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',
},
coverDropZone: {
border: '2px dashed var(--border-color)',
borderRadius: '8px',
padding: '2rem',
textAlign: 'center' as const,
background: 'var(--bg-card)',
cursor: 'pointer',
},
coverPreview: {
position: 'relative' as const,
},
coverImage: {
maxWidth: '100%',
maxHeight: '400px',
borderRadius: '8px',
},
uploadOverlay: {
position: 'absolute' as const,
bottom: '10px',
left: '50%',
transform: 'translateX(-50%)',
background: 'rgba(0, 0, 0, 0.8)',
padding: '10px 20px',
borderRadius: '8px',
color: 'white',
minWidth: '200px',
},
progressTrack: {
background: 'rgba(255, 255, 255, 0.2)',
borderRadius: '4px',
overflow: 'hidden',
height: '6px',
marginBottom: '5px',
},
progressBar: {
background: 'var(--accent-color, #4CAF50)',
height: '100%',
transition: 'width 0.3s ease',
},
helperText: {
color: 'var(--text-secondary)',
fontSize: '1rem',
},
helperCaption: {
color: 'var(--text-secondary)',
fontSize: '0.9rem',
marginTop: '0.5rem',
},
errorBanner: {
marginTop: '1rem',
padding: '1rem',
background: 'rgba(244, 67, 54, 0.1)',
border: '1px solid rgba(244, 67, 54, 0.3)',
borderRadius: '8px',
color: '#F44336',
},
editorWrapper: {
border: '2px solid var(--border-color)',
borderRadius: '8px',
overflow: 'hidden',
background: 'var(--bg-card)',
},
actions: {
display: 'flex',
gap: '1rem',
justifyContent: 'flex-end',
},
secondaryButton: {
padding: '1rem 2rem',
fontSize: '1rem',
fontWeight: 600,
border: '2px solid var(--border-color)',
borderRadius: '8px',
background: 'transparent',
color: 'var(--text-primary)',
cursor: 'pointer',
},
primaryButton: {
padding: '1rem 2rem',
fontSize: '1rem',
fontWeight: 600,
border: 'none',
borderRadius: '8px',
background: 'var(--accent-color)',
color: 'white',
cursor: 'pointer',
transition: 'transform 0.2s ease',
},
disabledButton: {
background: 'var(--bg-secondary)',
cursor: 'not-allowed',
opacity: 0.6,
boxShadow: 'none',
transform: 'none',
},
loading: {
color: 'var(--text-secondary)',
},
errorContainer: {
padding: '2rem',
borderRadius: '12px',
border: '1px solid rgba(244, 67, 54, 0.3)',
background: 'rgba(244, 67, 54, 0.1)',
color: '#F44336',
},
};
interface TitleFieldProps {
title: string;
onChange: (value: string) => void;
}
function TitleField({ title, onChange }: TitleFieldProps) {
return (
<div style={styles.fieldWrapper}>
<label style={styles.label}>Title *</label>
<input
type="text"
value={title}
onChange={(event) => onChange(event.target.value)}
placeholder="Enter your blog post title..."
style={styles.textInput}
/>
</div>
);
}
interface CoverImageSectionProps {
coverImage: string | null;
uploadError: string;
isUploading: boolean;
uploadProgress: number;
fileInputRef: React.RefObject<HTMLInputElement | null>;
onFileSelected: (event: ChangeEvent<HTMLInputElement>) => void;
}
function CoverImageSection({
coverImage,
uploadError,
isUploading,
uploadProgress,
fileInputRef,
onFileSelected,
}: CoverImageSectionProps) {
return (
<div style={styles.fieldWrapper}>
<label style={styles.label}>Cover Image *</label>
<div
style={styles.coverDropZone}
role="button"
tabIndex={0}
onClick={() => fileInputRef.current?.click()}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
fileInputRef.current?.click();
}
}}
>
{coverImage ? (
<div style={styles.coverPreview}>
<img src={coverImage} alt="Cover" style={styles.coverImage} />
{isUploading && (
<div style={styles.uploadOverlay}>
<div style={styles.progressTrack}>
<div
style={{
...styles.progressBar,
width: `${uploadProgress}%`,
}}
/>
</div>
<div style={{ fontSize: '14px' }}>Uploading... {Math.round(uploadProgress)}%</div>
</div>
)}
</div>
) : (
<div>
<div style={{ fontSize: '3rem', marginBottom: '1rem' }}>📷</div>
<p style={styles.helperText}>Click or press Enter to upload a cover image</p>
<p style={styles.helperCaption}>PNG, JPG, GIF up to 10MB</p>
</div>
)}
</div>
{uploadError && <div style={styles.errorBanner}> {uploadError}</div>}
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={onFileSelected}
style={{ display: 'none' }}
/>
</div>
);
}
interface EditorSectionProps {
editorRef: React.RefObject<BlogEditorRef | null>;
initialContent?: string;
}
function EditorSection({ editorRef, initialContent }: EditorSectionProps) {
return (
<div style={styles.fieldWrapper}>
<label style={styles.label}>Content *</label>
<div style={styles.editorWrapper}>
<BlogEditor ref={editorRef} initialContent={initialContent} />
</div>
</div>
);
}
interface ActionButtonsProps {
onCancel: () => void;
onSubmit: () => void;
disabled: boolean;
submitLabel: string;
}
function ActionButtons({ onCancel, onSubmit, disabled, submitLabel }: ActionButtonsProps) {
return (
<div style={styles.actions}>
<button type="button" style={styles.secondaryButton} onClick={onCancel}>
Cancel
</button>
<button
type="button"
onClick={onSubmit}
disabled={disabled}
style={{
...styles.primaryButton,
...(disabled ? styles.disabledButton : {}),
}}
>
{submitLabel}
</button>
</div>
);
}
interface ErrorStateProps {
message: string;
}
function ErrorState({ message }: ErrorStateProps) {
return <div style={styles.errorContainer}>{message}</div>;
}
function LoadingState() {
return <div style={styles.loading}>Loading post...</div>;
}
function PageHeader() {
return (
<header style={styles.header}>
<h1 style={styles.heading}>Edit Post</h1>
<p style={styles.subHeading}>Update your article content and cover image</p>
</header>
);
}
function EditPost() {
const { postId } = useParams<{ postId: string }>();
const navigate = useNavigate();
@@ -14,19 +315,23 @@ function EditPost() {
const fileInputRef = useRef<HTMLInputElement>(null);
const { toasts, removeToast, success, error } = useToast();
const [isLoading, setIsLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [title, setTitle] = useState('');
const [initialContent, setInitialContent] = useState<string | undefined>(undefined);
const [initialContent, setInitialContent] = useState<string | undefined>();
const [coverImage, setCoverImage] = useState<string | null>(null);
const [originalCoverImageKey, setOriginalCoverImageKey] = useState<string>('');
const [originalCoverImageKey, setOriginalCoverImageKey] = useState('');
const [newCoverImageKey, setNewCoverImageKey] = useState<string | null>(null);
const [uploadProgress, setUploadProgress] = useState(0);
const [isUploading, setIsUploading] = useState(false);
const [uploadError, setUploadError] = useState('');
const [isLoading, setIsLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [uploadError, setUploadError] = useState('');
const effectiveCoverImageKey = useMemo(
() => newCoverImageKey ?? originalCoverImageKey,
[newCoverImageKey, originalCoverImageKey],
);
const loadPost = useCallback(async () => {
if (!postId) {
@@ -43,9 +348,7 @@ function EditPost() {
setTitle(fetched.title);
setInitialContent(fetched.content);
setCoverImage(fetched.coverImageUrl ?? null);
const fetchedCoverKey = fetched.coverImageKey ?? '';
setOriginalCoverImageKey(fetchedCoverKey);
setOriginalCoverImageKey(fetched.coverImageKey ?? '');
setNewCoverImageKey(null);
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to load post.';
@@ -60,46 +363,49 @@ function EditPost() {
void loadPost();
}, [loadPost]);
const handleCoverImageChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) {
return;
}
const handleCoverImageChange = useCallback(
async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) {
return;
}
if (!file.type.startsWith('image/')) {
setUploadError('Please select an image 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);
const reader = new FileReader();
reader.onload = (readerEvent) => {
setCoverImage(readerEvent.target?.result as string);
};
reader.readAsDataURL(file);
setIsUploading(true);
setUploadError('');
setUploadProgress(0);
setIsUploading(true);
setUploadError('');
setUploadProgress(0);
try {
const { fileKey, url } = await uploadImage(file, (progress) => {
setUploadProgress(progress);
});
try {
const { fileKey, url } = await uploadImage(file, (progress) => {
setUploadProgress(progress);
});
setNewCoverImageKey(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);
}
};
setNewCoverImageKey(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);
}
},
[error, success],
);
const handleSubmit = async () => {
const handleSubmit = useCallback(async () => {
if (!postId) {
error('Invalid post identifier.');
return;
@@ -116,24 +422,20 @@ function EditPost() {
}
const content = editorRef.current.getEditorState();
setIsSubmitting(true);
try {
const coverKeyToSend = newCoverImageKey ?? originalCoverImageKey;
const payload: Parameters<typeof updateBlogPost>[0] = {
postId,
title: title.trim(),
content,
};
if (coverKeyToSend) {
payload.coverImageKey = coverKeyToSend;
if (effectiveCoverImageKey) {
payload.coverImageKey = effectiveCoverImageKey;
}
await updateBlogPost(payload);
success('Blog post updated successfully!', 2000);
setTimeout(() => {
@@ -145,292 +447,37 @@ function EditPost() {
} finally {
setIsSubmitting(false);
}
};
}, [effectiveCoverImageKey, error, navigate, postId, success, title]);
const handleCancel = useCallback(() => {
navigate(postId ? `/blog/${postId}` : '/blog');
}, [navigate, postId]);
return (
<Layout currentPage="blog">
<Toast toasts={toasts} onRemove={removeToast} />
<div
style={{
maxWidth: '900px',
margin: '0 auto',
padding: '120px 2rem 80px',
}}
>
{isLoading ? (
<div style={{ color: 'var(--text-secondary)' }}>Loading post...</div>
) : loadError ? (
<div
style={{
padding: '2rem',
borderRadius: '12px',
border: '1px solid rgba(244, 67, 54, 0.3)',
background: 'rgba(244, 67, 54, 0.1)',
color: '#F44336',
}}
>
{loadError}
</div>
) : (
<div style={styles.page}>
{isLoading && <LoadingState />}
{!isLoading && loadError && <ErrorState message={loadError} />}
{!isLoading && !loadError && (
<>
<div style={{ marginBottom: '3rem' }}>
<h1
style={{
fontSize: '2.5rem',
fontWeight: 'bold',
color: 'var(--text-primary)',
marginBottom: '1rem',
}}
>
Edit Post
</h1>
<p style={{ fontSize: '1.1rem', color: 'var(--text-secondary)' }}>
Update your article content and cover image
</p>
</div>
<div style={{ marginBottom: '2rem' }}>
<label
style={{
display: 'block',
fontSize: '1rem',
fontWeight: 600,
color: 'var(--text-primary)',
marginBottom: '0.5rem',
}}
>
Title *
</label>
<input
type="text"
value={title}
onChange={(e) => 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)';
}}
/>
</div>
<div style={{ marginBottom: '2rem' }}>
<label
style={{
display: 'block',
fontSize: '1rem',
fontWeight: 600,
color: 'var(--text-primary)',
marginBottom: '0.5rem',
}}
>
Cover Image *
</label>
<div
style={{
border: '2px dashed var(--border-color)',
borderRadius: '8px',
padding: '2rem',
textAlign: 'center',
background: 'var(--bg-card)',
cursor: 'pointer',
transition: 'border-color 0.3s ease',
}}
onClick={() => fileInputRef.current?.click()}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = 'var(--accent-color)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--border-color)';
}}
>
{coverImage ? (
<div style={{ position: 'relative' }}>
<img
src={coverImage}
alt="Cover"
style={{
maxWidth: '100%',
maxHeight: '400px',
borderRadius: '8px',
}}
/>
{isUploading && (
<div
style={{
position: 'absolute',
bottom: '10px',
left: '50%',
transform: 'translateX(-50%)',
background: 'rgba(0, 0, 0, 0.8)',
padding: '10px 20px',
borderRadius: '8px',
color: 'white',
minWidth: '200px',
}}
>
<div
style={{
background: 'rgba(255, 255, 255, 0.2)',
borderRadius: '4px',
overflow: 'hidden',
height: '6px',
marginBottom: '5px',
}}
>
<div
style={{
background: 'var(--accent-color, #4CAF50)',
height: '100%',
width: `${uploadProgress}%`,
transition: 'width 0.3s ease',
}}
/>
</div>
<div style={{ fontSize: '14px' }}>
Uploading... {Math.round(uploadProgress)}%
</div>
</div>
)}
</div>
) : (
<div>
<div style={{ fontSize: '3rem', marginBottom: '1rem' }}>📷</div>
<p style={{ color: 'var(--text-secondary)', fontSize: '1rem' }}>
Click to upload cover image
</p>
<p
style={{
color: 'var(--text-secondary)',
fontSize: '0.9rem',
marginTop: '0.5rem',
}}
>
PNG, JPG, GIF up to 10MB
</p>
</div>
)}
</div>
{uploadError && (
<div
style={{
marginTop: '1rem',
padding: '1rem',
background: 'rgba(244, 67, 54, 0.1)',
border: '1px solid rgba(244, 67, 54, 0.3)',
borderRadius: '8px',
color: '#F44336',
}}
>
{uploadError}
</div>
)}
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleCoverImageChange}
style={{ display: 'none' }}
/>
</div>
<div style={{ marginBottom: '2rem' }}>
<label
style={{
display: 'block',
fontSize: '1rem',
fontWeight: 600,
color: 'var(--text-primary)',
marginBottom: '0.5rem',
}}
>
Content *
</label>
<div
style={{
border: '2px solid var(--border-color)',
borderRadius: '8px',
overflow: 'hidden',
background: 'var(--bg-card)',
}}
>
<BlogEditor ref={editorRef} initialContent={initialContent} />
</div>
</div>
<div
style={{
display: 'flex',
gap: '1rem',
justifyContent: 'flex-end',
}}
>
<button
onClick={() => navigate(`/blog/${postId}`)}
style={{
padding: '1rem 2rem',
fontSize: '1rem',
fontWeight: 600,
border: '2px solid var(--border-color)',
borderRadius: '8px',
background: 'transparent',
color: 'var(--text-primary)',
cursor: 'pointer',
transition: 'all 0.3s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--bg-secondary)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
}}
>
Cancel
</button>
<button
onClick={handleSubmit}
disabled={isSubmitting || isUploading}
style={{
padding: '1rem 2rem',
fontSize: '1rem',
fontWeight: 600,
border: 'none',
borderRadius: '8px',
background: isSubmitting || isUploading ? 'var(--bg-secondary)' : 'var(--accent-color)',
color: 'white',
cursor: isSubmitting || isUploading ? 'not-allowed' : 'pointer',
transition: 'all 0.3s ease',
opacity: isSubmitting || isUploading ? 0.6 : 1,
}}
onMouseEnter={(e) => {
if (!isSubmitting && !isUploading) {
e.currentTarget.style.transform = 'translateY(-2px)';
e.currentTarget.style.boxShadow = '0 4px 12px var(--accent-shadow)';
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = 'none';
}}
>
{isSubmitting ? 'Saving...' : 'Save Changes'}
</button>
</div>
<PageHeader />
<TitleField title={title} onChange={setTitle} />
<CoverImageSection
coverImage={coverImage}
uploadError={uploadError}
isUploading={isUploading}
uploadProgress={uploadProgress}
fileInputRef={fileInputRef}
onFileSelected={handleCoverImageChange}
/>
<EditorSection editorRef={editorRef} initialContent={initialContent} />
<ActionButtons
onCancel={handleCancel}
onSubmit={handleSubmit}
disabled={isSubmitting || isUploading}
submitLabel={isSubmitting ? 'Saving...' : 'Save Changes'}
/>
</>
)}
</div>