feat: add pagination and fix blog post links
### Changes to Blog Listing Page (src/app/blog/page.tsx):
1. **Implemented Pagination**
- Added support for `page` query parameter (e.g., `/blog?page=2`)
- Displays 10 posts per page
- Shows pagination controls with Previous/Next buttons
- Page numbers displayed as clickable buttons
- Active page highlighted with brand color
- Handles edge cases (invalid page numbers, out of bounds)
2. **Fixed "Read more" Links**
- Changed from `/blog` to `/blog/${post.slug}`
- Each blog card's "Read more" now links to individual article page
- Clicking "Read more" on Cloud-Neutral-Hybrid-Network-Architecture → `/blog/Cloud-Neutral-Hybrid-Network-Architecture`
3. **Features**
- **Server-side pagination**: Posts are sliced on the server
- **URL-based navigation**: Pagination state in URL
- **Responsive design**: Clean pagination controls
- **Error handling**: 404 for invalid pages
- **Navigation**: Previous/Next buttons with disabled states
- **Accessibility**: aria-disabled attribute on navigation
### Example URLs:
- `/blog` - First page (default)
- `/blog?page=1` - First page
- `/blog?page=2` - Second page
- Each post card → `/blog/[post-slug]`
### Result:
- ✅ Blog list supports pagination
- ✅ "Read more" links work correctly
- ✅ Better UX for browsing multiple blog posts
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
f19694cf24
commit
0f727aa36a
@ -1,6 +1,7 @@
|
||||
import Link from 'next/link'
|
||||
import { getHomepagePosts } from '@cms/content'
|
||||
import type { Metadata } from 'next'
|
||||
import { notFound } from 'next/navigation'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Blog | Cloud-Neutral',
|
||||
@ -27,8 +28,23 @@ function formatDate(dateStr: string | undefined, language: 'zh' | 'en'): string
|
||||
}
|
||||
}
|
||||
|
||||
export default async function BlogPage() {
|
||||
type PageProps = {
|
||||
searchParams: { page?: string }
|
||||
}
|
||||
|
||||
export default async function BlogPage({ searchParams }: PageProps) {
|
||||
const posts = await getHomepagePosts()
|
||||
const postsPerPage = 10
|
||||
const currentPage = parseInt(searchParams.page || '1', 10)
|
||||
const totalPages = Math.ceil(posts.length / postsPerPage)
|
||||
|
||||
if (currentPage < 1 || currentPage > totalPages) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const startIndex = (currentPage - 1) * postsPerPage
|
||||
const endIndex = startIndex + postsPerPage
|
||||
const paginatedPosts = posts.slice(startIndex, endIndex)
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen flex-col bg-slate-50">
|
||||
@ -45,48 +61,92 @@ export default async function BlogPage() {
|
||||
<p className="text-slate-500">暂无博客文章</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-8">
|
||||
{posts.map((post) => (
|
||||
<article
|
||||
key={post.slug}
|
||||
className="rounded-2xl border border-slate-200 bg-white p-8 shadow-sm transition hover:shadow-md"
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<span className="text-sm font-semibold text-brand">Blog</span>
|
||||
{post.date && (
|
||||
<time className="text-sm text-slate-500">
|
||||
{formatDate(post.date, 'en')}
|
||||
</time>
|
||||
)}
|
||||
</div>
|
||||
<h2 className="mb-4 text-2xl font-bold text-slate-900">{post.title}</h2>
|
||||
{post.author && (
|
||||
<p className="mb-4 text-sm text-slate-500">By {post.author}</p>
|
||||
)}
|
||||
<p className="mb-6 text-slate-600">{post.excerpt}</p>
|
||||
<div className="flex items-center gap-4">
|
||||
{post.tags && post.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{post.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="rounded-full bg-slate-100 px-3 py-1 text-xs font-medium text-slate-700"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<>
|
||||
<div className="grid gap-8">
|
||||
{paginatedPosts.map((post) => (
|
||||
<article
|
||||
key={post.slug}
|
||||
className="rounded-2xl border border-slate-200 bg-white p-8 shadow-sm transition hover:shadow-md"
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<span className="text-sm font-semibold text-brand">Blog</span>
|
||||
{post.date && (
|
||||
<time className="text-sm text-slate-500">
|
||||
{formatDate(post.date, 'en')}
|
||||
</time>
|
||||
)}
|
||||
</div>
|
||||
<h2 className="mb-4 text-2xl font-bold text-slate-900">{post.title}</h2>
|
||||
{post.author && (
|
||||
<p className="mb-4 text-sm text-slate-500">By {post.author}</p>
|
||||
)}
|
||||
<p className="mb-6 text-slate-600">{post.excerpt}</p>
|
||||
<div className="flex items-center gap-4">
|
||||
{post.tags && post.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{post.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="rounded-full bg-slate-100 px-3 py-1 text-xs font-medium text-slate-700"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Link
|
||||
href={`/blog/${post.slug}`}
|
||||
className="ml-auto text-sm font-semibold text-brand transition hover:text-brand-dark"
|
||||
>
|
||||
Read more →
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<nav className="mt-12 flex items-center justify-center gap-2">
|
||||
<Link
|
||||
href={`/blog?page=${currentPage - 1}`}
|
||||
className={`px-4 py-2 text-sm font-semibold rounded-lg transition ${
|
||||
currentPage === 1
|
||||
? 'cursor-not-allowed text-slate-400'
|
||||
: 'text-brand hover:bg-slate-100'
|
||||
}`}
|
||||
aria-disabled={currentPage === 1}
|
||||
>
|
||||
Previous
|
||||
</Link>
|
||||
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
|
||||
<Link
|
||||
href="/blog"
|
||||
className="ml-auto text-sm font-semibold text-brand transition hover:text-brand-dark"
|
||||
key={page}
|
||||
href={`/blog?page=${page}`}
|
||||
className={`px-4 py-2 text-sm font-semibold rounded-lg transition ${
|
||||
page === currentPage
|
||||
? 'bg-brand text-white'
|
||||
: 'text-slate-700 hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
Read more →
|
||||
{page}
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Link
|
||||
href={`/blog?page=${currentPage + 1}`}
|
||||
className={`px-4 py-2 text-sm font-semibold rounded-lg transition ${
|
||||
currentPage === totalPages
|
||||
? 'cursor-not-allowed text-slate-400'
|
||||
: 'text-brand hover:bg-slate-100'
|
||||
}`}
|
||||
aria-disabled={currentPage === totalPages}
|
||||
>
|
||||
Next
|
||||
</Link>
|
||||
</nav>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user