Replace top Nav with Sidebar, add lib/group.js for library grouping, tokenized color/alert styles, and tighter Settings/Canvas export layout. Adds @tabler/icons-react. Verified production build (standalone) passes, so the Docker image built from this tree matches the local app. Co-authored-by: Claude <claude-code@anthropic.com>
356 lines
22 KiB
React
356 lines
22 KiB
React
"use client";
|
||
// app/settings/page.jsx — choose and configure your AI provider, all stored locally.
|
||
// Flush two-column "paper form": sticky table of contents + underline-only fields.
|
||
import { useEffect, useRef, useState } from "react";
|
||
import {
|
||
IconUserCircle, IconCpu, IconSchool, IconAdjustments,
|
||
IconPhoto, IconRefresh, IconPlug, IconCheck, IconX,
|
||
} from "@tabler/icons-react";
|
||
|
||
const PROVIDERS = [
|
||
{ id: "ollama", name: "Ollama", desc: "Free, private, runs on this computer", local: true },
|
||
{ id: "lmstudio", name: "LM Studio", desc: "Free, private, runs on this computer", local: true },
|
||
{ id: "anthropic", name: "Anthropic (Claude)", desc: "Cloud API — needs an API key", local: false },
|
||
{ id: "openai", name: "OpenAI (GPT)", desc: "Cloud API — needs an API key", local: false },
|
||
{ id: "google", name: "Google AI (Gemini)", desc: "Cloud API — needs an API key", local: false },
|
||
];
|
||
const KEY_LINKS = {
|
||
anthropic: "https://console.anthropic.com/",
|
||
openai: "https://platform.openai.com/api-keys",
|
||
google: "https://aistudio.google.com/apikey",
|
||
};
|
||
const TOC = [
|
||
{ id: "teacher", label: "Teacher & school", icon: IconUserCircle },
|
||
{ id: "provider", label: "AI provider", icon: IconCpu },
|
||
{ id: "canvas", label: "Canvas (LMS)", icon: IconSchool },
|
||
{ id: "generation", label: "Generation defaults", icon: IconAdjustments },
|
||
];
|
||
|
||
export default function SettingsPage() {
|
||
const [s, setS] = useState(null);
|
||
const [models, setModels] = useState({});
|
||
const [modelsBusy, setModelsBusy] = useState("");
|
||
const [modelsErr, setModelsErr] = useState({});
|
||
const [test, setTest] = useState({});
|
||
const [canvasTest, setCanvasTest] = useState(null);
|
||
const [autoInfo, setAutoInfo] = useState(null);
|
||
const [saving, setSaving] = useState(false);
|
||
const [toast, setToast] = useState("");
|
||
const [error, setError] = useState("");
|
||
const [activeToc, setActiveToc] = useState("teacher");
|
||
const toastTimer = useRef(null);
|
||
|
||
useEffect(() => {
|
||
fetch("/api/settings").then((r) => r.json()).then(setS).catch(() => setError("Could not load settings."));
|
||
}, []);
|
||
|
||
const activeProvider = s?.provider;
|
||
const activeModel = s?.providers?.[activeProvider]?.model || "";
|
||
const activeKey = s?.providers?.[activeProvider]?.apiKey || "";
|
||
const autoOn = s ? s.generation?.auto !== false : true;
|
||
|
||
useEffect(() => {
|
||
if (!s || !autoOn || !activeModel) { setAutoInfo(null); return; }
|
||
let cancelled = false;
|
||
setAutoInfo(null);
|
||
const t = setTimeout(() => {
|
||
fetch("/api/providers", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "defaults", provider: activeProvider, settings: s }) })
|
||
.then((r) => r.json())
|
||
.then((d) => { if (!cancelled && !d.error && d.maxTokens) setAutoInfo(d); })
|
||
.catch(() => {});
|
||
}, 500);
|
||
return () => { cancelled = true; clearTimeout(t); };
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [activeProvider, activeModel, activeKey, autoOn]);
|
||
|
||
// Highlight the TOC entry for the section currently in view.
|
||
useEffect(() => {
|
||
if (!s) return;
|
||
const obs = new IntersectionObserver(
|
||
(entries) => { entries.forEach((e) => { if (e.isIntersecting) setActiveToc(e.target.id); }); },
|
||
{ rootMargin: "-20% 0px -70% 0px", threshold: 0 }
|
||
);
|
||
TOC.forEach(({ id }) => { const el = document.getElementById(id); if (el) obs.observe(el); });
|
||
return () => obs.disconnect();
|
||
}, [s]);
|
||
|
||
function showToast(msg) { setToast(msg); clearTimeout(toastTimer.current); toastTimer.current = setTimeout(() => setToast(""), 2400); }
|
||
function setProviderField(provider, field, value) { setS((cur) => ({ ...cur, providers: { ...cur.providers, [provider]: { ...cur.providers[provider], [field]: value } } })); }
|
||
function setGen(field, value) { setS((cur) => ({ ...cur, generation: { ...cur.generation, [field]: value } })); }
|
||
function setProfile(field, value) { setS((cur) => ({ ...cur, profile: { ...(cur.profile || {}), [field]: value } })); }
|
||
function setCanvas(field, value) { setCanvasTest(null); setS((cur) => ({ ...cur, canvas: { ...(cur.canvas || {}), [field]: value } })); }
|
||
|
||
async function testCanvas() {
|
||
setCanvasTest({ busy: true });
|
||
try {
|
||
const res = await fetch("/api/canvas", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "test", settings: s }) });
|
||
const data = await res.json();
|
||
if (!res.ok) throw new Error(data.error || "Test failed.");
|
||
setCanvasTest({ ok: true, message: data.message });
|
||
} catch (e) { setCanvasTest({ ok: false, message: String(e.message || e) }); }
|
||
}
|
||
|
||
function onLogoFile(e) {
|
||
const file = e.target.files?.[0];
|
||
e.target.value = "";
|
||
if (!file) return;
|
||
if (!file.type.startsWith("image/")) { setError("Please choose an image file (PNG, JPG, etc.)."); return; }
|
||
const url = URL.createObjectURL(file);
|
||
const img = new Image();
|
||
img.onload = () => {
|
||
URL.revokeObjectURL(url);
|
||
const scale = Math.min(1, 512 / Math.max(img.width, img.height));
|
||
const canvas = document.createElement("canvas");
|
||
canvas.width = Math.max(1, Math.round(img.width * scale));
|
||
canvas.height = Math.max(1, Math.round(img.height * scale));
|
||
canvas.getContext("2d").drawImage(img, 0, 0, canvas.width, canvas.height);
|
||
setProfile("logo", canvas.toDataURL("image/png"));
|
||
};
|
||
img.onerror = () => { URL.revokeObjectURL(url); setError("Couldn't read that image — try a PNG or JPG."); };
|
||
img.src = url;
|
||
}
|
||
|
||
async function refreshModels(provider) {
|
||
setModelsBusy(provider); setModelsErr((e) => ({ ...e, [provider]: "" }));
|
||
try {
|
||
const res = await fetch("/api/providers", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "models", provider, settings: s }) });
|
||
const data = await res.json();
|
||
if (!res.ok) throw new Error(data.error || "Could not list models.");
|
||
setModels((m) => ({ ...m, [provider]: data.models }));
|
||
if (data.models.length && !data.models.includes(s.providers[provider].model)) setProviderField(provider, "model", data.models[0]);
|
||
} catch (e) { setModelsErr((er) => ({ ...er, [provider]: String(e.message || e) })); }
|
||
finally { setModelsBusy(""); }
|
||
}
|
||
|
||
async function testConnection(provider) {
|
||
setTest((t) => ({ ...t, [provider]: { busy: true } }));
|
||
try {
|
||
const res = await fetch("/api/providers", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "test", provider, settings: s }) });
|
||
const data = await res.json();
|
||
if (!res.ok) throw new Error(data.error || "Test failed.");
|
||
setTest((t) => ({ ...t, [provider]: { ok: true, message: data.message } }));
|
||
} catch (e) { setTest((t) => ({ ...t, [provider]: { ok: false, message: String(e.message || e) } })); }
|
||
}
|
||
|
||
async function save() {
|
||
setSaving(true); setError("");
|
||
try {
|
||
const res = await fetch("/api/settings", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(s) });
|
||
const data = await res.json();
|
||
if (!res.ok) throw new Error(data.error || "Save failed.");
|
||
setS(data); showToast("Settings saved");
|
||
} catch (e) { setError(String(e.message || e)); }
|
||
finally { setSaving(false); }
|
||
}
|
||
|
||
if (!s) return <div className="page page-narrow"><p className="muted"><span className="spinner" /> Loading…</p></div>;
|
||
|
||
const active = s.provider;
|
||
const activeCfg = s.providers[active] || {};
|
||
const isLocal = active === "ollama" || active === "lmstudio";
|
||
const modelList = models[active] || [];
|
||
const t = test[active];
|
||
|
||
return (
|
||
<div className="page page-narrow">
|
||
<div className="page-head">
|
||
<h1>Settings</h1>
|
||
<p>Pick the AI that powers generation. Local options keep everything — source material, questions, API traffic — on this computer. Keys and settings are stored only in your local <code>data/db.json</code> file.</p>
|
||
</div>
|
||
|
||
{error && <div className="alert alert-error">{error}</div>}
|
||
|
||
<div className="settings-grid">
|
||
<nav className="settings-toc" aria-label="Settings sections">
|
||
<span className="field-label" style={{ padding: "0 12px 8px" }}>On this page</span>
|
||
{TOC.map(({ id, label, icon: Icon }) => (
|
||
<a key={id} href={`#${id}`} className={`toc-link${activeToc === id ? " active" : ""}`}><Icon size={16} /> {label}</a>
|
||
))}
|
||
</nav>
|
||
|
||
<div className="settings-body">
|
||
{/* ---- Teacher & school ---- */}
|
||
<section className="sec" id="teacher">
|
||
<h2>Teacher & school</h2>
|
||
<p className="sec-hint">Shown in the header of every printed and exported assignment — leave anything blank to omit it.</p>
|
||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))", gap: 26, marginBottom: 26 }}>
|
||
<div className="ul-field" style={{ marginBottom: 0 }}>
|
||
<span className="field-label">Teacher name</span>
|
||
<input className="ul-input" type="text" value={s.profile?.teacherName || ""} onChange={(e) => setProfile("teacherName", e.target.value)} placeholder="e.g. Mr. Drew" />
|
||
</div>
|
||
<div className="ul-field" style={{ marginBottom: 0 }}>
|
||
<span className="field-label">Class / course</span>
|
||
<input className="ul-input" type="text" value={s.profile?.className || ""} onChange={(e) => setProfile("className", e.target.value)} placeholder="e.g. 7th Grade Science — Period 3" />
|
||
</div>
|
||
<div className="ul-field" style={{ marginBottom: 0 }}>
|
||
<span className="field-label">School name</span>
|
||
<input className="ul-input" type="text" value={s.profile?.schoolName || ""} onChange={(e) => setProfile("schoolName", e.target.value)} placeholder="e.g. Lincoln Middle School" />
|
||
</div>
|
||
</div>
|
||
<span className="field-label">School logo or mascot <span className="faint" style={{ textTransform: "none", letterSpacing: 0, fontWeight: 400 }}>(optional)</span></span>
|
||
<div style={{ display: "flex", gap: 12, alignItems: "center", flexWrap: "wrap", marginTop: 6 }}>
|
||
{s.profile?.logo && (
|
||
<img src={s.profile.logo} alt="School logo preview" style={{ height: 52, maxWidth: 140, objectFit: "contain", background: "#fff", border: "1px solid var(--line)", borderRadius: 6, padding: 4 }} />
|
||
)}
|
||
<label className="btn" style={{ cursor: "pointer" }}>
|
||
<IconPhoto size={16} /> {s.profile?.logo ? "Replace image" : "Upload image"}
|
||
<input type="file" accept="image/*" onChange={onLogoFile} style={{ display: "none" }} />
|
||
</label>
|
||
{s.profile?.logo && <button className="btn btn-ghost btn-danger" style={{ borderColor: "transparent" }} onClick={() => setProfile("logo", "")}>Remove</button>}
|
||
</div>
|
||
<span className="field-hint">Appears beside the school name on printed pages. PNG with transparency looks best; the image is stored locally and shrunk automatically.</span>
|
||
</section>
|
||
|
||
{/* ---- AI provider ---- */}
|
||
<section className="sec" id="provider">
|
||
<h2>AI provider</h2>
|
||
<div className="provider-list" style={{ marginTop: 16 }}>
|
||
{PROVIDERS.map((p) => (
|
||
<label key={p.id} className={"provider-row" + (active === p.id ? " selected" : "")}>
|
||
<input type="radio" name="provider" checked={active === p.id} onChange={() => setS((cur) => ({ ...cur, provider: p.id }))} />
|
||
<span style={{ flex: 1 }}>
|
||
<b style={active === p.id ? { color: "var(--board-deep)" } : undefined}>{p.name}</b>
|
||
<small>{p.desc}</small>
|
||
</span>
|
||
{p.local && <span className="chip chip-pill local-tag">Private · local</span>}
|
||
</label>
|
||
))}
|
||
</div>
|
||
|
||
<h3 style={{ margin: "26px 0 16px" }}>{PROVIDERS.find((p) => p.id === active)?.name} setup</h3>
|
||
|
||
{isLocal && (
|
||
<div className="ul-field">
|
||
<span className="field-label">Server address (base URL)</span>
|
||
<input className="ul-input" type="text" value={activeCfg.baseUrl || ""} onChange={(e) => setProviderField(active, "baseUrl", e.target.value)}
|
||
placeholder={active === "ollama" ? "http://localhost:11434" : "http://localhost:1234"} />
|
||
<span className="field-hint">
|
||
{active === "ollama"
|
||
? <>Where this app should find Ollama. Same computer: the default is right. On another machine, enter that machine’s address, e.g. <code>http://192.168.1.50:11434</code> — and set <code>OLLAMA_HOST=0.0.0.0</code> there. Use “Test connection” to confirm.</>
|
||
: <>Where this app should find LM Studio. Same computer: the default is right. On another machine: enter its address, e.g. <code>http://192.168.1.50:1234</code> — and in LM Studio’s Developer tab enable “Serve on Local Network”.</>}
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
{!isLocal && (
|
||
<div className="ul-field">
|
||
<span className="field-label">API key</span>
|
||
<input className="ul-input" type="password" value={activeCfg.apiKey || ""} onChange={(e) => setProviderField(active, "apiKey", e.target.value)} placeholder="Paste your API key" autoComplete="off" />
|
||
<span className="field-hint">Get a key at <a href={KEY_LINKS[active]} target="_blank" rel="noreferrer">{KEY_LINKS[active]}</a>. It is stored only on this computer.</span>
|
||
</div>
|
||
)}
|
||
|
||
<div className="ul-field">
|
||
<span className="field-label">Model</span>
|
||
<div style={{ display: "flex", gap: 12, flexWrap: "wrap", alignItems: "flex-end" }}>
|
||
{modelList.length > 0 ? (
|
||
<select className="ul-input" value={activeCfg.model || ""} onChange={(e) => setProviderField(active, "model", e.target.value)} style={{ flex: 1, minWidth: 220 }}>
|
||
{!modelList.includes(activeCfg.model) && activeCfg.model && <option value={activeCfg.model}>{activeCfg.model}</option>}
|
||
{modelList.map((m) => <option key={m} value={m}>{m}</option>)}
|
||
</select>
|
||
) : (
|
||
<input className="ul-input" type="text" value={activeCfg.model || ""} onChange={(e) => setProviderField(active, "model", e.target.value)}
|
||
placeholder={active === "ollama" ? "e.g. llama3.1:8b" : "Model name"} style={{ flex: 1, minWidth: 220 }} />
|
||
)}
|
||
<button className="btn" onClick={() => refreshModels(active)} disabled={modelsBusy === active}>
|
||
{modelsBusy === active ? <><span className="spinner" /> Looking…</> : <><IconRefresh size={15} /> Refresh models</>}
|
||
</button>
|
||
</div>
|
||
{modelsErr[active]
|
||
? <span className="field-hint redpen">{modelsErr[active]}</span>
|
||
: <span className="field-hint">Accuracy tip: bigger models write noticeably better questions. Locally, prefer an 8B+ model; in the cloud, the default models work well.</span>}
|
||
</div>
|
||
|
||
<div style={{ display: "flex", gap: 10, alignItems: "center", flexWrap: "wrap" }}>
|
||
<button className="btn" onClick={() => testConnection(active)} disabled={t?.busy}>
|
||
{t?.busy ? <><span className="spinner" /> Testing…</> : <><IconPlug size={15} /> Test connection</>}
|
||
</button>
|
||
{t && !t.busy && (
|
||
<span className="small" style={{ fontWeight: 600, color: t.ok ? "var(--board)" : "var(--redpen)", display: "inline-flex", alignItems: "center", gap: 4 }}>
|
||
{t.ok ? <IconCheck size={15} /> : <IconX size={15} />}{t.message}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</section>
|
||
|
||
{/* ---- Canvas LMS ---- */}
|
||
<section className="sec" id="canvas">
|
||
<h2>Canvas (LMS) integration</h2>
|
||
<p className="sec-hint">Optional. Lets you push a finished assignment straight into a Canvas course from the editor. You can always skip this and use the downloadable Canvas <code>.zip</code> instead.</p>
|
||
<div className="ul-field">
|
||
<span className="field-label">Canvas web address</span>
|
||
<input className="ul-input" type="text" value={s.canvas?.baseUrl || ""} onChange={(e) => setCanvas("baseUrl", e.target.value)} placeholder="https://yourschool.instructure.com" autoComplete="off" />
|
||
<span className="field-hint">The address you use to log into Canvas — e.g. <code>https://yourschool.instructure.com</code>.</span>
|
||
</div>
|
||
<div className="ul-field">
|
||
<span className="field-label">Access token</span>
|
||
<input className="ul-input" type="password" value={s.canvas?.token || ""} onChange={(e) => setCanvas("token", e.target.value)} placeholder="Paste your Canvas access token" autoComplete="off" />
|
||
<span className="field-hint">In Canvas: <b>Account → Settings → Approved Integrations → + New Access Token</b>. Stored only on this computer. Some schools restrict tokens — if yours does, use the <code>.zip</code> export instead.</span>
|
||
</div>
|
||
<div style={{ display: "flex", gap: 10, alignItems: "center", flexWrap: "wrap" }}>
|
||
<button className="btn" onClick={testCanvas} disabled={canvasTest?.busy || !s.canvas?.baseUrl || !s.canvas?.token}>
|
||
{canvasTest?.busy ? <><span className="spinner" /> Testing…</> : <><IconPlug size={15} /> Test connection</>}
|
||
</button>
|
||
{canvasTest && !canvasTest.busy && (
|
||
<span className="small" style={{ fontWeight: 600, color: canvasTest.ok ? "var(--board)" : "var(--redpen)", display: "inline-flex", alignItems: "center", gap: 4 }}>
|
||
{canvasTest.ok ? <IconCheck size={15} /> : <IconX size={15} />}{canvasTest.message}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</section>
|
||
|
||
{/* ---- Generation defaults ---- */}
|
||
<section className="sec" id="generation">
|
||
<h2>Generation defaults</h2>
|
||
<label className="check" style={{ marginTop: 14 }}>
|
||
<input type="checkbox" checked={autoOn} onChange={(e) => setGen("auto", e.target.checked)} />
|
||
<span>Set size limits automatically (recommended)<small>Matches source size and response length to the selected model’s real context window — local models get safe limits, large cloud models get room for much longer sources and answers.</small></span>
|
||
</label>
|
||
{autoOn && (
|
||
<p className="field-hint" style={{ margin: "0 0 8px 30px", fontStyle: "italic" }}>
|
||
{!activeModel ? "Pick a model above to see its tuned limits."
|
||
: autoInfo
|
||
? `Tuned for ${activeModel}: sources up to ${autoInfo.maxSourceChars.toLocaleString()} characters, responses up to ${autoInfo.maxTokens.toLocaleString()} tokens (context window ≈ ${Math.round(autoInfo.caps.contextTokens / 1000).toLocaleString()}k tokens${autoInfo.caps.source === "fallback" ? ", estimated — couldn't read the model's limits" : ""}).`
|
||
: <><span className="spinner" /> Checking the model’s limits…</>}
|
||
</p>
|
||
)}
|
||
<div className="row" style={{ marginTop: 14 }}>
|
||
<label className="field" style={{ maxWidth: 540 }}>
|
||
<span className="field-label">Temperature — {Number(s.generation.temperature).toFixed(1)}</span>
|
||
<input type="range" min="0" max="1" step="0.1" value={s.generation.temperature} onChange={(e) => setGen("temperature", Number(e.target.value))} style={{ width: "100%" }} />
|
||
<span className="field-hint">Lower = more precise and literal (best for accuracy). 0.2–0.4 recommended.</span>
|
||
</label>
|
||
{!autoOn && (
|
||
<label className="field">
|
||
<span className="field-label">Max response length (tokens)</span>
|
||
<input type="number" min="1000" max="64000" step="500" value={s.generation.maxTokens} onChange={(e) => setGen("maxTokens", Math.max(1000, Number(e.target.value) || 8000))} />
|
||
<span className="field-hint">Raise this if very long assignments come back cut off. Capped to the model’s own output limit.</span>
|
||
</label>
|
||
)}
|
||
{!autoOn && (
|
||
<label className="field">
|
||
<span className="field-label">Max source size (characters)</span>
|
||
<input type="number" min="4000" max="300000" step="1000" value={s.generation.maxSourceChars} onChange={(e) => setGen("maxSourceChars", Math.max(4000, Number(e.target.value) || 24000))} />
|
||
<span className="field-hint">Longer sources are trimmed to this before being sent to the model. Local models do better around 16,000–24,000.</span>
|
||
</label>
|
||
)}
|
||
</div>
|
||
<label className="check">
|
||
<input type="checkbox" checked={s.generation.verification !== false} onChange={(e) => setGen("verification", e.target.checked)} />
|
||
<span>Run the accuracy check by default<small>A second pass that verifies every answer against the source. You can still toggle it per assignment.</small></span>
|
||
</label>
|
||
</section>
|
||
|
||
<div style={{ display: "flex", justifyContent: "flex-end", paddingTop: 28, borderTop: "1px solid var(--line)", marginTop: 8 }}>
|
||
<button className="btn btn-primary btn-lg" onClick={save} disabled={saving}>
|
||
{saving ? "Saving…" : <><IconCheck size={16} /> Save settings</>}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{toast && <div className="toast">{toast}</div>}
|
||
</div>
|
||
);
|
||
}
|