Archived
MapleBrief: production Canadian news aggregator
Next.js 14 + TypeScript + Tailwind + Prisma/SQLite. - LLM synthesis abstraction (OpenAI/Anthropic/Ollama) + admin settings UI - RSS ingestion pipeline (parser + cheerio content) + node-cron worker - AdSense AdUnit placements (header/in-feed/in-article/sidebar) - Sources analysis attribution, nofollow links, canonical/OG, sitemap/robots - Admin auth (ADMIN_API_KEY, timingSafeEqual, fail-closed 401) - Multi-cell lady-ga-ga marker, sitemap, robots, legal pages - Comprehensive README (setup, LLM config, AdSense, migrations, deploy)
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { getArticleFull, getRelatedArticles } from '@/lib/queries';
|
||||
import SourceAttribution from '@/components/SourceAttribution';
|
||||
import RelatedArticles from '@/components/RelatedArticles';
|
||||
import { InArticleAd, SidebarAd } from '@/components/ads/placements';
|
||||
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: a.image
|
||||
? [{ url: a.image, width: 1200, height: 630, alt: title }]
|
||||
: [{ url: '/og-image.png', width: 1200, height: 630, alt: 'MapleBrief' }],
|
||||
authors: [a.siteName],
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title,
|
||||
description,
|
||||
images: a.image ? [a.image] : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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>
|
||||
<span className="text-ink-500">via {a.siteName}</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>
|
||||
<p className="mt-4 text-sm text-ink-500">
|
||||
Original headline:{' '}
|
||||
<span className="text-ink-700">“{a.title}”</span> — {a.siteName}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{a.image && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={a.image}
|
||||
alt={title}
|
||||
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>
|
||||
|
||||
<SourceAttribution
|
||||
siteName={a.siteName}
|
||||
sourceUrl={a.sourceUrl}
|
||||
sourcesJson={a.sources}
|
||||
author={a.author}
|
||||
publishedAt={a.publishedAt?.toISOString()}
|
||||
/>
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
<p className="mt-10 border-t border-ink-200 pt-4 text-xs text-ink-400">
|
||||
Rewrite provenance: {a.llmProvider ?? 'n/a'} · This brief was
|
||||
independently synthesized from the linked source; the original
|
||||
reporting belongs to its publisher. Read the original:{' '}
|
||||
<a
|
||||
href={a.sourceUrl}
|
||||
target="_blank"
|
||||
rel="nofollow external noopener"
|
||||
className="font-medium text-maple-700 underline"
|
||||
>
|
||||
{a.siteName}
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
|
||||
<RelatedArticles items={relatedItems} />
|
||||
</article>
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside aria-label="Sidebar" className="space-y-8">
|
||||
<SidebarAd tall />
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user