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,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;
|
||||
}
|
||||
@@ -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 “sources analyzed” attribution
|
||||
block and a “nofollow” 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
@@ -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() });
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 doesn’t 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 today’s briefs
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} (“MapleBrief”, “we”, “us”) 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'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'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>
|
||||
);
|
||||
}
|
||||
@@ -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`,
|
||||
};
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 (“the Site”), 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's briefs are independently
|
||||
authored; links to original sources are provided with{' '}
|
||||
<code className="rounded bg-ink-100 px-1 text-sm">rel="nofollow"</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'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user