bizzle 5a51a0f112 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>
2026-06-21 19:58:36 -04:00

473 lines
19 KiB
JavaScript

"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;
}