156 lines
5.9 KiB
TypeScript
156 lines
5.9 KiB
TypeScript
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 { Suspense } from 'react';
|
|
import ReactMarkdown from 'react-markdown';
|
|
import remarkGfm from 'remark-gfm';
|
|
|
|
type ArticlePageProps = {
|
|
params: Promise<{ slug: string }>;
|
|
};
|
|
|
|
type ArticleContentProps = {
|
|
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 ArticleContentSkeleton = () => (
|
|
<article className='w-full'>
|
|
<div className='h-64 w-full animate-pulse bg-muted md:h-96' />
|
|
|
|
<div className='container mx-auto max-w-3xl px-4'>
|
|
<div className='-mt-16 relative z-10'>
|
|
<div className='mb-6 h-4 w-24 animate-pulse rounded bg-muted' />
|
|
|
|
<header className='mb-10'>
|
|
<div className='mb-4 space-y-2'>
|
|
<div className='h-8 w-full animate-pulse rounded bg-muted' />
|
|
<div className='h-8 w-3/4 animate-pulse rounded bg-muted' />
|
|
</div>
|
|
|
|
<div className='mb-5 space-y-2'>
|
|
<div className='h-5 w-full animate-pulse rounded bg-muted' />
|
|
<div className='h-5 w-2/3 animate-pulse rounded bg-muted' />
|
|
</div>
|
|
|
|
<div className='flex gap-4'>
|
|
<div className='h-3 w-28 animate-pulse rounded bg-muted' />
|
|
<div className='h-3 w-16 animate-pulse rounded bg-muted' />
|
|
</div>
|
|
</header>
|
|
|
|
<hr className='mb-10 border-border' />
|
|
|
|
<div className='space-y-3 pb-16'>
|
|
{[...Array(4)].map((_, i) => (
|
|
<div key={i} className='space-y-2'>
|
|
<div className='h-4 w-full animate-pulse rounded bg-muted' />
|
|
<div className='h-4 w-full animate-pulse rounded bg-muted' />
|
|
<div className='h-4 w-5/6 animate-pulse rounded bg-muted' />
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</article>
|
|
);
|
|
|
|
const ArticleContent = async ({ params }: ArticleContentProps) => {
|
|
const { slug } = await params;
|
|
const articleResult = await getArticleBySlug(slug);
|
|
if (!articleResult.ok) throw articleResult.error;
|
|
const article = articleResult.value;
|
|
|
|
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>
|
|
);
|
|
};
|
|
|
|
const ArticlePage = ({ params }: ArticlePageProps) => {
|
|
return (
|
|
<Suspense fallback={<ArticleContentSkeleton />}>
|
|
<ArticleContent params={params} />
|
|
</Suspense>
|
|
);
|
|
};
|
|
|
|
export default ArticlePage;
|