Files
mr-drews-assignment-creator/app/page.jsx
T
bizzleandClaude b7416cc618 feat: UI revamp with sidebar nav and grouped library
Replace top Nav with Sidebar, add lib/group.js for library grouping,
tokenized color/alert styles, and tighter Settings/Canvas export layout.
Adds @tabler/icons-react. Verified production build (standalone) passes,
so the Docker image built from this tree matches the local app.

Co-authored-by: Claude <claude-code@anthropic.com>
2026-06-25 18:33:08 -04:00

442 lines
21 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
// app/page.jsx — the Create flow as an Open Workspace split screen:
// left pane = the current step's content, right pane = step tracker + actions.
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import {
IconArrowRight, IconArrowLeft, IconBulb, IconPencil, IconAlertTriangle,
IconCircleCheck, IconCircle, IconX,
} from "@tabler/icons-react";
import { ASSIGNMENT_TYPES, QUESTION_TYPES, GRADE_LEVELS, DIFFICULTIES } from "@/lib/schema";
const MAX_PASTE = 120000;
const STEPS = [
{ label: "Source", desc: "Add your reading material" },
{ label: "Configure", desc: "Set type, grade, questions" },
{ label: "Generate", desc: "Review the verified key" },
];
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}>
<span className="progress-dot">
{state === "done" ? <IconCircleCheck size={18} /> : state === "active" ? <span className="spinner" /> : <IconCircle size={16} />}
</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);
const isDiscussionOrCase = ["discussion", "case_study"].includes(config.assignmentType);
function canGoTo(i) {
return i === 0 || (i === 1 && sourceReady) || (i === 2 && sourceReady && configReady);
}
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) });
}
}
return (
<div className="create-split">
<h1 className="sr-only">Create an assignment</h1>
{/* ============ LEFT: current step content ============ */}
<section className="create-source">
{step === 0 && (
<>
<span className="field-label" style={{ color: "var(--board)", marginBottom: 8 }}>Step 1 · Source</span>
<h2>What should the questions come from?</h2>
<p className="muted" style={{ margin: "8px 0 0", fontSize: "0.95rem", maxWidth: "60ch" }}>
Questions are grounded strictly in this material the AI is instructed not to add outside facts.
</p>
<div className="tabs" style={{ marginTop: 20 }}>
{[["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>
)}
<div style={{ flex: 1, display: "flex", flexDirection: "column", marginTop: sourceTab === "paste" ? 4 : 0 }}>
<textarea
className="source-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."}
aria-label="Source material"
/>
<div className="small muted" style={{ display: "flex", alignItems: "center", marginTop: 12, paddingTop: 12, borderTop: "1px solid var(--line)" }}>
<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>
</div>
{error && <div className="alert alert-error"><IconAlertTriangle size={17} /> <span>{error}</span></div>}
</>
)}
{step === 1 && (
<>
<span className="field-label" style={{ color: "var(--board)", marginBottom: 8 }}>Step 2 · Configure</span>
<h2>Set up the assignment</h2>
<div style={{ margin: "20px 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%" }} />
</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 14px" }}>
<span className="field-label">Question types to include</span>
<div style={{ display: "flex", flexWrap: "wrap", gap: "2px 18px" }}>
{QUESTION_TYPES.map((t) => (
<label key={t.id} className="check" style={{ minWidth: 150 }}>
<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 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.</small></span>
</label>
<label className="field" style={{ marginTop: 14 }}>
<span className="field-label">Anything to focus on? <span className="faint" style={{ textTransform: "none", letterSpacing: 0, 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>
</>
)}
{step === 2 && (
<>
<span className="field-label" style={{ color: "var(--board)", marginBottom: 8 }}>Step 3 · Generate</span>
<h2>Ready to generate</h2>
<p className="muted" style={{ margin: "12px 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 && !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" style={{ marginTop: 20 }}>
<IconAlertTriangle size={17} /> <span><b>Generation failed.</b> {genState.error}</span>
</div>
)}
</>
)}
</section>
{/* ============ RIGHT: step tracker + actions ============ */}
<aside className="create-aside">
<span className="field-label" style={{ color: "var(--ink-soft)" }}>Progress</span>
<h2 style={{ fontSize: "1.2rem", margin: "8px 0 24px" }}>Create an assignment</h2>
{providerNote && (
<div className="alert alert-warn" style={{ marginTop: 0 }}>
<IconAlertTriangle size={17} />
<span>
{providerNote.missing === "key" ? "Your selected AI provider needs an API key." : "No AI model is selected yet."}{" "}
<a href="/settings">Open Settings</a> to finish setup.
</span>
</div>
)}
<div className="vsteps">
{STEPS.map((s, i) => {
const state = step === i ? "active" : step > i ? "done" : "";
return (
<button
key={s.label}
className={`vstep ${state}`}
style={{ border: 0, background: "none", textAlign: "left", cursor: canGoTo(i) && !generating ? "pointer" : "default", padding: 0, paddingBottom: i === STEPS.length - 1 ? 0 : 22, font: "inherit", width: "100%" }}
onClick={() => { if (canGoTo(i) && !generating) setStep(i); }}
disabled={!canGoTo(i) || generating}
>
<span className="vstep-n">{step > i ? <IconCircleCheck size={20} /> : i + 1}</span>
<span>
<span className="vstep-title" style={{ display: "block" }}>{s.label}</span>
<span className="vstep-desc">{s.desc}</span>
</span>
</button>
);
})}
</div>
<hr className="hr" style={{ margin: "26px 0" }} />
<div className="tip">
<IconBulb size={18} />
<p style={{ margin: 0 }}>
{step === 0 && <><b>Tip:</b> 300800 words of clean source text produces the most accurate questions.</>}
{step === 1 && <><b>Tip:</b> Mixing question types gives a more rounded check for understanding.</>}
{step === 2 && <><b>Tip:</b> Every question keeps a source quote so you can verify it against your material.</>}
</p>
</div>
<div style={{ marginTop: "auto", paddingTop: 28, display: "flex", flexDirection: "column", gap: 10 }}>
{step === 0 && (
<button className="btn btn-primary btn-lg btn-block" disabled={!sourceReady} onClick={() => setStep(1)}>
Next: Configure <IconArrowRight size={17} />
</button>
)}
{step === 1 && (
<>
<button className="btn btn-primary btn-lg btn-block" disabled={!configReady} onClick={() => setStep(2)}>
Next: Generate <IconArrowRight size={17} />
</button>
<button className="btn btn-block" onClick={() => setStep(0)}><IconArrowLeft size={16} /> Back</button>
</>
)}
{step === 2 && !genState && (
<>
<button className="btn btn-primary btn-lg btn-block" onClick={generate} disabled={!!providerNote}>
<IconPencil size={17} /> Generate assignment
</button>
<button className="btn btn-block" onClick={() => setStep(1)}><IconArrowLeft size={16} /> Back</button>
</>
)}
{step === 2 && genState?.error && (
<>
<button className="btn btn-primary btn-block" onClick={generate}>Try again</button>
<button className="btn btn-block" onClick={() => setGenState(null)}>Adjust and retry</button>
</>
)}
{step === 0 && !sourceReady && <p className="small faint" style={{ textAlign: "center", margin: 0 }}>Add at least 100 characters to continue</p>}
</div>
</aside>
</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;
}