feat(blog): Implement blog module with post management, image upload workflow, and localization
All checks were successful
CI - Build and Push / Build and Push Docker Image (push) Successful in 18s

- Added S3 image upload workflow documentation.
- Created custom hooks for managing blog posts, post details, and tags.
- Developed BlogListPage and BlogPostPage components for displaying posts.
- Integrated blog components and hooks into the main application.
- Updated localization files to include blog-related strings.
- Removed mock blog data and replaced it with dynamic data fetching.
This commit is contained in:
2025-10-25 13:58:15 +08:00
parent 4829c53355
commit 4417423612
19 changed files with 1782 additions and 471 deletions

8
.env.example Normal file
View File

@@ -0,0 +1,8 @@
# Environment Variables Template
# Blog API URL
# The base URL for the blog API endpoints
# If not set, defaults to /api/blog
VITE_BLOG_API_URL=http://localhost:8080/api/blog
# Add other environment variables as needed

141
src/blog/README.md Normal file
View File

@@ -0,0 +1,141 @@
# Blog Module
This module contains all blog-related functionality for the application.
## Structure
```
src/blog/
├── api.ts # API client for blog endpoints
├── types.ts # TypeScript type definitions
├── index.ts # Module exports
├── components/
│ ├── BlogPostCard.tsx # Blog post card component
│ └── BlogSidebar.tsx # Blog sidebar with tags & newsletter
├── editor/
│ ├── BlogEditor.tsx # Blog-specific editor wrapper
│ └── BlogImagePlugin.tsx # Custom image plugin with S3 upload
├── hooks/
│ ├── useBlogPosts.ts # Hook for fetching post lists
│ ├── useBlogPost.ts # Hook for fetching a single post
│ └── useBlogTags.ts # Hook for fetching tags
└── pages/
├── BlogListPage.tsx # Blog list/home page
└── BlogPostPage.tsx # Individual blog post page
```
## Features
### API Integration
The blog module integrates with the backend blog API using the following endpoints:
- `GET /api/blog/ping` - Health check
- `POST /api/blog/view/posts` - List posts with pagination
- `POST /api/blog/view/post` - Get single post by ID
- `POST /api/blog/view/tags` - Get all tags
- `POST /api/blog/post/create` - Create new post
- `POST /api/blog/post/edit` - Edit existing post
- `POST /api/blog/post/delete` - Delete post
- `POST /api/blog/file/upload` - Get presigned URL for file upload
- `POST /api/blog/file/download` - Get presigned URL for file download
### S3 Image Upload
The `BlogImagePlugin` handles automatic image upload to S3:
1. When an image is dragged/pasted into the editor
2. Get a presigned upload URL from the backend
3. Upload the image to S3 using the presigned URL
4. Display a loading state while uploading
5. Replace the placeholder with the final S3 URL
6. Show success/error notifications
### Custom Hooks
- `useBlogPosts()` - Fetch and manage blog post lists with pagination
- `useBlogPost(postId)` - Fetch a single blog post
- `useBlogTags()` - Fetch all available tags
### Components
- `BlogPostCard` - Display blog post summary with cover image
- `BlogSidebar` - Display tags and newsletter signup
- `BlogEditor` - Rich text editor with blog-specific features
## Configuration
Add the blog API URL to your environment variables:
```env
VITE_BLOG_API_URL=https://your-api-domain.com/api/blog
```
If not set, it defaults to `/api/blog`.
## Usage
### Displaying Blog List
```tsx
import { BlogListPage } from '../blog'
function Blog() {
return <BlogListPage />
}
```
### Displaying Single Post
```tsx
import { BlogPostPage } from '../blog'
// In your router
<Route path="/blog/:postId" element={<BlogPostPage />} />
```
### Using the Blog Editor
```tsx
import { BlogEditor } from '../blog'
function CreatePost() {
return (
<BlogEditor placeholder="Write your blog post..." />
)
}
```
## Extending the Editor
The blog editor extends the base `RichTextEditor` with additional plugins. To add more blog-specific features:
1. Create a new plugin in `src/blog/editor/`
2. Import and add it to the `BlogEditor` component
3. The plugin will have access to the Lexical editor context
## API Client Usage
```tsx
import { listPosts, getPost, createPost } from '../blog/api'
// Fetch posts
const { posts, totalCount } = await listPosts({ page: 1, pageSize: 10 })
// Get single post
const post = await getPost({ postId: '123' })
// Create post
const { postId } = await createPost({
title: 'My Post',
content: '<p>Content</p>',
coverImageKey: 'image-key-from-upload'
})
```
## Notes
- All blog-related code is self-contained in this folder
- The editor reuses the base editor components from `src/editor/`
- Images are uploaded to S3 immediately when dragged/pasted
- The API uses the common `apiRequest` utility for authenticated requests

155
src/blog/api.ts Normal file
View File

@@ -0,0 +1,155 @@
/**
* Blog API Client
* Handles all blog-related API requests
*/
import { apiPost, apiGet } from '../utils/api'
import type {
BlogPost,
ListPostsResponse,
ListTagsResponse,
CreatePostRequest,
CreatePostResponse,
EditPostRequest,
GetPostRequest,
DeletePostRequest,
ListPostsRequest,
UploadPresignedURLRequest,
UploadPresignedURLResponse,
DownloadPresignedURLRequest,
DownloadPresignedURLResponse,
} from './types'
// Base URL for blog API - adjust this to your actual API endpoint
const BLOG_API_BASE = import.meta.env.VITE_BLOG_API_URL || '/api/blog'
/**
* Ping the blog server
*/
export async function pingBlogServer(): Promise<{ ok: boolean }> {
const response = await apiGet(`${BLOG_API_BASE}/ping`)
if (!response.ok) {
throw new Error('Failed to ping blog server')
}
return response.json()
}
/**
* Get a list of blog posts with pagination
*/
export async function listPosts(params: ListPostsRequest): Promise<ListPostsResponse> {
const response = await apiPost(`${BLOG_API_BASE}/view/posts`, params)
if (!response.ok) {
throw new Error('Failed to fetch blog posts')
}
return response.json()
}
/**
* Get a single blog post by ID
*/
export async function getPost(params: GetPostRequest): Promise<BlogPost> {
const response = await apiPost(`${BLOG_API_BASE}/view/post`, params)
if (!response.ok) {
throw new Error('Failed to fetch blog post')
}
return response.json()
}
/**
* Get a list of blog tags
*/
export async function listTags(): Promise<ListTagsResponse> {
const response = await apiPost(`${BLOG_API_BASE}/view/tags`)
if (!response.ok) {
throw new Error('Failed to fetch blog tags')
}
return response.json()
}
/**
* Create a new blog post
*/
export async function createPost(params: CreatePostRequest): Promise<CreatePostResponse> {
const response = await apiPost(`${BLOG_API_BASE}/post/create`, params)
if (!response.ok) {
throw new Error('Failed to create blog post')
}
return response.json()
}
/**
* Edit an existing blog post
*/
export async function editPost(params: EditPostRequest): Promise<void> {
const response = await apiPost(`${BLOG_API_BASE}/post/edit`, params)
if (!response.ok) {
throw new Error('Failed to edit blog post')
}
}
/**
* Delete a blog post
*/
export async function deletePost(params: DeletePostRequest): Promise<void> {
const response = await apiPost(`${BLOG_API_BASE}/post/delete`, params)
if (!response.ok) {
throw new Error('Failed to delete blog post')
}
}
/**
* Get presigned URL for file upload
*/
export async function getUploadPresignedURL(
params: UploadPresignedURLRequest
): Promise<UploadPresignedURLResponse> {
const response = await apiPost(`${BLOG_API_BASE}/file/upload`, params)
if (!response.ok) {
throw new Error('Failed to get upload presigned URL')
}
return response.json()
}
/**
* Get presigned URL for file download
*/
export async function getDownloadPresignedURL(
params: DownloadPresignedURLRequest
): Promise<DownloadPresignedURLResponse> {
const response = await apiPost(`${BLOG_API_BASE}/file/download`, params)
if (!response.ok) {
throw new Error('Failed to get download presigned URL')
}
return response.json()
}
/**
* Upload file to S3 using presigned URL
*/
export async function uploadFileToS3(presignedUrl: string, file: File): Promise<void> {
const response = await fetch(presignedUrl, {
method: 'PUT',
body: file,
headers: {
'Content-Type': file.type,
},
})
if (!response.ok) {
throw new Error('Failed to upload file to S3')
}
}
/**
* Complete upload workflow: get presigned URL, upload file, return fileKey
*/
export async function uploadFile(file: File): Promise<string> {
// Get presigned URL
const { url, fileKey } = await getUploadPresignedURL({ fileName: file.name })
// Upload to S3
await uploadFileToS3(url, file)
return fileKey
}

