Files
mr-drews-assignment-creator/app/settings/page.jsx
T
bizzleandClaude b7416cc618 feat: UI revamp with sidebar nav and grouped library
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>
2026-06-25 18:33:08 -04:00

356 lines
22 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 &amp; 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&rsquo;s address, e.g. <code>http://192.168.1.50:11434</code> — and set <code>OLLAMA_HOST=0.0.0.0</code> there. Use &ldquo;Test connection&rdquo; 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&rsquo;s Developer tab enable &ldquo;Serve on Local Network&rdquo;.</>}
</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&rsquo;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&rsquo;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.20.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&rsquo;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,00024,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>
);
}