Files
blog-nextjs/components/post-list-with-controls.tsx
gbanyan 6a9296f33d Migrate to HeroUI v3 and Tailwind CSS v4
- Upgrade from Tailwind CSS v3 to v4 with CSS-first configuration
- Install HeroUI v3 beta packages (@heroui/react, @heroui/styles)
- Migrate PostCSS config to new @tailwindcss/postcss plugin
- Convert tailwind.config.cjs to CSS @theme directive in globals.css
- Replace @tailwindcss/typography with custom prose styles

Component migrations:
- theme-toggle, back-to-top: HeroUI Button with onPress
- post-card, post-list-item: HeroUI Card compound components
- right-sidebar: HeroUI Card, Avatar, Chip
- search-modal: HeroUI Modal with compound structure
- nav-menu: HeroUI Button for mobile controls
- post-list-with-controls: HeroUI Button for sorting/pagination

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 17:47:36 +08:00

202 lines
7.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useEffect, useMemo, useState } from 'react';
import { Post } from 'contentlayer2/generated';
import { FiArrowDown, FiArrowUp, FiSearch, FiList } from 'react-icons/fi';
import { siteConfig } from '@/lib/config';
import { PostListItem } from './post-list-item';
import { TimelineWrapper } from './timeline-wrapper';
import { Button, Input } from '@heroui/react';
interface Props {
posts: Post[];
pageSize?: number;
}
type SortOrder = 'new' | 'old';
export function PostListWithControls({ posts, pageSize }: Props) {
const [sortOrder, setSortOrder] = useState<SortOrder>('new');
const [page, setPage] = useState(1);
const [searchTerm, setSearchTerm] = useState('');
const size = pageSize ?? siteConfig.postsPerPage ?? 5;
const normalizedQuery = searchTerm.trim().toLowerCase();
const filteredPosts = useMemo(() => {
if (!normalizedQuery) return posts;
return posts.filter((post) => {
const haystack = [
post.title,
post.description,
post.custom_excerpt,
post.tags?.join(' ')
]
.filter(Boolean)
.join(' ')
.toLowerCase();
return haystack.includes(normalizedQuery);
});
}, [posts, normalizedQuery]);
const sortedPosts = useMemo(() => {
const arr = [...filteredPosts];
arr.sort((a, b) => {
const aDate = a.published_at
? new Date(a.published_at).getTime()
: 0;
const bDate = b.published_at
? new Date(b.published_at).getTime()
: 0;
return sortOrder === 'new' ? bDate - aDate : aDate - bDate;
});
return arr;
}, [filteredPosts, sortOrder]);
const totalPages = Math.max(1, Math.ceil(sortedPosts.length / size));
const currentPage = Math.min(page, totalPages);
const start = (currentPage - 1) * size;
const currentPosts = sortedPosts.slice(start, start + size);
useEffect(() => {
setPage(1);
}, [normalizedQuery]);
const handleChangeSort = (order: SortOrder) => {
setSortOrder(order);
setPage(1);
};
const goToPage = (p: number) => {
if (p < 1 || p > totalPages) return;
setPage(p);
};
return (
<div className="space-y-4">
<div className="flex flex-col gap-4 text-xs text-slate-500 dark:text-slate-400 sm:flex-row sm:items-center sm:justify-between">
<div className="inline-flex items-center gap-2 rounded-full bg-slate-100/70 px-2 py-1 text-slate-600 dark:bg-slate-800/70 dark:text-slate-300">
<FiList className="h-3.5 w-3.5" />
<span></span>
<Button
size="sm"
variant={sortOrder === 'new' ? 'primary' : 'ghost'}
onPress={() => handleChangeSort('new')}
className={`h-auto rounded-full px-2 py-0.5 text-xs ${sortOrder === 'new'
? 'bg-blue-600 text-white dark:bg-blue-500'
: 'bg-white text-slate-600 hover:bg-slate-200 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-700'
}`}
>
<FiArrowDown className="h-3 w-3" />
</Button>
<Button
size="sm"
variant={sortOrder === 'old' ? 'primary' : 'ghost'}
onPress={() => handleChangeSort('old')}
className={`h-auto rounded-full px-2 py-0.5 text-xs ${sortOrder === 'old'
? 'bg-blue-600 text-white dark:bg-blue-500'
: 'bg-white text-slate-600 hover:bg-slate-200 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-700'
}`}
>
<FiArrowUp className="h-3 w-3" />
</Button>
</div>
<div className="flex w-full items-center text-sm sm:w-auto">
<label htmlFor="post-search" className="sr-only">
</label>
<div className="relative w-full sm:w-64">
<FiSearch
className="pointer-events-none absolute left-3 top-1/2 z-10 h-3.5 w-3.5 -translate-y-1/2 text-slate-400"
/>
<input
id="post-search"
type="search"
placeholder="標題、標籤、摘要關鍵字"
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
className="w-full rounded-full border border-slate-200 bg-white py-1.5 pl-9 pr-3 text-sm text-slate-700 shadow-sm transition duration-180 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:focus:ring-blue-500"
/>
</div>
</div>
</div>
<div className="flex items-center justify-between text-xs text-slate-500 dark:text-slate-400">
<p>
{currentPage} / {totalPages} · {sortedPosts.length}
{normalizedQuery && `(搜尋「${searchTerm}」)`}
</p>
{normalizedQuery && sortedPosts.length === 0 && (
<Button
variant="ghost"
size="sm"
onPress={() => setSearchTerm('')}
className="h-auto p-0 text-blue-600 underline-offset-2 hover:underline dark:text-blue-400"
>
</Button>
)}
</div>
{currentPosts.length === 0 ? (
<div className="rounded-lg border border-dashed border-slate-200 p-6 text-center text-sm text-slate-500 dark:border-slate-700 dark:text-slate-400">
</div>
) : (
<TimelineWrapper className="space-y-3">
{currentPosts.map((post) => (
<PostListItem key={post._id} post={post} />
))}
</TimelineWrapper>
)}
{totalPages > 1 && currentPosts.length > 0 && (
<nav className="flex items-center justify-center gap-3 text-xs text-slate-600 dark:text-slate-300">
<Button
variant="secondary"
size="sm"
onPress={() => goToPage(currentPage - 1)}
isDisabled={currentPage === 1}
className="h-auto rounded border border-slate-200 px-2 py-1 text-xs disabled:opacity-40 dark:border-slate-700"
>
</Button>
<div className="flex items-center gap-1">
{Array.from({ length: totalPages }).map((_, i) => {
const p = i + 1;
const isActive = p === currentPage;
return (
<Button
key={p}
variant={isActive ? 'primary' : 'ghost'}
size="sm"
onPress={() => goToPage(p)}
className={`h-7 w-7 min-w-0 rounded p-0 text-xs ${isActive
? 'bg-blue-600 text-white dark:bg-blue-500'
: 'hover:bg-slate-100 dark:hover:bg-slate-800'
}`}
>
{p}
</Button>
);
})}
</div>
<Button
variant="secondary"
size="sm"
onPress={() => goToPage(currentPage + 1)}
isDisabled={currentPage === totalPages}
className="h-auto rounded border border-slate-200 px-2 py-1 text-xs disabled:opacity-40 dark:border-slate-700"
>
</Button>
</nav>
)}
</div>
);
}