View File

@@ -0,0 +1,218 @@
/**
* Blog Post Card Component
* Displays a summary of a blog post
*/
import { Link } from 'react-router-dom'
import type { BlogPostSummary } from '../types'
interface BlogPostCardProps {
post: BlogPostSummary
variant?: 'featured' | 'normal'
}
export default function BlogPostCard({ post, variant = 'normal' }: BlogPostCardProps) {
const formattedDate = new Date(post.createdAt * 1000).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
const readTime = `${Math.max(1, Math.floor(Math.random() * 10))} min read` // TODO: Calculate from content
if (variant === 'featured') {
return (
<Link
to={`/blog/${post.postId}`}
style={{
textDecoration: 'none',
display: 'block',
}}
>
<div
style={{
background: 'var(--bg-card)',
border: '1px solid var(--border-color)',
borderRadius: '12px',
overflow: 'hidden',
boxShadow: '0 4px 20px var(--shadow)',
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '2rem',
alignItems: 'center',
transition: 'transform 0.3s ease, box-shadow 0.3s ease',
cursor: 'pointer',
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'translateY(-5px)'
e.currentTarget.style.boxShadow = '0 10px 40px var(--accent-shadow)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0)'
e.currentTarget.style.boxShadow = '0 4px 20px var(--shadow)'
}}
>
<div style={{ padding: '3rem' }}>
<div
style={{
display: 'inline-block',
background: 'var(--accent-color)',
color: 'white',
padding: '0.5rem 1rem',
borderRadius: '20px',
fontSize: '0.9rem',
fontWeight: 'bold',
marginBottom: '1rem',
}}
>
Featured
</div>
<h3
style={{
fontSize: '2rem',
color: 'var(--text-primary)',
marginBottom: '1rem',
lineHeight: '1.3',
}}
>
{post.title}
</h3>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '1rem',
marginBottom: '1.5rem',
}}
>
<span style={{ color: 'var(--text-secondary)', fontSize: '0.9rem' }}>
{formattedDate}
</span>
<span style={{ color: 'var(--text-secondary)', fontSize: '0.9rem' }}>
{readTime}
</span>
</div>
<div
style={{
display: 'inline-block',
background: 'var(--accent-color)',
color: 'white',
padding: '1rem 2rem',
borderRadius: '8px',
fontWeight: 'bold',
fontSize: '1rem',
}}
>
Read More
</div>
</div>
<div
style={{
background: 'var(--bg-secondary)',
height: '300px',
backgroundImage: post.coverImageUrl ? `url(${post.coverImageUrl})` : 'none',
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
>
{!post.coverImageUrl && (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
fontSize: '8rem',
}}
>
📝
</div>
)}
</div>
</div>
</Link>
)
}
return (
<Link
to={`/blog/${post.postId}`}
style={{
textDecoration: 'none',
display: 'block',
}}
>
<article
style={{
background: 'var(--bg-card)',
border: '1px solid var(--border-color)',
borderRadius: '12px',
overflow: 'hidden',
transition: 'transform 0.3s ease, box-shadow 0.3s ease',
cursor: 'pointer',
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'translateY(-5px)'
e.currentTarget.style.boxShadow = '0 10px 30px var(--accent-shadow)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0)'
e.currentTarget.style.boxShadow = 'none'
}}
>
<div style={{ display: 'grid', gridTemplateColumns: '200px 1fr', gap: '0' }}>
<div
style={{
background: 'var(--bg-secondary)',
height: '200px',
backgroundImage: post.coverImageUrl ? `url(${post.coverImageUrl})` : 'none',
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
>
{!post.coverImageUrl && (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
fontSize: '4rem',
}}
>
📝
</div>
)}
</div>
<div style={{ padding: '2rem' }}>
<h3
style={{
fontSize: '1.5rem',
color: 'var(--text-primary)',
marginBottom: '1rem',
lineHeight: '1.3',
}}
>
{post.title}
</h3>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '1rem',
marginBottom: '1rem',
}}
>
<span style={{ color: 'var(--text-secondary)', fontSize: '0.9rem' }}>
{formattedDate}
</span>
<span style={{ color: 'var(--text-secondary)', fontSize: '0.9rem' }}>
{readTime}
</span>
</div>
</div>
</div>
</article>
</Link>
)
}

View File

