feat(blog): add blog post creation page with image upload and content editor
All checks were successful
CI - Build and Push / Build and Push Docker Image (push) Successful in 22s

This commit is contained in:
2025-10-25 14:31:26 +08:00
parent 4417423612
commit 1f2cb3268f
4 changed files with 447 additions and 1 deletions

View File

@@ -5,6 +5,7 @@
// Pages // Pages
export { default as BlogListPage } from './pages/BlogListPage' export { default as BlogListPage } from './pages/BlogListPage'
export { default as BlogPostPage } from './pages/BlogPostPage' export { default as BlogPostPage } from './pages/BlogPostPage'
export { default as BlogCreatePage } from './pages/BlogCreatePage'
// Components // Components
export { default as BlogPostCard } from './components/BlogPostCard' export { default as BlogPostCard } from './components/BlogPostCard'

View File

@@ -0,0 +1,416 @@
/**
* Blog Post Creation Page
* Admin interface for creating new blog posts
*/
import { useState, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import Layout from '../../components/Layout'
import BlogEditor from '../editor/BlogEditor'
import { createPost, uploadFile } from '../api'
import '../../App.css'
export default function BlogCreatePage() {
const navigate = useNavigate()
const [title, setTitle] = useState('')
const [coverImage, setCoverImage] = useState<File | null>(null)
const [coverImagePreview, setCoverImagePreview] = useState<string>('')
const [isUploading, setIsUploading] = useState(false)
const [error, setError] = useState<string | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
// Get editor content - we'll use a simple approach since BlogEditor doesn't expose ref
const [editorContent] = useState('')
const handleCoverImageSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
setCoverImage(file)
const reader = new FileReader()
reader.onloadend = () => {
setCoverImagePreview(reader.result as string)
}
reader.readAsDataURL(file)
}
}
const handleRemoveCoverImage = () => {
setCoverImage(null)
setCoverImagePreview('')
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!title.trim()) {
setError('Please enter a title')
return
}
if (!editorContent.trim()) {
setError('Please enter some content')
return
}
setIsUploading(true)
setError(null)
try {
// Upload cover image if selected
let coverImageKey = ''
if (coverImage) {
coverImageKey = await uploadFile(coverImage)
}
// Create the blog post
const { postId } = await createPost({
title: title.trim(),
content: editorContent,
coverImageKey,
})
// Show success message
alert('Blog post created successfully!')
// Navigate to the created post
navigate(`/blog/${postId}`)
} catch (err) {
console.error('Failed to create blog post:', err)
setError(err instanceof Error ? err.message : 'Failed to create blog post')
} finally {
setIsUploading(false)
}
}
const handleCancel = () => {
if (confirm('Are you sure you want to cancel? Your changes will be lost.')) {
navigate('/blog')
}
}
return (
<Layout currentPage="blog">
<section
style={{
padding: '120px 2rem 80px',
minHeight: '100vh',
}}
>
<div style={{ maxWidth: '1000px', margin: '0 auto' }}>
{/* Header */}
<div style={{ marginBottom: '3rem' }}>
<h1
style={{
fontSize: '2.5rem',
fontWeight: 'bold',
color: 'var(--text-primary)',
marginBottom: '0.5rem',
}}
>
Create New Blog Post
</h1>
<p style={{ color: 'var(--text-secondary)', fontSize: '1.1rem' }}>
Write and publish a new article
</p>
</div>
{/* Error Message */}
{error && (
<div
style={{
background: '#fee',
border: '1px solid #fcc',
borderRadius: '8px',
padding: '1rem',
marginBottom: '2rem',
color: '#c33',
}}
>
{error}
</div>
)}
{/* Form */}
<form onSubmit={handleSubmit}>
{/* Title Input */}
<div style={{ marginBottom: '2rem' }}>
<label
htmlFor="title"
style={{
display: 'block',
color: 'var(--text-primary)',
fontWeight: 'bold',
marginBottom: '0.5rem',
fontSize: '1.1rem',
}}
>
Title *
</label>
<input
id="title"
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Enter your blog post title..."
required
style={{
width: '100%',
padding: '1rem',
fontSize: '1.2rem',
border: '2px solid var(--border-color)',
borderRadius: '8px',
background: 'var(--bg-secondary)',
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>
{/* Cover Image Upload */}
<div style={{ marginBottom: '2rem' }}>
<label
style={{
display: 'block',
color: 'var(--text-primary)',
fontWeight: 'bold',
marginBottom: '0.5rem',
fontSize: '1.1rem',
}}
>
Cover Image
</label>
{!coverImagePreview ? (
<div
onClick={() => fileInputRef.current?.click()}
style={{
border: '2px dashed var(--border-color)',
borderRadius: '8px',
padding: '3rem',
textAlign: 'center',
cursor: 'pointer',
background: 'var(--bg-secondary)',
transition: 'border-color 0.3s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = 'var(--accent-color)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--border-color)'
}}
>
<div style={{ fontSize: '3rem', marginBottom: '1rem' }}>📷</div>
<p style={{ color: 'var(--text-primary)', marginBottom: '0.5rem' }}>
Click to upload cover image
</p>
<p style={{ color: 'var(--text-secondary)', fontSize: '0.9rem' }}>
PNG, JPG, GIF up to 10MB
</p>
</div>
) : (
<div
style={{
position: 'relative',
border: '2px solid var(--border-color)',
borderRadius: '8px',
overflow: 'hidden',
}}
>
<img
src={coverImagePreview}
alt="Cover preview"
style={{
width: '100%',
height: '300px',
objectFit: 'cover',
}}
/>
<button
type="button"
onClick={handleRemoveCoverImage}
style={{
position: 'absolute',
top: '1rem',
right: '1rem',
background: '#ef4444',
color: 'white',
border: 'none',
borderRadius: '50%',
width: '40px',
height: '40px',
cursor: 'pointer',
fontSize: '1.5rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 2px 8px rgba(0,0,0,0.2)',
}}
title="Remove cover image"
>
×
</button>
</div>
)}
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleCoverImageSelect}
style={{ display: 'none' }}
/>
</div>
{/* Content Editor */}
<div style={{ marginBottom: '2rem' }}>
<label
style={{
display: 'block',
color: 'var(--text-primary)',
fontWeight: 'bold',
marginBottom: '0.5rem',
fontSize: '1.1rem',
}}
>
Content *
</label>
<div
style={{
border: '2px solid var(--border-color)',
borderRadius: '8px',
overflow: 'hidden',
background: 'var(--bg-card)',
}}
>
<BlogEditor placeholder="Start writing your blog post... You can drag and drop images directly into the editor!" />
</div>
<p
style={{
color: 'var(--text-secondary)',
fontSize: '0.9rem',
marginTop: '0.5rem',
}}
>
💡 Tip: You can drag and drop or paste images directly into the editor. They will be automatically uploaded to S3.
</p>
</div>
{/* Action Buttons */}
<div
style={{
display: 'flex',
gap: '1rem',
justifyContent: 'flex-end',
paddingTop: '2rem',
borderTop: '1px solid var(--border-color)',
}}
>
<button
type="button"
onClick={handleCancel}
disabled={isUploading}
style={{
padding: '1rem 2rem',
fontSize: '1rem',
fontWeight: 'bold',
border: '2px solid var(--border-color)',
borderRadius: '8px',
background: 'transparent',
color: 'var(--text-primary)',
cursor: isUploading ? 'not-allowed' : 'pointer',
opacity: isUploading ? 0.5 : 1,
transition: 'all 0.3s ease',
}}
onMouseEnter={(e) => {
if (!isUploading) {
e.currentTarget.style.background = 'var(--bg-secondary)'
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent'
}}
>
Cancel
</button>
<button
type="submit"
disabled={isUploading}
style={{
padding: '1rem 2rem',
fontSize: '1rem',
fontWeight: 'bold',
border: 'none',
borderRadius: '8px',
background: isUploading ? 'var(--bg-secondary)' : 'var(--accent-color)',
color: 'white',
cursor: isUploading ? 'not-allowed' : 'pointer',
opacity: isUploading ? 0.7 : 1,
transition: 'all 0.3s ease',
minWidth: '150px',
}}
onMouseEnter={(e) => {
if (!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'
}}
>
{isUploading ? 'Publishing...' : 'Publish Post'}
</button>
</div>
</form>
{/* Help Text */}
<div
style={{
marginTop: '3rem',
padding: '1.5rem',
background: 'var(--bg-secondary)',
border: '1px solid var(--border-color)',
borderRadius: '8px',
}}
>
<h3
style={{
color: 'var(--text-primary)',
marginBottom: '1rem',
fontSize: '1.1rem',
}}
>
Editor Tips
</h3>
<ul
style={{
color: 'var(--text-secondary)',
lineHeight: '1.8',
paddingLeft: '1.5rem',
}}
>
<li>Use Markdown shortcuts for quick formatting</li>
<li>Drag and drop images directly into the editor</li>
<li>Paste images from clipboard (Ctrl+V or Cmd+V)</li>
<li>Use # for headings, * for lists, ``` for code blocks</li>
<li>Add @mentions and #hashtags for better engagement</li>
<li>All images are automatically uploaded to S3</li>
</ul>
</div>
</div>
</section>
</Layout>
)
}

View File

@@ -4,6 +4,7 @@
*/ */
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Link } from 'react-router-dom'
import Layout from '../../components/Layout' import Layout from '../../components/Layout'
import BlogPostCard from '../components/BlogPostCard' import BlogPostCard from '../components/BlogPostCard'
import BlogSidebar from '../components/BlogSidebar' import BlogSidebar from '../components/BlogSidebar'
@@ -43,10 +44,37 @@ export default function BlogListPage() {
fontSize: '1.3rem', fontSize: '1.3rem',
color: 'var(--text-secondary)', color: 'var(--text-secondary)',
lineHeight: '1.6', lineHeight: '1.6',
marginBottom: '2rem',
}} }}
> >
{t('blog.subtitle')} {t('blog.subtitle')}
</p> </p>
{/* Create Post Button */}
<Link
to="/blog/create"
style={{
display: 'inline-block',
background: 'var(--accent-color)',
color: 'white',
padding: '1rem 2rem',
borderRadius: '8px',
textDecoration: 'none',
fontWeight: 'bold',
fontSize: '1rem',
transition: 'transform 0.3s ease, box-shadow 0.3s ease',
}}
onMouseEnter={(e) => {
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'
}}
>
Create New Post
</Link>
</div> </div>
</section> </section>

View File

@@ -15,7 +15,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 { BlogPostPage } from './blog' import { BlogPostPage, BlogCreatePage } from './blog'
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
@@ -29,6 +29,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/create" element={<BlogCreatePage />} />
<Route path="/blog/:postId" element={<BlogPostPage />} /> <Route path="/blog/:postId" element={<BlogPostPage />} />
<Route path="/servers" element={<Servers />} /> <Route path="/servers" element={<Servers />} />
<Route path="/forum" element={<Forum />} /> <Route path="/forum" element={<Forum />} />