From 4417423612fd6d3969a86e004a3d5bf59aae96dc Mon Sep 17 00:00:00 2001 From: cialloo Date: Sat, 25 Oct 2025 13:58:15 +0800 Subject: [PATCH] feat(blog): Implement blog module with post management, image upload workflow, and localization - 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. --- .env.example | 8 + src/blog/README.md | 141 ++++++++ src/blog/api.ts | 155 +++++++++ src/blog/components/BlogPostCard.tsx | 218 ++++++++++++ src/blog/components/BlogSidebar.tsx | 149 ++++++++ src/blog/editor/BlogEditor.tsx | 129 +++++++ src/blog/editor/BlogImagePlugin.tsx | 152 +++++++++ src/blog/editor/S3_UPLOAD_WORKFLOW.md | 193 +++++++++++ src/blog/hooks/useBlogPost.ts | 40 +++ src/blog/hooks/useBlogPosts.ts | 83 +++++ src/blog/hooks/useBlogTags.ts | 38 +++ src/blog/index.ts | 25 ++ src/blog/pages/BlogListPage.tsx | 166 +++++++++ src/blog/pages/BlogPostPage.tsx | 191 +++++++++++ src/blog/types.ts | 84 +++++ src/locales/en.json | 4 +- src/locales/zh.json | 4 +- src/main.tsx | 2 + src/pages/Blog.tsx | 471 +------------------------- 19 files changed, 1782 insertions(+), 471 deletions(-) create mode 100644 .env.example create mode 100644 src/blog/README.md create mode 100644 src/blog/api.ts create mode 100644 src/blog/components/BlogPostCard.tsx create mode 100644 src/blog/components/BlogSidebar.tsx create mode 100644 src/blog/editor/BlogEditor.tsx create mode 100644 src/blog/editor/BlogImagePlugin.tsx create mode 100644 src/blog/editor/S3_UPLOAD_WORKFLOW.md create mode 100644 src/blog/hooks/useBlogPost.ts create mode 100644 src/blog/hooks/useBlogPosts.ts create mode 100644 src/blog/hooks/useBlogTags.ts create mode 100644 src/blog/index.ts create mode 100644 src/blog/pages/BlogListPage.tsx create mode 100644 src/blog/pages/BlogPostPage.tsx create mode 100644 src/blog/types.ts 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 ( + + ) +} 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 && ( +
+
+ Loading posts... +
+
+ )} + + {/* Error State */} + {error && ( +
+
+ {error.message} +
+
+ )} + + {/* 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 && ( +
+ +
+ )} +
+ + {/* 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 ( + +
+
+ Loading post... +
+
+
+ ) + } + + 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 */} -
- -
-
- - {/* Sidebar */} - -
-
-
- ) + return } export default Blog \ No newline at end of file