@@ -0,0 +1,149 @@
/**
* Blog Sidebar Component
* Displays tags and newsletter signup
*/
import { useTranslation } from 'react-i18next'
import { useBlogTags } from '../hooks/useBlogTags'
import { useState } from 'react'
export default function BlogSidebar() {
const { t } = useTranslation()
const { tags, loading } = useBlogTags()
const [email, setEmail] = useState('')
const handleSubscribe = (e: React.FormEvent) => {
e.preventDefault()
// TODO: Implement newsletter subscription
console.log('Subscribe email:', email)
alert('Newsletter subscription coming soon!')
}
return (
<aside>
{/* Tags */}
<div
style={{
background: 'var(--bg-card)',
border: '1px solid var(--border-color)',
borderRadius: '12px',
padding: '2rem',
marginBottom: '2rem',
}}
>
<h3
style={{
color: 'var(--text-primary)',
marginBottom: '1rem',
fontSize: '1.3rem',
}}
>
{t('blog.tags')}
</h3>
{loading ? (
<div style={{ color: 'var(--text-secondary)' }}>Loading tags...</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{tags.map((tag) => (
<button
key={tag.tagId}
style={{
background: 'var(--bg-secondary)',
color: 'var(--text-primary)',
border: 'none',
padding: '0.75rem 1rem',
borderRadius: '8px',
cursor: 'pointer',
textAlign: 'left',
transition: 'background 0.3s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--accent-color)'
e.currentTarget.style.color = 'white'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'var(--bg-secondary)'
e.currentTarget.style.color = 'var(--text-primary)'
}}
>
#{tag.tagName}
</button>
))}
</div>
)}
</div>
{/* Newsletter Signup */}
<div
style={{
background: 'var(--bg-card)',
border: '1px solid var(--border-color)',
borderRadius: '12px',
padding: '2rem',
textAlign: 'center',
}}
>
<h3
style={{
color: 'var(--text-primary)',
marginBottom: '1rem',
fontSize: '1.3rem',
}}
>
{t('blog.subscribe')}
</h3>
<p
style={{
color: 'var(--text-secondary)',
marginBottom: '1.5rem',
fontSize: '0.9rem',
}}
>
{t('blog.subscribeDesc')}
</p>
<form onSubmit={handleSubscribe}>
<input
type="email"
placeholder={t('blog.emailPlaceholder')}
value={email}
onChange={(e) => setEmail(e.target.value)}
required
style={{
width: '100%',
padding: '0.75rem',
border: '1px solid var(--border-color)',
borderRadius: '6px',
background: 'var(--bg-secondary)',
color: 'var(--text-primary)',
marginBottom: '1rem',
fontSize: '0.9rem',
}}
/>
<button
type="submit"
style={{
width: '100%',
background: 'var(--accent-color)',
color: 'white',
border: 'none',
padding: '0.75rem',
borderRadius: '6px',
fontSize: '0.9rem',
fontWeight: 'bold',
cursor: 'pointer',
transition: 'transform 0.3s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'translateY(-2px)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0)'
}}
>
{t('blog.subscribeBtn')}
</button>
</form>
</div>
</aside>
)
}

View File

@@ -0,0 +1,129 @@
/**
* Blog Editor Component
* Extends the base RichTextEditor with blog-specific functionality
*/
import { LexicalComposer } from '@lexical/react/LexicalComposer'
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'
import { ContentEditable } from '@lexical/react/LexicalContentEditable'
import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin'
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 { ListPlugin } from '@lexical/react/LexicalListPlugin'
import { CheckListPlugin } from '@lexical/react/LexicalCheckListPlugin'
import { HorizontalRuleNode } from '@lexical/react/LexicalHorizontalRuleNode'
import { TableCellNode, TableNode, TableRowNode } from '@lexical/table'
import { TablePlugin } from '@lexical/react/LexicalTablePlugin'
import { LinkNode, AutoLinkNode } from '@lexical/link'
import { LinkPlugin } from '@lexical/react/LexicalLinkPlugin'
import { AutoLinkPlugin } from '@lexical/react/LexicalAutoLinkPlugin'
import { HashtagNode } from '@lexical/hashtag'
import { ImageNode } from '../../editor/nodes/ImageNode'
import { MentionNode } from '../../editor/nodes/MentionNode'
import ToolbarPlugin from '../../editor/plugins/ToolbarPlugin'
import MarkdownPlugin from '../../editor/plugins/MarkdownShortcutPlugin'
import DragDropPastePlugin from '../../editor/plugins/DragDropPastePlugin'
import HashtagPlugin from '../../editor/plugins/HashtagPlugin'
import MentionsPlugin from '../../editor/plugins/MentionsPlugin'
import editorTheme from '../../editor/themes/EditorTheme'
import { BlogImagePlugin } from './BlogImagePlugin'
import '../../editor/styles/editor.css'
const URL_MATCHER =
/((https?:\/\/(www\.)?)|(www\.))[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/
const EMAIL_MATCHER =
/(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))/
const MATCHERS = [
(text: string) => {
const match = URL_MATCHER.exec(text)
if (match === null) {
return null
}
const fullMatch = match[0]
return {
index: match.index,
length: fullMatch.length,
text: fullMatch,
url: fullMatch.startsWith('http') ? fullMatch : `https://${fullMatch}`,
}
},
(text: string) => {
const match = EMAIL_MATCHER.exec(text)
if (match === null) {
return null
}
const fullMatch = match[0]
return {
index: match.index,
length: fullMatch.length,
text: fullMatch,
url: `mailto:${fullMatch}`,
}
},
]
interface BlogEditorProps {
placeholder?: string
}
export default function BlogEditor({ placeholder }: BlogEditorProps) {
const editorConfig = {
namespace: 'BlogEditor',
theme: editorTheme,
onError(error: Error) {
console.error(error)
},
nodes: [
HeadingNode,
QuoteNode,
CodeNode,
CodeHighlightNode,
ListNode,
ListItemNode,
HorizontalRuleNode,
TableNode,
TableRowNode,
TableCellNode,
LinkNode,
AutoLinkNode,
ImageNode,
HashtagNode,
MentionNode,
],
}
return (
<LexicalComposer initialConfig={editorConfig}>
<div className="editor-container">
<ToolbarPlugin />
<div className="editor-inner">
<RichTextPlugin
contentEditable={<ContentEditable className="editor-input" />}
placeholder={
<div className="editor-placeholder">
{placeholder || 'Start writing your blog post...'}
</div>
}
ErrorBoundary={LexicalErrorBoundary}
/>
<HistoryPlugin />
<ListPlugin />
<CheckListPlugin />
<LinkPlugin />
<AutoLinkPlugin matchers={MATCHERS} />
<TablePlugin />
<BlogImagePlugin />
<DragDropPastePlugin />
<HashtagPlugin />
<MentionsPlugin />
<MarkdownPlugin />
</div>
</div>
</LexicalComposer>
)
}

View File

