diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..c260d63
--- /dev/null
+++ b/.env.example
@@ -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
diff --git a/src/blog/README.md b/src/blog/README.md
new file mode 100644
index 0000000..d50b391
--- /dev/null
+++ b/src/blog/README.md
@@ -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
+}
+```
+
+### Displaying Single Post
+
+```tsx
+import { BlogPostPage } from '../blog'
+
+// In your router
+ } />
+```
+
+### Using the Blog Editor
+
+```tsx
+import { BlogEditor } from '../blog'
+
+function CreatePost() {
+ return (
+
+ )
+}
+```
+
+## 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: '
Content
',
+ 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
diff --git a/src/blog/api.ts b/src/blog/api.ts
new file mode 100644
index 0000000..dd7be3e
--- /dev/null
+++ b/src/blog/api.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ // Get presigned URL
+ const { url, fileKey } = await getUploadPresignedURL({ fileName: file.name })
+
+ // Upload to S3
+ await uploadFileToS3(url, file)
+
+ return fileKey
+}
diff --git a/src/blog/components/BlogPostCard.tsx b/src/blog/components/BlogPostCard.tsx
new file mode 100644
index 0000000..1f5e776
--- /dev/null
+++ b/src/blog/components/BlogPostCard.tsx
@@ -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 (
+
+ {
+ 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)'
+ }}
+ >
+
+
+ Featured
+
+
+ {post.title}
+
+
+
+ {formattedDate}
+
+
+ {readTime}
+
+
+
+ Read More
+
+
+
+ {!post.coverImageUrl && (
+
+ 📝
+
+ )}
+
+
+
+ )
+ }
+
+ return (
+
+ {
+ 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'
+ }}
+ >
+
+
+ {!post.coverImageUrl && (
+
+ 📝
+
+ )}
+
+
+
+ {post.title}
+
+
+
+ {formattedDate}
+
+
+ {readTime}
+
+
+
+
+
+
+ )
+}
diff --git a/src/blog/components/BlogSidebar.tsx b/src/blog/components/BlogSidebar.tsx
new file mode 100644
index 0000000..b1896f6
--- /dev/null
+++ b/src/blog/components/BlogSidebar.tsx
@@ -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 (
+
+ {/* Tags */}
+
+
+ {t('blog.tags')}
+
+ {loading ? (
+
Loading tags...
+ ) : (
+
+ {tags.map((tag) => (
+ {
+ 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}
+
+ ))}
+
+ )}
+
+
+ {/* Newsletter Signup */}
+
+
+ {t('blog.subscribe')}
+
+
+ {t('blog.subscribeDesc')}
+
+
+
+
+ )
+}
diff --git a/src/blog/editor/BlogEditor.tsx b/src/blog/editor/BlogEditor.tsx
new file mode 100644
index 0000000..f8fbbce
--- /dev/null
+++ b/src/blog/editor/BlogEditor.tsx
@@ -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 (
+
+
+
+
+
}
+ placeholder={
+
+ {placeholder || 'Start writing your blog post...'}
+
+ }
+ ErrorBoundary={LexicalErrorBoundary}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/blog/editor/BlogImagePlugin.tsx b/src/blog/editor/BlogImagePlugin.tsx
new file mode 100644
index 0000000..1eed2f7
--- /dev/null
+++ b/src/blog/editor/BlogImagePlugin.tsx
@@ -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 = createCommand('INSERT_BLOG_IMAGE_COMMAND')
+
+interface UploadingImage {
+ id: string
+ file: File
+ localUrl: string
+}
+
+export function BlogImagePlugin() {
+ const [editor] = useLexicalComposerContext()
+ const [, setUploadingImages] = useState>(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}`)
+}
diff --git a/src/blog/editor/S3_UPLOAD_WORKFLOW.md b/src/blog/editor/S3_UPLOAD_WORKFLOW.md
new file mode 100644
index 0000000..7a37b3d
--- /dev/null
+++ b/src/blog/editor/S3_UPLOAD_WORKFLOW.md
@@ -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 {}
diff --git a/src/blog/hooks/useBlogPost.ts b/src/blog/hooks/useBlogPost.ts
new file mode 100644
index 0000000..5e9e908
--- /dev/null
+++ b/src/blog/hooks/useBlogPost.ts
@@ -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(null)
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState(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,
+ }
+}
diff --git a/src/blog/hooks/useBlogPosts.ts b/src/blog/hooks/useBlogPosts.ts
new file mode 100644
index 0000000..2d6832e
--- /dev/null
+++ b/src/blog/hooks/useBlogPosts.ts
@@ -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([])
+ const [totalCount, setTotalCount] = useState(0)
+ const [currentPage, setCurrentPage] = useState(1)
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState(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,
+ }
+}
diff --git a/src/blog/hooks/useBlogTags.ts b/src/blog/hooks/useBlogTags.ts
new file mode 100644
index 0000000..bb7d74c
--- /dev/null
+++ b/src/blog/hooks/useBlogTags.ts
@@ -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([])
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState(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,
+ }
+}
diff --git a/src/blog/index.ts b/src/blog/index.ts
new file mode 100644
index 0000000..4f1f5dc
--- /dev/null
+++ b/src/blog/index.ts
@@ -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'
diff --git a/src/blog/pages/BlogListPage.tsx b/src/blog/pages/BlogListPage.tsx
new file mode 100644
index 0000000..a9bd5a6
--- /dev/null
+++ b/src/blog/pages/BlogListPage.tsx
@@ -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 (
+
+ {/* Blog Page Header */}
+
+
+
+ {t('blog.title')}
+
+
+ {t('blog.subtitle')}
+
+
+
+
+ {/* Loading State */}
+ {loading && posts.length === 0 && (
+
+ )}
+
+ {/* Error State */}
+ {error && (
+
+ )}
+
+ {/* Featured Post */}
+ {featuredPost && !loading && (
+
+
+
+ {t('blog.featuredPost')}
+
+
+
+
+ )}
+
+ {/* Blog Content */}
+ {!loading && posts.length > 0 && (
+
+
+ {/* Main Content */}
+
+
+ {t('blog.recentPosts')}
+
+
+
+ {recentPosts.map((post) => (
+
+ ))}
+
+
+ {/* Load More Button */}
+ {hasMore && (
+
+ {
+ if (!loading) e.currentTarget.style.transform = 'translateY(-2px)'
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.transform = 'translateY(0)'
+ }}
+ >
+ {loading ? 'Loading...' : t('blog.loadMore')}
+
+
+ )}
+
+
+ {/* Sidebar */}
+
+
+
+ )}
+
+ {/* Empty State */}
+ {!loading && !error && posts.length === 0 && (
+
+
+ No blog posts yet. Check back soon!
+
+
+ )}
+
+ )
+}
diff --git a/src/blog/pages/BlogPostPage.tsx b/src/blog/pages/BlogPostPage.tsx
new file mode 100644
index 0000000..41a00cc
--- /dev/null
+++ b/src/blog/pages/BlogPostPage.tsx
@@ -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 (
+
+
+
+ )
+ }
+
+ if (error || !post) {
+ return (
+
+
+
+ {error?.message || 'Post not found'}
+
+
+ ← Back to Blog
+
+
+
+ )
+ }
+
+ const formattedDate = new Date(post.createdAt * 1000).toLocaleDateString('en-US', {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ })
+
+ return (
+
+ {/* Post Header */}
+
+
+
+ ← {t('blog.backToBlog')}
+
+
+ {post.title}
+
+
+ {formattedDate}
+ •
+ Updated: {new Date(post.updatedAt * 1000).toLocaleDateString('en-US')}
+
+
+
+
+ {/* Cover Image */}
+ {post.coverImageUrl && (
+
+ )}
+
+ {/* Post Content */}
+
+
+ {/* Main Content */}
+
+
+
+
+ {/* Sidebar */}
+
+
+
+
+ {/* Navigation */}
+
+
+ {
+ e.currentTarget.style.transform = 'translateY(-2px)'
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.transform = 'translateY(0)'
+ }}
+ >
+ ← {t('blog.backToBlog')}
+
+
+
+
+ )
+}
diff --git a/src/blog/types.ts b/src/blog/types.ts
new file mode 100644
index 0000000..fd3bc69
--- /dev/null
+++ b/src/blog/types.ts
@@ -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
+}
diff --git a/src/locales/en.json b/src/locales/en.json
index 9898faf..edac392 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -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",
diff --git a/src/locales/zh.json b/src/locales/zh.json
index fd2291b..8413344 100644
--- a/src/locales/zh.json
+++ b/src/locales/zh.json
@@ -125,11 +125,13 @@
"readMore": "阅读更多",
"by": "作者",
"categories": "分类",
+ "tags": "标签",
"loadMore": "加载更多文章",
"subscribe": "订阅新闻通讯",
"subscribeDesc": "将最新文章和更新发送到您的收件箱",
"emailPlaceholder": "输入您的电子邮件地址",
- "subscribeBtn": "订阅"
+ "subscribeBtn": "订阅",
+ "backToBlog": "返回博客"
},
"friends": {
"title": "友情链接",
diff --git a/src/main.tsx b/src/main.tsx
index 646d682..1de7ded 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -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(
@@ -28,6 +29,7 @@ createRoot(document.getElementById('root')!).render(
} />
} />
} />
+ } />
} />
} />
} />
diff --git a/src/pages/Blog.tsx b/src/pages/Blog.tsx
index 3d1a39e..9a49a4c 100644
--- a/src/pages/Blog.tsx
+++ b/src/pages/Blog.tsx
@@ -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 (
-
- {/* Blog Page Header */}
-
-
-
- {t('blog.title')}
-
-
- {t('blog.subtitle')}
-
-
-
-
- {/* Featured Post */}
-
-
-
- {t('blog.featuredPost')}
-
-
-
-
- {featuredPost.category}
-
-
- {featuredPost.title}
-
-
- {featuredPost.excerpt}
-
-
-
- {t('blog.by')} {featuredPost.author}
-
-
- {featuredPost.date}
-
-
- {featuredPost.readTime}
-
-
-
{
- e.currentTarget.style.transform = 'translateY(-2px)'
- }}
- onMouseLeave={(e) => {
- e.currentTarget.style.transform = 'translateY(0)'
- }}
- >
- {t('blog.readMore')}
-
-
-
- {featuredPost.image}
-
-
-
-
-
- {/* Blog Content */}
-
-
-
- {/* Main Content */}
-
-
- {t('blog.recentPosts')}
-
-
-
- {recentPosts.map(post => (
-
{
- 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'
- }}
- >
-
-
- {post.image}
-
-
-
- {post.category}
-
-
- {post.title}
-
-
- {post.excerpt}
-
-
-
- {t('blog.by')} {post.author}
-
-
- {post.date}
-
-
- {post.readTime}
-
-
-
- {post.tags.map(tag => (
-
- #{tag}
-
- ))}
-
-
-
-
- ))}
-
-
- {/* Load More Button */}
-
- {
- e.currentTarget.style.transform = 'translateY(-2px)'
- }}
- onMouseLeave={(e) => {
- e.currentTarget.style.transform = 'translateY(0)'
- }}
- >
- {t('blog.loadMore')}
-
-
-
-
- {/* Sidebar */}
-
- {/* Categories */}
-
-
- {t('blog.categories')}
-
-
- {categories.map(category => (
- {
- 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}
-
- ))}
-
-
-
- {/* Newsletter Signup */}
-
-
- {t('blog.subscribe')}
-
-
- {t('blog.subscribeDesc')}
-
-
-
{
- e.currentTarget.style.transform = 'translateY(-2px)'
- }}
- onMouseLeave={(e) => {
- e.currentTarget.style.transform = 'translateY(0)'
- }}
- >
- {t('blog.subscribeBtn')}
-
-
-
-
-
-
- )
+ return
}
export default Blog
\ No newline at end of file