feat: add article page and not found component with markdown rendering

This commit is contained in:
2026-04-10 23:24:41 -03:00
parent 792627b0f0
commit 87a5d82c74
8 changed files with 1619 additions and 12 deletions

View File

@@ -0,0 +1,22 @@
import { Button } from '@/ui/components/shadcn/button';
import { FileXIcon } from 'lucide-react';
import Link from 'next/link';
const ArticleNotFound = () => {
return (
<div className='flex flex-col items-center justify-center gap-4 py-24 text-center'>
<FileXIcon className='size-12 text-muted-foreground/40' />
<div>
<h1 className='text-2xl font-bold'>Article not found</h1>
<p className='mt-1 text-muted-foreground'>
This article may have been moved or deleted.
</p>
</div>
<Button asChild variant='outline' size='sm'>
<Link href='/home'>Back to all articles</Link>
</Button>
</div>
);
};
export default ArticleNotFound;

View File

@@ -0,0 +1,99 @@
import { getArticleBySlug } from '@/lib/feature/article/article.external';
import { ArrowLeftIcon, CalendarIcon, ClockIcon } from 'lucide-react';
import Link from 'next/link';
import { notFound } from 'next/navigation';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
interface ArticlePageProps {
params: Promise<{ slug: string }>;
}
function readingTime(text: string): number {
const words = text.trim().split(/\s+/).length;
return Math.max(1, Math.ceil(words / 200));
}
function formatDate(date: Date): string {
return new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
}).format(date);
}
const ArticlePage = async ({ params }: ArticlePageProps) => {
const { slug } = await params;
const article = await getArticleBySlug(slug);
if (!article) notFound();
const minutes = readingTime(article.content);
return (
<article className='w-full'>
{article.coverImageUrl && (
<div className='relative h-64 w-full overflow-hidden md:h-96'>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={article.coverImageUrl}
alt={article.title}
className='h-full w-full object-cover'
/>
<div className='absolute inset-0 bg-linear-to-t from-background via-background/40 to-transparent' />
</div>
)}
<div className='container mx-auto max-w-3xl px-4'>
<div
className={
article.coverImageUrl ? '-mt-16 relative z-10' : 'pt-12'
}
>
<Link
href='/home'
className='mb-6 inline-flex items-center gap-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground'
>
<ArrowLeftIcon className='size-3.5' />
All articles
</Link>
<header className='mb-10'>
<h1 className='mb-4 text-3xl font-bold leading-tight tracking-tight md:text-4xl'>
{article.title}
</h1>
<p className='mb-5 text-lg leading-relaxed text-muted-foreground'>
{article.description}
</p>
<div className='flex flex-wrap items-center gap-4 text-xs text-muted-foreground'>
<span className='flex items-center gap-1.5'>
<CalendarIcon className='size-3.5 shrink-0' />
<time
dateTime={article.createdAt.toISOString()}
>
{formatDate(article.createdAt)}
</time>
</span>
<span className='flex items-center gap-1.5'>
<ClockIcon className='size-3.5 shrink-0' />
{minutes} min read
</span>
</div>
</header>
<hr className='mb-10 border-border' />
<div className='prose prose-neutral dark:prose-invert max-w-none pb-16'>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{article.content}
</ReactMarkdown>
</div>
</div>
</div>
</article>
);
};
export default ArticlePage;

View File

@@ -13,7 +13,11 @@ const Home = async ({ searchParams }: HomeProps) => {
const { page: pageParam } = await searchParams;
const page = Math.max(1, Number(pageParam) || 1);
const { data: articles, totalPages, total } = await getArticlesPaginated(page, PAGE_SIZE);
const {
data: articles,
totalPages,
total,
} = await getArticlesPaginated(page, PAGE_SIZE);
return (
<div className='container mx-auto w-full flex-1 px-4 py-12 md:py-16'>
@@ -41,7 +45,10 @@ const Home = async ({ searchParams }: HomeProps) => {
) : (
<div className='grid gap-6 sm:grid-cols-2 xl:grid-cols-3'>
{articles.map((article) => (
<ArticleCard key={article.externalId} article={article} />
<ArticleCard
key={article.externalId}
article={article}
/>
))}
</div>
)}

View File

@@ -1,9 +1,11 @@
@import 'tailwindcss';
@import 'tw-animate-css';
@import 'shadcn/tailwind.css';
@plugin '@tailwindcss/typography';
html {
font-family: var(--font-source-code-pro), sans-serif;
font-size: 15px; /* 15px base instead of 16px */
}
h1,

View File

@@ -32,14 +32,17 @@ export const ArticleListPagination = ({
const pages = getPageNumbers(currentPage, totalPages);
return (
<nav className='flex items-center gap-1' aria-label='Article pagination'>
<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',
currentPage <= 1 && 'pointer-events-none opacity-40'
)}
>
<ChevronLeftIcon className='size-4' />
@@ -62,12 +65,12 @@ export const ArticleListPagination = ({
'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',
: 'border-border bg-card hover:bg-muted'
)}
>
{page}
</Link>
),
)
)}
<Link
@@ -76,7 +79,8 @@ export const ArticleListPagination = ({
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',
currentPage >= totalPages &&
'pointer-events-none opacity-40'
)}
>
<ChevronRightIcon className='size-4' />