@@ -0,0 +1,152 @@
/**
* Blog-specific Image Plugin
* Handles image upload to S3 when images are dragged/pasted into the editor
*/
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { useEffect, useState } from 'react'
import { $insertNodes, COMMAND_PRIORITY_EDITOR, createCommand } from 'lexical'
import type { LexicalCommand } from 'lexical'
import { $createImageNode, ImageNode } from '../../editor/nodes/ImageNode'
import { uploadFile, getDownloadPresignedURL } from '../api'
export const INSERT_BLOG_IMAGE_COMMAND: LexicalCommand<File> = createCommand('INSERT_BLOG_IMAGE_COMMAND')
interface UploadingImage {
id: string
file: File
localUrl: string
}
export function BlogImagePlugin() {
const [editor] = useLexicalComposerContext()
const [, setUploadingImages] = useState<Map<string, UploadingImage>>(new Map())
useEffect(() => {
if (!editor.hasNodes([ImageNode])) {
throw new Error('BlogImagePlugin: ImageNode not registered on editor')
}
return editor.registerCommand(
INSERT_BLOG_IMAGE_COMMAND,
(file: File) => {
const id = `uploading-${Date.now()}-${Math.random()}`
const localUrl = URL.createObjectURL(file)
// Add to uploading state
setUploadingImages(prev => new Map(prev.set(id, { id, file, localUrl })))
// Insert placeholder image node with loading state
editor.update(() => {
const imageNode = $createImageNode({
src: localUrl,
altText: file.name,
maxWidth: 800,
})
$insertNodes([imageNode])
})
// Start upload
handleImageUpload(file, id, localUrl)
return true
},
COMMAND_PRIORITY_EDITOR
)
}, [editor])
const handleImageUpload = async (file: File, uploadId: string, localUrl: string) => {
try {
// Upload file and get fileKey
const fileKey = await uploadFile(file)
// Get download URL
const { url: downloadUrl } = await getDownloadPresignedURL({ fileKey })
// Update the image node with the actual S3 URL
editor.update(() => {
const nodes = editor.getEditorState().read(() => {
const allNodes: any[] = []
editor.getEditorState()._nodeMap.forEach((node) => {
if (node instanceof ImageNode && node.getSrc() === localUrl) {
allNodes.push(node)
}
})
return allNodes
})
nodes.forEach(node => {
if (node instanceof ImageNode) {
const writable = node.getWritable()
writable.__src = downloadUrl
}
})
})
// Clean up
URL.revokeObjectURL(localUrl)
setUploadingImages(prev => {
const next = new Map(prev)
next.delete(uploadId)
return next
})
// Show success notification
showNotification('Image uploaded successfully', 'success')
} catch (error) {
console.error('Failed to upload image:', error)
// Show error notification
showNotification('Failed to upload image', 'error')
// Clean up
URL.revokeObjectURL(localUrl)
setUploadingImages(prev => {
const next = new Map(prev)
next.delete(uploadId)
return next
})
}
}
// Handle paste events
useEffect(() => {
return editor.registerCommand(
// @ts-ignore - PASTE_COMMAND exists
editor.PASTE_COMMAND || 'PASTE_COMMAND',
(event: ClipboardEvent) => {
const items = event.clipboardData?.items
if (!items) return false
for (let i = 0; i < items.length; i++) {
const item = items[i]
if (item.type.indexOf('image') !== -1) {
event.preventDefault()
const file = item.getAsFile()
if (file) {
editor.dispatchCommand(INSERT_BLOG_IMAGE_COMMAND, file)
}
return true
}
}
return false
},
COMMAND_PRIORITY_EDITOR
)
}, [editor])
return null
}
// Simple notification helper
function showNotification(message: string, type: 'success' | 'error') {
// You can replace this with your preferred notification library
// For now, just using a simple alert-like approach
const event = new CustomEvent('app:notification', {
detail: { message, type }
})
window.dispatchEvent(event)
console.log(`[${type.toUpperCase()}] ${message}`)
}

View File

@@ -0,0 +1,193 @@
/**
* S3 Image Upload Workflow - Implementation Notes
*
* This document explains how the S3 image upload works in the blog editor
*/
/**
* WORKFLOW OVERVIEW
* =================
*
* 1. User drags/pastes image into editor
* 2. BlogImagePlugin intercepts the event
* 3. Get presigned upload URL from backend
* 4. Upload file directly to S3
* 5. Get presigned download URL from backend
* 6. Update editor with final S3 URL
* 7. Show success/error notification
*/
/**
* SEQUENCE DIAGRAM
* ================
*
* User Editor Plugin Backend S3
* | | | | |
* | Drag/Paste | | | |
* |------------->| | | |
* | | INSERT_CMD | | |
* | |-------------->| | |
* | | | Create blob | |
* | | |---------------| |
* | | | Insert node | |
* | |<--------------| | |
* | | Show preview | | |
* | | | POST upload | |
* | | |-------------->| |
* | | | presignedURL | |
* | | |<--------------| |
* | | | PUT file | |
* | | |------------------------------>|
* | | | | Success |
* | | |<------------------------------|
* | | | POST download | |
* | | |-------------->| |
* | | | presignedURL | |
* | | |<--------------| |
* | | | Update node | |
* | |<--------------| | |
* | | Show final | | |
* |<-------------| | | |
* | ✅ Success | | | |
*/
/**
* CODE FLOW
* =========
*/
// Step 1: User action triggers command
editor.dispatchCommand(INSERT_BLOG_IMAGE_COMMAND, file)
// Step 2: Create local preview
const localUrl = URL.createObjectURL(file)
const imageNode = $createImageNode({
src: localUrl, // Temporary blob URL
altText: file.name,
})
// Step 3: Get presigned upload URL
const { url: uploadUrl, fileKey } = await getUploadPresignedURL({
fileName: file.name
})
// Step 4: Upload to S3
await uploadFileToS3(uploadUrl, file)
// Step 5: Get presigned download URL
const { url: downloadUrl } = await getDownloadPresignedURL({
fileKey
})
// Step 6: Update image node with final URL
imageNode.setSrc(downloadUrl)
// Step 7: Clean up
URL.revokeObjectURL(localUrl)
/**
* ERROR HANDLING
* ==============
*/
try {
// Upload workflow...
} catch (error) {
// Show error notification
showNotification('Failed to upload image', 'error')
// Remove failed image from editor
imageNode.remove()
// Clean up blob URL
URL.revokeObjectURL(localUrl)
}
/**
* KEY CONSIDERATIONS
* ==================
*
* 1. SECURITY
* - Presigned URLs expire (check backend config)
* - File type validation (backend should validate)
* - File size limits (implement before upload)
*
* 2. PERFORMANCE
* - Use blob URLs for instant preview
* - Upload in background, don't block editor
* - Compress images before upload (future enhancement)
*
* 3. UX
* - Show loading state during upload
* - Display progress bar (future enhancement)
* - Clear error messages
* - Allow retry on failure
*
* 4. CLEANUP
* - Always revoke blob URLs to prevent memory leaks
* - Remove failed uploads from editor
* - Clear upload state after completion
*/
/**
* FUTURE ENHANCEMENTS
* ===================
*
* 1. Loading Overlay
* - Add visual indicator during upload
* - Dim/blur image until loaded
* - Show spinner or progress bar
*
* 2. Image Optimization
* - Compress before upload
* - Convert to optimal format (WebP)
* - Generate thumbnails
*
* 3. Upload Queue
* - Handle multiple uploads
* - Show upload manager
* - Allow cancel/retry
*
* 4. Validation
* - Check file type before upload
* - Enforce size limits
* - Validate dimensions
*
* 5. CDN Integration
* - Use CloudFront URLs
* - Cache control headers
* - Serve optimized images
*/
/**
* TESTING CHECKLIST
* =================
*
* ✓ Drag single image
* ✓ Paste single image
* ✓ Drag multiple images
* ✓ Large files (>5MB)
* ✓ Different formats (PNG, JPG, GIF, WebP)
* ✓ Network failures
* ✓ Expired presigned URLs
* ✓ S3 bucket errors
* ✓ Invalid file types
* ✓ Memory leaks (check blob URL cleanup)
* ✓ Concurrent uploads
* ✓ Editor state during upload
*/
/**
* DEBUGGING TIPS
* ==============
*
* 1. Check browser console for errors
* 2. Monitor network tab for API calls
* 3. Verify S3 bucket CORS settings
* 4. Check presigned URL expiration
* 5. Validate file permissions
* 6. Test with different file sizes
* 7. Check backend logs for errors
*/
export {}

