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
+121
View File
@@ -0,0 +1,121 @@
'use client';
import { ReactNode, useCallback, useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
type Status = 'unknown' | 'authed' | 'denied';
/**
* Client-side admin gate.
*
* 1. If `x-admin-key` is stored in sessionStorage → validate via
* /api/admin/verify.
* 2. Otherwise show a key entry form (basic-auth fallback for users who
* prefer the `ADMIN_USER:ADMIN_KEY` header).
*
* The key is never written to localStorage and is cleared on a hard tab
* close. For hardening, front the admin area with real HTTP Basic auth
* at the reverse-proxy layer too.
*/
export default function AdminGate({ children }: { children: ReactNode }) {
const [status, setStatus] = useState<Status>('unknown');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const router = useRouter();
const [keyInput, setKeyInput] = useState('');
const validate = useCallback(
async (key: string) => {
setBusy(true);
setError(null);
try {
const res = await fetch('/api/admin/verify', {
headers: { 'x-admin-key': key },
});
if (res.ok) {
sessionStorage.setItem('mb_admin_key', key);
setStatus('authed');
} else {
setStatus('denied');
setError('Invalid admin key.');
}
} catch {
setError('Network error');
} finally {
setBusy(false);
}
},
[],
);
useEffect(() => {
const stored = sessionStorage.getItem('mb_admin_key');
if (stored) validate(stored);
else setStatus('denied');
}, [validate]);
async function submit(e: React.FormEvent) {
e.preventDefault();
if (keyInput.trim()) await validate(keyInput.trim());
}
function logout() {
sessionStorage.removeItem('mb_admin_key');
setStatus('denied');
setKeyInput('');
router.refresh();
}
if (status === 'authed') {
return (
<div className="mx-auto max-w-4xl px-4 py-8 sm:px-6">
<div className="mb-6 flex items-center justify-between">
<h1 className="font-display text-2xl font-bold text-ink-900">
Admin · Settings
</h1>
<button
onClick={logout}
className="rounded-md border border-ink-300 px-3 py-1.5 text-sm text-ink-600 hover:bg-ink-100"
>
Sign out
</button>
</div>
{children}
</div>
);
}
return (
<div className="mx-auto max-w-md px-4 py-24">
<div className="rounded-2xl border border-ink-200 bg-white p-8 shadow-sm">
<h1 className="font-display text-2xl font-bold text-ink-900">Admin</h1>
<p className="mt-1 text-sm text-ink-500">
Enter the admin key from <code>ADMIN_API_KEY</code> (or your{' '}
<code>USER:ADMIN_API_KEY</code> basic-auth string) to continue.
</p>
<form onSubmit={submit} className="mt-6 space-y-4">
<input
type="password"
value={keyInput}
onChange={(e) => setKeyInput(e.target.value)}
placeholder="Admin key"
autoFocus
className="w-full rounded-lg border border-ink-300 px-3 py-2.5 text-sm focus:border-maple-600 focus:outline-none"
/>
{error && <p className="text-sm text-red-700">{error}</p>}
<button
type="submit"
disabled={busy}
className="w-full rounded-lg bg-maple-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-maple-700 disabled:opacity-50"
>
{busy ? 'Verifying…' : 'Unlock'}
</button>
</form>
<p className="mt-4 text-xs text-ink-400">
Tip: keep the key in a password manager; it is only held in your
browser session while you have this tab open.
</p>
</div>
</div>
);
}