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:
2026-08-15 16:07:07 -04:00
commit 1010f27f44
76 changed files with 7177 additions and 0 deletions
+85
View File
@@ -0,0 +1,85 @@
import { getPublishedArticles, getTagList } from '@/lib/queries';
import ArticleCard from '@/components/ArticleCard';
import { InFeedAd, SidebarAd } from '@/components/ads/placements';
import { SECTIONS } from '@/data/feeds';
import type { ReactNode } from 'react';
import { notFound } from 'next/navigation';
export const dynamic = 'force-dynamic';
type Params = { section: string; page?: string };
export default async function SectionPage({
params,
searchParams,
}: {
params: Params;
searchParams: { page?: string };
}) {
const section = params.section;
const known = SECTIONS.find((s) => s.slug === section);
if (!known) notFound();
const page = Math.max(1, parseInt(searchParams.page ?? '1', 10) || 1);
const PER = 24;
const [articles, tags] = await Promise.all([
getPublishedArticles(section, PER, (page - 1) * PER),
getTagList(10),
]);
return (
<>
<div className="mx-auto max-w-6xl px-4 py-6 sm:px-6">
<h1 className="mb-6 font-display text-3xl font-bold text-ink-900">
{known.label}
</h1>
<div className="grid gap-8 lg:grid-cols-3">
<div className="lg:col-span-2">
{articles.length === 0 ? (
<p className="rounded-xl border border-dashed border-ink-300 bg-white p-10 text-center text-sm text-ink-500">
Nothing in {known.label} yet the worker will fill this in.
</p>
) : (
<div className="grid gap-5 sm:grid-cols-2">
{interleave(articles, 3)}
</div>
)}
</div>
<aside aria-label="Sidebar" className="space-y-8">
<SidebarAd tall />
{tags.length > 0 && (
<section className="rounded-xl border border-ink-200 bg-white p-5">
<h2 className="text-xs font-semibold uppercase tracking-wider text-ink-500">
Trending topics
</h2>
<div className="mt-3 flex flex-wrap gap-2">
{tags.map((t) => (
<a
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}
</a>
))}
</div>
</section>
)}
</aside>
</div>
</div>
</>
);
}
function interleave(
cards: Awaited<ReturnType<typeof getPublishedArticles>>,
every: number,
): ReactNode[] {
const out: ReactNode[] = [];
cards.forEach((card, i) => {
out.push(<ArticleCard key={card.id} card={card} />);
if ((i + 1) % every === 0) out.push(<InFeedAd key={`ad-${i + 1}`} index={i + 1} />);
});
return out;
}
+52
View File
@@ -0,0 +1,52 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { env } from '@/lib/env';
export const metadata: Metadata = {
title: 'About',
description: 'What MapleBrief is: an automated, AI-assisted digest of Canadian news with full attribution.',
alternates: { canonical: '/about' },
};
export default function AboutPage() {
return (
<div className="mx-auto max-w-3xl px-4 py-10 sm:px-6">
<h1 className="font-display text-4xl font-bold text-ink-950">About MapleBrief</h1>
<div className="mt-6 space-y-4 text-[15px] leading-relaxed text-ink-700">
<p>
{env.siteName} is a small, independent Canadian news-digest project.
Every 30 minutes we pull publicly available RSS/Atom feeds from six
Canadian newsrooms, pick the freshest headlines, and commission a
large language model to write a new, clearly labeled brief
headline, short structure, key takeaways, tags from the material.
</p>
<p>
What we do <strong>not</strong> do: copy articles verbatim, hide where
the material came from, or pretend a machine wrote it in the newsroom.
Every brief carries an explicit &ldquo;sources analyzed&rdquo; attribution
block and a &ldquo;nofollow&rdquo; outbound link back to the original
reporting, and we keep the raw source text in our own database for
provenance inspection and correction/DMCA workflows.
</p>
<p>
The site is supported by advertising through Google AdSense, and we
have written a{' '}
<Link href="/privacy-policy" className="font-medium text-maple-700 underline">
Privacy Policy
</Link>{' '}
to describe exactly how cookies and DART tracking work here.
</p>
<p>
If you believe a brief misrepresents the original reporting, or you
are a rights holder asking for attribution to be handled differently,
please use the{' '}
<Link href="/contact" className="font-medium text-maple-700 underline">
contact page
</Link>{' '}
we read every issue.
</p>
</div>
</div>
);
}
+17
View File
@@ -0,0 +1,17 @@
import type { Metadata } from 'next';
import AdminGate from '@/components/admin/AdminGate';
import SettingsForm from '@/components/admin/SettingsForm';
export const metadata: Metadata = {
title: 'Admin',
// Never let search engines index the admin area
robots: { index: false, follow: false },
};
export default function AdminSettingsPage() {
return (
<AdminGate>
<SettingsForm />
</AdminGate>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { NextResponse } from 'next/server';
import { isAuthorized } from '@/lib/auth/admin';
export const runtime = 'nodejs';
/**
* Used by the admin UI gate: confirm an admin key before rendering the form.
* Note: the actual key is only ever sent in request headers, never stored in
* localStorage, and the gate keeps it in sessionStorage for the browser tab.
*/
export async function GET(req: Request) {
if (isAuthorized(req as unknown as Parameters<typeof isAuthorized>[0])) {
return NextResponse.json({ ok: true });
}
return NextResponse.json({ ok: false, error: 'unauthorized' }, { status: 401 });
}
+44
View File
@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from 'next/server';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
interface ContactPayload {
email?: string;
topic?: string;
url?: string;
message?: string;
}
/**
* Placeholder contact sink.
*
* Product wiring options (in order of least effort):
* 1. Email: swap the `console.log` for `nodemailer` or an external
* transactional API (Resend, Postmark, SendGrid).
* 2. Form service: point the fetch at Formspree/Getform instead.
*
* The admin UI replies to itself and there is currently no secret channel;
* in production add a rate limit (e.g., 5/hour/IP) and CAPTCHA.
*/
export async function POST(req: NextRequest) {
let body: ContactPayload;
try {
body = await req.json();
} catch {
return NextResponse.json({ ok: false, error: 'invalid JSON' }, { status: 400 });
}
if (!body.email || !body.message) {
return NextResponse.json(
{ ok: false, error: 'email and message are required' },
{ status: 400 },
);
}
// Sensitive-ish: keep payload out of stdout logs in DEBUG mode.
console.log(`[contact] topic=${body.topic ?? 'other'} from=${body.email} url=${body.url ?? '-'}`);
console.log(`[contact] message: ${String(body.message).slice(0, 500)}`);
return NextResponse.json({ ok: true });
}
+32
View File
@@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from 'next/server';
import { runPipelinePass } from '@/lib/worker/cron';
import { isAuthorized } from '@/lib/auth/admin';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
// Ingestion (6+ feeds) + a batch of LLM calls can take a while.
export const maxDuration = 300;
/**
* Admin-triggered one-shot pipeline pass: ingest all enabled feeds, then
* synthesize pending articles.
*
* Auth: requires ADMIN_API_KEY via ?key=, the `x-admin-key` header, or
* Basic auth with any username.
*/
export async function POST(req: NextRequest) {
if (!(await isAuthorized(req))) {
return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
}
// One pass at a time; run in background so the client can connect-timeout.
runPipelinePass('api-refresh').catch((e) =>
console.error('[refresh] unhandled:', e),
);
return NextResponse.json({
started: true,
message: 'Pipeline pass started in background.',
time: new Date().toISOString(),
});
}
+138
View File
@@ -0,0 +1,138 @@
import { NextRequest, NextResponse } from 'next/server';
import { isAuthorized } from '@/lib/auth/admin';
import { prisma } from '@/lib/db';
import {
getLlmSettings,
upsertSetting,
SETTING_KEYS,
} from '@/lib/llm';
import type { LlmProviderId } from '@/lib/settings';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
const PROVIDERS: LlmProviderId[] = ['openai', 'anthropic', 'ollama'];
const PRO_MODEL_KEYS: Record<LlmProviderId, string> = {
openai: 'llm.model.openai',
anthropic: 'llm.model.anthropic',
ollama: 'llm.model.ollama',
};
/** Last-4 for display only — full secrets never leave the server. */
function last4(secret: string): string {
return secret.length >= 4 ? `${secret.slice(-4)}` : '';
}
function isProvider(v: unknown): v is LlmProviderId {
return v === 'openai' || v === 'anthropic' || v === 'ollama';
}
async function readSetting(key: string): Promise<string | null> {
const row = await prisma.setting.findUnique({ where: { key } });
return row?.value ?? null;
}
interface SettingsBody {
provider?: unknown;
model?: unknown; // legacy single-model field
models?: Partial<Record<LlmProviderId, unknown>>;
apiKeys?: { openai?: unknown; anthropic?: unknown };
ollamaBase?: unknown;
synthesisPrompt?: unknown;
}
async function toResponseBody() {
const s = await getLlmSettings();
const [dbProvider, dbModel, dbOpenai, dbAnthropic] = await Promise.all([
readSetting(SETTING_KEYS.provider),
readSetting(SETTING_KEYS.model),
readSetting(SETTING_KEYS.openaiKey),
readSetting(SETTING_KEYS.anthropicKey),
]);
const models = {} as Record<LlmProviderId, string>;
for (const p of PROVIDERS) models[p] = (await readSetting(PRO_MODEL_KEYS[p])) ?? s.model;
// A legacy single-model override applies to the provider it was set for.
const legacyOwner = isProvider(dbProvider) ? dbProvider : 'openai';
if (dbModel) models[legacyOwner] = dbModel;
return {
settings: {
provider: s.provider,
models,
apiKeys: { openai: '', anthropic: '' }, // secrets are never echoed
ollamaBase: s.ollamaBaseUrl,
synthesisPrompt: s.synthesisPrompt,
},
keySources: {
openai: Boolean(dbOpenai),
anthropic: Boolean(dbAnthropic),
ollama: true,
},
keys: {
openaiLast4: last4(s.openaiApiKey),
anthropicLast4: last4(s.anthropicApiKey),
},
};
}
export async function GET(req: NextRequest) {
if (!isAuthorized(req)) {
return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
}
return NextResponse.json(await toResponseBody());
}
export async function PUT(req: NextRequest) {
if (!isAuthorized(req)) {
return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
}
const body = (await req.json().catch(() => null)) as SettingsBody | null;
if (!body) return NextResponse.json({ error: 'invalid json' }, { status: 400 });
// Provider
if (body.provider !== undefined) {
if (!isProvider(body.provider)) {
return NextResponse.json(
{ error: 'provider must be openai | anthropic | ollama' },
{ status: 400 },
);
}
await upsertSetting(SETTING_KEYS.provider, body.provider);
}
const active =
(body.provider as LlmProviderId | undefined) ?? (await getLlmSettings()).provider;
// Models — per-provider, with single-model fallback for simple clients
if (body.models && typeof body.models === 'object') {
for (const p of PROVIDERS) {
const m = String(body.models[p] ?? '').trim();
if (m) {
await upsertSetting(PRO_MODEL_KEYS[p], m);
if (p === active) await upsertSetting(SETTING_KEYS.model, m);
}
}
} else if (typeof body.model === 'string' && body.model.trim()) {
await upsertSetting(SETTING_KEYS.model, body.model.trim());
await upsertSetting(PRO_MODEL_KEYS[active], body.model.trim());
}
// API keys (secrets; blank from the UI means "keep using the env var")
if (body.apiKeys) {
const openaiKey = String(body.apiKeys.openai ?? '').trim();
const anthropicKey = String(body.apiKeys.anthropic ?? '').trim();
if (openaiKey) await upsertSetting(SETTING_KEYS.openaiKey, openaiKey, true);
if (anthropicKey) await upsertSetting(SETTING_KEYS.anthropicKey, anthropicKey, true);
}
// Ollama base URL
const ollamaBase = String(body.ollamaBase ?? '').trim();
if (ollamaBase) await upsertSetting(SETTING_KEYS.ollamaBase, ollamaBase);
// Synthesis prompt
const prompt = String(body.synthesisPrompt ?? '').trim();
if (prompt) await upsertSetting(SETTING_KEYS.synthesisPrompt, prompt);
return NextResponse.json(await toResponseBody());
}
+50
View File
@@ -0,0 +1,50 @@
import { NextRequest, NextResponse } from 'next/server';
import { isAuthorized } from '@/lib/auth/admin';
import { resolveLlm } from '@/lib/llm';
import { OllamaProvider } from '@/lib/llm/ollama';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export const maxDuration = 60;
/**
* Connectivity probe for the active (or requested) provider.
* - ollama: GET /api/tags
* - openai/anthropic: a 4-token chat completion
*/
export async function POST(req: NextRequest) {
if (!isAuthorized(req)) return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
const body = (await req.json().catch(() => ({}))) as { provider?: string };
const providerId = body.provider;
try {
if (providerId === 'ollama') {
const { provider: settings } = { provider: undefined };
const { instance } = await resolveLlm();
if (instance instanceof OllamaProvider) {
return NextResponse.json({
ok: await (instance as OllamaProvider).ping(),
provider: 'ollama',
});
}
// fall through to generic below if active isn't ollama
}
const { instance } = await resolveLlm();
const result = await instance.synthesize({
sourceText: 'A test event: maple syrup production was reported stable this season.',
sources: ['Test Source'],
systemPrompt:
'You are a test probe. Reply with a JSON object: {"headline":"Test","body":"ok \\n\\nok \\n\\nok","takeaways":["test"],"tags":["test"]}',
});
return NextResponse.json({
ok: true,
provider: result.provider,
model: result.model,
tookMs: result.tookMs,
});
} catch (err) {
return NextResponse.json({ ok: false, error: (err as Error).message }, { status: 200 });
}
}
+40
View File
@@ -0,0 +1,40 @@
import { NextRequest, NextResponse } from 'next/server';
import { isAuthorized } from '@/lib/auth/admin';
import { prisma } from '@/lib/db';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
/**
* Pipeline telemetry for the admin dashboard: article counts by status,
* feeds with last-fetch times, recent synthesis activity.
*/
export async function GET(req: NextRequest) {
if (!(await isAuthorized(req))) {
return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
}
const [total, fetched, synthesized, failed, feedsByCategory] = await Promise.all([
prisma.article.count(),
prisma.article.count({ where: { status: 'fetched' } }),
prisma.article.count({ where: { status: 'synthesized' } }),
prisma.article.count({ where: { status: 'failed' } }),
prisma.feed.findMany({ orderBy: { name: 'asc' } }),
]);
const categories = await prisma.article
.groupBy({ by: ['category'], _count: { _all: true }, where: { status: 'synthesized' } })
.then((rows) => rows.map((r) => ({ category: r.category, count: r._count._all })));
return NextResponse.json({
articles: { total, fetched, synthesized, failed },
feeds: feedsByCategory.map((f) => ({
name: f.name,
category: f.category,
enabled: f.enabled,
lastFetchedAt: f.lastFetchedAt,
})),
categories,
time: new Date().toISOString(),
});
}
+27
View File
@@ -0,0 +1,27 @@
import { NextRequest, NextResponse } from 'next/server';
import { initWorker } from '@/lib/worker/cron';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
/**
* Keep-alive endpoint for the background worker.
*
* In long-lived Node deployments (`next start`, a container, or a VPS) the
* first hit to / initializes the node-cron scheduler; subsequent hits are
* no-ops. Call it on your launch script / healthcheck:
*
* curl -sX POST http://localhost:3000/api/worker/ping
*
* When RUN_SCHEDULER=true the worker then ingests + synthesizes automatically
* on CRON_SCHEDULE. On cold-start platforms (e.g. free serverless) drive
* ingestion with the `/api/refresh` route from an external timer instead.
*/
export async function POST(_req: NextRequest) {
const status = initWorker();
return NextResponse.json({ status, time: new Date().toISOString() });
}
export async function GET(_req: NextRequest) {
return NextResponse.json({ status: initWorker() });
}
+197
View File
@@ -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>
);
}
+29
View File
@@ -0,0 +1,29 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import ContactForm from '@/components/ContactForm';
export const metadata: Metadata = {
title: 'Contact',
description: 'Contact MapleBrief: corrections, legal DMCA inquiries, and advertising.',
alternates: { canonical: '/contact' },
};
export default function ContactPage() {
return (
<div className="mx-auto max-w-2xl px-4 py-10 sm:px-6">
<h1 className="font-display text-4xl font-bold text-ink-950">Contact</h1>
<p className="mt-3 text-[15px] text-ink-600">
Editorial corrections, DMCA / copyright notices, and AdSense partner
inquiries are all welcome. Legal requests should include the URL of the
affected brief and the original source URL.
</p>
<ContactForm />
<p className="mt-8 text-xs text-ink-400">
Prefer email? See the <Link href="/about" className="underline">about page</Link>{' '}
for the masthead, and this form returns the same destination.
</p>
</div>
);
}
+39
View File
@@ -0,0 +1,39 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--ink: #161614;
}
html {
scroll-behavior: smooth;
}
body {
@apply bg-ink-50 text-ink-900 antialiased;
}
::selection {
@apply bg-maple-200 text-ink-950;
}
/* Prose-like base for article bodies */
.article-prose p {
@apply mb-5 text-[1.0625rem] leading-[1.75] text-ink-800;
}
.article-prose p:first-of-type::first-letter {
@apply float-left mr-2 font-display text-5xl leading-[0.9] font-bold text-maple-600;
}
/* Screenshot-like shimmer for ad placeholders before AdSense loads */
@keyframes shimmer {
0% { background-position: -400px 0; }
100% { background-position: 400px 0; }
}
.ad-shimmer {
background: linear-gradient(90deg, #e6e6e3 25%, #f4f4f3 50%, #e6e6e3 75%);
background-size: 800px 100%;
animation: shimmer 1.6s infinite linear;
}
+68
View File
@@ -0,0 +1,68 @@
import type { Metadata } from 'next';
import './globals.css';
import Header, { SectionPills } from '@/components/layout/Header';
import { HeaderAd } from '@/components/ads/placements';
import Footer from '@/components/layout/Footer';
import { env } from '@/lib/env';
export const metadata: Metadata = {
metadataBase: new URL(env.siteUrl),
title: {
default: `${env.siteName} — Synthesized Canadian news briefings`,
template: `%s · ${env.siteName}`,
},
description: env.siteDescription,
alternates: { canonical: '/' },
icons: {
icon: [
{ url: '/favicon-64.png', sizes: '64x64', type: 'image/png' },
],
apple: '/icon-192.png',
},
openGraph: {
type: 'website',
siteName: env.siteName,
title: `${env.siteName} — Synthesized Canadian news briefings`,
description: env.siteDescription,
url: env.siteUrl,
images: [{ url: '/og-image.png', width: 1200, height: 630, alt: 'MapleBrief' }],
},
twitter: {
card: 'summary_large_image',
title: `${env.siteName} — Synthesized Canadian news briefings`,
description: env.siteDescription,
images: ['/og-image.png'],
},
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
'max-image-preview': 'large',
'max-snippet': -1,
},
},
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en-CA">
<body className="flex min-h-screen flex-col">
<a
href="#main"
className="sr-only focus:not-sr-focus-only focus:absolute focus:left-4 focus:top-4 focus:z-[100] focus:rounded focus:bg-maple-600 focus:px-4 focus:py-2 focus:text-white"
>
Skip to content
</a>
<Header />
<SectionPills />
<HeaderAd />
<main id="main" className="flex-1">
{children}
</main>
<Footer />
</body>
</html>
);
}
+22
View File
@@ -0,0 +1,22 @@
import Link from 'next/link';
export default function NotFound() {
return (
<div className="mx-auto flex max-w-2xl flex-col items-center px-4 py-24 text-center">
<p className="font-display text-6xl font-bold text-ink-300">404</p>
<h1 className="mt-4 font-display text-2xl font-bold text-ink-900">
That briefing has left the map
</h1>
<p className="mt-2 text-sm text-ink-500">
The page you were looking for doesnt exist or may have been superseded
by a newer synthesis.
</p>
<Link
href="/"
className="mt-8 rounded-lg bg-maple-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-maple-700"
>
Back to todays briefs
</Link>
</div>
);
}
+128
View File
@@ -0,0 +1,128 @@
import { getPublishedArticles, getTagList } from '@/lib/queries';
import ArticleCard from '@/components/ArticleCard';
import { InFeedAd, SidebarAd } from '@/components/ads/placements';
import Link from 'next/link';
import { env } from '@/lib/env';
export const dynamic = 'force-dynamic';
export default async function HomePage({
searchParams,
}: {
searchParams: { page?: string };
}) {
const page = Math.max(1, parseInt(searchParams.page ?? '1', 10) || 1);
const PER = 21;
const [articles, tags] = await Promise.all([
getPublishedArticles('top-stories', PER, (page - 1) * PER),
getTagList(12),
]);
const hero = articles[0];
const rest = articles.slice(1);
return (
<>
<div className="mx-auto max-w-6xl px-4 py-6 sm:px-6">
<div className="grid gap-8 lg:grid-cols-3">
{/* Main column */}
<div className="lg:col-span-2">
{hero ? (
<Link
href={`/article/${hero.slug}`}
className="group mb-8 block overflow-hidden rounded-2xl border border-ink-200 bg-white shadow-sm"
>
<div className="relative aspect-[16/8] overflow-hidden bg-ink-100">
{hero.image ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={hero.image}
alt=""
className="h-full w-full object-cover transition duration-500 group-hover:scale-[1.02]"
/>
) : (
<div className="flex h-full items-center justify-center bg-masthead-gradient">
<span className="font-display text-2xl font-bold text-white/90">
{env.siteName}
</span>
</div>
)}
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-ink-950/95 via-ink-950/70 to-transparent p-5 pt-16">
<span className="mb-2 inline-block rounded bg-maple-600 px-2 py-0.5 text-[11px] font-bold uppercase tracking-wider text-white">
Lead briefing
</span>
<h1 className="font-display text-2xl font-bold leading-tight text-white sm:text-3xl">
{hero.headline ?? hero.title}
</h1>
<p className="mt-2 line-clamp-2 text-sm text-ink-200">{hero.excerpt}</p>
</div>
</div>
</Link>
) : (
<EmptyState />
)}
{rest.length > 0 && (
<div className="grid gap-5 sm:grid-cols-2">
{interleaveInFeedAds(rest, 3).map((node) => node)}
</div>
)}
</div>
{/* Sidebar */}
<aside className="space-y-8" aria-label="Sidebar">
<SidebarAd tall />
{tags.length > 0 && (
<section className="rounded-xl border border-ink-200 bg-white p-5">
<h2 className="text-xs font-semibold uppercase tracking-wider text-ink-500">
Trending topics
</h2>
<div className="mt-3 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>
</section>
)}
</aside>
</div>
</div>
</>
);
}
import type { ReactNode } from 'react';
/** Interleave an InFeedAd after every `every`th card. */
function interleaveInFeedAds(
cards: Awaited<ReturnType<typeof getPublishedArticles>>,
every: number,
): ReactNode[] {
const out: ReactNode[] = [];
cards.forEach((card, i) => {
out.push(<ArticleCard key={card.id} card={card} />);
if ((i + 1) % every === 0) out.push(<InFeedAd key={`ad-${i + 1}`} index={i + 1} />);
});
return out;
}
function EmptyState() {
return (
<div className="rounded-2xl border border-dashed border-ink-300 bg-white p-12 text-center">
<p className="font-display text-xl font-bold text-ink-800">
No briefings yet
</p>
<p className="mx-auto mt-2 max-w-md text-sm text-ink-500">
The ingestion worker has not published anything yet. Trigger your first
run (see the README First run section) and this page will fill with
synthesized briefings.
</p>
</div>
);
}
+165
View File
@@ -0,0 +1,165 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { env } from '@/lib/env';
export const metadata: Metadata = {
title: 'Privacy Policy',
description: 'How MapleBrief collects, uses, and discloses personal information, including Google AdSense and DART cookies.',
alternates: { canonical: '/privacy-policy' },
};
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="mt-8">
<h2 className="font-display text-xl font-bold text-ink-900">{title}</h2>
<div className="mt-3 space-y-3 text-[15px] leading-relaxed text-ink-700">{children}</div>
</section>
);
}
export default function PrivacyPolicyPage() {
return (
<div className="mx-auto max-w-3xl px-4 py-10 sm:px-6">
<h1 className="font-display text-4xl font-bold text-ink-950">Privacy Policy</h1>
<p className="mt-3 text-sm text-ink-500">
Last updated: {new Date().toISOString().slice(0, 10)} · {env.siteName} ({env.siteUrl})
</p>
<Section title="1. Service overview">
<p>
{env.siteName} (&ldquo;MapleBrief&rdquo;, &ldquo;we&rdquo;, &ldquo;us&rdquo;) is an
automated news-digest site. We aggregate publicly available news feeds
from Canadian news publishers, independently review and summarize that
reporting into original briefs using a language model, and present them
with clear attribution to each original publisher.
</p>
</Section>
<Section title="2. Information we collect">
<p>
We do not require accounts and do not ask you to sign in. We do not
collect names, email addresses, or other identifying personal data
beyond what is automatically generated by your browser.
</p>
<ul className="list-disc space-y-1 pl-5">
<li>Server access logs (IP address, user agent, referrer, timestamp).</li>
<li>De-identified interaction metrics collected by third-party analytics and advertising vendors.</li>
<li>Data collected by our advertising partner, Google, as described below.</li>
</ul>
</Section>
<Section title="3. Google AdSense and third-party advertising">
<p>
We display advertising served by Google AdSense. Google and its
partners use cookies including the Google DART (DoubleClick
Ad Tracking) cookie to serve ads based on your prior visits to this
website and other sites on the Internet.
</p>
<p>
DART enables Google and its partners to serve ads to visitors based on
their visit to this site and other sites on the Internet.
</p>
<p>
Visitors may opt out of the use of the DART cookie by visiting the
Google Ad Settings page:
<a
href="https://www.google.com/settings/ads"
target="_blank"
rel="noopener noreferrer"
className="font-medium text-maple-700 underline"
>
https://www.google.com/settings/ads
</a>
. You can also opt out of third-party advertising cookies generally at
<a
href="https://www.aboutads.info/choices"
target="_blank"
rel="noopener noreferrer"
className="font-medium text-maple-700 underline"
>
https://www.aboutads.info/choices
</a>{' '}
(US/EU) or
<a
href="https://youradchoices.ca"
target="_blank"
rel="noopener noreferrer"
className="font-medium text-maple-700 underline"
>
https://youradchoices.ca
</a>{' '}
(Canada). Your choice of opt-out cookies applies per browser and must
be repeated for each browser and device you use.
</p>
<p>
Google&apos;s privacy policy regarding the use of advertising cookies
(including the DART cookie) is available at:
<a
href="https://policies.google.com/technologies/ads"
target="_blank"
rel="noopener noreferrer"
className="font-medium text-maple-700 underline"
>
https://policies.google.com/technologies/ads
</a>
.
</p>
</Section>
<Section title="4. Cookies">
<p>
We may use first-party cookies for basic site functionality (e.g.,
remembering preferences where applicable). Third-party cookies are
set by our advertising vendors as described in Section 3. You can
control cookies through your browser settings; blocking all cookies
may affect parts of the site.
</p>
</Section>
<Section title="5. Links to other sites">
<p>
Our briefs link out to original publisher articles. Those publishers
have their own privacy policies, and we are not responsible for their
content or practices.
</p>
</Section>
<Section title="6. Children&apos;s privacy">
<p>
Our service is not directed to children, and we do not knowingly
collect personal information from anyone under 13 (or the relevant
local age in Canada).
</p>
</Section>
<Section title="7. Your rights (PIPEDA and provincial privacy law)">
<p>
To the extent PIPEDA or provincial privacy laws apply, you may request
access to, correction of, or deletion of personal information we hold
about you. Contact us using the details below.
</p>
</Section>
<Section title="8. Changes to this policy">
<p>
We may update this policy from time to time. The most recent version
will always be published at{' '}
<Link href="/privacy-policy" className="font-medium text-maple-700 underline">
/privacy-policy
</Link>
.
</p>
</Section>
<Section title="9. Contact">
<p>
Questions about this policy: see our{' '}
<Link href="/contact" className="font-medium text-maple-700 underline">
contact page
</Link>
.
</p>
</Section>
</div>
);
}
+30
View File
@@ -0,0 +1,30 @@
import type { MetadataRoute } from 'next';
import { env } from '@/lib/env';
export const dynamic = 'force-dynamic';
export default function robots(): MetadataRoute.Robots {
const base = env.siteUrl.replace(/\/$/, '');
return {
rules: [
{
// Allow everything including Googlebot & Google-InspectionTool.
// Only block the admin area from indexing/crawling.
userAgent: ['*'],
allow: '/',
disallow: ['/admin', '/api/'],
},
{
userAgent: 'Googlebot',
allow: ['/', '/article/*/'],
disallow: ['/admin', '/api/'],
},
{
// AdSense injection backend (unofficial; harmless)
userAgent: 'Google-AdSense',
allow: '/',
},
],
sitemap: `${base}/sitemap.xml`,
};
}
+30
View File
@@ -0,0 +1,30 @@
import type { MetadataRoute } from 'next';
import { getPublishedArticles } from '@/lib/queries';
import { env } from '@/lib/env';
export const dynamic = 'force-dynamic';
const STATIC_PATHS = ['', '/about', '/contact', '/privacy-policy', '/terms-of-service'];
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const now = new Date();
const base = env.siteUrl.replace(/\/$/, '');
const staticEntries: MetadataRoute.Sitemap = STATIC_PATHS.map((p) => ({
url: `${base}${p}`,
lastModified: now,
changeFrequency: 'daily',
priority: p === '' ? 1.0 : 0.4,
}));
// All synthesized briefs, newest first, up to 5k entries (sitemap cap guidance).
const articles = await getPublishedArticles('all', 5000);
const articleEntries: MetadataRoute.Sitemap = articles.map((a) => ({
url: `${base}/article/${a.slug}`,
lastModified: a.publishedAt ? new Date(a.publishedAt) : now,
changeFrequency: 'hourly',
priority: 0.8,
}));
return [...staticEntries, ...articleEntries];
}
+55
View File
@@ -0,0 +1,55 @@
import { getArticlesByTag, getTagList } from '@/lib/queries';
import ArticleCard from '@/components/ArticleCard';
import type { Metadata } from 'next';
import Link from 'next/link';
import { notFound } from 'next/navigation';
export const dynamic = 'force-dynamic';
export async function generateMetadata({
params,
}: {
params: { tag: string };
}): Promise<Metadata> {
return { title: `#${params.tag}`, alternates: { canonical: `/tag/${params.tag}` } };
}
export default async function TagPage({ params }: { params: { tag: string } }) {
const tag = decodeURIComponent(params.tag);
const [articles, allTags] = await Promise.all([
getArticlesByTag(tag, 30),
getTagList(16),
]);
if (articles.length === 0 && !allTags.includes(tag.trim().toLowerCase())) {
// still render if tag existed in DB but had zero recent articles
}
return (
<div className="mx-auto max-w-6xl px-4 py-6 sm:px-6">
<h1 className="mb-6 font-display text-3xl font-bold text-ink-900">Topic: #{tag}</h1>
{articles.length === 0 ? (
<p className="rounded-xl border border-dashed border-ink-300 bg-white p-10 text-center text-sm text-ink-500">
No briefings under this tag yet.
</p>
) : (
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{articles.map((card) => (
<ArticleCard key={card.id} card={card} />
))}
</div>
)}
{allTags.length > 0 && (
<div className="mt-10 flex flex-wrap gap-2">
{allTags.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>
)}
</div>
);
}
+107
View File
@@ -0,0 +1,107 @@
import type { Metadata } from 'next';
import Link from 'next/link';
export const metadata: Metadata = {
title: 'Terms of Service',
description: 'Terms governing use of MapleBrief and its synthesized news briefs.',
alternates: { canonical: '/terms-of-service' },
};
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="mt-8">
<h2 className="font-display text-xl font-bold text-ink-900">{title}</h2>
<div className="mt-3 space-y-3 text-[15px] leading-relaxed text-ink-700">{children}</div>
</section>
);
}
export default function TermsPage() {
return (
<div className="mx-auto max-w-3xl px-4 py-10 sm:px-6">
<h1 className="font-display text-4xl font-bold text-ink-950">Terms of Service</h1>
<p className="mt-3 text-sm text-ink-500">
Last updated: {new Date().toISOString().slice(0, 10)}
</p>
<Section title="1. Acceptance of terms">
<p>
By accessing or using MapleBrief (&ldquo;the Site&rdquo;), you agree to be
bound by these Terms of Service. If you do not agree, discontinue use.
</p>
</Section>
<Section title="2. Nature of the service">
<p>
MapleBrief provides automated, AI-assisted summaries of publicly
available news from third-party Canadian publishers. Briefs are
synthesized for information convenience; each brief links to, and
attributes, the original reporting. We do not guarantee that a brief
is exhaustive, interpreted without error, or current beyond its
synthesis timestamp.
</p>
<p>
Nothing on the Site constitutes professional, legal, financial,
medical, or investment advice.
</p>
</Section>
<Section title="3. Intellectual property and attribution">
<p>
The original articles remain the exclusive property of their
respective publishers. MapleBrief&apos;s briefs are independently
authored; links to original sources are provided with{' '}
<code className="rounded bg-ink-100 px-1 text-sm">rel=&quot;nofollow&quot;</code>{' '}
and are intended as outbound referrals, not endorsements.
</p>
<p>
You may share brief links for personal, non-commercial reference. You
may not scrape, mirror, resell, or systematically redistribute the
Site&apos;s content.
</p>
</Section>
<Section title="4. Acceptable use">
<p>
You agree not to: (a) interfere with or disrupt the Site or its
infrastructure; (b) attempt to bypass rate limits or access controls;
(c) use the Site for unlawful purposes; (d) attempt to extract the
underlying raw feed content in bulk.
</p>
</Section>
<Section title="5. Termination">
<p>
We may modify or terminate any part of the Site at any time, with or
without notice.
</p>
</Section>
<Section title="6. Limitation of liability">
<p>
To the maximum extent permitted by law, MapleBrief shall not be
liable for indirect, incidental, special, consequential, or punitive
damages arising from your use of (or inability to use) the Site.
</p>
</Section>
<Section title="7. Governing law">
<p>
These terms are governed by the laws of Ontario, Canada, without
regard to conflict-of-law principles, and the exclusive remedy venue
is the courts of Ontario.
</p>
</Section>
<Section title="8. Contact">
<p>
For questions about these terms, see our{' '}
<Link href="/contact" className="font-medium text-maple-700 underline">
contact page
</Link>
.
</p>
</Section>
</div>
);
}