Archived
upstream fetch, magic-byte validation, persistent /data/media cache, immutable cache headers, CoverImage fallback component, og:image to absolute proxy URL. Ported from technews 6f08da3.
172 lines
5.4 KiB
TypeScript
172 lines
5.4 KiB
TypeScript
import type { Metadata } from 'next';
|
|
import Link from 'next/link';
|
|
import { notFound } from 'next/navigation';
|
|
import { getArticleFull, getRelatedArticles } from '@/lib/queries';
|
|
import RelatedArticles from '@/components/RelatedArticles';
|
|
import { InArticleAd, SidebarAd } from '@/components/ads/placements';
|
|
import { categoryCover, ogImageUrl, resolveCoverImage } from '@/lib/cover';
|
|
import { CoverImage } from '@/components/CoverImage';
|
|
import { env } from '@/lib/env';
|
|
import { formatFull } from '@/lib/format';
|
|
import { safeParse } from '@/lib/llm/parse';
|
|
import { SECTIONS } from '@/data/feeds';
|
|
|
|
export const dynamic = 'force-dynamic';
|
|
|
|
interface ArticleData {
|
|
id: string;
|
|
slug: string;
|
|
title: string;
|
|
headline: string | null;
|
|
body: string | null;
|
|
takeaways: string | null;
|
|
tags: string | null;
|
|
siteName: string;
|
|
category: string;
|
|
author: string | null;
|
|
publishedAt: Date | null;
|
|
synthesizedAt: Date | null;
|
|
sourceUrl: string;
|
|
canonicalUrl: string;
|
|
image: string | null;
|
|
sources: string | null;
|
|
originalText: string | null;
|
|
llmProvider: string | null;
|
|
}
|
|
|
|
export async function generateMetadata({
|
|
params,
|
|
}: {
|
|
params: { slug: string };
|
|
}): Promise<Metadata> {
|
|
const a = await getArticleFull(params.slug);
|
|
if (!a) return { title: 'Briefing not found' };
|
|
const title = a.headline ?? a.title;
|
|
const description = a.body?.split('\n\n')[0]?.slice(0, 160) ?? '';
|
|
const sectionLabel = SECTIONS.find((s) => s.slug === a.category)?.label ?? 'Briefing';
|
|
return {
|
|
title,
|
|
description,
|
|
alternates: { canonical: a.canonicalUrl },
|
|
openGraph: {
|
|
type: 'article',
|
|
title,
|
|
description,
|
|
url: a.canonicalUrl,
|
|
siteName: env.siteName,
|
|
publishedTime: a.publishedAt?.toISOString(),
|
|
modifiedTime: a.synthesizedAt?.toISOString(),
|
|
section: sectionLabel,
|
|
tags: a.tags ? safeParse<string[]>(a.tags).slice(0, 5) : undefined,
|
|
images: [{
|
|
url: ogImageUrl(a.image, a.category),
|
|
width: 1200,
|
|
height: 630,
|
|
alt: title,
|
|
}],
|
|
},
|
|
twitter: {
|
|
card: 'summary_large_image',
|
|
title,
|
|
description,
|
|
images: [ogImageUrl(a.image, a.category)],
|
|
},
|
|
};
|
|
}
|
|
|
|
export default async function ArticlePage({
|
|
params,
|
|
}: {
|
|
params: { slug: string };
|
|
}) {
|
|
const a = await getArticleFull(params.slug);
|
|
if (!a || !a.body) notFound();
|
|
|
|
const paragraphs = a.body.split('\n\n').map((p) => p.trim()).filter(Boolean);
|
|
const takeaways = a.takeaways ? safeParse<string[]>(a.takeaways) : [];
|
|
const tags = a.tags ? safeParse<string[]>(a.tags) : [];
|
|
const relatedItems = await getRelatedArticles(a.slug, 6);
|
|
const title = a.headline ?? a.title;
|
|
|
|
return (
|
|
<div className="mx-auto max-w-6xl px-4 py-6 sm:px-6">
|
|
<div className="grid gap-10 lg:grid-cols-3">
|
|
{/* Article column */}
|
|
<article className="lg:col-span-2">
|
|
<header>
|
|
<p className="mb-3 flex flex-wrap items-center gap-2 text-sm">
|
|
<span className="rounded bg-maple-600/10 px-2 py-0.5 text-xs font-bold uppercase tracking-wide text-maple-700">
|
|
{SECTIONS.find((s) => s.slug === a.category)?.label ?? 'Briefing'}
|
|
</span>
|
|
{a.publishedAt && (
|
|
<span className="text-ink-400">· {formatFull(a.publishedAt.toISOString())}</span>
|
|
)}
|
|
</p>
|
|
<h1 className="font-display text-3xl font-bold leading-tight text-ink-950 sm:text-4xl">
|
|
{title}
|
|
</h1>
|
|
</header>
|
|
|
|
<CoverImage
|
|
src={resolveCoverImage(a.image, a.category)}
|
|
category={a.category}
|
|
alt={title}
|
|
loading="eager"
|
|
className="mt-6 aspect-[16/8] w-full rounded-2xl object-cover"
|
|
/>
|
|
|
|
{takeaways.length > 0 && (
|
|
<section className="mt-8 rounded-xl border-l-4 border-maple-600 bg-maple-50 p-5">
|
|
<h2 className="text-xs font-bold uppercase tracking-wider text-maple-800">
|
|
Key takeaways
|
|
</h2>
|
|
<ul className="mt-3 grid gap-2 sm:grid-cols-1">
|
|
{takeaways.map((t, i) => (
|
|
<li key={i} className="flex gap-2 text-[15px] leading-snug text-ink-800">
|
|
<span aria-hidden className="mt-0.5 font-bold text-maple-600">•</span>
|
|
{t}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</section>
|
|
)}
|
|
|
|
<div className="article-prose mt-8">
|
|
{paragraphs.slice(0, 1).map((p, i) => (
|
|
<p key={i}>{p}</p>
|
|
))}
|
|
|
|
{/* In-article ad: between rewritten paragraphs 1 and 2 */}
|
|
<InArticleAd />
|
|
|
|
{paragraphs.slice(1).map((p, i) => (
|
|
<p key={i}>{p}</p>
|
|
))}
|
|
</div>
|
|
|
|
{tags.length > 0 && (
|
|
<div className="mt-8 flex flex-wrap gap-2">
|
|
{tags.map((t) => (
|
|
<Link
|
|
key={t}
|
|
href={`/tag/${t}`}
|
|
className="rounded-full bg-ink-100 px-3 py-1 text-sm text-ink-700 hover:bg-maple-100 hover:text-maple-800"
|
|
>
|
|
#{t}
|
|
</Link>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<RelatedArticles items={relatedItems} />
|
|
</article>
|
|
|
|
{/* Sidebar */}
|
|
<aside aria-label="Sidebar" className="space-y-8">
|
|
<SidebarAd tall />
|
|
</aside>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|