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