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:
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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’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
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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’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 “{query}”.</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
@@ -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;
|
||||
}
|
||||
@@ -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 & 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’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 “Test connection” 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’s Developer tab enable “Serve on Local Network”. Use “Test connection” 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.2–0.4 recommended.</span>
|
||||
</label>
|
||||
{!autoOn && (
|
||||
<label className="field">
|
||||
<span className="field-label">Max response length (tokens)</span>
|
||||
<input type="number" min="1000" max="64000" step="500" value={s.generation.maxTokens}
|
||||
onChange={(e) => setGen("maxTokens", Math.max(1000, Number(e.target.value) || 8000))} />
|
||||
<span className="field-hint">Raise this if very long assignments come back cut off. Capped to the model's own output limit.</span>
|
||||
</label>
|
||||
)}
|
||||
{!autoOn && (
|
||||
<label className="field">
|
||||
<span className="field-label">Max source size (characters)</span>
|
||||
<input type="number" min="4000" max="300000" step="1000" value={s.generation.maxSourceChars}
|
||||
onChange={(e) => setGen("maxSourceChars", Math.max(4000, Number(e.target.value) || 24000))} />
|
||||
<span className="field-hint">Longer sources are trimmed to this before being sent to the model. Local models with small context windows do better around 16,000–24,000.</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
<label className="check">
|
||||
<input type="checkbox" checked={s.generation.verification !== false} onChange={(e) => setGen("verification", e.target.checked)} />
|
||||
<span>Run the accuracy check by default<small>A second pass that verifies every answer against the source. You can still toggle it per assignment.</small></span>
|
||||
</label>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user