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
+283
View File
@@ -0,0 +1,283 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
interface Settings {
provider: 'openai' | 'anthropic' | 'ollama';
apiKeys: { openai: string; anthropic: string };
ollamaBase: string;
models: { openai: string; anthropic: string; ollama: string };
synthesisPrompt: string;
}
interface KeySource {
openai: boolean;
anthropic: boolean;
ollama: boolean;
}
export default function SettingsForm() {
const [settings, setSettings] = useState<Settings | null>(null);
const [keyFromDb, setKeyFromDb] = useState<KeySource | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [saveMsg, setSaveMsg] = useState<{ ok: boolean; text: string } | null>(null);
const [testMsg, setTestMsg] = useState<{ ok: boolean; text: string } | null>(null);
const [testing, setTesting] = useState(false);
const adminHeaders = useCallback(
() => ({
'x-admin-key': sessionStorage.getItem('mb_admin_key') ?? '',
'Content-Type': 'application/json',
}),
[],
);
useEffect(() => {
(async () => {
try {
const res = await fetch('/api/settings', {
headers: { 'x-admin-key': sessionStorage.getItem('mb_admin_key') ?? '' },
});
const data = await res.json();
if (!res.ok) throw new Error(data.error ?? 'load failed');
setSettings(data.settings as Settings);
setKeyFromDb(data.keySources as KeySource);
} catch (e) {
setLoadError(e instanceof Error ? e.message : 'load failed');
}
})();
}, []);
if (loadError) {
return <p className="text-sm text-red-700">Could not load settings: {loadError}</p>;
}
if (!settings || !keyFromDb) {
return <p className="text-sm text-ink-500">Loading</p>;
}
const prompt: Settings = {
provider: settings.provider,
apiKeys: { ...settings.apiKeys },
ollamaBase: settings.ollamaBase ?? 'http://localhost:11434',
models: { ...settings.models },
synthesisPrompt: settings.synthesisPrompt,
};
function patch<K extends keyof Settings>(key: K, value: Settings[K]) {
setSettings((s) => (s ? { ...s, [key]: value } : s));
setSaveMsg(null);
}
async function save(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setSaveMsg(null);
setTestMsg(null);
try {
const res = await fetch('/api/settings', {
method: 'PUT',
headers: adminHeaders(),
body: JSON.stringify(prompt),
});
const data = await res.json();
setSaveMsg({ ok: res.ok, text: res.ok ? 'Settings saved.' : (data.error ?? 'save failed') });
if (res.ok) {
setKeyFromDb(data.keySources as KeySource);
}
} catch {
setSaveMsg({ ok: false, text: 'Network error' });
} finally {
setSaving(false);
}
}
async function test() {
setTesting(true);
setTestMsg(null);
try {
const res = await fetch('/api/settings/test', {
method: 'POST',
headers: adminHeaders(),
});
const data = await res.json();
setTestMsg(
data.ok
? { ok: true, text: data.message ?? 'Connection OK.' }
: { ok: false, text: data.error ?? 'Test failed' },
);
} catch {
setTestMsg({ ok: false, text: 'Network error' });
} finally {
setTesting(false);
}
}
return (
<form onSubmit={save} className="space-y-8">
{/* Provider selection */}
<section className="rounded-xl border border-ink-200 bg-white p-6">
<h2 className="font-semibold text-ink-900">Active provider</h2>
<div className="mt-4 grid grid-cols-3 gap-3">
{(['openai', 'anthropic', 'ollama'] as const).map((p) => (
<label
key={p}
className={`cursor-pointer rounded-lg border p-4 text-center transition ${
prompt.provider === p
? 'border-maple-600 bg-maple-50 ring-1 ring-maple-600'
: 'border-ink-200 hover:border-ink-300'
}`}
>
<input
type="radio"
name="provider"
value={p}
checked={prompt.provider === p}
onChange={() => patch('provider', p)}
className="sr-only"
/>
<span className="block text-sm font-semibold capitalize text-ink-900">{p}</span>
<span className="mt-1 block text-xs text-ink-500">
{p === 'openai' && 'GPT-4o / 4o-mini · cloud'}
{p === 'anthropic' && 'Claude 3.5 Sonnet / Haiku · cloud'}
{p === 'ollama' && 'Local · zero cost'}
</span>
</label>
))}
</div>
{/* Model */}
<label className="mt-5 block text-sm">
<span className="font-medium text-ink-800">Model</span>
<input
type="text"
value={prompt.models[prompt.provider]}
onChange={(e) =>
patch('models', { ...prompt.models, [prompt.provider]: e.target.value })
}
list="model-suggestions"
className="mt-1 w-full rounded-lg border border-ink-300 px-3 py-2 font-mono text-sm focus:border-maple-600 focus:outline-none"
/>
<datalist id="model-suggestions">
{prompt.provider === 'openai' && (
<>
<option value="gpt-4o-mini" />
<option value="gpt-4o" />
</>
)}
{prompt.provider === 'anthropic' && (
<>
<option value="claude-3-5-sonnet-latest" />
<option value="claude-3-5-haiku-latest" />
</>
)}
{prompt.provider === 'ollama' && (
<>
<option value="llama3.1:8b" />
<option value="mistral:latest" />
<option value="qwen2.5:14b" />
</>
)}
</datalist>
</label>
{/* API key — only cloud providers */}
{prompt.provider !== 'ollama' ? (
<label className="mt-5 block text-sm">
<span className="font-medium text-ink-800">
{prompt.provider === 'openai' ? 'OpenAI' : 'Anthropic'} API key
<span className="ml-2 rounded bg-ink-100 px-1.5 py-0.5 text-[11px] font-normal text-ink-500">
{keyFromDb[prompt.provider] ? 'stored in database' : 'coming from env vars'}
</span>
</span>
<input
type="password"
value={prompt.apiKeys[prompt.provider]}
onChange={(e) =>
patch('apiKeys', { ...prompt.apiKeys, [prompt.provider]: e.target.value })
}
placeholder={keyFromDb[prompt.provider] ? 'Enter to update (current is saved)' : 'sk-…'}
className="mt-1 w-full rounded-lg border border-ink-300 px-3 py-2 font-mono text-sm focus:border-maple-600 focus:outline-none"
/>
{!keyFromDb[prompt.provider] && (
<span className="mt-1 block text-xs text-ink-400">
Blank means &ldquo;use the env var&rdquo; (OPENAI_API_KEY /
ANTHROPIC_API_KEY). Saving a value stores it encrypted-at-rest in
SQLite (phone-in-BE BYO; see README on secret handling).
</span>
)}
</label>
) : (
<label className="mt-5 block text-sm">
<span className="font-medium text-ink-800">Ollama base URL</span>
<input
type="url"
value={prompt.ollamaBase}
onChange={(e) => patch('ollamaBase', e.target.value)}
placeholder="http://localhost:11434"
className="mt-1 w-full rounded-lg border border-ink-300 px-3 py-2 font-mono text-sm focus:border-maple-600 focus:outline-none"
/>
<span className="mt-1 block text-xs text-ink-400">
For &ldquo;localhost&rdquo; you must also set OLLAMA_CORS=true on the
Ollama host (see README).
</span>
</label>
)}
</section>
{/* Synthesis prompt */}
<section className="rounded-xl border border-ink-200 bg-white p-6">
<h2 className="font-semibold text-ink-900">Synthesis system prompt</h2>
<p className="mt-1 text-xs text-ink-500">
This is the standing instruction the LLM receives on every
synthesis. Edit to change tone, length, or structure keep the
&ldquo;strict JSON only&rdquo; contract so parsing stays reliable.
</p>
<textarea
rows={12}
value={prompt.synthesisPrompt}
onChange={(e) => patch('synthesisPrompt', e.target.value)}
className="mt-3 w-full rounded-lg border border-ink-300 px-3 py-2 font-mono text-[13px] leading-relaxed focus:border-maple-600 focus:outline-none"
/>
</section>
{/* Actions */}
<section className="flex flex-wrap items-center gap-3">
<button
type="submit"
disabled={saving}
className="rounded-lg bg-maple-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-maple-700 disabled:opacity-50"
>
{saving ? 'Saving…' : 'Save settings'}
</button>
<button
type="button"
onClick={test}
disabled={testing || saving}
className="rounded-lg border border-ink-300 px-5 py-2.5 text-sm font-semibold text-ink-700 hover:bg-ink-100 disabled:opacity-50"
>
{testing ? 'Testing…' : 'Test connection'}
</button>
{saveMsg && (
<span
className={`text-sm ${saveMsg.ok ? 'text-emerald-700' : 'text-red-700'}`}
>
{saveMsg.text}
</span>
)}
</section>
{testMsg && (
<p
className={`rounded-lg border p-4 text-sm ${
testMsg.ok
? 'border-emerald-300 bg-emerald-50 text-emerald-900'
: 'border-red-300 bg-red-50 text-red-900'
}`}
>
{testMsg.text}
</p>
)}
</form>
);
}