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