docs: separate documentation and specs into initial commit

Establish baseline for project documentation including BMAD specs, PRD, and system architecture notes.
This commit is contained in:
2026-02-11 11:49:49 +08:00
parent 8c87d71aa2
commit e9897388dc
34 changed files with 11920 additions and 8777 deletions

View File

@@ -0,0 +1,688 @@
# Story 1-9: Blog System Implementation (Blog System Implementation)
**Status:** ready-for-dev
**Epic:** Epic 1 - Webflow to Payload CMS + Astro Migration
**Priority:** P1 (High - Content Marketing Core)
**Estimated Time:** 16 hours
## Story
**作為一位** 訪客 (Visitor),
**我想要** 瀏覽行銷文章和見解,
**以便** 我可以從恩群數位的專業知識中學習。
## Context
Yo 各位開發者這是我們部落格系統的實作故事Payload CMS 的 Posts 集合已經準備好了Categories 也設置完成了現在是時候讓它們在前台發光發亮啦Webflow 的 /news 頁面就是你的設計藍圖,目標是 95%+ 的視覺相似度!
**Story Source:**
- `docs/prd/05-epic-stories.md` - Story 1.9
- sprint-status.yaml - "1-9-blog-system"
- 依賴Story 1-2 (Collections Definition), Story 1-4 (Global Layout)
**原始 HTML 參考:**
- [Source: research/www.enchun.tw/news.html](../../research/www.enchun.tw/news.html) - Blog 列表頁
- [Source: research/www.enchun.tw/xing-xiao-fang-da-jing/](../../research/www.enchun.tw/xing-xiao-fang-da-jing/) - Blog 文章資料夾
- [Source: research/www.enchun.tw/wen-zhang-fen-lei](../../research/www.enchun.tw/wen-zhang-fen-lei) - Blog 分類資料夾
**Existing Infrastructure (Ready to Use!):**
- Posts collection at `apps/backend/src/collections/Posts/index.ts` with fields: title, slug, heroImage, ogImage, content (richText), excerpt, categories, relatedPosts, publishedAt, authors, status
- Categories collection at `apps/backend/src/collections/Categories.ts` with theming colors
- Placeholder pages already exist at `news.astro` and `xing-xiao-fang-da-jing/[slug].astro`
## Acceptance Criteria
### Blog Listing Page (`/blog` or `/news`)
1. **AC1 - Display Published Posts**: Only posts with status='published' are displayed
2. **AC2 - Category Filter**: 4 category filter buttons that filter posts dynamically
3. **AC3 - Article Cards**: Each card displays:
- Featured image (heroImage)
- Title (linked to detail page)
- Excerpt (truncated to ~150 chars)
- Category badge with category color theming
- Published date (formatted in Traditional Chinese)
4. **AC4 - Pagination**: Implement pagination (12 posts per page) with page navigation
5. **AC5 - Visual Fidelity**: Design matches Webflow news.html with 95%+ similarity
### Article Detail Page (`/blog/[slug]` or `/xing-xiao-fang-da-jing/[slug]`)
1. **AC6 - Full Content Display**: Rich text content rendered with proper styling
2. **AC7 - Meta Information**: Category badge and published date displayed
3. **AC8 - Related Articles**: Show 3-4 related posts from same category
4. **AC9 - Social Sharing**: OG tags configured (ogImage, ogTitle, ogDescription)
5. **AC10 - Rich Text Rendering**: Lexical editor output matches Webflow formatting
### Category Page (`/blog/category/[slug]` or `/wen-zhang-fen-lei/[slug]`)
1. **AC11 - Category Filtering**: Shows only posts belonging to selected category
2. **AC12 - Category Description**: Displays category name and description
3. **AC13 - Color Theming**: Page uses category's textColor and backgroundColor
## Dev Technical Guidance
### URL Structure Decision
**Important:** Choose between these URL patterns:
- Option A: `/blog` + `/blog/[slug]` + `/blog/category/[slug]` (Clean, SEO-friendly)
- Option B: `/news` + `/xing-xiao-fang-da-jing/[slug]` + `/wen-zhang-fen-lei/[slug]` (Matches Webflow)
**Recommendation:** Use Option A for new SEO, set up 301 redirects from Webflow URLs.
### Architecture Patterns
**Data Fetching Pattern (Astro SSR):**
```typescript
// apps/frontend/src/pages/blog/index.astro
---
import Layout from '../../layouts/Layout.astro'
import { payload } from '@payload/client' // Or use API endpoint
const PAGE_SIZE = 12
const page = Astro.url.searchParams.get('page') || '1'
const category = Astro.url.searchParams.get('category')
// Fetch from Payload API
const response = await fetch(`${import.meta.env.PAYLOAD_CMS_URL}/api/posts?where[status][equals]=published&page=${page}&limit=${PAGE_SIZE}&depth=1`)
const data = await response.json()
const posts = data.docs
const totalPages = data.totalPages
---
```
**Rich Text Rendering Pattern:**
```typescript
// Payload Lexical to HTML converter needed
import { serializeLexical } from '@/utilities/serializeLexical'
// In template
<div set:html={serializeLexical(post.content)} />
```
### File Structure
```
apps/frontend/src/
├── pages/
│ ├── blog/
│ │ ├── index.astro ← Blog listing page (CREATE)
│ │ ├── [slug].astro ← Article detail page (CREATE/UPDATE)
│ │ └── category/
│ │ └── [slug].astro ← Category page (CREATE/UPDATE)
├── components/
│ └── blog/
│ ├── ArticleCard.astro ← Reusable article card (CREATE)
│ ├── CategoryFilter.astro ← Category filter buttons (CREATE)
│ ├── RelatedPosts.astro ← Related posts section (CREATE)
│ └── ShareButtons.astro ← Social sharing buttons (CREATE - optional)
├── lib/
│ └── api.ts ← API client utilities (UPDATE)
└── utilities/
└── serializeLexical.ts ← Lexical to HTML converter (CREATE)
```
### Component Specifications
#### 1. ArticleCard.astro
```astro
---
interface Props {
post: {
title: string
slug: string
heroImage: { url: string } | null
excerpt: string
categories: Array<{ title: string, slug: string, backgroundColor: string, textColor: string }>
publishedAt: string
}
}
const { post } = Astro.props
const formatDate = (date: string) => {
return new Date(date).toLocaleDateString('zh-TW', {
year: 'numeric',
month: 'long',
day: 'numeric'
})
}
const category = post.categories?.[0]
---
<article class="blog-card group">
<a href={`/blog/${post.slug}`} class="block">
{post.heroImage && (
<div class="blog-card-image">
<img src={post.heroImage.url} alt={post.title} loading="lazy" />
</div>
)}
<div class="blog-card-content">
{category && (
<span
class="category-badge"
style={`background-color: ${category.backgroundColor}; color: ${category.textColor}`}
>
{category.title}
</span>
)}
<h3 class="blog-card-title">{post.title}</h3>
<p class="blog-card-excerpt">{post.excerpt?.slice(0, 150)}...</p>
<time class="blog-card-date">{formatDate(post.publishedAt)}</time>
</div>
</a>
</article>
<style>
.blog-card { @apply bg-white rounded-lg overflow-hidden shadow-sm hover:shadow-md transition-shadow; }
.blog-card-image { @apply aspect-video overflow-hidden; }
.blog-card-image img { @apply w-full h-full object-cover group-hover:scale-105 transition-transform duration-300; }
.blog-card-content { @apply p-6; }
.category-badge { @apply inline-block px-3 py-1 rounded-full text-xs font-medium mb-3; }
.blog-card-title { @apply text-xl font-semibold text-gray-900 mb-2 line-clamp-2; }
.blog-card-excerpt { @apply text-gray-600 mb-4 line-clamp-2; }
.blog-card-date { @apply text-sm text-gray-500; }
</style>
```
#### 2. CategoryFilter.astro
```astro
---
interface Props {
categories: Array<{ title: string, slug: string }>
activeCategory?: string
}
const { categories, activeCategory } = Astro.props
---
<nav class="category-filter" aria-label="文章分類篩選">
<a
href="/blog"
class:active={!activeCategory}
class="filter-button"
>
全部文章
</a>
{categories.map(category => (
<a
href={`/blog/category/${category.slug}`}
class:active={activeCategory === category.slug}
class="filter-button"
>
{category.title}
</a>
))}
</nav>
<style>
.category-filter { @apply flex flex-wrap gap-3 justify-center mb-8; }
.filter-button {
@apply px-5 py-2 rounded-full border border-gray-300 text-gray-700
hover:border-blue-500 hover:text-blue-500 transition-colors;
}
.filter-button.active {
@apply bg-blue-500 text-white border-blue-500;
}
</style>
```
#### 3. Blog Listing Page (blog/index.astro)
```astro
---
import Layout from '../../layouts/Layout.astro'
import ArticleCard from '../../components/blog/ArticleCard.astro'
import CategoryFilter from '../../components/blog/CategoryFilter.astro'
const PAGE_SIZE = 12
const page = Math.max(1, parseInt(Astro.url.searchParams.get('page') || '1'))
const categorySlug = Astro.url.searchParams.get('category')
// Fetch posts from Payload API
const postsQuery = new URLSearchParams({
where: categorySlug
? JSON.stringify({
status: { equals: 'published' },
'categories.slug': { equals: categorySlug }
})
: JSON.stringify({ status: { equals: 'published' } }),
sort: '-publishedAt',
limit: PAGE_SIZE.toString(),
page: page.toString(),
depth: '1'
})
const response = await fetch(`${import.meta.env.PAYLOAD_CMS_URL}/api/posts?${postsQuery}`)
const data = await response.json()
const posts = data.docs || []
const totalPages = data.totalPages || 1
// Fetch categories
const catsResponse = await fetch(`${import.meta.env.PAYLOAD_CMS_URL}/api/categories?sort=order&limit=10`)
const catsData = await catsResponse.json()
const categories = catsData.docs || []
const { title = '行銷放大鏡' } = Astro.props
---
<Layout title={title}>
<section class="blog-section">
<div class="container">
<h1 class="blog-title">{title}</h1>
<CategoryFilter categories={categories} activeCategory={categorySlug} />
<div class="blog-grid">
{posts.map(post => (
<ArticleCard post={post} />
))}
</div>
{posts.length === 0 && (
<p class="no-posts">沒有找到文章</p>
)}
{totalPages > 1 && (
<nav class="pagination" aria-label="分頁導航">
{page > 1 && (
<a href={`?page=${page - 1}${categorySlug ? `&category=${categorySlug}` : ''}`} class="pagination-link">
上一頁
</a>
)}
<span class="pagination-info">
第 {page} 頁,共 {totalPages} 頁
</span>
{page < totalPages && (
<a href={`?page=${page + 1}${categorySlug ? `&category=${categorySlug}` : ''}`} class="pagination-link">
下一頁
</a>
)}
</nav>
)}
</div>
</section>
</Layout>
<style>
.blog-section { @apply py-16; }
.container { @apply max-w-7xl mx-auto px-4 sm:px-6 lg:px-8; }
.blog-title { @apply text-4xl font-bold text-center mb-12; }
.blog-grid {
@apply grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 mb-12;
}
.no-posts { @apply text-center text-gray-500 py-12; }
.pagination { @apply flex justify-center items-center gap-4 mt-12; }
.pagination-link { @apply px-4 py-2 border border-gray-300 rounded hover:bg-gray-50; }
</style>
```
#### 4. Article Detail Page (blog/[slug].astro)
```astro
---
import Layout from '../../layouts/Layout.astro'
import RelatedPosts from '../../components/blog/RelatedPosts.astro'
import { getPayload } from 'payload'
import config from '@backend/payload.config'
const { slug } = Astro.params
// Fetch post
const payload = await getPayload({ config })
const post = await payload.find({
collection: 'posts',
slug,
depth: 2,
})
if (!post || post.status !== 'published') {
return Astro.redirect('/404')
}
// Fetch related posts
const relatedPosts = await payload.find({
collection: 'posts',
where: {
and: [
{ status: { equals: 'published' } },
{ id: { not_equals: post.id } },
{ 'categories.slug': { in: post.categories?.map(c => c.slug) || [] } }
]
},
limit: 3,
depth: 1,
})
const formatDate = (date: string) => {
return new Date(date).toLocaleDateString('zh-TW', {
year: 'numeric',
month: 'long',
day: 'numeric'
})
}
// SEO meta
const category = post.categories?.[0]
const metaImage = post.ogImage?.url || post.heroImage?.url
---
<Layout title={post.title}>
<article class="article">
<header class="article-header">
<div class="container">
{category && (
<span
class="article-category"
style={`background-color: ${category.backgroundColor}; color: ${category.textColor}`}
>
{category.title}
</span>
)}
<h1 class="article-title">{post.title}</h1>
<time class="article-date">{formatDate(post.publishedAt)}</time>
</div>
</header>
{post.heroImage && (
<div class="article-hero-image">
<img src={post.heroImage.url} alt={post.title} />
</div>
)}
<div class="article-content">
<div class="container">
<div class="prose" set:html={post.content} />
</div>
</div>
<section class="related-section">
<div class="container">
<h2>相關文章</h2>
<RelatedPosts posts={relatedPosts.docs} />
</div>
</section>
</article>
</Layout>
<!-- Open Graph tags -->
<script define:vars={{ metaImage, post }}>
if (typeof document !== 'undefined') {
document.querySelector('meta[property="og:image"]')?.setAttribute('content', metaImage)
document.querySelector('meta[property="og:title"]')?.setAttribute('content', post.title)
document.querySelector('meta[property="og:description"]')?.setAttribute('content', post.excerpt || '')
}
</script>
<style>
.article { @apply pb-16; }
.article-header { @apply py-8 text-center; }
.article-category { @apply inline-block px-4 py-1 rounded-full text-sm font-medium mb-4; }
.article-title { @apply text-3xl md:text-4xl font-bold mb-4; }
.article-date { @apply text-gray-500; }
.article-hero-image { @apply aspect-video overflow-hidden mb-12; }
.article-hero-image img { @apply w-full h-full object-cover; }
.article-content { @apply py-8; }
.prose {
@apply max-w-3xl mx-auto prose-headings:font-semibold prose-a:text-blue-600 prose-img:rounded-lg;
}
.related-section { @apply py-12 bg-gray-50; }
</style>
```
#### 5. Category Page (blog/category/[slug].astro)
```astro
---
import Layout from '../../../layouts/Layout.astro'
import ArticleCard from '../../../components/blog/ArticleCard.astro'
const { slug } = Astro.params
// Fetch category
const catResponse = await fetch(`${import.meta.env.PAYLOAD_CMS_URL}/api/categories?where[slug][equals]=${slug}&depth=1`)
const catData = await catResponse.json()
const category = catData.docs?.[0]
if (!category) {
return Astro.redirect('/404')
}
// Fetch category posts
const postsResponse = await fetch(`${import.meta.env.PAYLOAD_CMS_URL}/api/posts?where[status][equals]=published&where[categories][equals]=${category.id}&sort=-publishedAt&limit=100&depth=1`)
const postsData = await postsResponse.json()
const posts = postsData.docs || []
---
<Layout title={category.title}>
<section class="category-page" style={`background-color: ${category.backgroundColor}20`}>
<div class="container">
<header class="category-header">
<h1 class="category-title" style={`color: ${category.textColor}`}>
{category.title}
</h1>
{category.nameEn && (
<p class="category-subtitle">{category.nameEn}</p>
)}
</header>
<div class="blog-grid">
{posts.map(post => (
<ArticleCard post={post} />
))}
</div>
{posts.length === 0 && (
<p class="no-posts">此分類暫無文章</p>
)}
</div>
</section>
</Layout>
<style>
.category-page { @apply py-16 min-h-screen; }
.category-header { @apply text-center mb-12; }
.category-title { @apply text-3xl md:text-4xl font-bold mb-2; }
.category-subtitle { @apply text-xl text-gray-600; }
.blog-grid {
@apply grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8;
}
.no-posts { @apply text-center text-gray-500 py-12; }
</style>
```
### API Integration Notes
**Payload CMS API Endpoints:**
```
GET /api/posts - List all posts
GET /api/posts?where[status][equals]=published - Filter by status
GET /api/posts?slug=xxx - Get post by slug
GET /api/categories - List all categories
GET /api/categories?slug=xxx - Get category by slug
```
**Query Parameters:**
- `limit`: Number of items per page
- `page`: Page number
- `sort`: Sort field (e.g., `-publishedAt` for descending)
- `where`: JSON-encoded filter conditions
- `depth`: Populate related documents (1 for immediate relations)
## Tasks / Subtasks
### Phase 1: Architecture & Setup (2h)
- [ ] **Task 1.1**: Design blog URL structure and routing
- [ ] Decide between `/blog` vs `/news` URLs
- [ ] Plan 301 redirects from Webflow URLs
- [ ] Document routing decision
- [ ] **Task 1.2**: Create API utilities
- [ ] Create `apps/frontend/src/lib/api.ts` with helper functions
- [ ] Add type definitions for Post and Category
- [ ] Implement error handling for API calls
### Phase 2: Components (3h)
- [ ] **Task 2.1**: Create ArticleCard component
- [ ] Create `components/blog/ArticleCard.astro`
- [ ] Implement image, title, excerpt, category badge, date display
- [ ] Add hover effects and responsive design
- [ ] **Task 2.2**: Create CategoryFilter component
- [ ] Create `components/blog/CategoryFilter.astro`
- [ ] Implement filter button layout
- [ ] Add active state styling
- [ ] **Task 2.3**: Create RelatedPosts component
- [ ] Create `components/blog/RelatedPosts.astro`
- [ ] Display 3 related posts
- [ ] Reuse ArticleCard for consistency
### Phase 3: Blog Listing Page (2.5h)
- [ ] **Task 3.1**: Create blog listing page
- [ ] Create `pages/blog/index.astro`
- [ ] Implement data fetching from Payload API
- [ ] Add category filter integration
- [ ] Implement pagination
- [ ] **Task 3.2**: Style listing page
- [ ] Match Webflow design as closely as possible
- [ ] Ensure responsive behavior
- [ ] Add loading states
### Phase 4: Article Detail Page (3h)
- [ ] **Task 4.1**: Create article detail page
- [ ] Create/update `pages/blog/[slug].astro`
- [ ] Fetch post by slug from Payload API
- [ ] Handle 404 for non-existent posts
- [ ] **Task 4.2**: Render rich text content
- [ ] Implement Lexical to HTML conversion
- [ ] Style content with Tailwind typography
- [ ] Ensure responsive images
- [ ] **Task 4.3**: Add Open Graph tags
- [ ] Configure OG meta tags
- [ ] Use ogImage if available, fallback to heroImage
- [ ] Test social sharing preview
- [ ] **Task 4.4**: Add related posts section
- [ ] Fetch related posts by category
- [ ] Display RelatedPosts component
- [ ] Handle case with no related posts
### Phase 5: Category Page (2h)
- [ ] **Task 5.1**: Create category listing page
- [ ] Create `pages/blog/category/[slug].astro`
- [ ] Fetch category and posts
- [ ] Apply category theming colors
- [ ] **Task 5.2**: Style category page
- [ ] Match Webflow design
- [ ] Ensure responsive behavior
### Phase 6: Extend Posts Collection (1.5h)
- [ ] **Task 6.1**: Review Posts collection fields
- [ ] Verify all required fields exist
- [ ] Add missing fields if needed
- [ ] Configure admin UI columns
### Phase 7: Performance & Testing (2h)
- [ ] **Task 7.1**: Performance optimization
- [ ] Implement image lazy loading
- [ ] Add pagination to reduce initial load
- [ ] Consider caching strategies
- [ ] **Task 7.2**: Testing
- [ ] Test with 35+ migrated articles
- [ ] Verify category filtering works
- [ ] Test social sharing on Facebook/LINE
- [ ] Run Lighthouse audit (target 90+)
- [ ] Cross-browser testing
## Testing Requirements
### Unit Tests
```typescript
// apps/frontend/src/lib/__tests__/api.spec.ts
import { fetchPosts, fetchPostBySlug } from '../api'
describe('Blog API', () => {
it('should fetch published posts only', async () => {
const posts = await fetchPosts()
posts.forEach(post => {
expect(post.status).toBe('published')
})
})
it('should fetch post by slug', async () => {
const post = await fetchPostBySlug('test-slug')
expect(post).toBeDefined()
expect(post.slug).toBe('test-slug')
})
})
```
### Manual Testing Checklist
- [ ] Blog listing page displays all published posts
- [ ] Category filter buttons work correctly
- [ ] Article cards display all required information
- [ ] Pagination works (next/prev page)
- [ ] Article detail page loads for valid slug
- [ ] 404 page shows for invalid slug
- [ ] Rich text content renders correctly
- [ ] Category badges show correct colors
- [ ] Related posts are from same category
- [ ] Social sharing preview works
- [ ] Category page filters posts correctly
- [ ] Page loads quickly (< 2s LCP)
### Visual Comparison Checklist
- [ ] Article card spacing matches Webflow
- [ ] Typography matches Webflow
- [ ] Color scheme matches Webflow
- [ ] Responsive breakpoints match Webflow
## Risk Assessment
| Risk | Probability | Impact | Mitigation |
|------|------------|--------|------------|
| Lexical HTML conversion issues | Medium | High | Test with existing posts early |
| Performance with 35+ posts | Low | Medium | Implement pagination |
| Category theming complexity | Low | Low | Use inline styles for colors |
| Social sharing OG tag issues | Low | Medium | Test with Facebook debugger |
| URL structure mismatch | Low | High | Document 301 redirect plan |
## Definition of Done
- [ ] All three pages (listing, detail, category) implemented
- [ ] ArticleCard component with all required fields
- [ ] CategoryFilter component working
- [ ] RelatedPosts component working
- [ ] Pagination implemented
- [ ] Open Graph tags configured
- [ ] Category theming working
- [ ] Visual fidelity 95%+ compared to Webflow
- [ ] Lighthouse score 90+ achieved
- [ ] All 35+ articles accessible
- [ ] Cross-browser tested
- [ ] Mobile responsive
- [ ] sprint-status.yaml updated to mark story as in-progress
## Dev Agent Record
### Agent Model Used
*To be filled by Dev Agent*
### Debug Log References
*To be filled by Dev Agent*
### Completion Notes
*To be filled by Dev Agent*
### File List
*To be filled by Dev Agent*
## Change Log
| Date | Action | Author |
|------|--------|--------|
| 2026-01-31 | Story created (ready-for-dev) | SM Agent (Bob) |