Mr. Drew's Assignment Creator — Docker share build

Self-contained Dockerized build for end users. Run via docker compose;
see README.md for setup. Source-only, no sample data or build artifacts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-21 19:58:36 -04:00
co-authored by Claude Opus 4.8
commit 5a51a0f112
33 changed files with 5413 additions and 0 deletions
+27
View File
@@ -0,0 +1,27 @@
import { NextResponse } from "next/server";
import { getAssignment, updateAssignment, deleteAssignment } from "@/lib/store";
export const dynamic = "force-dynamic";
export async function GET(request, { params }) {
const a = getAssignment(params.id);
if (!a) return NextResponse.json({ error: "Assignment not found." }, { status: 404 });
return NextResponse.json(a);
}
export async function PUT(request, { params }) {
try {
const body = await request.json();
const updated = updateAssignment(params.id, body);
if (!updated) return NextResponse.json({ error: "Assignment not found." }, { status: 404 });
return NextResponse.json(updated);
} catch (e) {
return NextResponse.json({ error: String(e.message || e) }, { status: 400 });
}
}
export async function DELETE(request, { params }) {
const ok = deleteAssignment(params.id);
if (!ok) return NextResponse.json({ error: "Assignment not found." }, { status: 404 });
return NextResponse.json({ ok: true });
}
+19
View File
@@ -0,0 +1,19 @@
import { NextResponse } from "next/server";
import { listAssignments, createAssignment } from "@/lib/store";
export const dynamic = "force-dynamic";
export async function GET() {
return NextResponse.json({ assignments: listAssignments() });
}
export async function POST(request) {
try {
const body = await request.json();
if (!body || typeof body !== "object") throw new Error("Missing assignment body.");
const record = createAssignment(body);
return NextResponse.json(record, { status: 201 });
} catch (e) {
return NextResponse.json({ error: String(e.message || e) }, { status: 400 });
}
}
+44
View File
@@ -0,0 +1,44 @@
import { NextResponse } from "next/server";
import { htmlToText, extractTitle } from "@/lib/html-to-text";
export const dynamic = "force-dynamic";
export async function POST(request) {
try {
const { url } = await request.json();
let target;
try {
target = new URL(String(url || "").trim());
} catch {
throw new Error("That doesn't look like a valid URL. Include the full address, e.g. https://example.com/article");
}
if (!/^https?:$/.test(target.protocol)) throw new Error("Only http and https URLs are supported.");
const res = await fetch(target.toString(), {
headers: {
"User-Agent": "Mozilla/5.0 (compatible; MrDrewsAssignmentCreator/1.0)",
"Accept": "text/html,application/xhtml+xml,text/plain;q=0.9,*/*;q=0.8",
},
redirect: "follow",
signal: AbortSignal.timeout(20000),
});
if (!res.ok) throw new Error(`The page returned an error (HTTP ${res.status}). It may be behind a login or blocking automated access.`);
const contentType = res.headers.get("content-type") || "";
const raw = await res.text();
let text;
if (contentType.includes("text/plain")) {
text = raw;
} else {
text = htmlToText(raw);
}
text = text.slice(0, 200000);
if (text.trim().length < 200) {
throw new Error("Very little readable text was found on that page. It may be mostly images or load its content with JavaScript. Try copying the text and pasting it instead.");
}
return NextResponse.json({ title: extractTitle(raw), text, chars: text.length });
} catch (e) {
const msg = e?.name === "TimeoutError" ? "Timed out fetching that page (20s)." : String(e.message || e);
return NextResponse.json({ error: msg }, { status: 400 });
}
}
+99
View File
@@ -0,0 +1,99 @@
// app/api/generate/route.js — the generation pipeline.
// The client calls this once per stage so the progress UI is honest:
// stage "analyze" -> content map of the source
// stage "generate" -> full assignment (with one automatic JSON-repair retry)
// stage "verify" -> per-question accuracy verdicts
// stage "question" -> regenerate one question / add a new one
import { NextResponse } from "next/server";
import { getSettings } from "@/lib/store";
import { chat } from "@/lib/providers";
import { resolveGeneration } from "@/lib/model-caps";
import { extractJson } from "@/lib/json-utils";
import { analyzePrompt, generatePrompt, verifyPrompt, questionPrompt } from "@/lib/prompts";
import { normalizeAssignment, normalizeQuestion } from "@/lib/schema";
export const dynamic = "force-dynamic";
export const maxDuration = 600;
async function chatJson(settings, prompt, opts = {}) {
const raw = await chat(settings, { ...prompt, expectJson: true, ...opts });
try {
return extractJson(raw);
} catch (firstErr) {
// One repair attempt: ask the same model to re-emit valid JSON.
const fixed = await chat(settings, {
system: "You convert text into strictly valid JSON. Output ONLY the corrected JSON with no commentary and no code fences.",
user: "The following was supposed to be a single valid JSON object but is malformed. Re-emit it as strictly valid JSON, preserving all content:\n\n" + String(raw).slice(0, 60000),
expectJson: true,
temperature: 0,
});
try {
return extractJson(fixed);
} catch {
throw firstErr;
}
}
}
export async function POST(request) {
try {
const body = await request.json();
const { stage, source, config } = body;
const settings = getSettings();
// Auto mode sizes the source budget to the selected model's context window.
const { maxSourceChars } = await resolveGeneration(settings);
if (!source || String(source).trim().length < 100) {
throw new Error("The source material is too short to build a quality assignment from (minimum ~100 characters).");
}
if (stage === "analyze") {
const prompt = analyzePrompt({ source, config, maxSourceChars });
const analysis = await chatJson(settings, prompt, { maxTokens: 2500 });
return NextResponse.json({ analysis });
}
if (stage === "generate") {
const prompt = generatePrompt({ source, analysis: body.analysis, config, maxSourceChars });
const raw = await chatJson(settings, prompt);
const assignment = normalizeAssignment(raw, config);
if (!assignment.questions.length) {
throw new Error("The model did not return any usable questions. Try again, or switch to a stronger model in Settings.");
}
return NextResponse.json({ assignment });
}
if (stage === "verify") {
const questions = body.questions || [];
if (!questions.length) throw new Error("No questions to verify.");
const prompt = verifyPrompt({ source, questions, config, maxSourceChars });
const raw = await chatJson(settings, prompt, { temperature: 0.1 });
const results = Array.isArray(raw?.results) ? raw.results : [];
const byId = {};
for (const r of results) {
if (!r || !r.id) continue;
const verdict = r.verdict === "pass" ? "pass" : "warn";
const note = [r.issue, r.suggestedFix ? "Suggested fix: " + r.suggestedFix : ""].filter(Boolean).join(" ").trim();
byId[r.id] = { status: verdict, note: verdict === "pass" ? "" : (note || "The reviewer flagged this question — double-check it.") };
}
return NextResponse.json({ verifications: byId });
}
if (stage === "question") {
const { type, note, existingQuestions, replacing } = body;
const prompt = questionPrompt({ source, config, existingQuestions, type, note, replacing, maxSourceChars });
const raw = await chatJson(settings, prompt, { maxTokens: 2500 });
let candidate = raw;
if (Array.isArray(raw)) candidate = raw[0];
else if (Array.isArray(raw?.questions) && raw.questions.length) candidate = raw.questions[0];
else if (raw?.question && typeof raw.question === "object") candidate = raw.question;
const question = normalizeQuestion(candidate);
if (!question) throw new Error("The model returned a question in an unexpected shape. Try again.");
return NextResponse.json({ question });
}
throw new Error("Unknown stage: " + stage);
} catch (e) {
return NextResponse.json({ error: String(e.message || e) }, { status: 400 });
}
}
+31
View File
@@ -0,0 +1,31 @@
import { NextResponse } from "next/server";
import { mergeSettings } from "@/lib/store";
import { listModels, testConnection } from "@/lib/providers";
import { resolveGeneration } from "@/lib/model-caps";
export const dynamic = "force-dynamic";
// POST { action: "models" | "test" | "defaults", provider, settings }
// Settings come from the client form so you can test before saving.
export async function POST(request) {
try {
const { action, provider, settings } = await request.json();
const merged = mergeSettings(settings);
if (action === "models") {
const models = await listModels(merged, provider);
return NextResponse.json({ models });
}
if (action === "test") {
const result = await testConnection(merged, provider);
return NextResponse.json(result);
}
if (action === "defaults") {
const probe = provider ? { ...merged, provider } : merged;
const resolved = await resolveGeneration(probe);
return NextResponse.json(resolved);
}
throw new Error("Unknown action.");
} catch (e) {
return NextResponse.json({ error: String(e.message || e) }, { status: 400 });
}
}
+18
View File
@@ -0,0 +1,18 @@
import { NextResponse } from "next/server";
import { getSettings, saveSettings } from "@/lib/store";
export const dynamic = "force-dynamic";
export async function GET() {
return NextResponse.json(getSettings());
}
export async function PUT(request) {
try {
const body = await request.json();
const saved = saveSettings(body);
return NextResponse.json(saved);
} catch (e) {
return NextResponse.json({ error: String(e.message || e) }, { status: 400 });
}
}
+367
View File
@@ -0,0 +1,367 @@
"use client";
// app/editor/[id]/page.jsx — review and refine an assignment, then export it.
import { useEffect, useRef, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import QuestionCard from "@/components/QuestionCard";
import { QUESTION_TYPES, blankQuestion, totalPoints } from "@/lib/schema";
import { exportTxt, exportDoc, exportClipboard, exportPrint } from "@/lib/exporter";
export default function EditorPage() {
const { id } = useParams();
const router = useRouter();
const [a, setA] = useState(null);
const [loadErr, setLoadErr] = useState("");
const [dirty, setDirty] = useState(false);
const [saving, setSaving] = useState(false);
const [toast, setToast] = useState("");
const [busyQ, setBusyQ] = useState(null);
const [verifying, setVerifying] = useState(false);
const [addOpen, setAddOpen] = useState(false);
const [exportOpen, setExportOpen] = useState(false);
const [error, setError] = useState("");
const [profile, setProfile] = useState({});
const toastTimer = useRef(null);
useEffect(() => {
fetch("/api/assignments/" + id)
.then(async (r) => {
const data = await r.json();
if (!r.ok) throw new Error(data.error || "Could not load this assignment.");
setA(data);
})
.catch((e) => setLoadErr(String(e.message || e)));
fetch("/api/settings")
.then((r) => r.json())
.then((s) => setProfile(s?.profile || {}))
.catch(() => {});
}, [id]);
useEffect(() => {
function onBeforeUnload(e) {
if (dirty) { e.preventDefault(); e.returnValue = ""; }
}
window.addEventListener("beforeunload", onBeforeUnload);
return () => window.removeEventListener("beforeunload", onBeforeUnload);
}, [dirty]);
function showToast(msg) {
setToast(msg);
clearTimeout(toastTimer.current);
toastTimer.current = setTimeout(() => setToast(""), 2400);
}
function patch(p) {
setA((cur) => ({ ...cur, ...p }));
setDirty(true);
}
function setQuestion(i, q) {
setA((cur) => {
const questions = [...cur.questions];
questions[i] = q;
return { ...cur, questions };
});
setDirty(true);
}
function moveQuestion(i, dir) {
setA((cur) => {
const questions = [...cur.questions];
const j = i + dir;
if (j < 0 || j >= questions.length) return cur;
[questions[i], questions[j]] = [questions[j], questions[i]];
return { ...cur, questions };
});
setDirty(true);
}
function deleteQuestion(i) {
if (!confirm("Delete question " + (i + 1) + "?")) return;
setA((cur) => ({ ...cur, questions: cur.questions.filter((_, j) => j !== i) }));
setDirty(true);
}
async function save(silent) {
setSaving(true);
setError("");
try {
const res = await fetch("/api/assignments/" + id, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(a),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Save failed.");
setA(data);
setDirty(false);
if (!silent) showToast("Saved");
} catch (e) {
setError(String(e.message || e));
} finally {
setSaving(false);
}
}
async function regenerateQuestion(i, note) {
const q = a.questions[i];
setBusyQ(q.id);
setError("");
try {
const data = await postJson("/api/generate", {
stage: "question",
source: a.source?.text || "",
config: a.config || { assignmentType: a.assignmentType, gradeLevel: a.gradeLevel, subject: a.subject, difficulty: a.difficulty },
type: q.type,
note,
replacing: { question: q.question },
existingQuestions: a.questions.filter((_, j) => j !== i).map((x) => ({ question: x.question })),
});
const next = { ...data.question, points: q.points };
setQuestion(i, next);
showToast("Question " + (i + 1) + " regenerated");
} catch (e) {
setError(String(e.message || e));
} finally {
setBusyQ(null);
}
}
async function addQuestion(type, withAI) {
setAddOpen(false);
if (!withAI) {
setA((cur) => ({ ...cur, questions: [...cur.questions, blankQuestion(type)] }));
setDirty(true);
return;
}
setBusyQ("__new__");
setError("");
try {
const data = await postJson("/api/generate", {
stage: "question",
source: a.source?.text || "",
config: a.config || { assignmentType: a.assignmentType, gradeLevel: a.gradeLevel, subject: a.subject, difficulty: a.difficulty },
type,
existingQuestions: a.questions.map((x) => ({ question: x.question })),
});
setA((cur) => ({ ...cur, questions: [...cur.questions, data.question] }));
setDirty(true);
showToast("Question added");
} catch (e) {
setError(String(e.message || e));
} finally {
setBusyQ(null);
}
}
async function reverify() {
setVerifying(true);
setError("");
try {
const data = await postJson("/api/generate", {
stage: "verify",
source: a.source?.text || "",
config: a.config || { assignmentType: a.assignmentType, gradeLevel: a.gradeLevel, subject: a.subject, difficulty: a.difficulty },
questions: a.questions,
});
setA((cur) => ({
...cur,
questions: cur.questions.map((q) =>
data.verifications[q.id]
? { ...q, verification: data.verifications[q.id] }
: { ...q, verification: { status: "unchecked", note: "" } }
),
}));
setDirty(true);
const warns = Object.values(data.verifications).filter((v) => v.status === "warn").length;
showToast(warns
? `Accuracy check done — ${warns} question${warns === 1 ? "" : "s"} flagged`
: "Accuracy check done — all clear"
);
} catch (e) {
setError(String(e.message || e));
} finally {
setVerifying(false);
}
}
function doExport(kind, who) {
setExportOpen(false);
const opts = who === "packet" ? { packet: true, profile } : { teacher: who === true, profile };
try {
if (kind === "txt") { exportTxt(a, opts); showToast("Downloaded .txt"); }
if (kind === "doc") { exportDoc(a, opts); showToast("Downloaded Word file"); }
if (kind === "print") { exportPrint(a, opts); }
if (kind === "copy") { exportClipboard(a, opts).then(() => showToast("Copied to clipboard")); }
} catch (e) {
setError(String(e.message || e));
}
}
if (loadErr) {
return (
<div className="empty">
<h3>Couldn&rsquo;t open that assignment</h3>
<p>{loadErr}</p>
<button className="btn btn-primary" style={{ marginTop: 12 }} onClick={() => router.push("/library")}>Go to Library</button>
</div>
);
}
if (!a) {
return (
<div style={{ padding: "40px 0" }}>
<div className="card skeleton-card" style={{ marginBottom: 16 }}>
<div className="skeleton-line" style={{ width: "60%", height: 28, borderRadius: 6, marginBottom: 12 }} />
<div className="skeleton-line short" />
</div>
{[0, 1, 2].map((i) => (
<div key={i} className="card skeleton-card" style={{ marginBottom: 14, animationDelay: `${i * 0.15}s` }}>
<div className="skeleton-chip" />
<div className="skeleton-line full" />
<div className="skeleton-line medium" />
</div>
))}
</div>
);
}
const warnCount = a.questions.filter((q) => q.verification?.status === "warn").length;
const uncheckedCount = a.questions.filter((q) => !q.verification || q.verification.status === "unchecked").length;
return (
<div>
<div className="page-head" style={{ display: "flex", alignItems: "flex-start", gap: 12, flexWrap: "wrap" }}>
<div style={{ flex: 1, minWidth: 260 }}>
<input
type="text"
value={a.title}
onChange={(e) => patch({ title: e.target.value })}
aria-label="Assignment title"
style={{
fontFamily: "var(--font-display)", fontSize: "1.6rem", fontWeight: 700,
border: "1.5px solid transparent", background: "transparent",
padding: "4px 8px", marginLeft: -8, borderRadius: 8, width: "100%",
transition: "border-color 0.15s, background 0.15s",
}}
onFocus={(e) => { e.target.style.borderColor = "var(--line-strong)"; e.target.style.background = "var(--field-bg)"; }}
onBlur={(e) => { e.target.style.borderColor = "transparent"; e.target.style.background = "transparent"; }}
/>
<p className="muted small" style={{ margin: "4px 0 0 2px" }}>
{[a.assignmentType?.replace("_", " "), a.gradeLevel, a.subject].filter(Boolean).join(" · ")} · {a.questions.length} questions · {totalPoints(a.questions)} points
{a.source?.name ? <> · from <i>{a.source.name}</i></> : null}
</p>
</div>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
<div style={{ position: "relative" }}>
<button className="btn" onClick={() => { setExportOpen((o) => !o); setAddOpen(false); }}>Export </button>
{exportOpen && (
<div className="card" style={{ position: "absolute", right: 0, top: "calc(100% + 6px)", zIndex: 30, width: 295, padding: 16, animation: "fade-in-up 0.18s ease" }}>
<div className="field-label">Student version</div>
<div style={{ display: "flex", gap: 6, flexWrap: "wrap", marginBottom: 12 }}>
<button className="btn btn-sm" onClick={() => doExport("print", false)}>Print / PDF</button>
<button className="btn btn-sm" onClick={() => doExport("doc", false)}>Word</button>
<button className="btn btn-sm" onClick={() => doExport("txt", false)}>Text</button>
<button className="btn btn-sm" onClick={() => doExport("copy", false)}>Copy</button>
</div>
<div className="field-label" style={{ color: "var(--redpen)" }}>Teacher version (answer key)</div>
<div style={{ display: "flex", gap: 6, flexWrap: "wrap", marginBottom: 12 }}>
<button className="btn btn-sm" onClick={() => doExport("print", true)}>Print / PDF</button>
<button className="btn btn-sm" onClick={() => doExport("doc", true)}>Word</button>
<button className="btn btn-sm" onClick={() => doExport("txt", true)}>Text</button>
<button className="btn btn-sm" onClick={() => doExport("copy", true)}>Copy</button>
</div>
<div className="field-label">Complete packet student + answer key</div>
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
<button className="btn btn-sm" onClick={() => doExport("print", "packet")}>Print / PDF</button>
<button className="btn btn-sm" onClick={() => doExport("doc", "packet")}>Word</button>
</div>
</div>
)}
</div>
<button className="btn" onClick={reverify} disabled={verifying || !a.source?.text}>
{verifying ? <><span className="spinner" /> Checking</> : "Re-run accuracy check"}
</button>
<button className="btn btn-primary" onClick={() => save(false)} disabled={saving || !dirty}>
{saving ? <><span className="spinner" /> Saving</> : dirty ? "Save" : "Saved ✓"}
</button>
</div>
</div>
{error && <div className="alert alert-error">{error}</div>}
{warnCount > 0 && (
<div className="alert alert-warn">
<b>{warnCount} question{warnCount === 1 ? "" : "s"} flagged by the accuracy check.</b> Look for the stamps below each has a reviewer note. Edit or regenerate those questions, then re-run the check.
</div>
)}
{warnCount === 0 && uncheckedCount === 0 && a.questions.length > 0 && (
<div className="alert alert-info"> Every question passed the accuracy check against your source.</div>
)}
<label className="field">
<span className="field-label">Student instructions</span>
<textarea rows={2} value={a.instructions || ""} onChange={(e) => patch({ instructions: e.target.value })} placeholder="Instructions students see at the top…" />
</label>
{a.caseStudy ? (
<label className="field">
<span className="field-label">Case study scenario (students read this first)</span>
<textarea rows={8} value={a.caseStudy} onChange={(e) => patch({ caseStudy: e.target.value })} />
</label>
) : null}
{a.questions.map((q, i) => (
<QuestionCard
key={q.id}
q={q}
index={i}
count={a.questions.length}
busy={busyQ === q.id}
onChange={(next) => setQuestion(i, next)}
onMove={(dir) => moveQuestion(i, dir)}
onDelete={() => deleteQuestion(i)}
onRegenerate={(note) => regenerateQuestion(i, note)}
/>
))}
<div style={{ marginTop: 18, position: "relative", display: "flex", gap: 10 }}>
<button className="btn" onClick={() => { setAddOpen((o) => !o); setExportOpen(false); }} disabled={busyQ === "__new__"}>
{busyQ === "__new__" ? <><span className="spinner" /> Writing question</> : "+ Add question ▾"}
</button>
{addOpen && (
<div className="card" style={{ position: "absolute", left: 0, bottom: "calc(100% + 6px)", zIndex: 30, width: 324, padding: 16, animation: "fade-in-up 0.18s ease" }}>
{[...QUESTION_TYPES, { id: "discussion", label: "Discussion prompt" }].map((t) => (
<div key={t.id} style={{ display: "flex", alignItems: "center", gap: 8, padding: "5px 0", borderBottom: "1px solid var(--line)" }}>
<span style={{ flex: 1, fontSize: "0.92rem", fontWeight: 600 }}>{t.label}</span>
<button
className="btn btn-sm btn-primary"
onClick={() => addQuestion(t.id, true)}
disabled={!a.source?.text}
title={a.source?.text ? "Generate from your source" : "No source stored with this assignment"}
>AI</button>
<button className="btn btn-sm" onClick={() => addQuestion(t.id, false)}>Blank</button>
</div>
))}
</div>
)}
<span className="spacer" />
<span className="muted small" style={{ alignSelf: "center" }}>
Total: <b>{totalPoints(a.questions)}</b> points
</span>
</div>
{toast && <div className="toast">{toast}</div>}
</div>
);
}
async function postJson(url, body) {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || `Request failed (${res.status}).`);
return data;
}
+537
View File
@@ -0,0 +1,537 @@
/* Google Fonts — must come before the Tailwind import */
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Lora:wght@600;700&display=swap");
@import "tailwindcss";
/* =============================================================================
Mr. Drew's Assignment Creator — design system
Identity: a well-kept teacher's desk. Lora serif headings, chalkboard
green for primary actions, and the signature: everything answer-key wears
red pen — the color teachers actually grade in.
============================================================================= */
@layer base {
:root {
/* palette */
--paper: #f0f4f1;
--panel: #ffffff;
--ink: #1e2d28;
--ink-soft: #58706a;
--board: #2f6b58;
--board-deep: #245546;
--board-tint: #e4eeea;
--board-glow: rgba(47, 107, 88, 0.12);
--redpen: #b8412f;
--redpen-tint: #faece9;
--gold: #b98a23;
--gold-tint: #faf3e2;
--line: #dde4df;
--line-strong: #c5d0cb;
--field-bg: #ffffff;
--hover-bg: #f2f6f4;
--tab-track: #e6ecea;
--chip-neutral-bg: #eaeeec;
--empty-bg: #fafcfb;
--shadow-sm: 0 1px 3px rgba(34, 49, 44, 0.07), 0 2px 8px rgba(34, 49, 44, 0.05);
--shadow: 0 2px 6px rgba(34, 49, 44, 0.06), 0 6px 20px rgba(34, 49, 44, 0.07);
--shadow-lg: 0 8px 32px rgba(34, 49, 44, 0.12), 0 2px 8px rgba(34, 49, 44, 0.06);
color-scheme: light;
/* type */
--font-display: "Lora", "Iowan Old Style", "Palatino Linotype", Georgia, serif;
--font-body: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-mono: ui-monospace, SFMono-Regular, "Cascadia Mono", Consolas, Menlo, monospace;
--radius: 12px;
--radius-sm: 8px;
--radius-xs: 5px;
--transition: 0.18s ease;
--transition-fast: 0.1s ease;
}
/* The same desk after dark */
html[data-theme="dark"] {
--paper: #111815;
--panel: #1b2320;
--ink: #e2eae5;
--ink-soft: #92aaa2;
--board: #4d9c82;
--board-deep: #7bc0a8;
--board-tint: #1e3028;
--board-glow: rgba(77, 156, 130, 0.15);
--redpen: #e07a63;
--redpen-tint: #38221e;
--gold: #d3a94c;
--gold-tint: #342d1a;
--line: #263028;
--line-strong: #374440;
--field-bg: #151c18;
--hover-bg: #1f2a26;
--tab-track: #161d1a;
--chip-neutral-bg: #262f2b;
--empty-bg: #171e1a;
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3), 0 2px 8px rgba(0, 0, 0, 0.25);
--shadow: 0 2px 6px rgba(0, 0, 0, 0.3), 0 6px 20px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.4), 0 2px 8px rgba(0, 0, 0, 0.3);
color-scheme: dark;
}
html[data-theme="dark"] .alert-error { border-color: #5a2f26; color: #f0a795; }
html[data-theme="dark"] .alert-warn { border-color: #564820; color: #e2c47e; }
html[data-theme="dark"] .alert-info { border-color: #2d5040; color: #8dcbb5; }
html[data-theme="dark"] .toast { background: #e2eae5; color: #111815; }
html[data-theme="dark"] .brand-mark { color: #f1f5f2; }
html[data-theme="dark"] .nav-scrolled { box-shadow: 0 4px 24px rgba(0,0,0,0.5); }
* { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
background: var(--paper);
color: var(--ink);
font-family: var(--font-body);
font-size: 15.5px;
line-height: 1.58;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
h1, h2, h3 {
font-family: var(--font-display);
font-weight: 700;
letter-spacing: -0.01em;
margin: 0;
line-height: 1.25;
}
h1 { font-size: 1.85rem; }
h2 { font-size: 1.3rem; }
h3 { font-size: 1.08rem; }
a { color: var(--board); text-decoration: none; }
a:hover { text-decoration: underline; }
:focus-visible {
outline: 2px solid var(--board);
outline-offset: 2px;
border-radius: 4px;
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation: none !important; transition: none !important; }
}
}
/* =====================================================================
KEYFRAME ANIMATIONS
===================================================================== */
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes fade-in-up {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slide-up {
from { opacity: 0; transform: translate(-50%, 12px); }
to { opacity: 1; transform: translate(-50%, 0); }
}
@keyframes step-complete {
0% { transform: scale(1); }
50% { transform: scale(1.2); }
100% { transform: scale(1); }
}
@keyframes skeleton-pulse {
0%, 100% { opacity: 0.55; }
50% { opacity: 1; }
}
@keyframes progress-fill {
from { width: 0%; }
to { width: 100%; }
}
@keyframes spin-ring {
0% { transform: rotate(0deg); stroke-dashoffset: 60; }
50% { stroke-dashoffset: 15; }
100% { transform: rotate(360deg); stroke-dashoffset: 60; }
}
/* =====================================================================
LAYOUT SHELL
===================================================================== */
@layer components {
.shell { max-width: 1020px; margin: 0 auto; padding: 32px 22px 90px; }
/* ---------- navigation ---------- */
.topnav {
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
border-bottom: 1px solid var(--line);
position: sticky; top: 0; z-index: 50;
transition: box-shadow var(--transition);
}
html[data-theme="dark"] .topnav {
background: rgba(27, 35, 32, 0.85);
}
.topnav.nav-scrolled {
box-shadow: 0 4px 24px rgba(34, 49, 44, 0.1);
border-bottom-color: var(--line-strong);
}
.topnav-inner {
max-width: 1020px; margin: 0 auto; padding: 0 22px;
display: flex; align-items: center; gap: 24px; height: 60px;
}
.brand {
font-family: var(--font-display); font-size: 1.08rem; font-weight: 700; color: var(--ink);
display: flex; align-items: center; gap: 10px; white-space: nowrap;
}
.brand:hover { text-decoration: none; }
.brand-mark {
width: 30px; height: 30px; border-radius: 8px;
background: linear-gradient(135deg, var(--board) 0%, var(--board-deep) 100%);
color: #fff;
display: inline-flex; align-items: center; justify-content: center;
font-size: 15px; flex: none;
box-shadow: 0 2px 6px rgba(47, 107, 88, 0.35);
}
.navlinks { display: flex; gap: 2px; margin-left: auto; }
.theme-toggle {
flex: none; width: 36px; height: 36px; border-radius: 9px;
font-size: 1rem; transition: background var(--transition), transform var(--transition-fast);
}
.theme-toggle:hover { transform: rotate(18deg); }
.navlink {
padding: 7px 14px; border-radius: var(--radius-sm); color: var(--ink-soft);
font-weight: 500; font-size: 0.93rem; transition: background var(--transition), color var(--transition);
}
.navlink:hover { background: var(--hover-bg); color: var(--ink); text-decoration: none; }
.navlink.active { background: var(--board-tint); color: var(--board-deep); font-weight: 600; }
/* ---------- cards & panels ---------- */
.card {
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
padding: 24px;
transition: box-shadow var(--transition), border-color var(--transition), transform 0.2s ease;
animation: fade-in-up 0.3s ease both;
}
.card + .card { margin-top: 16px; }
.card:hover { box-shadow: var(--shadow); }
.card-lift:hover {
box-shadow: var(--shadow-lg);
transform: translateY(-2px);
border-color: var(--line-strong);
}
.page-head { margin: 4px 0 24px; }
.page-head p { color: var(--ink-soft); margin: 8px 0 0; max-width: 62ch; font-size: 0.97rem; }
/* ---------- buttons ---------- */
.btn {
appearance: none;
border: 1px solid var(--line-strong);
background: var(--panel);
color: var(--ink);
font: inherit; font-family: var(--font-body); font-weight: 600; font-size: 0.92rem;
padding: 9px 17px; border-radius: var(--radius-sm); cursor: pointer;
display: inline-flex; align-items: center; gap: 7px;
transition: background var(--transition), border-color var(--transition), box-shadow var(--transition), transform var(--transition-fast);
white-space: nowrap; user-select: none;
}
.btn:hover {
background: var(--hover-bg);
border-color: var(--board);
box-shadow: 0 1px 4px var(--board-glow);
}
.btn:active { transform: translateY(1px); box-shadow: none; }
.btn:disabled { opacity: 0.45; cursor: not-allowed; transform: none; box-shadow: none; }
.btn-primary {
background: var(--board);
border-color: var(--board);
color: #fff;
box-shadow: 0 2px 6px rgba(47, 107, 88, 0.25);
}
.btn-primary:hover {
background: var(--board-deep);
border-color: var(--board-deep);
box-shadow: 0 4px 14px rgba(47, 107, 88, 0.35);
}
.btn-danger { color: var(--redpen); border-color: var(--line-strong); }
.btn-danger:hover { background: var(--redpen-tint); border-color: var(--redpen); box-shadow: none; }
.btn-sm { padding: 5px 11px; font-size: 0.84rem; border-radius: var(--radius-xs); }
.btn-lg { padding: 12px 26px; font-size: 1rem; border-radius: var(--radius); }
/* ---------- forms ---------- */
label.field { display: block; margin-bottom: 14px; }
.field-label { display: block; font-weight: 600; font-size: 0.87rem; margin-bottom: 5px; color: var(--ink); letter-spacing: 0.01em; }
.field-hint { font-size: 0.82rem; color: var(--ink-soft); margin-top: 5px; line-height: 1.5; }
input[type="text"], input[type="password"], input[type="number"], input[type="url"], select, textarea {
width: 100%;
font: inherit;
font-family: var(--font-body);
color: var(--ink);
background: var(--field-bg);
border: 1.5px solid var(--line-strong);
border-radius: var(--radius-sm);
padding: 9px 12px;
transition: border-color var(--transition), box-shadow var(--transition), background var(--transition);
}
input:focus, select:focus, textarea:focus {
border-color: var(--board);
outline: none;
box-shadow: 0 0 0 3px var(--board-glow);
background: var(--field-bg);
}
textarea { resize: vertical; min-height: 80px; }
.row { display: flex; gap: 14px; flex-wrap: wrap; }
.row > * { flex: 1; min-width: 180px; }
.check {
display: flex; align-items: flex-start; gap: 10px; margin: 10px 0; cursor: pointer;
padding: 8px 10px; border-radius: var(--radius-xs); transition: background var(--transition);
}
.check:hover { background: var(--hover-bg); }
.check input { width: 16px; height: 16px; margin-top: 3px; accent-color: var(--board); cursor: pointer; flex: none; }
.check span { font-size: 0.94rem; }
.check small { display: block; color: var(--ink-soft); }
/* ---------- step tabs (Create flow) ---------- */
.steps {
display: flex; gap: 0;
border-bottom: 1.5px solid var(--line);
margin-bottom: 24px;
overflow: hidden;
}
.step {
display: flex; align-items: center; gap: 10px;
padding: 12px 20px 13px; margin-bottom: -1.5px;
border-bottom: 2.5px solid transparent;
color: var(--ink-soft); font-weight: 500; font-size: 0.93rem;
background: none; border-top: 0; border-left: 0; border-right: 0;
cursor: pointer; font-family: var(--font-body);
transition: color var(--transition), border-color var(--transition);
}
.step .step-n {
width: 24px; height: 24px; border-radius: 50%; flex: none;
border: 2px solid currentColor;
display: inline-flex; align-items: center; justify-content: center;
font-size: 0.78rem; font-weight: 700;
transition: background var(--transition), border-color var(--transition), transform 0.2s ease;
}
.step.active { color: var(--board-deep); border-bottom-color: var(--board); font-weight: 600; }
.step.done { color: var(--board); }
.step.done .step-n {
background: var(--board); border-color: var(--board); color: #fff;
animation: step-complete 0.3s ease;
}
.step:disabled { cursor: default; opacity: 0.5; }
/* ---------- choice cards ---------- */
.choice-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 10px; }
.choice {
border: 1.5px solid var(--line-strong); border-radius: var(--radius); background: var(--field-bg);
padding: 14px 15px; cursor: pointer; text-align: left; font: inherit; font-family: var(--font-body);
transition: border-color var(--transition), background var(--transition), box-shadow var(--transition), transform 0.15s ease;
}
.choice:hover { border-color: var(--board); box-shadow: 0 2px 10px var(--board-glow); transform: translateY(-1px); }
.choice.selected { border-color: var(--board); background: var(--board-tint); box-shadow: 0 2px 10px var(--board-glow); }
.choice b { display: block; font-size: 0.93rem; font-weight: 600; }
.choice small { color: var(--ink-soft); font-size: 0.79rem; line-height: 1.35; display: block; margin-top: 4px; }
/* ---------- tabs (source input) ---------- */
.tabs {
display: inline-flex; background: var(--tab-track);
border-radius: var(--radius-sm); padding: 3px; gap: 2px; margin-bottom: 16px;
}
.tab {
border: 0; background: none; font: inherit; font-family: var(--font-body);
font-weight: 600; font-size: 0.88rem;
padding: 7px 16px; border-radius: 6px; cursor: pointer; color: var(--ink-soft);
transition: background var(--transition), color var(--transition), box-shadow var(--transition);
}
.tab.active {
background: var(--panel); color: var(--ink);
box-shadow: var(--shadow-sm);
}
/* ---------- badges & chips ---------- */
.chip {
display: inline-flex; align-items: center; gap: 5px;
font-size: 0.73rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase;
padding: 3px 10px; border-radius: 99px;
background: var(--board-tint); color: var(--board-deep);
}
.chip-neutral { background: var(--chip-neutral-bg); color: var(--ink-soft); }
.stamp {
display: inline-flex; align-items: center; gap: 5px;
font-family: var(--font-mono); font-size: 0.71rem; font-weight: 700;
letter-spacing: 0.08em; text-transform: uppercase;
padding: 3px 8px; border: 1.5px solid currentColor; border-radius: 4px;
transform: rotate(-1.2deg);
}
.stamp-pass { color: var(--board); background: rgba(47, 107, 88, 0.06); }
.stamp-warn { color: var(--gold); background: var(--gold-tint); transform: rotate(1deg); }
/* ---------- answer key (red pen) ---------- */
.answer-key {
margin-top: 14px; padding: 13px 15px;
background: var(--redpen-tint);
border-left: 3px solid var(--redpen);
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
}
.answer-key .ak-label {
font-family: var(--font-mono); font-size: 0.69rem; font-weight: 700;
letter-spacing: 0.1em; text-transform: uppercase; color: var(--redpen);
display: block; margin-bottom: 6px;
}
.answer-key, .answer-key textarea, .answer-key input { font-size: 0.92rem; }
.redpen { color: var(--redpen); font-weight: 600; }
/* ---------- question cards ---------- */
.qcard { position: relative; }
.qcard-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 14px; }
.qnum { font-family: var(--font-display); font-size: 1.15rem; font-weight: 700; color: var(--board-deep); min-width: 28px; }
.qcard-actions { margin-left: auto; display: flex; gap: 6px; flex-wrap: wrap; }
.icon-btn {
border: 1px solid var(--line); background: var(--field-bg); border-radius: 7px;
width: 32px; height: 32px; cursor: pointer; font-size: 0.93rem; line-height: 1;
display: inline-flex; align-items: center; justify-content: center; color: var(--ink-soft);
transition: background var(--transition), border-color var(--transition), color var(--transition), transform var(--transition-fast);
}
.icon-btn:hover { border-color: var(--board); color: var(--ink); background: var(--board-tint); transform: scale(1.05); }
.icon-btn:disabled { opacity: 0.3; cursor: default; transform: none; }
.icon-btn.danger:hover { border-color: var(--redpen); color: var(--redpen); background: var(--redpen-tint); }
.opt-row { display: flex; align-items: center; gap: 9px; margin: 7px 0; }
.opt-row input[type="radio"] { accent-color: var(--redpen); width: 16px; height: 16px; flex: none; cursor: pointer; }
.opt-letter { font-weight: 700; font-size: 0.84rem; color: var(--ink-soft); width: 18px; flex: none; }
.points-input { width: 64px !important; text-align: center; }
/* ---------- alerts & toasts ---------- */
.alert {
padding: 13px 16px; border-radius: var(--radius-sm); font-size: 0.92rem;
margin: 14px 0; border: 1px solid;
animation: fade-in 0.2s ease;
}
.alert-error { background: var(--redpen-tint); border-color: #e8c5be; color: #8c3022; }
.alert-warn { background: var(--gold-tint); border-color: #e9d8a6; color: #7a5a14; }
.alert-info { background: var(--board-tint); border-color: #cde0d8; color: var(--board-deep); }
.toast {
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
background: var(--ink); color: #fff;
padding: 11px 22px; border-radius: 99px;
font-size: 0.91rem; font-weight: 600;
box-shadow: 0 8px 30px rgba(0,0,0,0.3);
z-index: 100;
animation: slide-up 0.22s cubic-bezier(0.34, 1.56, 0.64, 1) both;
white-space: nowrap;
}
/* ---------- spinner ---------- */
.spinner {
width: 16px; height: 16px; border-radius: 50%; flex: none;
border: 2px solid rgba(47, 107, 88, 0.22);
border-top-color: var(--board);
animation: spin 0.7s linear infinite;
display: inline-block; vertical-align: -3px;
}
/* ---------- generation progress ---------- */
.progress-list { list-style: none; margin: 22px 0 0; padding: 0; }
.progress-list li {
display: flex; align-items: center; gap: 12px;
padding: 11px 0; font-size: 0.97rem; color: var(--ink-soft);
border-bottom: 1px solid var(--line);
opacity: 0;
animation: fade-in-up 0.35s ease forwards;
}
.progress-list li:last-child { border-bottom: none; }
.progress-list li:nth-child(1) { animation-delay: 0.0s; }
.progress-list li:nth-child(2) { animation-delay: 0.08s; }
.progress-list li:nth-child(3) { animation-delay: 0.16s; }
.progress-list li:nth-child(4) { animation-delay: 0.24s; }
.progress-list li.active { color: var(--ink); font-weight: 600; }
.progress-list li.done { color: var(--board); }
.progress-dot { width: 20px; text-align: center; flex: none; font-size: 1rem; }
/* ---------- library ---------- */
.lib-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(276px, 1fr)); gap: 16px; }
.lib-card { animation: fade-in-up 0.35s ease both; }
.lib-card:nth-child(2) { animation-delay: 0.06s; }
.lib-card:nth-child(3) { animation-delay: 0.12s; }
.lib-card:nth-child(4) { animation-delay: 0.18s; }
.lib-card:nth-child(5) { animation-delay: 0.24s; }
.lib-card:nth-child(6) { animation-delay: 0.30s; }
.lib-card h3 { margin-bottom: 6px; }
.lib-meta { color: var(--ink-soft); font-size: 0.83rem; margin: 3px 0 14px; line-height: 1.55; }
.lib-actions { display: flex; gap: 7px; }
/* Skeleton loader */
.skeleton-card { animation: skeleton-pulse 1.5s ease-in-out infinite; }
.skeleton-line {
background: var(--line); border-radius: 5px; height: 14px; margin-bottom: 10px;
}
.skeleton-line.short { width: 55%; }
.skeleton-line.medium { width: 75%; }
.skeleton-line.full { width: 100%; }
.skeleton-chip { background: var(--line); border-radius: 99px; height: 20px; width: 64px; margin-bottom: 12px; }
.empty {
text-align: center; padding: 60px 24px; color: var(--ink-soft);
border: 1.5px dashed var(--line-strong); border-radius: var(--radius);
background: var(--empty-bg);
animation: fade-in 0.3s ease;
}
.empty h3 { color: var(--ink); margin-bottom: 8px; }
/* ---------- provider cards on settings ---------- */
.provider-row {
display: flex; align-items: center; gap: 12px;
padding: 13px 15px; border: 1.5px solid var(--line-strong);
border-radius: var(--radius); cursor: pointer; background: var(--field-bg);
margin-bottom: 10px;
transition: border-color var(--transition), background var(--transition), box-shadow var(--transition);
}
.provider-row:hover { border-color: var(--board); background: var(--hover-bg); }
.provider-row.selected { border-color: var(--board); background: var(--board-tint); box-shadow: 0 2px 8px var(--board-glow); }
.provider-row input { accent-color: var(--board); width: 17px; height: 17px; flex: none; }
.provider-row b { font-size: 0.96rem; }
.provider-row small { color: var(--ink-soft); display: block; }
.local-tag { margin-left: auto; }
/* ---------- misc helpers ---------- */
.muted { color: var(--ink-soft); }
.small { font-size: 0.84rem; }
.spacer { flex: 1; }
.hr { border: 0; border-top: 1px solid var(--line); margin: 20px 0; }
@media (max-width: 640px) {
.shell { padding: 20px 15px 74px; }
.topnav-inner { gap: 10px; padding: 0 15px; }
.brand span.brand-text { display: none; }
.card { padding: 18px; }
.steps { overflow-x: auto; }
h1 { font-size: 1.5rem; }
}
}
+27
View File
@@ -0,0 +1,27 @@
import "./globals.css";
import Nav from "@/components/Nav";
export const metadata = {
title: "Mr. Drew's Assignment Creator",
description: "Local, private, high-accuracy assignments, tests, quizzes, worksheets, discussion questions, and case studies.",
};
// Applies the saved (or system-preferred) theme before first paint so dark
// mode doesn't flash white on load.
const THEME_SCRIPT = `try{var t=localStorage.getItem("theme");if(!t)t=window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light";if(t==="dark")document.documentElement.dataset.theme="dark";}catch(e){}`;
export default function RootLayout({ children }) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: THEME_SCRIPT }} />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
</head>
<body>
<Nav />
<main className="shell">{children}</main>
</body>
</html>
);
}
+165
View File
@@ -0,0 +1,165 @@
"use client";
// app/library/page.jsx — everything you've made, saved locally in data/db.json.
import { useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
const TYPE_LABELS = {
quiz: "Quiz",
test: "Test",
worksheet: "Worksheet",
discussion: "Discussion",
case_study: "Case study",
};
function SkeletonCard() {
return (
<div className="card skeleton-card" aria-hidden="true">
<div className="skeleton-chip" />
<div className="skeleton-line medium" />
<div className="skeleton-line short" style={{ marginBottom: 18 }} />
<div className="skeleton-line full" />
<div className="skeleton-line" style={{ width: "40%", marginBottom: 16 }} />
<div style={{ display: "flex", gap: 7 }}>
<div className="skeleton-line" style={{ width: 64, height: 30, borderRadius: 7, marginBottom: 0 }} />
<div className="skeleton-line" style={{ width: 80, height: 30, borderRadius: 7, marginBottom: 0 }} />
<div className="skeleton-line" style={{ width: 64, height: 30, borderRadius: 7, marginBottom: 0 }} />
</div>
</div>
);
}
export default function LibraryPage() {
const router = useRouter();
const [items, setItems] = useState(null);
const [query, setQuery] = useState("");
const [error, setError] = useState("");
const [busy, setBusy] = useState("");
function load() {
fetch("/api/assignments")
.then((r) => r.json())
.then((d) => setItems(d.assignments || []))
.catch(() => setError("Could not load your library."));
}
useEffect(load, []);
async function duplicate(id) {
setBusy(id);
setError("");
try {
const res = await fetch("/api/assignments/" + id);
const full = await res.json();
if (!res.ok) throw new Error(full.error || "Could not load that assignment.");
const { id: _id, createdAt, updatedAt, ...copy } = full;
copy.title = (copy.title || "Untitled") + " (copy)";
const res2 = await fetch("/api/assignments", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(copy),
});
const created = await res2.json();
if (!res2.ok) throw new Error(created.error || "Could not duplicate.");
load();
} catch (e) {
setError(String(e.message || e));
} finally {
setBusy("");
}
}
async function remove(id, title) {
if (!confirm(`Delete "${title}"? This can't be undone.`)) return;
setBusy(id);
try {
await fetch("/api/assignments/" + id, { method: "DELETE" });
load();
} finally {
setBusy("");
}
}
const filtered = (items || []).filter((a) => {
const q = query.trim().toLowerCase();
if (!q) return true;
return [a.title, a.subject, a.gradeLevel, TYPE_LABELS[a.assignmentType]]
.filter(Boolean)
.join(" ")
.toLowerCase()
.includes(q);
});
return (
<div>
<div className="page-head" style={{ display: "flex", alignItems: "center", gap: 14, flexWrap: "wrap" }}>
<div style={{ flex: 1 }}>
<h1>Library</h1>
<p>Everything you&rsquo;ve created, stored locally on this computer.</p>
</div>
<Link href="/" className="btn btn-primary"> New assignment</Link>
</div>
{error && <div className="alert alert-error">{error}</div>}
{/* Skeleton loading state */}
{items === null && (
<div className="lib-grid">
<SkeletonCard />
<SkeletonCard />
<SkeletonCard />
</div>
)}
{items !== null && items.length === 0 && (
<div className="empty">
<h3>Nothing here yet</h3>
<p>Create your first assignment and it will be saved here automatically.</p>
<Link href="/" className="btn btn-primary" style={{ marginTop: 12 }}>Create an assignment</Link>
</div>
)}
{items !== null && items.length > 0 && (
<>
<input
type="text"
placeholder="Search by title, subject, grade, or type…"
value={query}
onChange={(e) => setQuery(e.target.value)}
style={{ marginBottom: 18, maxWidth: 440 }}
aria-label="Search library"
/>
{filtered.length === 0 && <p className="muted">No matches for &ldquo;{query}&rdquo;.</p>}
<div className="lib-grid">
{filtered.map((a) => (
<div key={a.id} className="card lib-card card-lift">
<span className="chip">{TYPE_LABELS[a.assignmentType] || a.assignmentType}</span>
<h3 style={{ marginTop: 10 }}>
<Link href={"/editor/" + a.id} style={{ color: "inherit" }}>{a.title}</Link>
</h3>
<p className="lib-meta">
{[a.gradeLevel, a.subject].filter(Boolean).join(" · ")}<br />
{a.questionCount} question{a.questionCount === 1 ? "" : "s"} · {a.totalPoints} pts · updated {formatDate(a.updatedAt)}
</p>
<div className="lib-actions">
<button className="btn btn-sm btn-primary" onClick={() => router.push("/editor/" + a.id)}>Open</button>
<button className="btn btn-sm" disabled={busy === a.id} onClick={() => duplicate(a.id)}>
{busy === a.id ? <span className="spinner" /> : "Duplicate"}
</button>
<button className="btn btn-sm btn-danger" disabled={busy === a.id} onClick={() => remove(a.id, a.title)}>Delete</button>
</div>
</div>
))}
</div>
</>
)}
</div>
);
}
function formatDate(iso) {
try {
return new Date(iso).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
} catch {
return "";
}
}
+472
View File
@@ -0,0 +1,472 @@
"use client";
// app/page.jsx — the Create flow: Source -> Configure -> Generate.
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { ASSIGNMENT_TYPES, QUESTION_TYPES, GRADE_LEVELS, DIFFICULTIES } from "@/lib/schema";
const MAX_PASTE = 120000;
// Maps a generation phase to its 0-based order index
const PHASE_ORDER = ["analyze", "generate", "verify", "save"];
function ProgressStep({ phase, label, currentPhase, hasVerify }) {
if (phase === "verify" && !hasVerify) return null;
const cur = PHASE_ORDER.indexOf(currentPhase);
const me = PHASE_ORDER.indexOf(phase);
const state = me < cur ? "done" : me === cur ? "active" : "";
return (
<li className={state} style={{ animationDelay: `${me * 0.08}s` }}>
<span className="progress-dot">
{state === "done" ? "✓" : state === "active" ? <span className="spinner" /> : "·"}
</span>
{label}{state === "active" ? "…" : ""}
</li>
);
}
export default function CreatePage() {
const router = useRouter();
const [step, setStep] = useState(0);
// --- source state ---
const [sourceTab, setSourceTab] = useState("paste");
const [text, setText] = useState("");
const [sourceName, setSourceName] = useState("");
const [url, setUrl] = useState("");
const [fetching, setFetching] = useState(false);
const [error, setError] = useState("");
const fileRef = useRef(null);
// --- config state ---
const [config, setConfig] = useState({
assignmentType: "quiz",
gradeLevel: "Grade 8",
subject: "",
questionCount: 10,
difficulty: "Mixed",
questionTypes: ["multiple_choice", "true_false", "short_answer", "fill_blank"],
includeExplanations: true,
includeRubrics: true,
focusNote: "",
verify: true,
});
// --- provider readiness ---
const [providerNote, setProviderNote] = useState(null);
useEffect(() => {
fetch("/api/settings")
.then((r) => r.json())
.then((s) => {
const cfg = s.providers?.[s.provider] || {};
if (!cfg.model) {
setProviderNote({ provider: s.provider, missing: "model" });
} else if (["openai", "anthropic", "google"].includes(s.provider) && !cfg.apiKey) {
setProviderNote({ provider: s.provider, missing: "key" });
} else {
setProviderNote(null);
}
setConfig((c) => ({ ...c, verify: s.generation?.verification !== false }));
})
.catch(() => {});
}, []);
// --- generation state ---
const [genState, setGenState] = useState(null);
const generating = !!genState && !genState.error;
function update(patch) {
setConfig((c) => ({ ...c, ...patch }));
}
function toggleQType(id) {
setConfig((c) => {
const has = c.questionTypes.includes(id);
const next = has ? c.questionTypes.filter((t) => t !== id) : [...c.questionTypes, id];
return { ...c, questionTypes: next };
});
}
async function onFile(e) {
setError("");
const file = e.target.files?.[0];
if (!file) return;
if (!/\.(txt|md|markdown|text|csv)$/i.test(file.name)) {
setError("Please choose a plain-text file (.txt or .md). For Word docs or PDFs, copy the text and paste it instead.");
return;
}
try {
const content = await file.text();
setText(content.slice(0, MAX_PASTE));
setSourceName(file.name);
setSourceTab("upload");
} catch {
setError("Could not read that file.");
}
}
async function fetchUrl() {
setError("");
setFetching(true);
try {
const res = await fetch("/api/fetch-url", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Could not fetch that page.");
setText(data.text.slice(0, MAX_PASTE));
setSourceName(data.title || url);
} catch (e) {
setError(String(e.message || e));
} finally {
setFetching(false);
}
}
const sourceReady = text.trim().length >= 100;
const configReady =
config.subject.trim().length > 0 &&
(["discussion", "case_study"].includes(config.assignmentType) || config.questionTypes.length > 0);
async function generate() {
setGenState({ phase: "analyze" });
const source = text.trim();
const cfg = { ...config, subject: config.subject.trim() };
try {
let analysis = null;
try {
const r1 = await postJson("/api/generate", { stage: "analyze", source, config: cfg });
analysis = r1.analysis;
} catch (e) {
console.warn("Analysis stage failed, continuing:", e);
}
setGenState({ phase: "generate" });
const r2 = await postJson("/api/generate", { stage: "generate", source, analysis, config: cfg });
const assignment = r2.assignment;
if (cfg.verify) {
setGenState({ phase: "verify" });
try {
const r3 = await postJson("/api/generate", {
stage: "verify",
source,
config: cfg,
questions: assignment.questions,
});
for (const q of assignment.questions) {
if (r3.verifications[q.id]) q.verification = r3.verifications[q.id];
}
} catch (e) {
console.warn("Verification stage failed, continuing:", e);
}
}
setGenState({ phase: "save" });
const saved = await postJson("/api/assignments", {
...assignment,
source: { type: sourceTab, name: sourceName || "Pasted text", text: source },
config: cfg,
});
router.push("/editor/" + saved.id);
} catch (e) {
setGenState({ phase: null, error: String(e.message || e) });
}
}
const isDiscussionOrCase = ["discussion", "case_study"].includes(config.assignmentType);
return (
<div>
<div className="page-head">
<h1>Create an assignment</h1>
<p>Give it your source material, set the parameters, and get a classroom-ready assignment with a verified answer key all on your own machine.</p>
</div>
{providerNote && (
<div className="alert alert-warn">
{providerNote.missing === "key"
? "Your selected AI provider needs an API key before you can generate."
: "No AI model is selected yet."}{" "}
<a href="/settings">Open Settings</a> to finish setup.
</div>
)}
<div className="steps" role="tablist">
{["Source", "Configure", "Generate"].map((label, i) => (
<button
key={label}
className={`step${step === i ? " active" : ""}${step > i ? " done" : ""}`}
onClick={() => {
if (i === 0 || (i === 1 && sourceReady) || (i === 2 && sourceReady && configReady)) setStep(i);
}}
disabled={
generating ||
(i === 1 && !sourceReady) ||
(i === 2 && (!sourceReady || !configReady))
}
role="tab"
aria-selected={step === i}
>
<span className="step-n">{step > i ? "✓" : i + 1}</span> {label}
</button>
))}
</div>
{/* ============ STEP 1: SOURCE ============ */}
{step === 0 && (
<div className="card">
<h2>What should the questions come from?</h2>
<p className="muted small" style={{ margin: "6px 0 16px" }}>
Questions are grounded strictly in this material the AI is instructed not to add outside facts.
</p>
<div className="tabs">
{[["paste", "Paste text"], ["upload", "Upload file"], ["url", "From a web page"]].map(([id, label]) => (
<button
key={id}
className={`tab${sourceTab === id ? " active" : ""}`}
onClick={() => { setSourceTab(id); setError(""); }}
>
{label}
</button>
))}
</div>
{sourceTab === "upload" && (
<div style={{ marginBottom: 14 }}>
<input ref={fileRef} type="file" accept=".txt,.md,.markdown,.text,.csv" onChange={onFile} style={{ display: "none" }} />
<button className="btn" onClick={() => fileRef.current?.click()}>Choose a .txt or .md file</button>
{sourceName && <span className="small muted" style={{ marginLeft: 10 }}>{sourceName}</span>}
</div>
)}
{sourceTab === "url" && (
<div style={{ display: "flex", gap: 10, marginBottom: 14, flexWrap: "wrap" }}>
<input
type="url"
placeholder="https://example.com/article"
value={url}
onChange={(e) => setUrl(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter" && url.trim()) fetchUrl(); }}
style={{ flex: 1, minWidth: 240 }}
/>
<button className="btn btn-primary" onClick={fetchUrl} disabled={fetching || !url.trim()}>
{fetching ? <><span className="spinner" /> Fetching</> : "Fetch page"}
</button>
</div>
)}
<textarea
value={text}
onChange={(e) => { setText(e.target.value.slice(0, MAX_PASTE)); if (sourceTab === "paste") setSourceName(""); }}
placeholder={
sourceTab === "paste"
? "Paste your reading passage, chapter, article, or lecture notes here…"
: "The file or page content will appear here — you can trim or edit it before generating."
}
rows={12}
aria-label="Source material"
/>
<div className="small muted" style={{ display: "flex", marginTop: 7 }}>
<span>
{text.length.toLocaleString()} / {MAX_PASTE.toLocaleString()} characters
{sourceReady ? "" : " — at least 100 needed"}
</span>
<span className="spacer" />
{text && <button className="btn btn-sm" onClick={() => { setText(""); setSourceName(""); }}>Clear</button>}
</div>
{error && <div className="alert alert-error">{error}</div>}
<div style={{ display: "flex", marginTop: 20 }}>
<span className="spacer" />
<button className="btn btn-primary btn-lg" disabled={!sourceReady} onClick={() => setStep(1)}>
Next: Configure
</button>
</div>
</div>
)}
{/* ============ STEP 2: CONFIGURE ============ */}
{step === 1 && (
<div className="card">
<h2>Set up the assignment</h2>
<div style={{ margin: "18px 0" }}>
<span className="field-label">Assignment type</span>
<div className="choice-grid">
{ASSIGNMENT_TYPES.map((t) => (
<button
key={t.id}
className={`choice${config.assignmentType === t.id ? " selected" : ""}`}
onClick={() => update({ assignmentType: t.id })}
>
<b>{t.label}</b>
<small>{t.hint}</small>
</button>
))}
</div>
</div>
<div className="row">
<label className="field">
<span className="field-label">Grade level</span>
<select value={config.gradeLevel} onChange={(e) => update({ gradeLevel: e.target.value })}>
{GRADE_LEVELS.map((g) => <option key={g}>{g}</option>)}
</select>
</label>
<label className="field">
<span className="field-label">Subject</span>
<input
type="text"
list="subjects"
placeholder="e.g. U.S. History, Biology, English Language Arts"
value={config.subject}
onChange={(e) => update({ subject: e.target.value })}
/>
<datalist id="subjects">
{["English Language Arts","U.S. History","World History","Civics / Government","Biology","Chemistry","Physics","Earth Science","Mathematics","Geography","Economics","Health","Computer Science","Spanish","Art History"].map((s) => (
<option key={s} value={s} />
))}
</datalist>
</label>
</div>
<div className="row">
<label className="field">
<span className="field-label">
{isDiscussionOrCase ? "Number of prompts/questions" : "Number of questions"} {config.questionCount}
</span>
<input
type="range" min="1" max="30" value={config.questionCount}
onChange={(e) => update({ questionCount: Number(e.target.value) })}
style={{ width: "100%", accentColor: "var(--board)" }}
/>
</label>
<label className="field">
<span className="field-label">Difficulty</span>
<select value={config.difficulty} onChange={(e) => update({ difficulty: e.target.value })}>
{DIFFICULTIES.map((d) => <option key={d}>{d}</option>)}
</select>
</label>
</div>
{!isDiscussionOrCase && (
<div style={{ margin: "4px 0 12px" }}>
<span className="field-label">Question types to include</span>
<div className="row" style={{ gap: 4 }}>
{QUESTION_TYPES.map((t) => (
<label key={t.id} className="check" style={{ minWidth: 150, flex: "0 0 auto" }}>
<input
type="checkbox"
checked={config.questionTypes.includes(t.id)}
onChange={() => toggleQType(t.id)}
/>
<span>{t.label}</span>
</label>
))}
</div>
{config.questionTypes.length === 0 && (
<div className="field-hint" style={{ color: "var(--redpen)" }}>Pick at least one question type.</div>
)}
</div>
)}
<label className="check">
<input type="checkbox" checked={config.includeExplanations} onChange={(e) => update({ includeExplanations: e.target.checked })} />
<span>Include explanations in the answer key<small>Why each answer is correct and for multiple choice, why the others are wrong.</small></span>
</label>
{!isDiscussionOrCase && (
<label className="check">
<input type="checkbox" checked={config.includeRubrics} onChange={(e) => update({ includeRubrics: e.target.checked })} />
<span>Include rubrics for essay questions<small>Point-based criteria that sum to the question total.</small></span>
</label>
)}
<label className="check">
<input type="checkbox" checked={config.verify} onChange={(e) => update({ verify: e.target.checked })} />
<span>Run the accuracy check<small>A second AI pass reviews every question and answer against your source. Strongly recommended adds a little time.</small></span>
</label>
<label className="field" style={{ marginTop: 12 }}>
<span className="field-label">Anything to focus on? <span className="muted" style={{ fontWeight: 400 }}>(optional)</span></span>
<input
type="text"
placeholder="e.g. focus on causes rather than dates; include the vocabulary terms"
value={config.focusNote}
onChange={(e) => update({ focusNote: e.target.value })}
/>
</label>
<div style={{ display: "flex", marginTop: 20, gap: 10 }}>
<button className="btn" onClick={() => setStep(0)}> Back</button>
<span className="spacer" />
<button className="btn btn-primary btn-lg" disabled={!configReady} onClick={() => setStep(2)}>
Next: Generate
</button>
</div>
</div>
)}
{/* ============ STEP 3: GENERATE ============ */}
{step === 2 && (
<div className="card">
<h2>Ready to generate</h2>
<p className="muted" style={{ margin: "10px 0 4px", fontSize: "0.96rem" }}>
<b style={{ color: "var(--ink)" }}>
{ASSIGNMENT_TYPES.find((t) => t.id === config.assignmentType)?.label}
</b>{" "}
· {config.gradeLevel} · {config.subject || "—"} · {config.questionCount} question{config.questionCount === 1 ? "" : "s"} · {config.difficulty} difficulty
</p>
<p className="muted small">Source: {sourceName || "Pasted text"} ({text.length.toLocaleString()} characters)</p>
{!genState && (
<div style={{ display: "flex", marginTop: 20, gap: 10 }}>
<button className="btn" onClick={() => setStep(1)}> Back</button>
<span className="spacer" />
<button className="btn btn-primary btn-lg" onClick={generate} disabled={!!providerNote}>
Generate assignment
</button>
</div>
)}
{genState && !genState.error && (
<>
<ul className="progress-list" aria-live="polite">
<ProgressStep phase="analyze" label="Reading the source and mapping key concepts" currentPhase={genState.phase} hasVerify={config.verify} />
<ProgressStep phase="generate" label="Writing questions and the answer key" currentPhase={genState.phase} hasVerify={config.verify} />
{config.verify && <ProgressStep phase="verify" label="Checking every answer against the source" currentPhase={genState.phase} hasVerify={config.verify} />}
<ProgressStep phase="save" label="Saving and opening the editor" currentPhase={genState.phase} hasVerify={config.verify} />
</ul>
<p className="small muted" style={{ marginTop: 16 }}>
Local models can take a few minutes for large assignments. Leave this tab open.
</p>
</>
)}
{genState?.error && (
<>
<div className="alert alert-error"><b>Generation failed.</b> {genState.error}</div>
<div style={{ display: "flex", gap: 10 }}>
<button className="btn" onClick={() => setGenState(null)}>Adjust and retry</button>
<button className="btn btn-primary" onClick={generate}>Try again</button>
</div>
</>
)}
</div>
)}
</div>
);
}
async function postJson(url, body) {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || `Request failed (${res.status}).`);
return data;
}
+372
View File
@@ -0,0 +1,372 @@
"use client";
// app/settings/page.jsx — choose and configure your AI provider, all stored locally.
import { useEffect, useRef, useState } from "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",
};
export default function SettingsPage() {
const [s, setS] = useState(null);
const [models, setModels] = useState({}); // provider -> string[]
const [modelsBusy, setModelsBusy] = useState("");
const [modelsErr, setModelsErr] = useState({}); // provider -> error
const [test, setTest] = useState({}); // provider -> {busy, ok, message}
const [autoInfo, setAutoInfo] = useState(null); // resolved auto limits for the active model
const [saving, setSaving] = useState(false);
const [toast, setToast] = useState("");
const [error, setError] = useState("");
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;
// Preview the auto-tuned limits whenever the model selection (or key) changes.
// Debounced so typing an API key doesn't fire a request per keystroke.
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]);
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 } }));
}
// Downscale the logo client-side (max 512px) so it stays a small data URL in db.json
// while staying crisp at the printed header size.
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 <p className="muted"><span className="spinner" /> Loading</p>;
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>
<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="card">
<h2>Teacher &amp; school</h2>
<p className="field-hint" style={{ marginTop: 4 }}>
Shown in the header of every printed and exported assignment leave anything blank to omit it.
</p>
<div className="row" style={{ marginTop: 14 }}>
<label className="field">
<span className="field-label">Teacher name</span>
<input type="text" value={s.profile?.teacherName || ""}
onChange={(e) => setProfile("teacherName", e.target.value)}
placeholder="e.g. Mr. Drew" />
</label>
<label className="field">
<span className="field-label">Class / course</span>
<input type="text" value={s.profile?.className || ""}
onChange={(e) => setProfile("className", e.target.value)}
placeholder="e.g. 7th Grade Science — Period 3" />
</label>
<label className="field">
<span className="field-label">School name</span>
<input type="text" value={s.profile?.schoolName || ""}
onChange={(e) => setProfile("schoolName", e.target.value)}
placeholder="e.g. Lincoln Middle School" />
</label>
</div>
<div className="field">
<span className="field-label">School logo or mascot (optional)</span>
<div style={{ display: "flex", gap: 12, alignItems: "center", flexWrap: "wrap" }}>
{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" }}>
{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-danger" 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>
</div>
</div>
<div className="card">
<h2>AI provider</h2>
<div style={{ marginTop: 14 }}>
{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>
<b>{p.name}</b>
<small>{p.desc}</small>
</span>
{p.local && <span className="chip local-tag">Private · local</span>}
</label>
))}
</div>
<hr className="hr" />
<h3 style={{ marginBottom: 12 }}>{PROVIDERS.find((p) => p.id === active)?.name} setup</h3>
{isLocal && (
<label className="field">
<span className="field-label">Server address (base URL)</span>
<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. Ollama on another machine (or this app in Docker on a different box): enter that machine&rsquo;s address, e.g. <code>http://192.168.1.50:11434</code> — and on that machine set <code>OLLAMA_HOST=0.0.0.0</code> so Ollama accepts network connections. Use &ldquo;Test connection&rdquo; below to confirm.</>
: <>Where this app should find LM Studio. Same computer: the default is right. LM Studio 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;. Use &ldquo;Test connection&rdquo; below to confirm.</>}
</span>
</label>
)}
{!isLocal && (
<label className="field">
<span className="field-label">API key</span>
<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>
</label>
)}
<label className="field">
<span className="field-label">Model</span>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
{modelList.length > 0 ? (
<select 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
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</> : "Refresh models"}
</button>
</div>
{modelsErr[active] && <span className="field-hint" style={{ color: "var(--redpen)" }}>{modelsErr[active]}</span>}
{!modelsErr[active] && (
<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>
)}
</label>
<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</> : "Test connection"}
</button>
{t && !t.busy && (
<span className={"small " + (t.ok ? "" : "redpen")} style={t.ok ? { color: "var(--board)", fontWeight: 600 } : { fontWeight: 600 }}>
{t.ok ? "✓ " : "✕ "}{t.message}
</span>
)}
</div>
</div>
<div className="card">
<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 4px 30px" }}>
{!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">
<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%", accentColor: "var(--board)" }}
/>
<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'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 with small context windows 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>
</div>
<div style={{ display: "flex", marginTop: 18 }}>
<span className="spacer" />
<button className="btn btn-primary btn-lg" onClick={save} disabled={saving}>
{saving ? "Saving…" : "Save settings"}
</button>
</div>
{toast && <div className="toast">{toast}</div>}
</div>
);
}