feat: add createdAt and updatedAt fields to Article model and implement article pagination with new components
This commit is contained in:
66
src/ui/components/internal/article-card.tsx
Normal file
66
src/ui/components/internal/article-card.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { ArticleModel } from '@/lib/feature/article/article.model';
|
||||
import { cn } from '@/ui/components/shadcn/lib/utils';
|
||||
import { CalendarIcon } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface ArticleCardProps {
|
||||
article: ArticleModel;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const formatDate = (date: Date) =>
|
||||
new Intl.DateTimeFormat('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}).format(date);
|
||||
|
||||
export const ArticleCard = ({ article, className }: ArticleCardProps) => {
|
||||
return (
|
||||
<Link
|
||||
href={`/article/${article.slug}`}
|
||||
className={cn(
|
||||
'group flex flex-col overflow-hidden rounded-xl border border-border bg-card text-card-foreground transition-all duration-200 hover:border-foreground/20 hover:shadow-md',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className='relative aspect-video w-full overflow-hidden bg-muted'>
|
||||
{article.coverImageUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={article.coverImageUrl}
|
||||
alt={article.title}
|
||||
className='h-full w-full object-cover transition-transform duration-300 group-hover:scale-105'
|
||||
/>
|
||||
) : (
|
||||
<div className='flex h-full items-center justify-center bg-gradient-to-br from-muted to-muted/60'>
|
||||
<span className='font-mono text-5xl font-bold text-muted-foreground/20'>
|
||||
{'{ }'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='flex flex-1 flex-col gap-3 p-5'>
|
||||
<div className='flex items-center gap-1.5 text-xs text-muted-foreground'>
|
||||
<CalendarIcon className='size-3 shrink-0' />
|
||||
<time dateTime={article.createdAt.toISOString()}>
|
||||
{formatDate(article.createdAt)}
|
||||
</time>
|
||||
</div>
|
||||
|
||||
<h2 className='line-clamp-2 text-base font-bold leading-snug tracking-tight transition-colors group-hover:text-primary'>
|
||||
{article.title}
|
||||
</h2>
|
||||
|
||||
<p className='line-clamp-3 flex-1 text-sm leading-relaxed text-muted-foreground'>
|
||||
{article.description}
|
||||
</p>
|
||||
|
||||
<span className='mt-1 text-xs font-medium text-foreground/60 underline-offset-4 group-hover:text-primary group-hover:underline'>
|
||||
Read more →
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
86
src/ui/components/internal/article-list-pagination.tsx
Normal file
86
src/ui/components/internal/article-list-pagination.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { cn } from '@/ui/components/shadcn/lib/utils';
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface ArticleListPaginationProps {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
baseUrl?: string;
|
||||
}
|
||||
|
||||
function getPageNumbers(current: number, total: number): (number | '...')[] {
|
||||
if (total <= 7) {
|
||||
return Array.from({ length: total }, (_, i) => i + 1);
|
||||
}
|
||||
if (current <= 4) {
|
||||
return [1, 2, 3, 4, 5, '...', total];
|
||||
}
|
||||
if (current >= total - 3) {
|
||||
return [1, '...', total - 4, total - 3, total - 2, total - 1, total];
|
||||
}
|
||||
return [1, '...', current - 1, current, current + 1, '...', total];
|
||||
}
|
||||
|
||||
export const ArticleListPagination = ({
|
||||
currentPage,
|
||||
totalPages,
|
||||
baseUrl = '/home',
|
||||
}: ArticleListPaginationProps) => {
|
||||
if (totalPages <= 1) return null;
|
||||
|
||||
const buildUrl = (page: number) => `${baseUrl}?page=${page}`;
|
||||
const pages = getPageNumbers(currentPage, totalPages);
|
||||
|
||||
return (
|
||||
<nav className='flex items-center gap-1' aria-label='Article pagination'>
|
||||
<Link
|
||||
href={buildUrl(currentPage - 1)}
|
||||
aria-disabled={currentPage <= 1}
|
||||
aria-label='Previous page'
|
||||
className={cn(
|
||||
'flex size-9 items-center justify-center rounded-lg border border-border bg-card transition-colors hover:bg-muted',
|
||||
currentPage <= 1 && 'pointer-events-none opacity-40',
|
||||
)}
|
||||
>
|
||||
<ChevronLeftIcon className='size-4' />
|
||||
</Link>
|
||||
|
||||
{pages.map((page, i) =>
|
||||
page === '...' ? (
|
||||
<span
|
||||
key={`ellipsis-${i}`}
|
||||
className='flex size-9 items-center justify-center text-sm text-muted-foreground'
|
||||
>
|
||||
…
|
||||
</span>
|
||||
) : (
|
||||
<Link
|
||||
key={page}
|
||||
href={buildUrl(page as number)}
|
||||
aria-current={page === currentPage ? 'page' : undefined}
|
||||
className={cn(
|
||||
'flex size-9 items-center justify-center rounded-lg border text-sm font-medium transition-colors',
|
||||
page === currentPage
|
||||
? 'border-foreground bg-foreground text-background'
|
||||
: 'border-border bg-card hover:bg-muted',
|
||||
)}
|
||||
>
|
||||
{page}
|
||||
</Link>
|
||||
),
|
||||
)}
|
||||
|
||||
<Link
|
||||
href={buildUrl(currentPage + 1)}
|
||||
aria-disabled={currentPage >= totalPages}
|
||||
aria-label='Next page'
|
||||
className={cn(
|
||||
'flex size-9 items-center justify-center rounded-lg border border-border bg-card transition-colors hover:bg-muted',
|
||||
currentPage >= totalPages && 'pointer-events-none opacity-40',
|
||||
)}
|
||||
>
|
||||
<ChevronRightIcon className='size-4' />
|
||||
</Link>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user