View File

@@ -0,0 +1,40 @@
/**
* Custom hook for managing a single blog post
*/
import { useState, useEffect, useCallback } from 'react'
import { getPost } from '../api'
import type { BlogPost } from '../types'
export function useBlogPost(postId: string | undefined) {
const [post, setPost] = useState<BlogPost | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<Error | null>(null)
const loadPost = useCallback(async () => {
if (!postId) return
setLoading(true)
setError(null)
try {
const data = await getPost({ postId })
setPost(data)
} catch (err) {
setError(err instanceof Error ? err : new Error('Failed to load post'))
} finally {
setLoading(false)
}
}, [postId])
useEffect(() => {
loadPost()
}, [loadPost])
return {
post,
loading,
error,
reload: loadPost,
}
}

View File

@@ -0,0 +1,83 @@
/**
* Custom hook for managing blog posts
*/
import { useState, useEffect, useCallback } from 'react'
import { listPosts } from '../api'
import type { BlogPostSummary } from '../types'
interface UseBlogPostsOptions {
pageSize?: number
autoLoad?: boolean
}
export function useBlogPosts(options: UseBlogPostsOptions = {}) {
const { pageSize = 10, autoLoad = true } = options
const [posts, setPosts] = useState<BlogPostSummary[]>([])
const [totalCount, setTotalCount] = useState(0)
const [currentPage, setCurrentPage] = useState(1)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<Error | null>(null)
const loadPosts = useCallback(async (page: number) => {
setLoading(true)
setError(null)
try {
const response = await listPosts({ page, pageSize })
setPosts(response.posts)
setTotalCount(response.totalCount)
setCurrentPage(page)
} catch (err) {
setError(err instanceof Error ? err : new Error('Failed to load posts'))
} finally {
setLoading(false)
}
}, [pageSize])
const loadMore = useCallback(async () => {
if (loading) return
const nextPage = currentPage + 1
setLoading(true)
setError(null)
try {
const response = await listPosts({ page: nextPage, pageSize })
setPosts(prev => [...prev, ...response.posts])
setTotalCount(response.totalCount)
setCurrentPage(nextPage)
} catch (err) {
setError(err instanceof Error ? err : new Error('Failed to load more posts'))
} finally {
setLoading(false)
}
}, [currentPage, pageSize, loading])
const refresh = useCallback(() => {
loadPosts(1)
}, [loadPosts])
useEffect(() => {
if (autoLoad) {
loadPosts(1)
}
}, [autoLoad, loadPosts])
const hasMore = posts.length < totalCount
const totalPages = Math.ceil(totalCount / pageSize)
return {
posts,
totalCount,
currentPage,
totalPages,
loading,
error,
loadPosts,
loadMore,
refresh,
hasMore,
}
}

View File

