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>
This commit is contained in:
+267
-298
@@ -1,12 +1,20 @@
|
||||
"use client";
|
||||
// app/page.jsx — the Create flow: Source -> Configure -> Generate.
|
||||
// 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;
|
||||
|
||||
// Maps a generation phase to its 0-based order index
|
||||
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 }) {
|
||||
@@ -15,9 +23,9 @@ function ProgressStep({ phase, label, currentPhase, hasVerify }) {
|
||||
const me = PHASE_ORDER.indexOf(phase);
|
||||
const state = me < cur ? "done" : me === cur ? "active" : "";
|
||||
return (
|
||||
<li className={state} style={{ animationDelay: `${me * 0.08}s` }}>
|
||||
<li className={state}>
|
||||
<span className="progress-dot">
|
||||
{state === "done" ? "✓" : state === "active" ? <span className="spinner" /> : "·"}
|
||||
{state === "done" ? <IconCircleCheck size={18} /> : state === "active" ? <span className="spinner" /> : <IconCircle size={16} />}
|
||||
</span>
|
||||
{label}{state === "active" ? "…" : ""}
|
||||
</li>
|
||||
@@ -58,13 +66,9 @@ export default function CreatePage() {
|
||||
.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);
|
||||
}
|
||||
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(() => {});
|
||||
@@ -74,9 +78,7 @@ export default function CreatePage() {
|
||||
const [genState, setGenState] = useState(null);
|
||||
const generating = !!genState && !genState.error;
|
||||
|
||||
function update(patch) {
|
||||
setConfig((c) => ({ ...c, ...patch }));
|
||||
}
|
||||
function update(patch) { setConfig((c) => ({ ...c, ...patch })); }
|
||||
|
||||
function toggleQType(id) {
|
||||
setConfig((c) => {
|
||||
@@ -99,9 +101,7 @@ export default function CreatePage() {
|
||||
setText(content.slice(0, MAX_PASTE));
|
||||
setSourceName(file.name);
|
||||
setSourceTab("upload");
|
||||
} catch {
|
||||
setError("Could not read that file.");
|
||||
}
|
||||
} catch { setError("Could not read that file."); }
|
||||
}
|
||||
|
||||
async function fetchUrl() {
|
||||
@@ -117,17 +117,19 @@ export default function CreatePage() {
|
||||
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);
|
||||
}
|
||||
} 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" });
|
||||
@@ -138,9 +140,7 @@ export default function CreatePage() {
|
||||
try {
|
||||
const r1 = await postJson("/api/generate", { stage: "analyze", source, config: cfg });
|
||||
analysis = r1.analysis;
|
||||
} catch (e) {
|
||||
console.warn("Analysis stage failed, continuing:", e);
|
||||
}
|
||||
} catch (e) { console.warn("Analysis stage failed, continuing:", e); }
|
||||
|
||||
setGenState({ phase: "generate" });
|
||||
const r2 = await postJson("/api/generate", { stage: "generate", source, analysis, config: cfg });
|
||||
@@ -149,18 +149,11 @@ export default function CreatePage() {
|
||||
if (cfg.verify) {
|
||||
setGenState({ phase: "verify" });
|
||||
try {
|
||||
const r3 = await postJson("/api/generate", {
|
||||
stage: "verify",
|
||||
source,
|
||||
config: cfg,
|
||||
questions: assignment.questions,
|
||||
});
|
||||
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);
|
||||
}
|
||||
} catch (e) { console.warn("Verification stage failed, continuing:", e); }
|
||||
}
|
||||
|
||||
setGenState({ phase: "save" });
|
||||
@@ -175,297 +168,273 @@ export default function CreatePage() {
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
<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>
|
||||
|
||||
{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>
|
||||
<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>
|
||||
</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 })}
|
||||
{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"
|
||||
/>
|
||||
<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="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>
|
||||
|
||||
<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>
|
||||
{error && <div className="alert alert-error"><IconAlertTriangle size={17} /> <span>{error}</span></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>
|
||||
{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>
|
||||
{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 && (
|
||||
<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.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>
|
||||
<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="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>
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
|
||||
<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 === 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>
|
||||
|
||||
{/* ============ 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 && !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 && (
|
||||
<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>
|
||||
{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> 300–800 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 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;
|
||||
|
||||
Reference in New Issue
Block a user