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
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:
8
.env.example
Normal file
8
.env.example
Normal 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
141
src/blog/README.md
Normal 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
155
src/blog/api.ts
Normal 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
|
||||||
|
}
|
||||||
218
src/blog/components/BlogPostCard.tsx
Normal file
218
src/blog/components/BlogPostCard.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
149
src/blog/components/BlogSidebar.tsx
Normal file
149
src/blog/components/BlogSidebar.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
129
src/blog/editor/BlogEditor.tsx
Normal file
129
src/blog/editor/BlogEditor.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
152
src/blog/editor/BlogImagePlugin.tsx
Normal file
152
src/blog/editor/BlogImagePlugin.tsx
Normal 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}`)
|
||||||
|
}
|
||||||
193
src/blog/editor/S3_UPLOAD_WORKFLOW.md
Normal file
193
src/blog/editor/S3_UPLOAD_WORKFLOW.md
Normal 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 {}
|
||||||
40
src/blog/hooks/useBlogPost.ts
Normal file
40
src/blog/hooks/useBlogPost.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
83
src/blog/hooks/useBlogPosts.ts
Normal file
83
src/blog/hooks/useBlogPosts.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/blog/hooks/useBlogTags.ts
Normal file
38
src/blog/hooks/useBlogTags.ts
Normal 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
25
src/blog/index.ts
Normal 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'
|
||||||
166
src/blog/pages/BlogListPage.tsx
Normal file
166
src/blog/pages/BlogListPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
191
src/blog/pages/BlogPostPage.tsx
Normal file
191
src/blog/pages/BlogPostPage.tsx
Normal 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
84
src/blog/types.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -126,11 +126,13 @@
|
|||||||
"readMore": "Read More",
|
"readMore": "Read More",
|
||||||
"by": "by",
|
"by": "by",
|
||||||
"categories": "Categories",
|
"categories": "Categories",
|
||||||
|
"tags": "Tags",
|
||||||
"loadMore": "Load More Articles",
|
"loadMore": "Load More Articles",
|
||||||
"subscribe": "Subscribe to Newsletter",
|
"subscribe": "Subscribe to Newsletter",
|
||||||
"subscribeDesc": "Get the latest articles and updates delivered to your inbox",
|
"subscribeDesc": "Get the latest articles and updates delivered to your inbox",
|
||||||
"emailPlaceholder": "Enter your email address",
|
"emailPlaceholder": "Enter your email address",
|
||||||
"subscribeBtn": "Subscribe"
|
"subscribeBtn": "Subscribe",
|
||||||
|
"backToBlog": "Back to Blog"
|
||||||
},
|
},
|
||||||
"friends": {
|
"friends": {
|
||||||
"title": "Friend Links",
|
"title": "Friend Links",
|
||||||
|
|||||||
@@ -125,11 +125,13 @@
|
|||||||
"readMore": "阅读更多",
|
"readMore": "阅读更多",
|
||||||
"by": "作者",
|
"by": "作者",
|
||||||
"categories": "分类",
|
"categories": "分类",
|
||||||
|
"tags": "标签",
|
||||||
"loadMore": "加载更多文章",
|
"loadMore": "加载更多文章",
|
||||||
"subscribe": "订阅新闻通讯",
|
"subscribe": "订阅新闻通讯",
|
||||||
"subscribeDesc": "将最新文章和更新发送到您的收件箱",
|
"subscribeDesc": "将最新文章和更新发送到您的收件箱",
|
||||||
"emailPlaceholder": "输入您的电子邮件地址",
|
"emailPlaceholder": "输入您的电子邮件地址",
|
||||||
"subscribeBtn": "订阅"
|
"subscribeBtn": "订阅",
|
||||||
|
"backToBlog": "返回博客"
|
||||||
},
|
},
|
||||||
"friends": {
|
"friends": {
|
||||||
"title": "友情链接",
|
"title": "友情链接",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import Servers from './pages/Servers.tsx'
|
|||||||
import Forum from './pages/Forum.tsx'
|
import Forum from './pages/Forum.tsx'
|
||||||
import AuthCallback from './pages/AuthCallback.tsx'
|
import AuthCallback from './pages/AuthCallback.tsx'
|
||||||
import EditorDemo from './pages/EditorDemo.tsx'
|
import EditorDemo from './pages/EditorDemo.tsx'
|
||||||
|
import { BlogPostPage } from './blog'
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
@@ -28,6 +29,7 @@ createRoot(document.getElementById('root')!).render(
|
|||||||
<Route path="/" element={<App />} />
|
<Route path="/" element={<App />} />
|
||||||
<Route path="/friends" element={<Friends />} />
|
<Route path="/friends" element={<Friends />} />
|
||||||
<Route path="/blog" element={<Blog />} />
|
<Route path="/blog" element={<Blog />} />
|
||||||
|
<Route path="/blog/:postId" element={<BlogPostPage />} />
|
||||||
<Route path="/servers" element={<Servers />} />
|
<Route path="/servers" element={<Servers />} />
|
||||||
<Route path="/forum" element={<Forum />} />
|
<Route path="/forum" element={<Forum />} />
|
||||||
<Route path="/auth/callback" element={<AuthCallback />} />
|
<Route path="/auth/callback" element={<AuthCallback />} />
|
||||||
|
|||||||
@@ -1,474 +1,7 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { BlogListPage } from '../blog'
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
function Blog() {
|
function Blog() {
|
||||||
const { t } = useTranslation()
|
return <BlogListPage />
|
||||||
|
|
||||||
// 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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Blog
|
export default Blog
|
||||||
Reference in New Issue
Block a user