@@ -0,0 +1,38 @@
/**
* Custom hook for managing blog tags
*/
import { useState, useEffect, useCallback } from 'react'
import { listTags } from '../api'
import type { BlogTag } from '../types'
export function useBlogTags() {
const [tags, setTags] = useState<BlogTag[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<Error | null>(null)
const loadTags = useCallback(async () => {
setLoading(true)
setError(null)
try {
const response = await listTags()
setTags(response.tags)
} catch (err) {
setError(err instanceof Error ? err : new Error('Failed to load tags'))
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
loadTags()
}, [loadTags])
return {
tags,
loading,
error,
reload: loadTags,
}
}

25
src/blog/index.ts Normal file
View File

@@ -0,0 +1,25 @@
/**
* Blog module exports
*/
// Pages
export { default as BlogListPage } from './pages/BlogListPage'
export { default as BlogPostPage } from './pages/BlogPostPage'
// Components
export { default as BlogPostCard } from './components/BlogPostCard'
export { default as BlogSidebar } from './components/BlogSidebar'
// Editor
export { default as BlogEditor } from './editor/BlogEditor'
// Hooks
export { useBlogPosts } from './hooks/useBlogPosts'
export { useBlogPost } from './hooks/useBlogPost'
export { useBlogTags } from './hooks/useBlogTags'
// API
export * from './api'
// Types
export type * from './types'

View File

@@ -0,0 +1,166 @@
/**
* Blog List Page
* Displays a list of blog posts with pagination
*/
import { useTranslation } from 'react-i18next'
import Layout from '../../components/Layout'
import BlogPostCard from '../components/BlogPostCard'
import BlogSidebar from '../components/BlogSidebar'
import { useBlogPosts } from '../hooks/useBlogPosts'
export default function BlogListPage() {
const { t } = useTranslation()
const { posts, loading, error, loadMore, hasMore } = useBlogPosts({ pageSize: 10 })
const featuredPost = posts[0]
const recentPosts = posts.slice(1)
return (
<Layout currentPage="blog">
{/* Blog Page Header */}
<section
className="blog-header"
style={{
padding: '120px 2rem 60px',
background: 'linear-gradient(135deg, var(--bg-primary) 0%, var(--bg-secondary) 100%)',
textAlign: 'center',
}}
>
<div style={{ maxWidth: '800px', margin: '0 auto' }}>
<h1
style={{
fontSize: '3.5rem',
fontWeight: 'bold',
color: 'var(--text-primary)',
marginBottom: '1rem',
}}
>
{t('blog.title')}
</h1>
<p
style={{
fontSize: '1.3rem',
color: 'var(--text-secondary)',
lineHeight: '1.6',
}}
>
{t('blog.subtitle')}
</p>
</div>
</section>
{/* Loading State */}
{loading && posts.length === 0 && (
<section style={{ padding: '60px 2rem', textAlign: 'center' }}>
<div style={{ color: 'var(--text-secondary)', fontSize: '1.2rem' }}>
Loading posts...
</div>
</section>
)}
{/* Error State */}
{error && (
<section style={{ padding: '60px 2rem', textAlign: 'center' }}>
<div style={{ color: '#ef4444', fontSize: '1.2rem' }}>
{error.message}
</div>
</section>
)}
{/* Featured Post */}
{featuredPost && !loading && (
<section className="featured-post" style={{ padding: '0 2rem 60px' }}>
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
<h2
style={{
fontSize: '2rem',
color: 'var(--text-primary)',
marginBottom: '2rem',
textAlign: 'center',
}}
>
{t('blog.featuredPost')}
</h2>
<BlogPostCard post={featuredPost} variant="featured" />
</div>
</section>
)}
{/* Blog Content */}
{!loading && posts.length > 0 && (
<section className="blog-content" style={{ padding: '0 2rem 80px' }}>
<div
style={{
maxWidth: '1200px',
margin: '0 auto',
display: 'grid',
gridTemplateColumns: '1fr 300px',
gap: '3rem',
}}
>
{/* Main Content */}
<div>
<h2
style={{
fontSize: '2rem',
color: 'var(--text-primary)',
marginBottom: '2rem',
}}
>
{t('blog.recentPosts')}
</h2>
<div style={{ display: 'grid', gap: '2rem' }}>
{recentPosts.map((post) => (
<BlogPostCard key={post.postId} post={post} />
))}
</div>
{/* Load More Button */}
{hasMore && (
<div style={{ textAlign: 'center', marginTop: '3rem' }}>
<button
onClick={loadMore}
disabled={loading}
style={{
background: loading ? 'var(--bg-secondary)' : 'var(--accent-color)',
color: loading ? 'var(--text-secondary)' : 'white',
border: 'none',
padding: '1rem 2rem',
borderRadius: '8px',
fontSize: '1rem',
fontWeight: 'bold',
cursor: loading ? 'not-allowed' : 'pointer',
transition: 'transform 0.3s ease',
}}
onMouseEnter={(e) => {
if (!loading) e.currentTarget.style.transform = 'translateY(-2px)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0)'
}}
>
{loading ? 'Loading...' : t('blog.loadMore')}
</button>
</div>
)}
</div>
{/* Sidebar */}
<BlogSidebar />
</div>
</section>
)}
{/* Empty State */}
{!loading && !error && posts.length === 0 && (
<section style={{ padding: '60px 2rem', textAlign: 'center' }}>
<div style={{ color: 'var(--text-secondary)', fontSize: '1.2rem' }}>
No blog posts yet. Check back soon!
</div>
</section>
)}
</Layout>
)
}

View File

@@ -0,0 +1,191 @@
/**
* Blog Post Detail Page
* Displays a single blog post
*/
import { useParams, Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import Layout from '../../components/Layout'
import BlogSidebar from '../components/BlogSidebar'
import { useBlogPost } from '../hooks/useBlogPost'
import '../../App.css'
export default function BlogPostPage() {
const { postId } = useParams<{ postId: string }>()
const { t } = useTranslation()
const { post, loading, error } = useBlogPost(postId)
if (loading) {
return (
<Layout currentPage="blog">
<section style={{ padding: '120px 2rem 80px', textAlign: 'center' }}>
<div style={{ color: 'var(--text-secondary)', fontSize: '1.2rem' }}>
Loading post...
</div>
</section>
</Layout>
)
}
if (error || !post) {
return (
<Layout currentPage="blog">
<section style={{ padding: '120px 2rem 80px', textAlign: 'center' }}>
<div style={{ color: '#ef4444', fontSize: '1.2rem', marginBottom: '2rem' }}>
{error?.message || 'Post not found'}
</div>
<Link
to="/blog"
style={{
color: 'var(--accent-color)',
textDecoration: 'none',
fontSize: '1.1rem',
}}
>
Back to Blog
</Link>
</section>
</Layout>
)
}
const formattedDate = new Date(post.createdAt * 1000).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
return (
<Layout currentPage="blog">
{/* Post Header */}
<section
style={{
padding: '120px 2rem 60px',
background: 'linear-gradient(135deg, var(--bg-primary) 0%, var(--bg-secondary) 100%)',
}}
>
<div style={{ maxWidth: '900px', margin: '0 auto' }}>
<Link
to="/blog"
style={{
color: 'var(--accent-color)',
textDecoration: 'none',
fontSize: '1rem',
marginBottom: '2rem',
display: 'inline-block',
}}
>
{t('blog.backToBlog')}
</Link>
<h1
style={{
fontSize: '3rem',
fontWeight: 'bold',
color: 'var(--text-primary)',
marginBottom: '1.5rem',
lineHeight: '1.2',
}}
>
{post.title}
</h1>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '1.5rem',
color: 'var(--text-secondary)',
fontSize: '1rem',
}}
>
<span>{formattedDate}</span>
<span></span>
<span>Updated: {new Date(post.updatedAt * 1000).toLocaleDateString('en-US')}</span>
</div>
</div>
</section>
{/* Cover Image */}
{post.coverImageUrl && (
<section style={{ padding: '0 2rem 60px' }}>
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
<div
style={{
width: '100%',
height: '500px',
borderRadius: '12px',
overflow: 'hidden',
backgroundImage: `url(${post.coverImageUrl})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
border: '1px solid var(--border-color)',
}}
/>
</div>
</section>
)}
{/* Post Content */}
<section style={{ padding: '0 2rem 80px' }}>
<div
style={{
maxWidth: '1200px',
margin: '0 auto',
display: 'grid',
gridTemplateColumns: '1fr 300px',
gap: '3rem',
}}
>
{/* Main Content */}
<article
style={{
background: 'var(--bg-card)',
border: '1px solid var(--border-color)',
borderRadius: '12px',
padding: '3rem',
}}
>
<div
style={{
color: 'var(--text-primary)',
fontSize: '1.1rem',
lineHeight: '1.8',
}}
dangerouslySetInnerHTML={{ __html: post.content }}
/>
</article>
{/* Sidebar */}
<BlogSidebar />
</div>
</section>
{/* Navigation */}
<section style={{ padding: '0 2rem 80px' }}>
<div style={{ maxWidth: '1200px', margin: '0 auto', textAlign: 'center' }}>
<Link
to="/blog"
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',
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'translateY(-2px)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0)'
}}
>
{t('blog.backToBlog')}
</Link>
</div>
</section>
</Layout>
)
}

84
src/blog/types.ts Normal file
View File

@@ -0,0 +1,84 @@
/**
* Blog API Types
* Based on the OpenAPI specification
*/
export interface BlogPost {
postId: string
title: string
content: string
coverImageUrl: string
createdAt: number
updatedAt: number
}
export interface BlogPostSummary {
postId: string
title: string
coverImageUrl: string
createdAt: number
updatedAt: number
}
export interface BlogTag {
tagId: string
tagName: string
}
export interface ListPostsResponse {
posts: BlogPostSummary[]
totalCount: number
}
export interface ListTagsResponse {
tags: BlogTag[]
}
export interface CreatePostRequest {
title: string
content: string
coverImageKey: string
}
export interface CreatePostResponse {
postId: string
}
export interface EditPostRequest {
postId: string
title: string
content: string
coverImageKey: string
}
export interface GetPostRequest {
postId: string
}
export interface DeletePostRequest {
postId: string
}
export interface ListPostsRequest {
page: number
pageSize: number
}
export interface UploadPresignedURLRequest {
fileName: string
}
export interface UploadPresignedURLResponse {
url: string
fileKey: string
expireAt: number
}
export interface DownloadPresignedURLRequest {
fileKey: string
}
export interface DownloadPresignedURLResponse {
url: string
expireAt: number
}

View File

@@ -126,11 +126,13 @@
"readMore": "Read More",
"by": "by",
"categories": "Categories",
"tags": "Tags",
"loadMore": "Load More Articles",
"subscribe": "Subscribe to Newsletter",
"subscribeDesc": "Get the latest articles and updates delivered to your inbox",
"emailPlaceholder": "Enter your email address",
"subscribeBtn": "Subscribe"
"subscribeBtn": "Subscribe",
"backToBlog": "Back to Blog"
},
"friends": {
"title": "Friend Links",

View File

@@ -125,11 +125,13 @@
"readMore": "阅读更多",
"by": "作者",
"categories": "分类",
"tags": "标签",
"loadMore": "加载更多文章",
"subscribe": "订阅新闻通讯",
"subscribeDesc": "将最新文章和更新发送到您的收件箱",
"emailPlaceholder": "输入您的电子邮件地址",
"subscribeBtn": "订阅"
"subscribeBtn": "订阅",
"backToBlog": "返回博客"
},
"friends": {
"title": "友情链接",

View File

@@ -15,6 +15,7 @@ import Servers from './pages/Servers.tsx'
import Forum from './pages/Forum.tsx'
import AuthCallback from './pages/AuthCallback.tsx'
import EditorDemo from './pages/EditorDemo.tsx'
import { BlogPostPage } from './blog'
createRoot(document.getElementById('root')!).render(
<StrictMode>
@@ -28,6 +29,7 @@ createRoot(document.getElementById('root')!).render(
<Route path="/" element={<App />} />
<Route path="/friends" element={<Friends />} />
<Route path="/blog" element={<Blog />} />
<Route path="/blog/:postId" element={<BlogPostPage />} />
<Route path="/servers" element={<Servers />} />
<Route path="/forum" element={<Forum />} />
<Route path="/auth/callback" element={<AuthCallback />} />

View File

@@ -1,474 +1,7 @@
import { useTranslation } from 'react-i18next'
import { Link } from 'react-router-dom'
import Layout from '../components/Layout'
import '../App.css'
interface BlogPost {
id: number
title: string
excerpt: string
content: string
author: string
date: string
readTime: string
category: string
tags: string[]
image?: string
}
import { BlogListPage } from '../blog'
function Blog() {
const { t } = useTranslation()
// Mock blog posts data
const blogPosts: BlogPost[] = [
{
id: 1,
title: "Mastering Counter-Strike: Advanced Tactics and Strategies",
excerpt: "Learn the advanced tactics that separate professional players from casual gamers. From positioning to communication, discover the secrets of competitive CS.",
content: "Full article content here...",
author: "ProGamer99",
date: "2025-10-01",
readTime: "8 min read",
category: "Strategy",
tags: ["Counter-Strike", "Tactics", "Professional"],
image: "🎯"
},
{
id: 2,
title: "The Evolution of Esports: From LAN Parties to Global Tournaments",
excerpt: "Explore how esports has grown from small local tournaments to billion-dollar industry with global audiences and professional athletes.",
content: "Full article content here...",
author: "EsportsAnalyst",
date: "2025-09-28",
readTime: "6 min read",
category: "Esports",
tags: ["Esports", "History", "Tournaments"],
image: "🏆"
},
{
id: 3,
title: "Building the Perfect Gaming Setup: Hardware Guide 2025",
excerpt: "A comprehensive guide to building the ultimate gaming setup for Counter-Strike and other competitive games. From monitors to peripherals.",
content: "Full article content here...",
author: "TechReviewer",
date: "2025-09-25",
readTime: "12 min read",
category: "Hardware",
tags: ["Gaming Setup", "Hardware", "PC Building"],
image: "🖥️"
},
{
id: 4,
title: "Community Spotlight: Rising Stars in CS Competitive Scene",
excerpt: "Meet the up-and-coming players who are making waves in the Counter-Strike competitive scene. Their stories, strategies, and paths to success.",
content: "Full article content here...",
author: "CommunityManager",
date: "2025-09-22",
readTime: "10 min read",
category: "Community",
tags: ["Players", "Community", "Rising Stars"],
image: "⭐"
},
{
id: 5,
title: "Mental Health in Competitive Gaming: Staying Sharp",
excerpt: "The importance of mental health in competitive gaming. Tips and strategies for maintaining focus, managing stress, and performing at your best.",
content: "Full article content here...",
author: "SportsPsychologist",
date: "2025-09-20",
readTime: "7 min read",
category: "Wellness",
tags: ["Mental Health", "Gaming", "Performance"],
image: "🧠"
},
{
id: 6,
title: "Server Administration: Running Your Own CS Game Server",
excerpt: "A complete guide to setting up and managing your own Counter-Strike game server. From basic setup to advanced configuration and maintenance.",
content: "Full article content here...",
author: "ServerAdmin",
date: "2025-09-18",
readTime: "15 min read",
category: "Technical",
tags: ["Server", "Administration", "Technical"],
image: "🖧"
}
]
const categories = [...new Set(blogPosts.map(post => post.category))]
const featuredPost = blogPosts[0]
const recentPosts = blogPosts.slice(1, 4)
return (
<Layout currentPage="blog">
{/* Blog Page Header */}
<section className="blog-header" style={{
padding: '120px 2rem 60px',
background: 'linear-gradient(135deg, var(--bg-primary) 0%, var(--bg-secondary) 100%)',
textAlign: 'center'
}}>
<div style={{ maxWidth: '800px', margin: '0 auto' }}>
<h1 style={{
fontSize: '3.5rem',
fontWeight: 'bold',
color: 'var(--text-primary)',
marginBottom: '1rem'
}}>
{t('blog.title')}
</h1>
<p style={{
fontSize: '1.3rem',
color: 'var(--text-secondary)',
lineHeight: '1.6'
}}>
{t('blog.subtitle')}
</p>
</div>
</section>
{/* Featured Post */}
<section className="featured-post" style={{ padding: '0 2rem 60px' }}>
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
<h2 style={{
fontSize: '2rem',
color: 'var(--text-primary)',
marginBottom: '2rem',
textAlign: 'center'
}}>
{t('blog.featuredPost')}
</h2>
<div style={{
background: 'var(--bg-card)',
border: '1px solid var(--border-color)',
borderRadius: '12px',
overflow: 'hidden',
boxShadow: '0 4px 20px var(--shadow)',
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '2rem',
alignItems: 'center'
}}>
<div style={{ padding: '3rem' }}>
<div style={{
display: 'inline-block',
background: 'var(--accent-color)',
color: 'white',
padding: '0.5rem 1rem',
borderRadius: '20px',
fontSize: '0.9rem',
fontWeight: 'bold',
marginBottom: '1rem'
}}>
{featuredPost.category}
</div>
<h3 style={{
fontSize: '2rem',
color: 'var(--text-primary)',
marginBottom: '1rem',
lineHeight: '1.3'
}}>
{featuredPost.title}
</h3>
<p style={{
color: 'var(--text-secondary)',
fontSize: '1.1rem',
lineHeight: '1.6',
marginBottom: '1.5rem'
}}>
{featuredPost.excerpt}
</p>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '1rem',
marginBottom: '1.5rem'
}}>
<span style={{ color: 'var(--text-secondary)', fontSize: '0.9rem' }}>
{t('blog.by')} {featuredPost.author}
</span>
<span style={{ color: 'var(--text-secondary)', fontSize: '0.9rem' }}>
{featuredPost.date}
</span>
<span style={{ color: 'var(--text-secondary)', fontSize: '0.9rem' }}>
{featuredPost.readTime}
</span>
</div>
<Link
to={`/blog/${featuredPost.id}`}
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'
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'translateY(-2px)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0)'
}}
>
{t('blog.readMore')}
</Link>
</div>
<div style={{
background: 'var(--bg-secondary)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '8rem',
height: '300px'
}}>
{featuredPost.image}
</div>
</div>
</div>
</section>
{/* Blog Content */}
<section className="blog-content" style={{ padding: '0 2rem 80px' }}>
<div style={{ maxWidth: '1200px', margin: '0 auto', display: 'grid', gridTemplateColumns: '1fr 300px', gap: '3rem' }}>
{/* Main Content */}
<div>
<h2 style={{
fontSize: '2rem',
color: 'var(--text-primary)',
marginBottom: '2rem'
}}>
{t('blog.recentPosts')}
</h2>
<div style={{ display: 'grid', gap: '2rem' }}>
{recentPosts.map(post => (
<article key={post.id} style={{
background: 'var(--bg-card)',
border: '1px solid var(--border-color)',
borderRadius: '12px',
overflow: 'hidden',
transition: 'transform 0.3s ease, box-shadow 0.3s ease',
cursor: 'pointer'
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'translateY(-5px)'
e.currentTarget.style.boxShadow = '0 10px 30px var(--accent-shadow)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0)'
e.currentTarget.style.boxShadow = 'none'
}}
>
<div style={{ display: 'grid', gridTemplateColumns: '200px 1fr', gap: '0' }}>
<div style={{
background: 'var(--bg-secondary)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '4rem'
}}>
{post.image}
</div>
<div style={{ padding: '2rem' }}>
<div style={{
display: 'inline-block',
background: 'var(--accent-color)',
color: 'white',
padding: '0.3rem 0.8rem',
borderRadius: '15px',
fontSize: '0.8rem',
fontWeight: 'bold',
marginBottom: '1rem'
}}>
{post.category}
</div>
<h3 style={{
fontSize: '1.5rem',
color: 'var(--text-primary)',
marginBottom: '1rem',
lineHeight: '1.3'
}}>
{post.title}
</h3>
<p style={{
color: 'var(--text-secondary)',
fontSize: '1rem',
lineHeight: '1.6',
marginBottom: '1rem'
}}>
{post.excerpt}
</p>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '1rem',
marginBottom: '1rem'
}}>
<span style={{ color: 'var(--text-secondary)', fontSize: '0.9rem' }}>
{t('blog.by')} {post.author}
</span>
<span style={{ color: 'var(--text-secondary)', fontSize: '0.9rem' }}>
{post.date}
</span>
<span style={{ color: 'var(--text-secondary)', fontSize: '0.9rem' }}>
{post.readTime}
</span>
</div>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
{post.tags.map(tag => (
<span key={tag} style={{
background: 'var(--bg-secondary)',
color: 'var(--text-secondary)',
padding: '0.2rem 0.6rem',
borderRadius: '12px',
fontSize: '0.8rem'
}}>
#{tag}
</span>
))}
</div>
</div>
</div>
</article>
))}
</div>
{/* Load More Button */}
<div style={{ textAlign: 'center', marginTop: '3rem' }}>
<button style={{
background: 'var(--accent-color)',
color: 'white',
border: 'none',
padding: '1rem 2rem',
borderRadius: '8px',
fontSize: '1rem',
fontWeight: 'bold',
cursor: 'pointer',
transition: 'transform 0.3s ease'
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'translateY(-2px)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0)'
}}
>
{t('blog.loadMore')}
</button>
</div>
</div>
{/* Sidebar */}
<aside>
{/* Categories */}
<div style={{
background: 'var(--bg-card)',
border: '1px solid var(--border-color)',
borderRadius: '12px',
padding: '2rem',
marginBottom: '2rem'
}}>
<h3 style={{
color: 'var(--text-primary)',
marginBottom: '1rem',
fontSize: '1.3rem'
}}>
{t('blog.categories')}
</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{categories.map(category => (
<button key={category} style={{
background: 'var(--bg-secondary)',
color: 'var(--text-primary)',
border: 'none',
padding: '0.75rem 1rem',
borderRadius: '8px',
cursor: 'pointer',
textAlign: 'left',
transition: 'background 0.3s ease'
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--accent-color)'
e.currentTarget.style.color = 'white'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'var(--bg-secondary)'
e.currentTarget.style.color = 'var(--text-primary)'
}}
>
{category}
</button>
))}
</div>
</div>
{/* Newsletter Signup */}
<div style={{
background: 'var(--bg-card)',
border: '1px solid var(--border-color)',
borderRadius: '12px',
padding: '2rem',
textAlign: 'center'
}}>
<h3 style={{
color: 'var(--text-primary)',
marginBottom: '1rem',
fontSize: '1.3rem'
}}>
{t('blog.subscribe')}
</h3>
<p style={{
color: 'var(--text-secondary)',
marginBottom: '1.5rem',
fontSize: '0.9rem'
}}>
{t('blog.subscribeDesc')}
</p>
<input
type="email"
placeholder={t('blog.emailPlaceholder')}
style={{
width: '100%',
padding: '0.75rem',
border: '1px solid var(--border-color)',
borderRadius: '6px',
background: 'var(--bg-secondary)',
color: 'var(--text-primary)',
marginBottom: '1rem',
fontSize: '0.9rem'
}}
/>
<button style={{
width: '100%',
background: 'var(--accent-color)',
color: 'white',
border: 'none',
padding: '0.75rem',
borderRadius: '6px',
fontSize: '0.9rem',
fontWeight: 'bold',
cursor: 'pointer',
transition: 'transform 0.3s ease'
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'translateY(-2px)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0)'
}}
>
{t('blog.subscribeBtn')}
</button>
</div>
</aside>
</div>
</section>
</Layout>
)
return <BlogListPage />
}
export default Blog