feat: category listing page with load-more pagination
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
import { notFound } from 'next/navigation'
|
||||
import type { Metadata } from 'next'
|
||||
import { getCategoryBySlug, getArticles, getAllCategories } from '@/lib/directus'
|
||||
import ArticleCard from '@/components/article/ArticleCard'
|
||||
import LoadMoreButton from '@/components/article/LoadMoreButton'
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ category: string }>
|
||||
}
|
||||
|
||||
export const revalidate = false
|
||||
|
||||
export async function generateStaticParams() {
|
||||
try {
|
||||
const categories = await getAllCategories()
|
||||
return categories.map((cat) => ({ category: cat.slug }))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { category: categorySlug } = await params
|
||||
try {
|
||||
const category = await getCategoryBySlug(categorySlug)
|
||||
if (!category) return {}
|
||||
return {
|
||||
title: category.name,
|
||||
description:
|
||||
category.description ?? `Latest ${category.name} news and articles on Kotobane.`,
|
||||
}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
export default async function CategoryPage({ params }: Props) {
|
||||
const { category: categorySlug } = await params
|
||||
let category = null
|
||||
let articles: import('@/lib/types').Article[] = []
|
||||
|
||||
try {
|
||||
const [cat, arts] = await Promise.all([
|
||||
getCategoryBySlug(categorySlug),
|
||||
getArticles({ categorySlug, limit: 24 }),
|
||||
])
|
||||
category = cat
|
||||
articles = arts
|
||||
} catch {
|
||||
category = null
|
||||
}
|
||||
|
||||
if (!category) notFound()
|
||||
|
||||
return (
|
||||
<div className="max-w-[1200px] mx-auto px-6 pt-10">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-text-primary mb-2">{category.name}</h1>
|
||||
{category.description && (
|
||||
<p className="text-text-secondary text-sm">{category.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{articles.length === 0 ? (
|
||||
<p className="text-text-muted text-sm">No articles yet.</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{articles.map((article) => (
|
||||
<ArticleCard key={article.id} article={article} />
|
||||
))}
|
||||
</div>
|
||||
<LoadMoreButton categorySlug={categorySlug} initialCount={articles.length} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import type { Article } from '@/lib/types'
|
||||
import ArticleCard from './ArticleCard'
|
||||
|
||||
interface Props {
|
||||
categorySlug: string
|
||||
initialCount: number
|
||||
}
|
||||
|
||||
export default function LoadMoreButton({ categorySlug, initialCount }: Props) {
|
||||
const [articles, setArticles] = useState<Article[]>([])
|
||||
const [offset, setOffset] = useState(initialCount)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [hasMore, setHasMore] = useState(true)
|
||||
|
||||
async function loadMore() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
'filter[status][_eq]': 'published',
|
||||
'filter[category][slug][_eq]': categorySlug,
|
||||
sort: '-published_at',
|
||||
limit: '12',
|
||||
offset: String(offset),
|
||||
fields:
|
||||
'id,title,slug,excerpt,featured_image,published_at,is_featured,category.id,category.name,category.slug',
|
||||
access_token: process.env.NEXT_PUBLIC_DIRECTUS_TOKEN ?? '',
|
||||
})
|
||||
const res = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_DIRECTUS_URL}/items/articles?${params}`
|
||||
)
|
||||
const data = await res.json()
|
||||
const newArticles: Article[] = data.data ?? []
|
||||
setArticles((prev) => [...prev, ...newArticles])
|
||||
setOffset((prev) => prev + newArticles.length)
|
||||
if (newArticles.length < 12) setHasMore(false)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{articles.length > 0 && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 mt-4">
|
||||
{articles.map((article) => (
|
||||
<ArticleCard key={article.id} article={article} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{hasMore && (
|
||||
<div className="flex justify-center mt-8 pb-8">
|
||||
<button
|
||||
onClick={loadMore}
|
||||
disabled={loading}
|
||||
className="bg-bg-card border border-border text-text-secondary text-sm font-medium px-6 py-2.5 rounded-sm hover:border-accent hover:text-accent transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Loading…' : 'Load more'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user