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:
bizzle
2026-06-25 18:33:08 -04:00
co-authored by Claude
parent 4036ad5839
commit b7416cc618
13 changed files with 1564 additions and 1364 deletions
+93 -126
View File
@@ -2,9 +2,13 @@
// app/editor/[id]/page.jsx — review and refine an assignment, then export it. // app/editor/[id]/page.jsx — review and refine an assignment, then export it.
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import {
IconChevronDown, IconRefresh, IconCheck, IconCircleCheck, IconAlertTriangle,
IconPlus, IconPencil, IconArrowLeft,
} from "@tabler/icons-react";
import QuestionCard from "@/components/QuestionCard"; import QuestionCard from "@/components/QuestionCard";
import CanvasExportDialog from "@/components/CanvasExportDialog"; import CanvasExportDialog from "@/components/CanvasExportDialog";
import { QUESTION_TYPES, blankQuestion, totalPoints } from "@/lib/schema"; import { QUESTION_TYPES, blankQuestion, totalPoints, newId } from "@/lib/schema";
import { exportTxt, exportDoc, exportClipboard, exportPrint } from "@/lib/exporter"; import { exportTxt, exportDoc, exportClipboard, exportPrint } from "@/lib/exporter";
export default function EditorPage() { export default function EditorPage() {
@@ -23,6 +27,8 @@ export default function EditorPage() {
const [canvasOpen, setCanvasOpen] = useState(false); const [canvasOpen, setCanvasOpen] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [profile, setProfile] = useState({}); const [profile, setProfile] = useState({});
const [dragIndex, setDragIndex] = useState(null);
const [overIndex, setOverIndex] = useState(null);
const toastTimer = useRef(null); const toastTimer = useRef(null);
useEffect(() => { useEffect(() => {
@@ -30,19 +36,17 @@ export default function EditorPage() {
.then(async (r) => { .then(async (r) => {
const data = await r.json(); const data = await r.json();
if (!r.ok) throw new Error(data.error || "Could not load this assignment."); if (!r.ok) throw new Error(data.error || "Could not load this assignment.");
// Guarantee every question carries a stable id (older saves may lack one),
// so React keys and drag-reorder identity stay unique.
data.questions = (data.questions || []).map((q) => (q && q.id ? q : { ...q, id: newId() }));
setA(data); setA(data);
}) })
.catch((e) => setLoadErr(String(e.message || e))); .catch((e) => setLoadErr(String(e.message || e)));
fetch("/api/settings") fetch("/api/settings").then((r) => r.json()).then((s) => setProfile(s?.profile || {})).catch(() => {});
.then((r) => r.json())
.then((s) => setProfile(s?.profile || {}))
.catch(() => {});
}, [id]); }, [id]);
useEffect(() => { useEffect(() => {
function onBeforeUnload(e) { function onBeforeUnload(e) { if (dirty) { e.preventDefault(); e.returnValue = ""; } }
if (dirty) { e.preventDefault(); e.returnValue = ""; }
}
window.addEventListener("beforeunload", onBeforeUnload); window.addEventListener("beforeunload", onBeforeUnload);
return () => window.removeEventListener("beforeunload", onBeforeUnload); return () => window.removeEventListener("beforeunload", onBeforeUnload);
}, [dirty]); }, [dirty]);
@@ -53,17 +57,10 @@ export default function EditorPage() {
toastTimer.current = setTimeout(() => setToast(""), 2400); toastTimer.current = setTimeout(() => setToast(""), 2400);
} }
function patch(p) { function patch(p) { setA((cur) => ({ ...cur, ...p })); setDirty(true); }
setA((cur) => ({ ...cur, ...p }));
setDirty(true);
}
function setQuestion(i, q) { function setQuestion(i, q) {
setA((cur) => { setA((cur) => { const questions = [...cur.questions]; questions[i] = q; return { ...cur, questions }; });
const questions = [...cur.questions];
questions[i] = q;
return { ...cur, questions };
});
setDirty(true); setDirty(true);
} }
@@ -78,6 +75,19 @@ export default function EditorPage() {
setDirty(true); setDirty(true);
} }
function reorder(from, to) {
if (from === to || from == null || to == null) return;
setA((cur) => {
const questions = [...cur.questions];
const [moved] = questions.splice(from, 1);
questions.splice(to, 0, moved);
return { ...cur, questions };
});
setDirty(true);
}
function endDrag() { setDragIndex(null); setOverIndex(null); }
function deleteQuestion(i) { function deleteQuestion(i) {
if (!confirm("Delete question " + (i + 1) + "?")) return; if (!confirm("Delete question " + (i + 1) + "?")) return;
setA((cur) => ({ ...cur, questions: cur.questions.filter((_, j) => j !== i) })); setA((cur) => ({ ...cur, questions: cur.questions.filter((_, j) => j !== i) }));
@@ -85,48 +95,31 @@ export default function EditorPage() {
} }
async function save(silent) { async function save(silent) {
setSaving(true); setSaving(true); setError("");
setError("");
try { try {
const res = await fetch("/api/assignments/" + id, { const res = await fetch("/api/assignments/" + id, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(a) });
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(a),
});
const data = await res.json(); const data = await res.json();
if (!res.ok) throw new Error(data.error || "Save failed."); if (!res.ok) throw new Error(data.error || "Save failed.");
setA(data); setA(data); setDirty(false);
setDirty(false);
if (!silent) showToast("Saved"); if (!silent) showToast("Saved");
} catch (e) { } catch (e) { setError(String(e.message || e)); }
setError(String(e.message || e)); finally { setSaving(false); }
} finally {
setSaving(false);
}
} }
async function regenerateQuestion(i, note) { async function regenerateQuestion(i, note) {
const q = a.questions[i]; const q = a.questions[i];
setBusyQ(q.id); setBusyQ(q.id); setError("");
setError("");
try { try {
const data = await postJson("/api/generate", { const data = await postJson("/api/generate", {
stage: "question", stage: "question", source: a.source?.text || "",
source: a.source?.text || "",
config: a.config || { assignmentType: a.assignmentType, gradeLevel: a.gradeLevel, subject: a.subject, difficulty: a.difficulty }, config: a.config || { assignmentType: a.assignmentType, gradeLevel: a.gradeLevel, subject: a.subject, difficulty: a.difficulty },
type: q.type, type: q.type, note, replacing: { question: q.question },
note,
replacing: { question: q.question },
existingQuestions: a.questions.filter((_, j) => j !== i).map((x) => ({ question: x.question })), existingQuestions: a.questions.filter((_, j) => j !== i).map((x) => ({ question: x.question })),
}); });
const next = { ...data.question, points: q.points }; setQuestion(i, { ...data.question, points: q.points });
setQuestion(i, next);
showToast("Question " + (i + 1) + " regenerated"); showToast("Question " + (i + 1) + " regenerated");
} catch (e) { } catch (e) { setError(String(e.message || e)); }
setError(String(e.message || e)); finally { setBusyQ(null); }
} finally {
setBusyQ(null);
}
} }
async function addQuestion(type, withAI) { async function addQuestion(type, withAI) {
@@ -136,55 +129,38 @@ export default function EditorPage() {
setDirty(true); setDirty(true);
return; return;
} }
setBusyQ("__new__"); setBusyQ("__new__"); setError("");
setError("");
try { try {
const data = await postJson("/api/generate", { const data = await postJson("/api/generate", {
stage: "question", stage: "question", source: a.source?.text || "",
source: a.source?.text || "",
config: a.config || { assignmentType: a.assignmentType, gradeLevel: a.gradeLevel, subject: a.subject, difficulty: a.difficulty }, config: a.config || { assignmentType: a.assignmentType, gradeLevel: a.gradeLevel, subject: a.subject, difficulty: a.difficulty },
type, type, existingQuestions: a.questions.map((x) => ({ question: x.question })),
existingQuestions: a.questions.map((x) => ({ question: x.question })),
}); });
setA((cur) => ({ ...cur, questions: [...cur.questions, data.question] })); setA((cur) => ({ ...cur, questions: [...cur.questions, data.question] }));
setDirty(true); setDirty(true);
showToast("Question added"); showToast("Question added");
} catch (e) { } catch (e) { setError(String(e.message || e)); }
setError(String(e.message || e)); finally { setBusyQ(null); }
} finally {
setBusyQ(null);
}
} }
async function reverify() { async function reverify() {
setVerifying(true); setVerifying(true); setError("");
setError("");
try { try {
const data = await postJson("/api/generate", { const data = await postJson("/api/generate", {
stage: "verify", stage: "verify", source: a.source?.text || "",
source: a.source?.text || "",
config: a.config || { assignmentType: a.assignmentType, gradeLevel: a.gradeLevel, subject: a.subject, difficulty: a.difficulty }, config: a.config || { assignmentType: a.assignmentType, gradeLevel: a.gradeLevel, subject: a.subject, difficulty: a.difficulty },
questions: a.questions, questions: a.questions,
}); });
setA((cur) => ({ setA((cur) => ({
...cur, ...cur,
questions: cur.questions.map((q) => questions: cur.questions.map((q) =>
data.verifications[q.id] data.verifications[q.id] ? { ...q, verification: data.verifications[q.id] } : { ...q, verification: { status: "unchecked", note: "" } }),
? { ...q, verification: data.verifications[q.id] }
: { ...q, verification: { status: "unchecked", note: "" } }
),
})); }));
setDirty(true); setDirty(true);
const warns = Object.values(data.verifications).filter((v) => v.status === "warn").length; const warns = Object.values(data.verifications).filter((v) => v.status === "warn").length;
showToast(warns showToast(warns ? `Accuracy check done — ${warns} question${warns === 1 ? "" : "s"} flagged` : "Accuracy check done — all clear");
? `Accuracy check done — ${warns} question${warns === 1 ? "" : "s"} flagged` } catch (e) { setError(String(e.message || e)); }
: "Accuracy check done — all clear" finally { setVerifying(false); }
);
} catch (e) {
setError(String(e.message || e));
} finally {
setVerifying(false);
}
} }
function doExport(kind, who) { function doExport(kind, who) {
@@ -195,30 +171,30 @@ export default function EditorPage() {
if (kind === "doc") { exportDoc(a, opts); showToast("Downloaded Word file"); } if (kind === "doc") { exportDoc(a, opts); showToast("Downloaded Word file"); }
if (kind === "print") { exportPrint(a, opts); } if (kind === "print") { exportPrint(a, opts); }
if (kind === "copy") { exportClipboard(a, opts).then(() => showToast("Copied to clipboard")); } if (kind === "copy") { exportClipboard(a, opts).then(() => showToast("Copied to clipboard")); }
} catch (e) { } catch (e) { setError(String(e.message || e)); }
setError(String(e.message || e));
}
} }
if (loadErr) { if (loadErr) {
return ( return (
<div className="page page-narrow">
<div className="empty"> <div className="empty">
<h3>Couldn&rsquo;t open that assignment</h3> <h3>Couldn&rsquo;t open that assignment</h3>
<p>{loadErr}</p> <p>{loadErr}</p>
<button className="btn btn-primary" style={{ marginTop: 12 }} onClick={() => router.push("/library")}>Go to Library</button> <button className="btn btn-primary" style={{ marginTop: 12 }} onClick={() => router.push("/library")}>Go to Library</button>
</div> </div>
</div>
); );
} }
if (!a) { if (!a) {
return ( return (
<div style={{ padding: "40px 0" }}> <div className="page page-narrow">
<div className="card skeleton-card" style={{ marginBottom: 16 }}> <div className="panel panel-pad skeleton-card" style={{ marginBottom: 16 }}>
<div className="skeleton-line" style={{ width: "60%", height: 28, borderRadius: 6, marginBottom: 12 }} /> <div className="skeleton-line" style={{ width: "60%", height: 28, borderRadius: 6, marginBottom: 12 }} />
<div className="skeleton-line short" /> <div className="skeleton-line short" />
</div> </div>
{[0, 1, 2].map((i) => ( {[0, 1, 2].map((i) => (
<div key={i} className="card skeleton-card" style={{ marginBottom: 14, animationDelay: `${i * 0.15}s` }}> <div key={i} className="panel panel-pad skeleton-card" style={{ marginBottom: 14 }}>
<div className="skeleton-chip" /> <div className="skeleton-chip" />
<div className="skeleton-line full" /> <div className="skeleton-line full" />
<div className="skeleton-line medium" /> <div className="skeleton-line medium" />
@@ -232,79 +208,69 @@ export default function EditorPage() {
const uncheckedCount = a.questions.filter((q) => !q.verification || q.verification.status === "unchecked").length; const uncheckedCount = a.questions.filter((q) => !q.verification || q.verification.status === "unchecked").length;
return ( return (
<div> <div className="page page-narrow">
<div className="page-head" style={{ display: "flex", alignItems: "flex-start", gap: 12, flexWrap: "wrap" }}> <h1 className="sr-only">{a.title || "Untitled assignment"}</h1>
<div className="page-head" style={{ display: "flex", alignItems: "flex-start", gap: 12, flexWrap: "wrap", marginBottom: 18 }}>
<div style={{ flex: 1, minWidth: 260 }}> <div style={{ flex: 1, minWidth: 260 }}>
<input <div className="title-edit">
type="text" <input type="text" value={a.title} onChange={(e) => patch({ title: e.target.value })} aria-label="Assignment title" placeholder="Untitled assignment" />
value={a.title} <IconPencil size={17} className="pen" />
onChange={(e) => patch({ title: e.target.value })} </div>
aria-label="Assignment title" <p className="muted small" style={{ margin: "6px 0 0 2px" }}>
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.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} {a.source?.name ? <> · from <i>{a.source.name}</i></> : null}
</p> </p>
</div> </div>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}> <div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
<div style={{ position: "relative" }}> <div style={{ position: "relative" }}>
<button className="btn" onClick={() => { setExportOpen((o) => !o); setAddOpen(false); }}>Export </button> <button className="btn" onClick={() => { setExportOpen((o) => !o); setAddOpen(false); }}>Export <IconChevronDown size={15} /></button>
{exportOpen && ( {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="pop" style={{ position: "absolute", right: 0, top: "calc(100% + 6px)", zIndex: 30, width: 300, padding: 16 }}>
<div className="field-label">Student version</div> <div className="field-label">Student version</div>
<div style={{ display: "flex", gap: 6, flexWrap: "wrap", marginBottom: 12 }}> <div style={{ display: "flex", gap: 6, flexWrap: "wrap", marginBottom: 14 }}>
<button className="btn btn-sm" onClick={() => doExport("print", false)}>Print / PDF</button> <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("doc", false)}>Word</button>
<button className="btn btn-sm" onClick={() => doExport("txt", false)}>Text</button> <button className="btn btn-sm" onClick={() => doExport("txt", false)}>Text</button>
<button className="btn btn-sm" onClick={() => doExport("copy", false)}>Copy</button> <button className="btn btn-sm" onClick={() => doExport("copy", false)}>Copy</button>
</div> </div>
<div className="field-label" style={{ color: "var(--redpen)" }}>Teacher version (answer key)</div> <div className="field-label" style={{ color: "var(--redpen)" }}>Teacher version (answer key)</div>
<div style={{ display: "flex", gap: 6, flexWrap: "wrap", marginBottom: 12 }}> <div style={{ display: "flex", gap: 6, flexWrap: "wrap", marginBottom: 14 }}>
<button className="btn btn-sm" onClick={() => doExport("print", true)}>Print / PDF</button> <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("doc", true)}>Word</button>
<button className="btn btn-sm" onClick={() => doExport("txt", true)}>Text</button> <button className="btn btn-sm" onClick={() => doExport("txt", true)}>Text</button>
<button className="btn btn-sm" onClick={() => doExport("copy", true)}>Copy</button> <button className="btn btn-sm" onClick={() => doExport("copy", true)}>Copy</button>
</div> </div>
<div className="field-label">Complete packet student + answer key</div> <div className="field-label">Complete packet student + answer key</div>
<div style={{ display: "flex", gap: 6, flexWrap: "wrap", marginBottom: 12 }}> <div style={{ display: "flex", gap: 6, flexWrap: "wrap", marginBottom: 14 }}>
<button className="btn btn-sm" onClick={() => doExport("print", "packet")}>Print / PDF</button> <button className="btn btn-sm" onClick={() => doExport("print", "packet")}>Print / PDF</button>
<button className="btn btn-sm" onClick={() => doExport("doc", "packet")}>Word</button> <button className="btn btn-sm" onClick={() => doExport("doc", "packet")}>Word</button>
</div> </div>
<div className="field-label">Canvas (LMS)</div> <div className="field-label">Canvas (LMS)</div>
<button className="btn btn-sm" onClick={() => { setExportOpen(false); setCanvasOpen(true); }}> <button className="btn btn-sm" onClick={() => { setExportOpen(false); setCanvasOpen(true); }}>Set up &amp; download .zip</button>
Set up &amp; download .zip
</button>
</div> </div>
)} )}
</div> </div>
<button className="btn" onClick={reverify} disabled={verifying || !a.source?.text}> <button className="btn" onClick={reverify} disabled={verifying || !a.source?.text}>
{verifying ? <><span className="spinner" /> Checking</> : "Re-run accuracy check"} {verifying ? <><span className="spinner" /> Checking</> : <><IconRefresh size={15} /> Re-run accuracy check</>}
</button> </button>
<button className="btn btn-primary" onClick={() => save(false)} disabled={saving || !dirty}> <button className="btn btn-primary" onClick={() => save(false)} disabled={saving || !dirty}>
{saving ? <><span className="spinner" /> Saving</> : dirty ? "Save" : "Saved ✓"} {saving ? <><span className="spinner" /> Saving</> : dirty ? "Save" : <>Saved <IconCheck size={15} /></>}
</button> </button>
</div> </div>
</div> </div>
{error && <div className="alert alert-error">{error}</div>} {error && <div className="alert alert-error"><IconAlertTriangle size={17} /> <span>{error}</span></div>}
{warnCount > 0 && ( {warnCount > 0 && (
<div className="alert alert-warn"> <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. <IconAlertTriangle size={17} />
<span><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.</span>
</div> </div>
)} )}
{warnCount === 0 && uncheckedCount === 0 && a.questions.length > 0 && ( {warnCount === 0 && uncheckedCount === 0 && a.questions.length > 0 && (
<div className="alert alert-info"> Every question passed the accuracy check against your source.</div> <div className="alert alert-info"><IconCircleCheck size={17} /> <span>Every question passed the accuracy check against your source.</span></div>
)} )}
<label className="field"> <label className="field" style={{ marginTop: 8 }}>
<span className="field-label">Student instructions</span> <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…" /> <textarea rows={2} value={a.instructions || ""} onChange={(e) => patch({ instructions: e.target.value })} placeholder="Instructions students see at the top…" />
</label> </label>
@@ -316,6 +282,8 @@ export default function EditorPage() {
</label> </label>
) : null} ) : null}
{/* the questions as one continuous document */}
<div className="panel doc" style={{ marginTop: 16 }}>
{a.questions.map((q, i) => ( {a.questions.map((q, i) => (
<QuestionCard <QuestionCard
key={q.id} key={q.id}
@@ -323,28 +291,31 @@ export default function EditorPage() {
index={i} index={i}
count={a.questions.length} count={a.questions.length}
busy={busyQ === q.id} busy={busyQ === q.id}
dragging={dragIndex === i}
over={overIndex === i && dragIndex !== i}
onChange={(next) => setQuestion(i, next)} onChange={(next) => setQuestion(i, next)}
onMove={(dir) => moveQuestion(i, dir)} onMove={(dir) => moveQuestion(i, dir)}
onDelete={() => deleteQuestion(i)} onDelete={() => deleteQuestion(i)}
onRegenerate={(note) => regenerateQuestion(i, note)} onRegenerate={(note) => regenerateQuestion(i, note)}
onDragStart={() => setDragIndex(i)}
onDragEnter={() => { if (dragIndex !== null && dragIndex !== i) setOverIndex(i); }}
onDrop={() => { reorder(dragIndex, i); endDrag(); }}
onDragEnd={endDrag}
/> />
))} ))}
</div>
<div style={{ marginTop: 18, position: "relative", display: "flex", gap: 10 }}> <div style={{ marginTop: 18, position: "relative", display: "flex", gap: 10, alignItems: "center" }}>
<button className="btn" onClick={() => { setAddOpen((o) => !o); setExportOpen(false); }} disabled={busyQ === "__new__"}> <button className="btn" onClick={() => { setAddOpen((o) => !o); setExportOpen(false); }} disabled={busyQ === "__new__"}>
{busyQ === "__new__" ? <><span className="spinner" /> Writing question</> : "+ Add question ▾"} {busyQ === "__new__" ? <><span className="spinner" /> Writing question</> : <><IconPlus size={16} /> Add question <IconChevronDown size={14} /></>}
</button> </button>
{addOpen && ( {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" }}> <div className="pop" style={{ position: "absolute", left: 0, bottom: "calc(100% + 6px)", zIndex: 30, width: 324, padding: 16 }}>
{[...QUESTION_TYPES, { id: "discussion", label: "Discussion prompt" }].map((t) => ( {[...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)" }}> <div key={t.id} style={{ display: "flex", alignItems: "center", gap: 8, padding: "6px 0", borderBottom: "1px solid var(--line)" }}>
<span style={{ flex: 1, fontSize: "0.92rem", fontWeight: 600 }}>{t.label}</span> <span style={{ flex: 1, fontSize: "0.92rem", fontWeight: 600 }}>{t.label}</span>
<button <button className="btn btn-sm btn-primary" onClick={() => addQuestion(t.id, true)} disabled={!a.source?.text}
className="btn btn-sm btn-primary" title={a.source?.text ? "Generate from your source" : "No source stored with this assignment"}>AI</button>
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> <button className="btn btn-sm" onClick={() => addQuestion(t.id, false)}>Blank</button>
</div> </div>
))} ))}
@@ -352,7 +323,7 @@ export default function EditorPage() {
)} )}
<span className="spacer" /> <span className="spacer" />
<span className="muted small" style={{ alignSelf: "center" }}> <span className="muted small" style={{ alignSelf: "center" }}>
Total: <b>{totalPoints(a.questions)}</b> points Total: <b style={{ fontFamily: "var(--font-display)", fontWeight: 700, fontSize: "1.05rem", color: "var(--ink)" }}>{totalPoints(a.questions)}</b> points
</span> </span>
</div> </div>
@@ -371,11 +342,7 @@ export default function EditorPage() {
} }
async function postJson(url, body) { async function postJson(url, body) {
const res = await fetch(url, { const res = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || `Request failed (${res.status}).`); if (!res.ok) throw new Error(data.error || `Request failed (${res.status}).`);
return data; return data;
+454 -249
View File
@@ -1,13 +1,14 @@
/* Google Fonts — must come before the Tailwind import */ /* 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 url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Lora:wght@500;600;700&display=swap");
@import "tailwindcss"; @import "tailwindcss";
/* ============================================================================= /* =============================================================================
Mr. Drew's Assignment Creator — design system Mr. Drew's Assignment Creator — design system ("Open Workspace")
Identity: a well-kept teacher's desk. Lora serif headings, chalkboard Identity: a well-kept teacher's desk, now laid out as a workspace.
green for primary actions, and the signature: everything answer-key wears A solid chalkboard-green sidebar, flat ruled surfaces (elevation is earned,
red pen — the color teachers actually grade in. not decorative), Lora serif for authority, and the signature: everything
in the answer key wears red pen — the color teachers actually grade in.
============================================================================= */ ============================================================================= */
@layer base { @layer base {
@@ -17,13 +18,16 @@
--panel: #ffffff; --panel: #ffffff;
--ink: #1e2d28; --ink: #1e2d28;
--ink-soft: #58706a; --ink-soft: #58706a;
--ink-faint: #8aa099;
--board: #2f6b58; --board: #2f6b58;
--board-deep: #245546; --board-deep: #245546;
--board-tint: #e4eeea; --board-tint: #e4eeea;
--board-glow: rgba(47, 107, 88, 0.12); --board-glow: rgba(47, 107, 88, 0.12);
--redpen: #b8412f; --redpen: #b8412f;
--redpen-ink: #8c3022;
--redpen-tint: #faece9; --redpen-tint: #faece9;
--gold: #b98a23; --gold: #b98a23;
--gold-ink: #7a5a14;
--gold-tint: #faf3e2; --gold-tint: #faf3e2;
--line: #dde4df; --line: #dde4df;
--line-strong: #c5d0cb; --line-strong: #c5d0cb;
@@ -32,9 +36,24 @@
--tab-track: #e6ecea; --tab-track: #e6ecea;
--chip-neutral-bg: #eaeeec; --chip-neutral-bg: #eaeeec;
--empty-bg: #fafcfb; --empty-bg: #fafcfb;
--shadow-sm: 0 1px 3px rgba(34, 49, 44, 0.07), 0 2px 8px rgba(34, 49, 44, 0.05); --disabled-bg: #eaeeec;
--shadow: 0 2px 6px rgba(34, 49, 44, 0.06), 0 6px 20px rgba(34, 49, 44, 0.07); --disabled-ink: #97a8a2;
--shadow-lg: 0 8px 32px rgba(34, 49, 44, 0.12), 0 2px 8px rgba(34, 49, 44, 0.06);
/* tokenized feedback colors (light) */
--alert-error-bg: var(--redpen-tint);
--alert-error-border: #e8c5be;
--alert-error-ink: var(--redpen-ink);
--alert-warn-bg: var(--gold-tint);
--alert-warn-border: #e9d8a6;
--alert-warn-ink: var(--gold-ink);
--alert-info-bg: var(--board-tint);
--alert-info-border: #cde0d8;
--alert-info-ink: var(--board-deep);
--shadow-sm: 0 1px 2px rgba(34, 49, 44, 0.05);
/* elevation is reserved for things that truly float */
--shadow-pop: 0 10px 34px rgba(34, 49, 44, 0.14), 0 2px 8px rgba(34, 49, 44, 0.06);
--shadow-modal: 0 24px 60px rgba(20, 30, 26, 0.28), 0 4px 14px rgba(20, 30, 26, 0.14);
color-scheme: light; color-scheme: light;
/* type */ /* type */
@@ -47,6 +66,8 @@
--radius-xs: 5px; --radius-xs: 5px;
--transition: 0.18s ease; --transition: 0.18s ease;
--transition-fast: 0.1s ease; --transition-fast: 0.1s ease;
--sidebar-w: 264px;
} }
/* The same desk after dark */ /* The same desk after dark */
@@ -55,13 +76,16 @@
--panel: #1b2320; --panel: #1b2320;
--ink: #e2eae5; --ink: #e2eae5;
--ink-soft: #92aaa2; --ink-soft: #92aaa2;
--ink-faint: #6c847c;
--board: #4d9c82; --board: #4d9c82;
--board-deep: #7bc0a8; --board-deep: #7bc0a8;
--board-tint: #1e3028; --board-tint: #1e3028;
--board-glow: rgba(77, 156, 130, 0.15); --board-glow: rgba(77, 156, 130, 0.15);
--redpen: #e07a63; --redpen: #e07a63;
--redpen-ink: #f0a795;
--redpen-tint: #38221e; --redpen-tint: #38221e;
--gold: #d3a94c; --gold: #d3a94c;
--gold-ink: #e2c47e;
--gold-tint: #342d1a; --gold-tint: #342d1a;
--line: #263028; --line: #263028;
--line-strong: #374440; --line-strong: #374440;
@@ -70,18 +94,27 @@
--tab-track: #161d1a; --tab-track: #161d1a;
--chip-neutral-bg: #262f2b; --chip-neutral-bg: #262f2b;
--empty-bg: #171e1a; --empty-bg: #171e1a;
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3), 0 2px 8px rgba(0, 0, 0, 0.25); --disabled-bg: #222b27;
--shadow: 0 2px 6px rgba(0, 0, 0, 0.3), 0 6px 20px rgba(0, 0, 0, 0.3); --disabled-ink: #5d726b;
--shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.4), 0 2px 8px rgba(0, 0, 0, 0.3);
--alert-error-bg: var(--redpen-tint);
--alert-error-border: #5a2f26;
--alert-error-ink: #f0a795;
--alert-warn-bg: var(--gold-tint);
--alert-warn-border: #564820;
--alert-warn-ink: #e2c47e;
--alert-info-bg: var(--board-tint);
--alert-info-border: #2d5040;
--alert-info-ink: #8dcbb5;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-pop: 0 12px 36px rgba(0, 0, 0, 0.5), 0 2px 8px rgba(0, 0, 0, 0.3);
--shadow-modal: 0 24px 60px rgba(0, 0, 0, 0.6), 0 4px 14px rgba(0, 0, 0, 0.4);
color-scheme: dark; 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"] .toast { background: #e2eae5; color: #111815; }
html[data-theme="dark"] .brand-mark { color: #f1f5f2; } 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; } * { box-sizing: border-box; }
@@ -91,21 +124,23 @@
background: var(--paper); background: var(--paper);
color: var(--ink); color: var(--ink);
font-family: var(--font-body); font-family: var(--font-body);
font-size: 15.5px; font-size: 14.75px;
line-height: 1.58; line-height: 1.6;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
/* Wider, clearer type ramp — levels separated by size AND family/weight */
h1, h2, h3 { h1, h2, h3 {
font-family: var(--font-display); font-family: var(--font-display);
font-weight: 700; font-weight: 700;
letter-spacing: -0.01em; letter-spacing: -0.012em;
margin: 0; margin: 0;
line-height: 1.25; line-height: 1.2;
color: var(--ink);
} }
h1 { font-size: 1.85rem; } h1 { font-size: 2.1rem; }
h2 { font-size: 1.3rem; } h2 { font-size: 1.35rem; }
h3 { font-size: 1.08rem; } h3 { font-size: 1.08rem; }
a { color: var(--board); text-decoration: none; } a { color: var(--board); text-decoration: none; }
@@ -123,13 +158,13 @@
} }
/* ===================================================================== /* =====================================================================
KEYFRAME ANIMATIONS KEYFRAME ANIMATIONS (used sparingly — overlays & real state changes)
===================================================================== */ ===================================================================== */
@keyframes spin { to { transform: rotate(360deg); } } @keyframes spin { to { transform: rotate(360deg); } }
@keyframes fade-in-up { @keyframes fade-in-up {
from { opacity: 0; transform: translateY(10px); } from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); } to { opacity: 1; transform: translateY(0); }
} }
@@ -145,7 +180,7 @@
@keyframes step-complete { @keyframes step-complete {
0% { transform: scale(1); } 0% { transform: scale(1); }
50% { transform: scale(1.2); } 50% { transform: scale(1.18); }
100% { transform: scale(1); } 100% { transform: scale(1); }
} }
@@ -154,296 +189,393 @@
50% { opacity: 1; } 50% { opacity: 1; }
} }
@keyframes progress-fill { @layer components {
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 APP SHELL — fixed left sidebar + scrolling content
===================================================================== */ ===================================================================== */
.app { display: flex; min-height: 100vh; align-items: stretch; }
@layer components { .content { flex: 1; min-width: 0; }
.shell { max-width: 1020px; margin: 0 auto; padding: 32px 22px 90px; } /* pages opt into padding; the Create flow renders full-bleed */
.page { padding: 36px 48px 90px; }
.page-narrow { max-width: 1120px; }
/* ---------- navigation ---------- */ /* ---------- sidebar ---------- */
.topnav { .sidebar {
background: rgba(255, 255, 255, 0.85); width: var(--sidebar-w); flex: none;
backdrop-filter: blur(14px); background: var(--panel);
-webkit-backdrop-filter: blur(14px); border-right: 1px solid var(--line);
border-bottom: 1px solid var(--line); display: flex; flex-direction: column;
position: sticky; top: 0; z-index: 50; position: sticky; top: 0; height: 100vh;
transition: box-shadow var(--transition); z-index: 40;
} }
html[data-theme="dark"] .topnav { .sidebar-brand {
background: rgba(27, 35, 32, 0.85); display: flex; align-items: center; gap: 11px;
padding: 20px 18px 16px;
white-space: nowrap; text-decoration: none;
} }
.topnav.nav-scrolled { .sidebar-brand:hover { text-decoration: none; }
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 { .brand-mark {
width: 30px; height: 30px; border-radius: 8px; width: 34px; height: 34px; border-radius: 9px; flex: none;
background: linear-gradient(135deg, var(--board) 0%, var(--board-deep) 100%); background: linear-gradient(135deg, var(--board) 0%, var(--board-deep) 100%);
color: #fff; color: #fff;
display: inline-flex; align-items: center; justify-content: center; 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); box-shadow: 0 2px 6px rgba(47, 107, 88, 0.35);
} }
.navlinks { display: flex; gap: 2px; margin-left: auto; } .brand-name { font-family: var(--font-display); font-weight: 700; font-size: 1.02rem; color: var(--ink); line-height: 1.15; }
.theme-toggle { .brand-sub { font-size: 0.72rem; color: var(--ink-soft); font-weight: 500; }
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 ---------- */ .sidebar-nav { padding: 4px 12px; display: flex; flex-direction: column; gap: 2px; }
.navrow {
display: flex; align-items: center; gap: 11px;
height: 40px; padding: 0 12px; border-radius: var(--radius-sm);
color: var(--ink-soft); font-weight: 500; font-size: 0.93rem; cursor: pointer;
transition: background var(--transition), color var(--transition);
}
.navrow:hover { background: var(--hover-bg); color: var(--ink); text-decoration: none; }
.navrow.active { background: var(--board-tint); color: var(--board-deep); font-weight: 600; }
.navrow svg { flex: none; }
.sidebar-scroll { flex: 1; overflow-y: auto; padding: 8px 12px 12px; }
.sidebar-label {
display: flex; align-items: center; justify-content: space-between;
padding: 10px 10px 8px;
font-family: var(--font-mono); font-size: 0.66rem; font-weight: 700;
letter-spacing: 0.12em; text-transform: uppercase; color: var(--ink-soft);
}
/* library folder tree */
.tree-group { margin-bottom: 1px; }
.tree-folder {
display: flex; align-items: center; gap: 8px;
width: 100%; text-align: left;
padding: 7px 10px; border: 0; background: none;
border-radius: var(--radius-xs); cursor: pointer;
color: var(--ink); font-family: var(--font-body); font-weight: 600; font-size: 0.83rem;
transition: background var(--transition);
}
.tree-folder:hover { background: var(--hover-bg); }
.tree-folder .chev { transition: transform var(--transition); color: var(--ink-soft); flex: none; }
.tree-folder .tree-count { margin-left: auto; font-size: 0.72rem; color: var(--ink-faint); font-weight: 600; }
.tree-leaf {
display: flex; align-items: center; gap: 8px;
padding: 6px 10px 6px 30px; border-radius: var(--radius-xs);
color: var(--ink-soft); font-size: 0.82rem; cursor: pointer; text-decoration: none;
transition: background var(--transition), color var(--transition);
}
.tree-leaf:hover { background: var(--hover-bg); color: var(--ink); text-decoration: none; }
.tree-leaf.active { background: var(--board-tint); color: var(--board-deep); font-weight: 600; }
.tree-leaf span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sidebar-foot {
padding: 12px; border-top: 1px solid var(--line);
display: flex; align-items: center; gap: 10px;
}
/* mobile top-bar fallback */
.sidebar-toggle { display: none; }
/* ---------- page heading ---------- */
.page-head { margin: 2px 0 26px; }
.page-head p { color: var(--ink-soft); margin: 9px 0 0; max-width: 64ch; font-size: 0.97rem; }
/* ---------- surfaces ---------- */
/* Flat panel — ONE separator (a hairline border). No resting shadow. */
.panel {
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius);
}
.panel-pad { padding: 24px; }
/* legacy alias kept lean: a flat bordered surface, no decorative shadow */
.card { .card {
background: var(--panel); background: var(--panel);
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: var(--radius); border-radius: var(--radius);
box-shadow: var(--shadow-sm);
padding: 24px; padding: 24px;
transition: box-shadow var(--transition), border-color var(--transition), transform 0.2s ease; transition: border-color var(--transition);
animation: fade-in-up 0.3s ease both;
} }
.card + .card { margin-top: 16px; } .card + .card { margin-top: 16px; }
.card:hover { box-shadow: var(--shadow); }
.card-lift:hover { /* The only things that float get real elevation */
box-shadow: var(--shadow-lg); .pop {
transform: translateY(-2px); background: var(--panel);
border-color: var(--line-strong); border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow-pop);
animation: fade-in-up 0.16s ease both;
} }
.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 ---------- */ /* ---------- buttons ---------- */
.btn { .btn {
appearance: none; appearance: none;
border: 1px solid var(--line-strong); border: 1px solid var(--line-strong);
background: var(--panel); background: var(--panel);
color: var(--ink); color: var(--ink);
font: inherit; font-family: var(--font-body); font-weight: 600; font-size: 0.92rem; font: inherit; font-family: var(--font-body); font-weight: 600; font-size: 0.9rem;
padding: 9px 17px; border-radius: var(--radius-sm); cursor: pointer; padding: 9px 16px; border-radius: var(--radius-sm); cursor: pointer;
display: inline-flex; align-items: center; gap: 7px; display: inline-flex; align-items: center; gap: 7px;
transition: background var(--transition), border-color var(--transition), box-shadow var(--transition), transform var(--transition-fast); transition: background var(--transition), border-color var(--transition), box-shadow var(--transition), transform var(--transition-fast);
white-space: nowrap; user-select: none; white-space: nowrap; user-select: none;
} }
.btn:hover { .btn:hover { background: var(--hover-bg); border-color: var(--board); }
background: var(--hover-bg); .btn:active { transform: translateY(1px); }
border-color: var(--board); .btn:disabled {
box-shadow: 0 1px 4px var(--board-glow); background: var(--disabled-bg); color: var(--disabled-ink);
border-color: var(--line); cursor: not-allowed; transform: none;
} }
.btn:active { transform: translateY(1px); box-shadow: none; }
.btn:disabled { opacity: 0.45; cursor: not-allowed; transform: none; box-shadow: none; }
.btn-primary { .btn-primary {
background: var(--board); background: var(--board); border-color: var(--board); color: #fff;
border-color: var(--board);
color: #fff;
box-shadow: 0 2px 6px rgba(47, 107, 88, 0.25); box-shadow: 0 2px 6px rgba(47, 107, 88, 0.25);
} }
.btn-primary:hover { .btn-primary:hover { background: var(--board-deep); border-color: var(--board-deep); box-shadow: 0 4px 14px rgba(47, 107, 88, 0.32); }
background: var(--board-deep); html[data-theme="dark"] .btn-primary { color: #0d1512; }
border-color: var(--board-deep); .btn-primary:disabled { background: var(--disabled-bg); color: var(--disabled-ink); border-color: var(--line); box-shadow: none; }
box-shadow: 0 4px 14px rgba(47, 107, 88, 0.35);
.btn-danger { color: var(--redpen); border-color: var(--line-strong); background: var(--panel); }
.btn-danger:hover { background: var(--redpen-tint); border-color: var(--redpen); }
.btn-ghost { border-color: transparent; background: none; }
.btn-ghost:hover { background: var(--hover-bg); border-color: transparent; }
.btn-sm { padding: 6px 12px; font-size: 0.83rem; border-radius: var(--radius-xs); }
.btn-lg { padding: 12px 24px; font-size: 0.98rem; border-radius: var(--radius); }
.btn-block { width: 100%; justify-content: center; }
/* ---------- icon button (min 36px touch target) ---------- */
.icon-btn {
border: 1px solid var(--line); background: var(--field-bg); border-radius: var(--radius-xs);
width: 36px; height: 36px; cursor: pointer; line-height: 1; flex: none;
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); }
.btn-danger { color: var(--redpen); border-color: var(--line-strong); } .icon-btn:disabled { background: var(--disabled-bg); color: var(--disabled-ink); border-color: var(--line); cursor: default; }
.btn-danger:hover { background: var(--redpen-tint); border-color: var(--redpen); box-shadow: none; } .icon-btn.danger:hover { border-color: var(--redpen); color: var(--redpen); background: var(--redpen-tint); }
.theme-toggle { width: 40px; height: 40px; border-radius: 9px; }
.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 ---------- */ /* ---------- forms ---------- */
label.field { display: block; margin-bottom: 14px; } label.field { display: block; margin-bottom: 16px; }
.field-label { display: block; font-weight: 600; font-size: 0.87rem; margin-bottom: 5px; color: var(--ink); letter-spacing: 0.01em; } .field-label {
.field-hint { font-size: 0.82rem; color: var(--ink-soft); margin-top: 5px; line-height: 1.5; } display: block; font-family: var(--font-mono); font-weight: 700; font-size: 0.7rem;
letter-spacing: 0.08em; text-transform: uppercase;
margin-bottom: 7px; color: var(--ink-soft);
}
.field-hint { font-size: 0.82rem; color: var(--ink-soft); margin-top: 6px; line-height: 1.5; }
input[type="text"], input[type="password"], input[type="number"], input[type="url"], select, textarea { input[type="text"], input[type="password"], input[type="number"], input[type="url"], select, textarea {
width: 100%; width: 100%; font: inherit; font-family: var(--font-body);
font: inherit; color: var(--ink); background: var(--field-bg);
font-family: var(--font-body); border: 1.5px solid var(--line-strong); border-radius: var(--radius-sm);
color: var(--ink);
background: var(--field-bg);
border: 1.5px solid var(--line-strong);
border-radius: var(--radius-sm);
padding: 9px 12px; padding: 9px 12px;
transition: border-color var(--transition), box-shadow var(--transition), background var(--transition); transition: border-color var(--transition), box-shadow var(--transition), background var(--transition);
} }
input:focus, select:focus, textarea:focus { input:focus, select:focus, textarea:focus {
border-color: var(--board); border-color: var(--board); outline: none; box-shadow: 0 0 0 3px var(--board-glow);
outline: none;
box-shadow: 0 0 0 3px var(--board-glow);
background: var(--field-bg);
} }
textarea { resize: vertical; min-height: 80px; } textarea { resize: vertical; min-height: 80px; line-height: 1.6; }
::placeholder { color: var(--ink-faint); }
.row { display: flex; gap: 14px; flex-wrap: wrap; } /* underline-only "paper form" input — used on Settings */
.ul-field { display: block; margin-bottom: 20px; }
.ul-input {
width: 100%; font: inherit; font-family: var(--font-body); font-size: 0.95rem;
color: var(--ink); background: transparent;
border: 0; border-bottom: 1.5px solid var(--line-strong);
border-radius: 0; padding: 8px 2px;
transition: border-color var(--transition);
}
.ul-input:focus { outline: none; border-bottom-color: var(--board); box-shadow: none; }
select.ul-input { padding-left: 0; }
.row { display: flex; gap: 18px; flex-wrap: wrap; }
.row > * { flex: 1; min-width: 180px; } .row > * { flex: 1; min-width: 180px; }
.check { .check {
display: flex; align-items: flex-start; gap: 10px; margin: 10px 0; cursor: pointer; display: flex; align-items: flex-start; gap: 11px; 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: 18px; height: 18px; margin-top: 2px; accent-color: var(--board); cursor: pointer; flex: none; }
.check input { width: 16px; height: 16px; margin-top: 3px; accent-color: var(--board); cursor: pointer; flex: none; }
.check span { font-size: 0.94rem; } .check span { font-size: 0.94rem; }
.check small { display: block; color: var(--ink-soft); } .check small { display: block; color: var(--ink-soft); margin-top: 1px; }
/* ---------- step tabs (Create flow) ---------- */ /* ---------- create flow: split workspace ---------- */
.steps { .create-split { display: flex; min-height: 100vh; align-items: stretch; }
display: flex; gap: 0; .create-source {
border-bottom: 1.5px solid var(--line); flex: 0 0 58%; max-width: 58%;
margin-bottom: 24px; background: var(--empty-bg); border-right: 1px solid var(--line);
overflow: hidden; padding: 36px 44px; display: flex; flex-direction: column;
} }
.step { .create-aside {
display: flex; align-items: center; gap: 10px; flex: 1; background: var(--paper);
padding: 12px 20px 13px; margin-bottom: -1.5px; padding: 36px 38px; display: flex; flex-direction: column;
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 { .source-textarea {
width: 24px; height: 24px; border-radius: 50%; flex: none; flex: 1; min-height: 360px; width: 100%; resize: none;
border: 2px solid currentColor; border: 0; outline: none; background: transparent; box-shadow: none;
font-family: var(--font-body); font-size: 1rem; line-height: 1.7; padding: 6px 0;
color: var(--ink);
}
.source-textarea:focus { box-shadow: none; }
/* vertical step tracker */
.vsteps { position: relative; }
.vsteps::before {
content: ""; position: absolute; left: 17px; top: 18px; bottom: 18px;
width: 2px; background: var(--line-strong);
}
.vstep { position: relative; display: flex; gap: 14px; padding-bottom: 22px; }
.vstep:last-child { padding-bottom: 0; }
.vstep-n {
width: 36px; height: 36px; flex: none; border-radius: 50%; z-index: 1;
display: inline-flex; align-items: center; justify-content: center; display: inline-flex; align-items: center; justify-content: center;
font-size: 0.78rem; font-weight: 700; font-weight: 700; font-size: 0.9rem;
transition: background var(--transition), border-color var(--transition), transform 0.2s ease; background: var(--paper); border: 2px solid var(--line-strong); color: var(--ink-soft);
transition: background var(--transition), border-color var(--transition), color var(--transition);
} }
.step.active { color: var(--board-deep); border-bottom-color: var(--board); font-weight: 600; } .vstep.active .vstep-n { background: var(--board); border-color: var(--board); color: #fff; box-shadow: 0 0 0 4px var(--board-tint); }
.step.done { color: var(--board); } html[data-theme="dark"] .vstep.active .vstep-n { color: #0d1512; }
.step.done .step-n { .vstep.done .vstep-n { background: var(--board); border-color: var(--board); color: #fff; animation: step-complete 0.3s ease; }
background: var(--board); border-color: var(--board); color: #fff; .vstep-title { font-family: var(--font-body); font-weight: 600; font-size: 0.98rem; color: var(--ink-soft); }
animation: step-complete 0.3s ease; .vstep.active .vstep-title { font-family: var(--font-display); font-weight: 700; color: var(--board-deep); font-size: 1rem; }
.vstep-desc { color: var(--ink-soft); font-size: 0.84rem; margin-top: 2px; }
.tip {
display: flex; gap: 11px; padding: 14px 15px; border-radius: var(--radius);
background: var(--board-tint); color: var(--board-deep); font-size: 0.86rem; line-height: 1.5;
} }
.step:disabled { cursor: default; opacity: 0.5; } .tip svg { flex: none; margin-top: 1px; }
/* ---------- choice cards ---------- */ /* ---------- choice cards ---------- */
.choice-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 10px; } .choice-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(168px, 1fr)); gap: 10px; }
.choice { .choice {
border: 1.5px solid var(--line-strong); border-radius: var(--radius); background: var(--field-bg); 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); 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; transition: border-color var(--transition), background var(--transition), transform var(--transition-fast);
} }
.choice:hover { border-color: var(--board); box-shadow: 0 2px 10px var(--board-glow); transform: translateY(-1px); } .choice:hover { border-color: var(--board); transform: translateY(-1px); }
.choice.selected { border-color: var(--board); background: var(--board-tint); box-shadow: 0 2px 10px var(--board-glow); } .choice.selected { border-color: var(--board); background: var(--board-tint); }
.choice b { display: block; font-size: 0.93rem; font-weight: 600; } .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; } .choice small { color: var(--ink-soft); font-size: 0.79rem; line-height: 1.35; display: block; margin-top: 4px; }
/* ---------- tabs (source input) ---------- */ /* ---------- segmented tabs (source input) ---------- */
.tabs { .tabs {
display: inline-flex; background: var(--tab-track); display: inline-flex; background: var(--tab-track);
border-radius: var(--radius-sm); padding: 3px; gap: 2px; margin-bottom: 16px; border-radius: var(--radius-sm); padding: 3px; gap: 2px; margin-bottom: 16px;
} }
.tab { .tab {
border: 0; background: none; font: inherit; font-family: var(--font-body); border: 0; background: none; font: inherit; font-family: var(--font-body);
font-weight: 600; font-size: 0.88rem; font-weight: 600; font-size: 0.87rem;
padding: 7px 16px; border-radius: 6px; cursor: pointer; color: var(--ink-soft); padding: 7px 16px; border-radius: 6px; cursor: pointer; color: var(--ink-soft);
transition: background var(--transition), color var(--transition), box-shadow var(--transition); transition: background var(--transition), color var(--transition);
}
.tab.active {
background: var(--panel); color: var(--ink);
box-shadow: var(--shadow-sm);
} }
.tab.active { background: var(--panel); color: var(--ink); box-shadow: var(--shadow-sm); }
/* ---------- badges & chips ---------- */ /* ---------- badges & chips ---------- */
.chip { .chip {
display: inline-flex; align-items: center; gap: 5px; display: inline-flex; align-items: center; gap: 5px;
font-size: 0.73rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; font-family: var(--font-mono); font-size: 0.68rem; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase;
padding: 3px 10px; border-radius: 99px; padding: 3px 9px; border-radius: 6px; flex: none;
background: var(--board-tint); color: var(--board-deep); background: var(--board-tint); color: var(--board-deep);
} }
.chip-pill { border-radius: 99px; }
.chip-neutral { background: var(--chip-neutral-bg); color: var(--ink-soft); } .chip-neutral { background: var(--chip-neutral-bg); color: var(--ink-soft); }
.chip-gold { background: var(--gold-tint); color: var(--gold-ink); }
.chip-red { background: var(--redpen-tint); color: var(--redpen); }
.stamp { .stamp {
display: inline-flex; align-items: center; gap: 5px; display: inline-flex; align-items: center; gap: 4px;
font-family: var(--font-mono); font-size: 0.71rem; font-weight: 700; font-family: var(--font-mono); font-size: 0.68rem; font-weight: 700;
letter-spacing: 0.08em; text-transform: uppercase; letter-spacing: 0.07em; text-transform: uppercase;
padding: 3px 8px; border: 1.5px solid currentColor; border-radius: 4px; padding: 3px 8px; border: 1.5px solid currentColor; border-radius: 4px;
transform: rotate(-1.2deg); transform: rotate(-1.2deg); flex: none;
} }
.stamp-pass { color: var(--board); background: rgba(47, 107, 88, 0.06); } .stamp-pass { color: var(--board); background: rgba(47, 107, 88, 0.07); }
.stamp-warn { color: var(--gold); background: var(--gold-tint); transform: rotate(1deg); } .stamp-warn { color: var(--gold); background: var(--gold-tint); transform: rotate(1deg); }
/* ---------- answer key (red pen) ---------- */ /* ---------- answer key (red pen) ---------- */
.answer-key { .answer-key {
margin-top: 14px; padding: 13px 15px; padding: 13px 15px;
background: var(--redpen-tint); background: var(--redpen-tint);
border-left: 3px solid var(--redpen); border-left: 3px solid var(--redpen);
border-radius: 0 var(--radius-sm) var(--radius-sm) 0; border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
} }
.answer-key + .answer-key { margin-top: 10px; }
.answer-key .ak-label { .answer-key .ak-label {
font-family: var(--font-mono); font-size: 0.69rem; font-weight: 700; font-family: var(--font-mono); font-size: 0.67rem; font-weight: 700;
letter-spacing: 0.1em; text-transform: uppercase; color: var(--redpen); letter-spacing: 0.1em; text-transform: uppercase; color: var(--redpen);
display: block; margin-bottom: 6px; display: block; margin-bottom: 7px;
} }
.answer-key, .answer-key textarea, .answer-key input { font-size: 0.92rem; } .answer-key textarea, .answer-key input[type="text"] { background: var(--field-bg); font-size: 0.9rem; }
.redpen { color: var(--redpen); font-weight: 600; } .redpen { color: var(--redpen); font-weight: 600; }
/* ---------- question cards ---------- */ /* ---------- editor: continuous document ---------- */
.qcard { position: relative; } .doc { overflow: hidden; } /* a .panel that holds the question rows */
.qcard-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 14px; } .qrow {
.qnum { font-family: var(--font-display); font-size: 1.15rem; font-weight: 700; color: var(--board-deep); min-width: 28px; } display: grid;
.qcard-actions { margin-left: auto; display: flex; gap: 6px; flex-wrap: wrap; } grid-template-columns: 44px minmax(0, 1fr) minmax(300px, 360px);
.icon-btn { gap: 22px;
border: 1px solid var(--line); background: var(--field-bg); border-radius: 7px; padding: 24px 26px;
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); } .qrow + .qrow { border-top: 1px solid var(--line); }
.icon-btn:disabled { opacity: 0.3; cursor: default; transform: none; } .qrow.dragging { opacity: 0.4; }
.icon-btn.danger:hover { border-color: var(--redpen); color: var(--redpen); background: var(--redpen-tint); } .qrow.drag-over { background: var(--board-tint); }
.qrail { display: flex; flex-direction: column; align-items: center; gap: 8px; padding-top: 2px; }
.qgrip {
color: var(--ink-faint); cursor: grab; display: inline-flex;
border: 0; background: none; padding: 2px; border-radius: 4px;
transition: color var(--transition), background var(--transition);
}
.qgrip:hover { color: var(--board); background: var(--hover-bg); }
.qgrip:active { cursor: grabbing; }
.qnum { font-family: var(--font-display); font-size: 1.2rem; font-weight: 700; color: var(--board-deep); }
/* editable assignment title — clear affordance */
.title-edit { position: relative; display: flex; align-items: center; gap: 8px; margin-left: -8px; }
.title-edit input {
font-family: var(--font-display); font-size: 1.75rem; font-weight: 700; color: var(--ink);
border: 1.5px solid transparent; background: transparent; padding: 4px 8px;
border-radius: var(--radius-sm); width: 100%; border-bottom: 1.5px dashed var(--line-strong);
transition: background var(--transition), border-color var(--transition);
}
.title-edit input:hover { background: var(--hover-bg); }
.title-edit input:focus { outline: none; background: var(--field-bg); border-color: var(--board); border-bottom-style: solid; box-shadow: 0 0 0 3px var(--board-glow); }
.title-edit .pen { color: var(--ink-faint); flex: none; opacity: 0; transition: opacity var(--transition); pointer-events: none; }
.title-edit:hover .pen { opacity: 1; }
.qmain { min-width: 0; }
.qmargin { min-width: 0; }
.qcard-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; }
.qcard-actions { margin-left: auto; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.q-prompt { font-size: 1.02rem; }
.opt-row { display: flex; align-items: center; gap: 9px; margin: 7px 0; } .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-row input[type="radio"] { accent-color: var(--redpen); width: 17px; height: 17px; flex: none; cursor: pointer; }
.opt-letter { font-weight: 700; font-size: 0.84rem; color: var(--ink-soft); width: 18px; flex: none; } .opt-letter { font-weight: 700; font-size: 0.82rem; color: var(--ink-soft); width: 18px; flex: none; }
.points-input { width: 64px !important; text-align: center; } .points-input { width: 56px !important; text-align: center; padding: 6px 4px !important; }
/* ---------- alerts & toasts ---------- */ @media (max-width: 1180px) {
.qrow { grid-template-columns: 44px minmax(0, 1fr); }
.qmargin { grid-column: 1 / -1; padding-left: 22px; }
}
/* ---------- alerts & toasts (tokenized) ---------- */
.alert { .alert {
padding: 13px 16px; border-radius: var(--radius-sm); font-size: 0.92rem; padding: 13px 16px; border-radius: var(--radius-sm); font-size: 0.92rem;
margin: 14px 0; border: 1px solid; margin: 14px 0; border: 1px solid; display: flex; gap: 9px; align-items: flex-start;
animation: fade-in 0.2s ease; animation: fade-in 0.2s ease;
} }
.alert-error { background: var(--redpen-tint); border-color: #e8c5be; color: #8c3022; } .alert svg { flex: none; margin-top: 1px; }
.alert-warn { background: var(--gold-tint); border-color: #e9d8a6; color: #7a5a14; } .alert-error { background: var(--alert-error-bg); border-color: var(--alert-error-border); color: var(--alert-error-ink); }
.alert-info { background: var(--board-tint); border-color: #cde0d8; color: var(--board-deep); } .alert-warn { background: var(--alert-warn-bg); border-color: var(--alert-warn-border); color: var(--alert-warn-ink); }
.alert-info { background: var(--alert-info-bg); border-color: var(--alert-info-border); color: var(--alert-info-ink); }
.toast { .toast {
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
background: var(--ink); color: #fff; background: var(--ink); color: var(--panel);
padding: 11px 22px; border-radius: 99px; padding: 11px 22px; border-radius: 99px; font-size: 0.91rem; font-weight: 600;
font-size: 0.91rem; font-weight: 600; box-shadow: var(--shadow-modal); z-index: 100;
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; animation: slide-up 0.22s cubic-bezier(0.34, 1.56, 0.64, 1) both;
white-space: nowrap; white-space: nowrap;
} }
@@ -451,10 +583,8 @@
/* ---------- spinner ---------- */ /* ---------- spinner ---------- */
.spinner { .spinner {
width: 16px; height: 16px; border-radius: 50%; flex: none; width: 16px; height: 16px; border-radius: 50%; flex: none;
border: 2px solid rgba(47, 107, 88, 0.22); border: 2px solid var(--board-glow); border-top-color: var(--board);
border-top-color: var(--board); animation: spin 0.7s linear infinite; display: inline-block; vertical-align: -3px;
animation: spin 0.7s linear infinite;
display: inline-block; vertical-align: -3px;
} }
/* ---------- generation progress ---------- */ /* ---------- generation progress ---------- */
@@ -463,75 +593,150 @@
display: flex; align-items: center; gap: 12px; display: flex; align-items: center; gap: 12px;
padding: 11px 0; font-size: 0.97rem; color: var(--ink-soft); padding: 11px 0; font-size: 0.97rem; color: var(--ink-soft);
border-bottom: 1px solid var(--line); 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: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.active { color: var(--ink); font-weight: 600; }
.progress-list li.done { color: var(--board); } .progress-list li.done { color: var(--board); }
.progress-dot { width: 20px; text-align: center; flex: none; font-size: 1rem; } .progress-dot { width: 20px; text-align: center; flex: none; }
/* ---------- library ---------- */ /* ---------- library: search + filters ---------- */
.lib-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(276px, 1fr)); gap: 16px; } .lib-toolbar { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; margin-bottom: 28px; }
.lib-card { animation: fade-in-up 0.35s ease both; } .search-wrap { position: relative; flex: 1; max-width: 480px; min-width: 240px; }
.lib-card:nth-child(2) { animation-delay: 0.06s; } .search-wrap svg { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); color: var(--ink-soft); }
.lib-card:nth-child(3) { animation-delay: 0.12s; } .search-wrap input { padding-left: 38px; }
.lib-card:nth-child(4) { animation-delay: 0.18s; } .fchip {
.lib-card:nth-child(5) { animation-delay: 0.24s; } font-size: 0.79rem; font-weight: 600; padding: 7px 14px; border-radius: 99px; cursor: pointer;
.lib-card:nth-child(6) { animation-delay: 0.30s; } border: 1.5px solid var(--line-strong); background: var(--panel); color: var(--ink-soft);
.lib-card h3 { margin-bottom: 6px; } transition: border-color var(--transition), color var(--transition), background var(--transition);
.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;
} }
.fchip:hover { border-color: var(--board); color: var(--board-deep); }
.fchip.active { border-color: var(--board); background: var(--board-tint); color: var(--board-deep); }
/* grouped library lists */
.lib-group { margin-bottom: 32px; }
.lib-group-head {
display: flex; align-items: center; gap: 11px;
padding-bottom: 10px; margin-bottom: 14px; border-bottom: 1px solid var(--line);
}
.lib-group-head h2 { font-size: 1.3rem; }
.lib-rows { overflow: hidden; } /* a .panel */
.lib-row { display: flex; align-items: center; gap: 16px; padding: 15px 18px; transition: background var(--transition); }
.lib-row + .lib-row { border-top: 1px solid var(--line); }
.lib-row:hover { background: var(--hover-bg); }
.lib-row-title { font-family: var(--font-display); font-weight: 600; font-size: 1.02rem; color: var(--ink); cursor: pointer; }
.lib-row-title:hover { color: var(--board-deep); text-decoration: underline; text-underline-offset: 2px; }
.lib-meta { color: var(--ink-soft); font-size: 0.83rem; margin: 3px 0 0; }
.lib-actions { display: flex; align-items: center; gap: 8px; flex: none; }
/* skeletons */
.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.short { width: 55%; }
.skeleton-line.medium { width: 75%; } .skeleton-line.medium { width: 75%; }
.skeleton-line.full { width: 100%; } .skeleton-line.full { width: 100%; }
.skeleton-chip { background: var(--line); border-radius: 99px; height: 20px; width: 64px; margin-bottom: 12px; } .skeleton-chip { background: var(--line); border-radius: 6px; height: 20px; width: 56px; margin-bottom: 12px; }
.empty { .empty {
text-align: center; padding: 60px 24px; color: var(--ink-soft); text-align: center; padding: 64px 24px; color: var(--ink-soft);
border: 1.5px dashed var(--line-strong); border-radius: var(--radius); border: 1.5px dashed var(--line-strong); border-radius: var(--radius);
background: var(--empty-bg); background: var(--empty-bg);
animation: fade-in 0.3s ease;
} }
.empty h3 { color: var(--ink); margin-bottom: 8px; } .empty h3 { color: var(--ink); margin-bottom: 8px; }
/* ---------- provider cards on settings ---------- */ /* ---------- settings: flush two-column ---------- */
.provider-row { .settings-grid { display: flex; gap: 48px; align-items: flex-start; }
display: flex; align-items: center; gap: 12px; .settings-toc {
padding: 13px 15px; border: 1.5px solid var(--line-strong); width: 210px; flex: none; position: sticky; top: 36px;
border-radius: var(--radius); cursor: pointer; background: var(--field-bg); display: flex; flex-direction: column; gap: 1px;
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); } .toc-link {
.provider-row.selected { border-color: var(--board); background: var(--board-tint); box-shadow: 0 2px 8px var(--board-glow); } display: flex; align-items: center; gap: 9px; padding: 8px 12px;
.provider-row input { accent-color: var(--board); width: 17px; height: 17px; flex: none; } border-left: 2px solid transparent; color: var(--ink-soft);
.provider-row b { font-size: 0.96rem; } font-size: 0.87rem; font-weight: 500; cursor: pointer; text-decoration: none;
transition: color var(--transition), background var(--transition);
}
.toc-link:hover { color: var(--ink); background: var(--hover-bg); text-decoration: none; }
.toc-link.active { color: var(--board-deep); border-left-color: var(--board); font-weight: 600; }
.settings-body { flex: 1; min-width: 0; }
.sec { padding: 34px 0; border-top: 1px solid var(--line); }
.sec:first-child { border-top: 0; padding-top: 4px; }
.sec > h2 { margin-bottom: 6px; }
.sec-hint { color: var(--ink-soft); font-size: 0.9rem; margin: 0 0 20px; max-width: 70ch; }
/* provider list — flat rows, not nested cards */
.provider-list { border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; }
.provider-row {
display: flex; align-items: center; gap: 13px; padding: 14px 16px;
cursor: pointer; background: var(--panel); border-left: 3px solid transparent;
transition: background var(--transition);
}
.provider-row + .provider-row { border-top: 1px solid var(--line); }
.provider-row:hover { background: var(--hover-bg); }
.provider-row.selected { background: var(--board-tint); border-left-color: var(--board); }
.provider-row input { accent-color: var(--board); width: 18px; height: 18px; flex: none; }
.provider-row b { font-size: 0.95rem; }
.provider-row .selected & b { color: var(--board-deep); }
.provider-row small { color: var(--ink-soft); display: block; } .provider-row small { color: var(--ink-soft); display: block; }
.local-tag { margin-left: auto; } .local-tag { margin-left: auto; }
/* slider */
input[type="range"] { accent-color: var(--board); }
/* ---------- modal ---------- */
.modal-scrim {
position: fixed; inset: 0; z-index: 100; background: rgba(20, 30, 26, 0.5);
display: flex; align-items: flex-start; justify-content: center;
padding: 40px 16px; overflow-y: auto; animation: fade-in 0.15s ease;
}
.modal {
background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius);
box-shadow: var(--shadow-modal); animation: fade-in-up 0.18s ease both;
}
/* ---------- misc helpers ---------- */ /* ---------- misc helpers ---------- */
.sr-only {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
}
.muted { color: var(--ink-soft); } .muted { color: var(--ink-soft); }
.faint { color: var(--ink-faint); }
.small { font-size: 0.84rem; } .small { font-size: 0.84rem; }
.spacer { flex: 1; } .spacer { flex: 1; }
.hr { border: 0; border-top: 1px solid var(--line); margin: 20px 0; } .hr { border: 0; border-top: 1px solid var(--line); margin: 20px 0; }
code { font-family: var(--font-mono); font-size: 0.88em; background: var(--chip-neutral-bg); padding: 1px 5px; border-radius: 4px; }
@media (max-width: 640px) { /* =====================================================================
.shell { padding: 20px 15px 74px; } RESPONSIVE — collapse the sidebar into a top bar on small screens
.topnav-inner { gap: 10px; padding: 0 15px; } ===================================================================== */
.brand span.brand-text { display: none; } @media (max-width: 880px) {
.card { padding: 18px; } .app { flex-direction: column; }
.steps { overflow-x: auto; } .sidebar {
h1 { font-size: 1.5rem; } width: 100%; height: auto; position: sticky; top: 0;
flex-direction: row; align-items: center; gap: 6px;
border-right: 0; border-bottom: 1px solid var(--line);
padding: 0 12px;
}
.sidebar-brand { padding: 12px 6px; }
.brand-sub { display: none; }
.sidebar-nav { flex-direction: row; padding: 0; margin-left: 8px; }
.navrow { height: 44px; }
.navrow span.navrow-label { display: none; }
.sidebar-scroll { display: none; } /* tree hidden on mobile; Library page covers it */
.sidebar-foot { margin-left: auto; border-top: 0; padding: 8px 0; }
.sidebar-foot .btn-block { width: auto; }
.page { padding: 24px 18px 80px; }
.create-split { flex-direction: column; min-height: 0; }
.create-source, .create-aside { flex: 1 1 auto; max-width: 100%; border-right: 0; }
.create-source { border-bottom: 1px solid var(--line); }
.settings-grid { flex-direction: column; gap: 18px; }
.settings-toc { position: static; flex-direction: row; flex-wrap: wrap; width: 100%; gap: 4px; }
.toc-link { border-left: 0; border-bottom: 2px solid transparent; }
.toc-link.active { border-left: 0; border-bottom-color: var(--board); }
}
@media (max-width: 560px) {
.page { padding: 20px 14px 72px; }
.qrow { padding: 18px 14px; gap: 14px; }
h1 { font-size: 1.6rem; }
} }
} }
+5 -3
View File
@@ -1,5 +1,5 @@
import "./globals.css"; import "./globals.css";
import Nav from "@/components/Nav"; import Sidebar from "@/components/Sidebar";
export const metadata = { export const metadata = {
title: "Mr. Drew's Assignment Creator", title: "Mr. Drew's Assignment Creator",
@@ -19,8 +19,10 @@ export default function RootLayout({ children }) {
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" /> <link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
</head> </head>
<body> <body>
<Nav /> <div className="app">
<main className="shell">{children}</main> <Sidebar />
<main className="content">{children}</main>
</div>
</body> </body>
</html> </html>
); );
+82 -78
View File
@@ -1,29 +1,38 @@
"use client"; "use client";
// app/library/page.jsx — everything you've made, saved locally in data/db.json. // app/library/page.jsx — everything you've made, grouped by subject, saved locally.
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { IconSearch, IconPlus, IconCopy, IconTrash, IconArrowRight } from "@tabler/icons-react";
import { groupBySubject } from "@/lib/group";
const TYPE_LABELS = { const TYPE_LABELS = {
quiz: "Quiz", quiz: "Quiz", test: "Test", worksheet: "Worksheet", discussion: "Discussion", case_study: "Case study",
test: "Test",
worksheet: "Worksheet",
discussion: "Discussion",
case_study: "Case study",
}; };
const TYPE_CHIP = {
quiz: "chip", test: "chip chip-red", worksheet: "chip chip-gold", discussion: "chip chip-neutral", case_study: "chip chip-neutral",
};
const FILTERS = [
{ id: "all", label: "All" },
{ id: "quiz", label: "Quizzes" },
{ id: "test", label: "Tests" },
{ id: "worksheet", label: "Worksheets" },
];
function SkeletonCard() { function SkeletonGroup() {
return ( return (
<div className="card skeleton-card" aria-hidden="true"> <div className="lib-group">
<div className="skeleton-chip" /> <div className="skeleton-line short" style={{ height: 22, marginBottom: 14 }} />
<div className="skeleton-line medium" /> <div className="panel lib-rows skeleton-card">
<div className="skeleton-line short" style={{ marginBottom: 18 }} /> {[0, 1].map((i) => (
<div className="skeleton-line full" /> <div key={i} className="lib-row" style={{ borderTop: i ? "1px solid var(--line)" : "none" }}>
<div className="skeleton-line" style={{ width: "40%", marginBottom: 16 }} /> <div className="skeleton-chip" style={{ marginBottom: 0 }} />
<div style={{ display: "flex", gap: 7 }}> <div style={{ flex: 1 }}>
<div className="skeleton-line" style={{ width: 64, height: 30, borderRadius: 7, marginBottom: 0 }} /> <div className="skeleton-line medium" style={{ marginBottom: 6 }} />
<div className="skeleton-line" style={{ width: 80, height: 30, borderRadius: 7, marginBottom: 0 }} /> <div className="skeleton-line short" style={{ marginBottom: 0 }} />
<div className="skeleton-line" style={{ width: 64, height: 30, borderRadius: 7, marginBottom: 0 }} /> </div>
</div>
))}
</div> </div>
</div> </div>
); );
@@ -33,6 +42,7 @@ export default function LibraryPage() {
const router = useRouter(); const router = useRouter();
const [items, setItems] = useState(null); const [items, setItems] = useState(null);
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [filter, setFilter] = useState("all");
const [error, setError] = useState(""); const [error, setError] = useState("");
const [busy, setBusy] = useState(""); const [busy, setBusy] = useState("");
@@ -45,70 +55,53 @@ export default function LibraryPage() {
useEffect(load, []); useEffect(load, []);
async function duplicate(id) { async function duplicate(id) {
setBusy(id); setBusy(id); setError("");
setError("");
try { try {
const res = await fetch("/api/assignments/" + id); const res = await fetch("/api/assignments/" + id);
const full = await res.json(); const full = await res.json();
if (!res.ok) throw new Error(full.error || "Could not load that assignment."); if (!res.ok) throw new Error(full.error || "Could not load that assignment.");
const { id: _id, createdAt, updatedAt, ...copy } = full; const { id: _id, createdAt, updatedAt, ...copy } = full;
copy.title = (copy.title || "Untitled") + " (copy)"; copy.title = (copy.title || "Untitled") + " (copy)";
const res2 = await fetch("/api/assignments", { const res2 = await fetch("/api/assignments", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(copy) });
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(copy),
});
const created = await res2.json(); const created = await res2.json();
if (!res2.ok) throw new Error(created.error || "Could not duplicate."); if (!res2.ok) throw new Error(created.error || "Could not duplicate.");
load(); load();
} catch (e) { } catch (e) { setError(String(e.message || e)); }
setError(String(e.message || e)); finally { setBusy(""); }
} finally {
setBusy("");
}
} }
async function remove(id, title) { async function remove(id, title) {
if (!confirm(`Delete "${title}"? This can't be undone.`)) return; if (!confirm(`Delete "${title}"? This can't be undone.`)) return;
setBusy(id); setBusy(id);
try { try { await fetch("/api/assignments/" + id, { method: "DELETE" }); load(); }
await fetch("/api/assignments/" + id, { method: "DELETE" }); finally { setBusy(""); }
load();
} finally {
setBusy("");
}
} }
const filtered = (items || []).filter((a) => { const filtered = useMemo(() => {
const q = query.trim().toLowerCase(); const q = query.trim().toLowerCase();
return (items || []).filter((a) => {
if (filter !== "all" && a.assignmentType !== filter) return false;
if (!q) return true; if (!q) return true;
return [a.title, a.subject, a.gradeLevel, TYPE_LABELS[a.assignmentType]] return [a.title, a.subject, a.gradeLevel, TYPE_LABELS[a.assignmentType]]
.filter(Boolean) .filter(Boolean).join(" ").toLowerCase().includes(q);
.join(" ")
.toLowerCase()
.includes(q);
}); });
}, [items, query, filter]);
const groups = useMemo(() => groupBySubject(filtered), [filtered]);
return ( return (
<div> <div className="page page-narrow">
<div className="page-head" style={{ display: "flex", alignItems: "center", gap: 14, flexWrap: "wrap" }}> <div className="page-head" style={{ display: "flex", alignItems: "flex-start", gap: 14, flexWrap: "wrap", marginBottom: 22 }}>
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<h1>Library</h1> <h1>Library</h1>
<p>Everything you&rsquo;ve created, stored locally on this computer.</p> <p>Everything you&rsquo;ve created, stored locally on this computer.</p>
</div> </div>
<Link href="/" className="btn btn-primary"> New assignment</Link> <Link href="/" className="btn btn-primary"><IconPlus size={17} /> New assignment</Link>
</div> </div>
{error && <div className="alert alert-error">{error}</div>} {error && <div className="alert alert-error">{error}</div>}
{/* Skeleton loading state */} {items === null && (<><SkeletonGroup /><SkeletonGroup /></>)}
{items === null && (
<div className="lib-grid">
<SkeletonCard />
<SkeletonCard />
<SkeletonCard />
</div>
)}
{items !== null && items.length === 0 && ( {items !== null && items.length === 0 && (
<div className="empty"> <div className="empty">
@@ -120,36 +113,50 @@ export default function LibraryPage() {
{items !== null && items.length > 0 && ( {items !== null && items.length > 0 && (
<> <>
<input <div className="lib-toolbar">
type="text" <div className="search-wrap">
placeholder="Search by title, subject, grade, or type…" <IconSearch size={16} />
value={query} <input type="text" placeholder="Search by title, subject, grade, or type…" value={query} onChange={(e) => setQuery(e.target.value)} aria-label="Search library" />
onChange={(e) => setQuery(e.target.value)} </div>
style={{ marginBottom: 18, maxWidth: 440 }} <div style={{ display: "flex", gap: 7, flexWrap: "wrap" }}>
aria-label="Search library" {FILTERS.map((f) => (
/> <button key={f.id} className={`fchip${filter === f.id ? " active" : ""}`} onClick={() => setFilter(f.id)}>{f.label}</button>
{filtered.length === 0 && <p className="muted">No matches for &ldquo;{query}&rdquo;.</p>} ))}
<div className="lib-grid"> </div>
{filtered.map((a) => ( </div>
<div key={a.id} className="card lib-card card-lift">
<span className="chip">{TYPE_LABELS[a.assignmentType] || a.assignmentType}</span> {groups.length === 0 && <p className="muted">No matches{query ? <> for &ldquo;{query}&rdquo;</> : ""}.</p>}
<h3 style={{ marginTop: 10 }}>
<Link href={"/editor/" + a.id} style={{ color: "inherit" }}>{a.title}</Link> {groups.map((g) => (
</h3> <section key={g.key} className="lib-group">
<div className="lib-group-head">
<h2>{g.key}</h2>
<span className="chip chip-pill chip-neutral">{g.items.length}</span>
</div>
<div className="panel lib-rows">
{g.items.map((a) => (
<div key={a.id} className="lib-row">
<span className={TYPE_CHIP[a.assignmentType] || "chip chip-neutral"}>{TYPE_LABELS[a.assignmentType] || a.assignmentType}</span>
<div style={{ flex: 1, minWidth: 0 }}>
<Link href={"/editor/" + a.id} className="lib-row-title" style={{ display: "block" }}>{a.title}</Link>
<p className="lib-meta"> <p className="lib-meta">
{[a.gradeLevel, a.subject].filter(Boolean).join(" · ")}<br /> {[a.gradeLevel, a.subject].filter(Boolean).join(" · ")}
{(a.gradeLevel || a.subject) ? " · " : ""}
{a.questionCount} question{a.questionCount === 1 ? "" : "s"} · {a.totalPoints} pts · updated {formatDate(a.updatedAt)} {a.questionCount} question{a.questionCount === 1 ? "" : "s"} · {a.totalPoints} pts · updated {formatDate(a.updatedAt)}
</p> </p>
</div>
<div className="lib-actions"> <div className="lib-actions">
<button className="btn btn-sm btn-primary" onClick={() => router.push("/editor/" + a.id)}>Open</button> <button className="btn btn-sm btn-primary" onClick={() => router.push("/editor/" + a.id)}><IconArrowRight size={14} /> Open</button>
<button className="btn btn-sm" disabled={busy === a.id} onClick={() => duplicate(a.id)}> <button className="icon-btn" title="Duplicate" aria-label="Duplicate" disabled={busy === a.id} onClick={() => duplicate(a.id)}>
{busy === a.id ? <span className="spinner" /> : "Duplicate"} {busy === a.id ? <span className="spinner" /> : <IconCopy size={16} />}
</button> </button>
<button className="btn btn-sm btn-danger" disabled={busy === a.id} onClick={() => remove(a.id, a.title)}>Delete</button> <button className="icon-btn danger" title="Delete" aria-label="Delete" disabled={busy === a.id} onClick={() => remove(a.id, a.title)}><IconTrash size={16} /></button>
</div> </div>
</div> </div>
))} ))}
</div> </div>
</section>
))}
</> </>
)} )}
</div> </div>
@@ -157,9 +164,6 @@ export default function LibraryPage() {
} }
function formatDate(iso) { function formatDate(iso) {
try { try { return new Date(iso).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" }); }
return new Date(iso).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" }); catch { return ""; }
} catch {
return "";
}
} }
+155 -186
View File
@@ -1,12 +1,20 @@
"use client"; "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 { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation"; 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"; import { ASSIGNMENT_TYPES, QUESTION_TYPES, GRADE_LEVELS, DIFFICULTIES } from "@/lib/schema";
const MAX_PASTE = 120000; const MAX_PASTE = 120000;
const STEPS = [
// Maps a generation phase to its 0-based order index { 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"]; const PHASE_ORDER = ["analyze", "generate", "verify", "save"];
function ProgressStep({ phase, label, currentPhase, hasVerify }) { function ProgressStep({ phase, label, currentPhase, hasVerify }) {
@@ -15,9 +23,9 @@ function ProgressStep({ phase, label, currentPhase, hasVerify }) {
const me = PHASE_ORDER.indexOf(phase); const me = PHASE_ORDER.indexOf(phase);
const state = me < cur ? "done" : me === cur ? "active" : ""; const state = me < cur ? "done" : me === cur ? "active" : "";
return ( return (
<li className={state} style={{ animationDelay: `${me * 0.08}s` }}> <li className={state}>
<span className="progress-dot"> <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> </span>
{label}{state === "active" ? "…" : ""} {label}{state === "active" ? "…" : ""}
</li> </li>
@@ -58,13 +66,9 @@ export default function CreatePage() {
.then((r) => r.json()) .then((r) => r.json())
.then((s) => { .then((s) => {
const cfg = s.providers?.[s.provider] || {}; const cfg = s.providers?.[s.provider] || {};
if (!cfg.model) { if (!cfg.model) setProviderNote({ provider: s.provider, missing: "model" });
setProviderNote({ provider: s.provider, missing: "model" }); else if (["openai", "anthropic", "google"].includes(s.provider) && !cfg.apiKey) setProviderNote({ provider: s.provider, missing: "key" });
} else if (["openai", "anthropic", "google"].includes(s.provider) && !cfg.apiKey) { else setProviderNote(null);
setProviderNote({ provider: s.provider, missing: "key" });
} else {
setProviderNote(null);
}
setConfig((c) => ({ ...c, verify: s.generation?.verification !== false })); setConfig((c) => ({ ...c, verify: s.generation?.verification !== false }));
}) })
.catch(() => {}); .catch(() => {});
@@ -74,9 +78,7 @@ export default function CreatePage() {
const [genState, setGenState] = useState(null); const [genState, setGenState] = useState(null);
const generating = !!genState && !genState.error; const generating = !!genState && !genState.error;
function update(patch) { function update(patch) { setConfig((c) => ({ ...c, ...patch })); }
setConfig((c) => ({ ...c, ...patch }));
}
function toggleQType(id) { function toggleQType(id) {
setConfig((c) => { setConfig((c) => {
@@ -99,9 +101,7 @@ export default function CreatePage() {
setText(content.slice(0, MAX_PASTE)); setText(content.slice(0, MAX_PASTE));
setSourceName(file.name); setSourceName(file.name);
setSourceTab("upload"); setSourceTab("upload");
} catch { } catch { setError("Could not read that file."); }
setError("Could not read that file.");
}
} }
async function fetchUrl() { async function fetchUrl() {
@@ -117,17 +117,19 @@ export default function CreatePage() {
if (!res.ok) throw new Error(data.error || "Could not fetch that page."); if (!res.ok) throw new Error(data.error || "Could not fetch that page.");
setText(data.text.slice(0, MAX_PASTE)); setText(data.text.slice(0, MAX_PASTE));
setSourceName(data.title || url); setSourceName(data.title || url);
} catch (e) { } catch (e) { setError(String(e.message || e)); }
setError(String(e.message || e)); finally { setFetching(false); }
} finally {
setFetching(false);
}
} }
const sourceReady = text.trim().length >= 100; const sourceReady = text.trim().length >= 100;
const configReady = const configReady =
config.subject.trim().length > 0 && config.subject.trim().length > 0 &&
(["discussion", "case_study"].includes(config.assignmentType) || config.questionTypes.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() { async function generate() {
setGenState({ phase: "analyze" }); setGenState({ phase: "analyze" });
@@ -138,9 +140,7 @@ export default function CreatePage() {
try { try {
const r1 = await postJson("/api/generate", { stage: "analyze", source, config: cfg }); const r1 = await postJson("/api/generate", { stage: "analyze", source, config: cfg });
analysis = r1.analysis; analysis = r1.analysis;
} catch (e) { } catch (e) { console.warn("Analysis stage failed, continuing:", e); }
console.warn("Analysis stage failed, continuing:", e);
}
setGenState({ phase: "generate" }); setGenState({ phase: "generate" });
const r2 = await postJson("/api/generate", { stage: "generate", source, analysis, config: cfg }); const r2 = await postJson("/api/generate", { stage: "generate", source, analysis, config: cfg });
@@ -149,18 +149,11 @@ export default function CreatePage() {
if (cfg.verify) { if (cfg.verify) {
setGenState({ phase: "verify" }); setGenState({ phase: "verify" });
try { try {
const r3 = await postJson("/api/generate", { const r3 = await postJson("/api/generate", { stage: "verify", source, config: cfg, questions: assignment.questions });
stage: "verify",
source,
config: cfg,
questions: assignment.questions,
});
for (const q of assignment.questions) { for (const q of assignment.questions) {
if (r3.verifications[q.id]) q.verification = r3.verifications[q.id]; if (r3.verifications[q.id]) q.verification = r3.verifications[q.id];
} }
} catch (e) { } catch (e) { console.warn("Verification stage failed, continuing:", e); }
console.warn("Verification stage failed, continuing:", e);
}
} }
setGenState({ phase: "save" }); setGenState({ phase: "save" });
@@ -175,60 +168,22 @@ export default function CreatePage() {
} }
} }
const isDiscussionOrCase = ["discussion", "case_study"].includes(config.assignmentType);
return ( return (
<div> <div className="create-split">
<div className="page-head"> <h1 className="sr-only">Create an assignment</h1>
<h1>Create an assignment</h1> {/* ============ LEFT: current step content ============ */}
<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> <section className="create-source">
</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 && ( {step === 0 && (
<div className="card"> <>
<span className="field-label" style={{ color: "var(--board)", marginBottom: 8 }}>Step 1 · Source</span>
<h2>What should the questions come from?</h2> <h2>What should the questions come from?</h2>
<p className="muted small" style={{ margin: "6px 0 16px" }}> <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. Questions are grounded strictly in this material the AI is instructed not to add outside facts.
</p> </p>
<div className="tabs"> <div className="tabs" style={{ marginTop: 20 }}>
{[["paste", "Paste text"], ["upload", "Upload file"], ["url", "From a web page"]].map(([id, label]) => ( {[["paste", "Paste text"], ["upload", "Upload file"], ["url", "From a web page"]].map(([id, label]) => (
<button <button key={id} className={`tab${sourceTab === id ? " active" : ""}`} onClick={() => { setSourceTab(id); setError(""); }}>
key={id}
className={`tab${sourceTab === id ? " active" : ""}`}
onClick={() => { setSourceTab(id); setError(""); }}
>
{label} {label}
</button> </button>
))} ))}
@@ -244,65 +199,47 @@ export default function CreatePage() {
{sourceTab === "url" && ( {sourceTab === "url" && (
<div style={{ display: "flex", gap: 10, marginBottom: 14, flexWrap: "wrap" }}> <div style={{ display: "flex", gap: 10, marginBottom: 14, flexWrap: "wrap" }}>
<input <input type="url" placeholder="https://example.com/article" value={url}
type="url"
placeholder="https://example.com/article"
value={url}
onChange={(e) => setUrl(e.target.value)} onChange={(e) => setUrl(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter" && url.trim()) fetchUrl(); }} onKeyDown={(e) => { if (e.key === "Enter" && url.trim()) fetchUrl(); }}
style={{ flex: 1, minWidth: 240 }} style={{ flex: 1, minWidth: 240 }} />
/>
<button className="btn btn-primary" onClick={fetchUrl} disabled={fetching || !url.trim()}> <button className="btn btn-primary" onClick={fetchUrl} disabled={fetching || !url.trim()}>
{fetching ? <><span className="spinner" /> Fetching</> : "Fetch page"} {fetching ? <><span className="spinner" /> Fetching</> : "Fetch page"}
</button> </button>
</div> </div>
)} )}
<div style={{ flex: 1, display: "flex", flexDirection: "column", marginTop: sourceTab === "paste" ? 4 : 0 }}>
<textarea <textarea
className="source-textarea"
value={text} value={text}
onChange={(e) => { setText(e.target.value.slice(0, MAX_PASTE)); if (sourceTab === "paste") setSourceName(""); }} onChange={(e) => { setText(e.target.value.slice(0, MAX_PASTE)); if (sourceTab === "paste") setSourceName(""); }}
placeholder={ placeholder={sourceTab === "paste"
sourceTab === "paste"
? "Paste your reading passage, chapter, article, or lecture notes here…" ? "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." : "The file or page content will appear here — you can trim or edit it before generating."}
}
rows={12}
aria-label="Source material" aria-label="Source material"
/> />
<div className="small muted" style={{ display: "flex", marginTop: 7 }}> <div className="small muted" style={{ display: "flex", alignItems: "center", marginTop: 12, paddingTop: 12, borderTop: "1px solid var(--line)" }}>
<span> <span>{text.length.toLocaleString()} / {MAX_PASTE.toLocaleString()} characters{sourceReady ? "" : " — at least 100 needed"}</span>
{text.length.toLocaleString()} / {MAX_PASTE.toLocaleString()} characters
{sourceReady ? "" : " — at least 100 needed"}
</span>
<span className="spacer" /> <span className="spacer" />
{text && <button className="btn btn-sm" onClick={() => { setText(""); setSourceName(""); }}>Clear</button>} {text && <button className="btn btn-sm" onClick={() => { setText(""); setSourceName(""); }}>Clear</button>}
</div> </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> </div>
{error && <div className="alert alert-error"><IconAlertTriangle size={17} /> <span>{error}</span></div>}
</>
)} )}
{/* ============ STEP 2: CONFIGURE ============ */}
{step === 1 && ( {step === 1 && (
<div className="card"> <>
<span className="field-label" style={{ color: "var(--board)", marginBottom: 8 }}>Step 2 · Configure</span>
<h2>Set up the assignment</h2> <h2>Set up the assignment</h2>
<div style={{ margin: "18px 0" }}> <div style={{ margin: "20px 0" }}>
<span className="field-label">Assignment type</span> <span className="field-label">Assignment type</span>
<div className="choice-grid"> <div className="choice-grid">
{ASSIGNMENT_TYPES.map((t) => ( {ASSIGNMENT_TYPES.map((t) => (
<button <button key={t.id} className={`choice${config.assignmentType === t.id ? " selected" : ""}`} onClick={() => update({ assignmentType: t.id })}>
key={t.id}
className={`choice${config.assignmentType === t.id ? " selected" : ""}`}
onClick={() => update({ assignmentType: t.id })}
>
<b>{t.label}</b> <b>{t.label}</b>
<small>{t.hint}</small> <small>{t.hint}</small>
</button> </button>
@@ -319,13 +256,8 @@ export default function CreatePage() {
</label> </label>
<label className="field"> <label className="field">
<span className="field-label">Subject</span> <span className="field-label">Subject</span>
<input <input type="text" list="subjects" placeholder="e.g. U.S. History, Biology, English Language Arts"
type="text" value={config.subject} onChange={(e) => update({ subject: e.target.value })} />
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"> <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) => ( {["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} /> <option key={s} value={s} />
@@ -336,14 +268,9 @@ export default function CreatePage() {
<div className="row"> <div className="row">
<label className="field"> <label className="field">
<span className="field-label"> <span className="field-label">{isDiscussionOrCase ? "Number of prompts/questions" : "Number of questions"} {config.questionCount}</span>
{isDiscussionOrCase ? "Number of prompts/questions" : "Number of questions"} {config.questionCount} <input type="range" min="1" max="30" value={config.questionCount}
</span> onChange={(e) => update({ questionCount: Number(e.target.value) })} style={{ width: "100%" }} />
<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>
<label className="field"> <label className="field">
<span className="field-label">Difficulty</span> <span className="field-label">Difficulty</span>
@@ -354,22 +281,18 @@ export default function CreatePage() {
</div> </div>
{!isDiscussionOrCase && ( {!isDiscussionOrCase && (
<div style={{ margin: "4px 0 12px" }}> <div style={{ margin: "4px 0 14px" }}>
<span className="field-label">Question types to include</span> <span className="field-label">Question types to include</span>
<div className="row" style={{ gap: 4 }}> <div style={{ display: "flex", flexWrap: "wrap", gap: "2px 18px" }}>
{QUESTION_TYPES.map((t) => ( {QUESTION_TYPES.map((t) => (
<label key={t.id} className="check" style={{ minWidth: 150, flex: "0 0 auto" }}> <label key={t.id} className="check" style={{ minWidth: 150 }}>
<input <input type="checkbox" checked={config.questionTypes.includes(t.id)} onChange={() => toggleQType(t.id)} />
type="checkbox"
checked={config.questionTypes.includes(t.id)}
onChange={() => toggleQType(t.id)}
/>
<span>{t.label}</span> <span>{t.label}</span>
</label> </label>
))} ))}
</div> </div>
{config.questionTypes.length === 0 && ( {config.questionTypes.length === 0 && (
<div className="field-hint" style={{ color: "var(--redpen)" }}>Pick at least one question type.</div> <div className="field-hint redpen">Pick at least one question type.</div>
)} )}
</div> </div>
)} )}
@@ -386,51 +309,27 @@ export default function CreatePage() {
)} )}
<label className="check"> <label className="check">
<input type="checkbox" checked={config.verify} onChange={(e) => update({ verify: e.target.checked })} /> <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> <span>Run the accuracy check<small>A second AI pass reviews every question and answer against your source. Strongly recommended.</small></span>
</label> </label>
<label className="field" style={{ marginTop: 12 }}> <label className="field" style={{ marginTop: 14 }}>
<span className="field-label">Anything to focus on? <span className="muted" style={{ fontWeight: 400 }}>(optional)</span></span> <span className="field-label">Anything to focus on? <span className="faint" style={{ textTransform: "none", letterSpacing: 0, fontWeight: 400 }}>(optional)</span></span>
<input <input type="text" placeholder="e.g. focus on causes rather than dates; include the vocabulary terms"
type="text" value={config.focusNote} onChange={(e) => update({ focusNote: e.target.value })} />
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>
</>
<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 && ( {step === 2 && (
<div className="card"> <>
<span className="field-label" style={{ color: "var(--board)", marginBottom: 8 }}>Step 3 · Generate</span>
<h2>Ready to generate</h2> <h2>Ready to generate</h2>
<p className="muted" style={{ margin: "10px 0 4px", fontSize: "0.96rem" }}> <p className="muted" style={{ margin: "12px 0 4px", fontSize: "0.96rem" }}>
<b style={{ color: "var(--ink)" }}> <b style={{ color: "var(--ink)" }}>{ASSIGNMENT_TYPES.find((t) => t.id === config.assignmentType)?.label}</b>{" "}
{ASSIGNMENT_TYPES.find((t) => t.id === config.assignmentType)?.label}
</b>{" "}
· {config.gradeLevel} · {config.subject || "—"} · {config.questionCount} question{config.questionCount === 1 ? "" : "s"} · {config.difficulty} difficulty · {config.gradeLevel} · {config.subject || "—"} · {config.questionCount} question{config.questionCount === 1 ? "" : "s"} · {config.difficulty} difficulty
</p> </p>
<p className="muted small">Source: {sourceName || "Pasted text"} ({text.length.toLocaleString()} characters)</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 && ( {genState && !genState.error && (
<> <>
<ul className="progress-list" aria-live="polite"> <ul className="progress-list" aria-live="polite">
@@ -439,33 +338,103 @@ export default function CreatePage() {
{config.verify && <ProgressStep phase="verify" label="Checking every answer against the source" 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} /> <ProgressStep phase="save" label="Saving and opening the editor" currentPhase={genState.phase} hasVerify={config.verify} />
</ul> </ul>
<p className="small muted" style={{ marginTop: 16 }}> <p className="small muted" style={{ marginTop: 16 }}>Local models can take a few minutes for large assignments. Leave this tab open.</p>
Local models can take a few minutes for large assignments. Leave this tab open.
</p>
</> </>
)} )}
{genState?.error && ( {genState?.error && (
<> <div className="alert alert-error" style={{ marginTop: 20 }}>
<div className="alert alert-error"><b>Generation failed.</b> {genState.error}</div> <IconAlertTriangle size={17} /> <span><b>Generation failed.</b> {genState.error}</span>
<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>
)}
</> </>
)} )}
</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>
)} )}
<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> </div>
); );
} }
async function postJson(url, body) { async function postJson(url, body) {
const res = await fetch(url, { const res = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || `Request failed (${res.status}).`); if (!res.ok) throw new Error(data.error || `Request failed (${res.status}).`);
return data; return data;
+141 -221
View File
@@ -1,6 +1,11 @@
"use client"; "use client";
// app/settings/page.jsx — choose and configure your AI provider, all stored locally. // app/settings/page.jsx — choose and configure your AI provider, all stored locally.
// Flush two-column "paper form": sticky table of contents + underline-only fields.
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import {
IconUserCircle, IconCpu, IconSchool, IconAdjustments,
IconPhoto, IconRefresh, IconPlug, IconCheck, IconX,
} from "@tabler/icons-react";
const PROVIDERS = [ const PROVIDERS = [
{ id: "ollama", name: "Ollama", desc: "Free, private, runs on this computer", local: true }, { id: "ollama", name: "Ollama", desc: "Free, private, runs on this computer", local: true },
@@ -9,24 +14,30 @@ const PROVIDERS = [
{ id: "openai", name: "OpenAI (GPT)", 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 }, { id: "google", name: "Google AI (Gemini)", desc: "Cloud API — needs an API key", local: false },
]; ];
const KEY_LINKS = { const KEY_LINKS = {
anthropic: "https://console.anthropic.com/", anthropic: "https://console.anthropic.com/",
openai: "https://platform.openai.com/api-keys", openai: "https://platform.openai.com/api-keys",
google: "https://aistudio.google.com/apikey", google: "https://aistudio.google.com/apikey",
}; };
const TOC = [
{ id: "teacher", label: "Teacher & school", icon: IconUserCircle },
{ id: "provider", label: "AI provider", icon: IconCpu },
{ id: "canvas", label: "Canvas (LMS)", icon: IconSchool },
{ id: "generation", label: "Generation defaults", icon: IconAdjustments },
];
export default function SettingsPage() { export default function SettingsPage() {
const [s, setS] = useState(null); const [s, setS] = useState(null);
const [models, setModels] = useState({}); // provider -> string[] const [models, setModels] = useState({});
const [modelsBusy, setModelsBusy] = useState(""); const [modelsBusy, setModelsBusy] = useState("");
const [modelsErr, setModelsErr] = useState({}); // provider -> error const [modelsErr, setModelsErr] = useState({});
const [test, setTest] = useState({}); // provider -> {busy, ok, message} const [test, setTest] = useState({});
const [canvasTest, setCanvasTest] = useState(null); // {busy, ok, message} const [canvasTest, setCanvasTest] = useState(null);
const [autoInfo, setAutoInfo] = useState(null); // resolved auto limits for the active model const [autoInfo, setAutoInfo] = useState(null);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [toast, setToast] = useState(""); const [toast, setToast] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
const [activeToc, setActiveToc] = useState("teacher");
const toastTimer = useRef(null); const toastTimer = useRef(null);
useEffect(() => { useEffect(() => {
@@ -38,18 +49,12 @@ export default function SettingsPage() {
const activeKey = s?.providers?.[activeProvider]?.apiKey || ""; const activeKey = s?.providers?.[activeProvider]?.apiKey || "";
const autoOn = s ? s.generation?.auto !== false : true; 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(() => { useEffect(() => {
if (!s || !autoOn || !activeModel) { setAutoInfo(null); return; } if (!s || !autoOn || !activeModel) { setAutoInfo(null); return; }
let cancelled = false; let cancelled = false;
setAutoInfo(null); setAutoInfo(null);
const t = setTimeout(() => { const t = setTimeout(() => {
fetch("/api/providers", { fetch("/api/providers", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "defaults", provider: activeProvider, settings: s }) })
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "defaults", provider: activeProvider, settings: s }),
})
.then((r) => r.json()) .then((r) => r.json())
.then((d) => { if (!cancelled && !d.error && d.maxTokens) setAutoInfo(d); }) .then((d) => { if (!cancelled && !d.error && d.maxTokens) setAutoInfo(d); })
.catch(() => {}); .catch(() => {});
@@ -58,50 +63,33 @@ export default function SettingsPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeProvider, activeModel, activeKey, autoOn]); }, [activeProvider, activeModel, activeKey, autoOn]);
function showToast(msg) { // Highlight the TOC entry for the section currently in view.
setToast(msg); useEffect(() => {
clearTimeout(toastTimer.current); if (!s) return;
toastTimer.current = setTimeout(() => setToast(""), 2400); const obs = new IntersectionObserver(
} (entries) => { entries.forEach((e) => { if (e.isIntersecting) setActiveToc(e.target.id); }); },
{ rootMargin: "-20% 0px -70% 0px", threshold: 0 }
);
TOC.forEach(({ id }) => { const el = document.getElementById(id); if (el) obs.observe(el); });
return () => obs.disconnect();
}, [s]);
function setProviderField(provider, field, value) { function showToast(msg) { setToast(msg); clearTimeout(toastTimer.current); toastTimer.current = setTimeout(() => setToast(""), 2400); }
setS((cur) => ({ function setProviderField(provider, field, value) { setS((cur) => ({ ...cur, providers: { ...cur.providers, [provider]: { ...cur.providers[provider], [field]: value } } })); }
...cur, function setGen(field, value) { setS((cur) => ({ ...cur, generation: { ...cur.generation, [field]: value } })); }
providers: { ...cur.providers, [provider]: { ...cur.providers[provider], [field]: value } }, function setProfile(field, value) { setS((cur) => ({ ...cur, profile: { ...(cur.profile || {}), [field]: value } })); }
})); function setCanvas(field, value) { setCanvasTest(null); setS((cur) => ({ ...cur, canvas: { ...(cur.canvas || {}), [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 } }));
}
function setCanvas(field, value) {
setCanvasTest(null);
setS((cur) => ({ ...cur, canvas: { ...(cur.canvas || {}), [field]: value } }));
}
async function testCanvas() { async function testCanvas() {
setCanvasTest({ busy: true }); setCanvasTest({ busy: true });
try { try {
const res = await fetch("/api/canvas", { const res = await fetch("/api/canvas", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "test", settings: s }) });
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "test", settings: s }),
});
const data = await res.json(); const data = await res.json();
if (!res.ok) throw new Error(data.error || "Test failed."); if (!res.ok) throw new Error(data.error || "Test failed.");
setCanvasTest({ ok: true, message: data.message }); setCanvasTest({ ok: true, message: data.message });
} catch (e) { } catch (e) { setCanvasTest({ ok: false, message: String(e.message || e) }); }
setCanvasTest({ ok: false, message: String(e.message || e) });
}
} }
// 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) { function onLogoFile(e) {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
e.target.value = ""; e.target.value = "";
@@ -123,64 +111,39 @@ export default function SettingsPage() {
} }
async function refreshModels(provider) { async function refreshModels(provider) {
setModelsBusy(provider); setModelsBusy(provider); setModelsErr((e) => ({ ...e, [provider]: "" }));
setModelsErr((e) => ({ ...e, [provider]: "" }));
try { try {
const res = await fetch("/api/providers", { const res = await fetch("/api/providers", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "models", provider, settings: s }) });
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "models", provider, settings: s }),
});
const data = await res.json(); const data = await res.json();
if (!res.ok) throw new Error(data.error || "Could not list models."); if (!res.ok) throw new Error(data.error || "Could not list models.");
setModels((m) => ({ ...m, [provider]: data.models })); setModels((m) => ({ ...m, [provider]: data.models }));
if (data.models.length && !data.models.includes(s.providers[provider].model)) { if (data.models.length && !data.models.includes(s.providers[provider].model)) setProviderField(provider, "model", data.models[0]);
setProviderField(provider, "model", data.models[0]); } catch (e) { setModelsErr((er) => ({ ...er, [provider]: String(e.message || e) })); }
} finally { setModelsBusy(""); }
} catch (e) {
setModelsErr((er) => ({ ...er, [provider]: String(e.message || e) }));
} finally {
setModelsBusy("");
}
} }
async function testConnection(provider) { async function testConnection(provider) {
setTest((t) => ({ ...t, [provider]: { busy: true } })); setTest((t) => ({ ...t, [provider]: { busy: true } }));
try { try {
const res = await fetch("/api/providers", { const res = await fetch("/api/providers", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "test", provider, settings: s }) });
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "test", provider, settings: s }),
});
const data = await res.json(); const data = await res.json();
if (!res.ok) throw new Error(data.error || "Test failed."); if (!res.ok) throw new Error(data.error || "Test failed.");
setTest((t) => ({ ...t, [provider]: { ok: true, message: data.message } })); setTest((t) => ({ ...t, [provider]: { ok: true, message: data.message } }));
} catch (e) { } catch (e) { setTest((t) => ({ ...t, [provider]: { ok: false, message: String(e.message || e) } })); }
setTest((t) => ({ ...t, [provider]: { ok: false, message: String(e.message || e) } }));
}
} }
async function save() { async function save() {
setSaving(true); setSaving(true); setError("");
setError("");
try { try {
const res = await fetch("/api/settings", { const res = await fetch("/api/settings", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(s) });
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(s),
});
const data = await res.json(); const data = await res.json();
if (!res.ok) throw new Error(data.error || "Save failed."); if (!res.ok) throw new Error(data.error || "Save failed.");
setS(data); setS(data); showToast("Settings saved");
showToast("Settings saved"); } catch (e) { setError(String(e.message || e)); }
} catch (e) { finally { setSaving(false); }
setError(String(e.message || e));
} finally {
setSaving(false);
}
} }
if (!s) return <p className="muted"><span className="spinner" /> Loading</p>; if (!s) return <div className="page page-narrow"><p className="muted"><span className="spinner" /> Loading</p></div>;
const active = s.provider; const active = s.provider;
const activeCfg = s.providers[active] || {}; const activeCfg = s.providers[active] || {};
@@ -189,7 +152,7 @@ export default function SettingsPage() {
const t = test[active]; const t = test[active];
return ( return (
<div> <div className="page page-narrow">
<div className="page-head"> <div className="page-head">
<h1>Settings</h1> <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> <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>
@@ -197,222 +160,178 @@ export default function SettingsPage() {
{error && <div className="alert alert-error">{error}</div>} {error && <div className="alert alert-error">{error}</div>}
<div className="card"> <div className="settings-grid">
<nav className="settings-toc" aria-label="Settings sections">
<span className="field-label" style={{ padding: "0 12px 8px" }}>On this page</span>
{TOC.map(({ id, label, icon: Icon }) => (
<a key={id} href={`#${id}`} className={`toc-link${activeToc === id ? " active" : ""}`}><Icon size={16} /> {label}</a>
))}
</nav>
<div className="settings-body">
{/* ---- Teacher & school ---- */}
<section className="sec" id="teacher">
<h2>Teacher &amp; school</h2> <h2>Teacher &amp; school</h2>
<p className="field-hint" style={{ marginTop: 4 }}> <p className="sec-hint">Shown in the header of every printed and exported assignment leave anything blank to omit it.</p>
Shown in the header of every printed and exported assignment leave anything blank to omit it. <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))", gap: 26, marginBottom: 26 }}>
</p> <div className="ul-field" style={{ marginBottom: 0 }}>
<div className="row" style={{ marginTop: 14 }}>
<label className="field">
<span className="field-label">Teacher name</span> <span className="field-label">Teacher name</span>
<input type="text" value={s.profile?.teacherName || ""} <input className="ul-input" type="text" value={s.profile?.teacherName || ""} onChange={(e) => setProfile("teacherName", e.target.value)} placeholder="e.g. Mr. Drew" />
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>
<div className="field"> <div className="ul-field" style={{ marginBottom: 0 }}>
<span className="field-label">School logo or mascot (optional)</span> <span className="field-label">Class / course</span>
<div style={{ display: "flex", gap: 12, alignItems: "center", flexWrap: "wrap" }}> <input className="ul-input" type="text" value={s.profile?.className || ""} onChange={(e) => setProfile("className", e.target.value)} placeholder="e.g. 7th Grade Science — Period 3" />
</div>
<div className="ul-field" style={{ marginBottom: 0 }}>
<span className="field-label">School name</span>
<input className="ul-input" type="text" value={s.profile?.schoolName || ""} onChange={(e) => setProfile("schoolName", e.target.value)} placeholder="e.g. Lincoln Middle School" />
</div>
</div>
<span className="field-label">School logo or mascot <span className="faint" style={{ textTransform: "none", letterSpacing: 0, fontWeight: 400 }}>(optional)</span></span>
<div style={{ display: "flex", gap: 12, alignItems: "center", flexWrap: "wrap", marginTop: 6 }}>
{s.profile?.logo && ( {s.profile?.logo && (
<img src={s.profile.logo} alt="School logo preview" <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 }} />
style={{ height: 52, maxWidth: 140, objectFit: "contain", background: "#fff", border: "1px solid var(--line)", borderRadius: 6, padding: 4 }} />
)} )}
<label className="btn" style={{ cursor: "pointer" }}> <label className="btn" style={{ cursor: "pointer" }}>
{s.profile?.logo ? "Replace image" : "Upload image"} <IconPhoto size={16} /> {s.profile?.logo ? "Replace image" : "Upload image"}
<input type="file" accept="image/*" onChange={onLogoFile} style={{ display: "none" }} /> <input type="file" accept="image/*" onChange={onLogoFile} style={{ display: "none" }} />
</label> </label>
{s.profile?.logo && ( {s.profile?.logo && <button className="btn btn-ghost btn-danger" style={{ borderColor: "transparent" }} onClick={() => setProfile("logo", "")}>Remove</button>}
<button className="btn btn-danger" onClick={() => setProfile("logo", "")}>Remove</button>
)}
</div> </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> <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> </section>
</div>
<div className="card"> {/* ---- AI provider ---- */}
<section className="sec" id="provider">
<h2>AI provider</h2> <h2>AI provider</h2>
<div style={{ marginTop: 14 }}> <div className="provider-list" style={{ marginTop: 16 }}>
{PROVIDERS.map((p) => ( {PROVIDERS.map((p) => (
<label key={p.id} className={"provider-row" + (active === p.id ? " selected" : "")}> <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 }))} /> <input type="radio" name="provider" checked={active === p.id} onChange={() => setS((cur) => ({ ...cur, provider: p.id }))} />
<span> <span style={{ flex: 1 }}>
<b>{p.name}</b> <b style={active === p.id ? { color: "var(--board-deep)" } : undefined}>{p.name}</b>
<small>{p.desc}</small> <small>{p.desc}</small>
</span> </span>
{p.local && <span className="chip local-tag">Private · local</span>} {p.local && <span className="chip chip-pill local-tag">Private · local</span>}
</label> </label>
))} ))}
</div> </div>
<hr className="hr" /> <h3 style={{ margin: "26px 0 16px" }}>{PROVIDERS.find((p) => p.id === active)?.name} setup</h3>
<h3 style={{ marginBottom: 12 }}>{PROVIDERS.find((p) => p.id === active)?.name} setup</h3>
{isLocal && ( {isLocal && (
<label className="field"> <div className="ul-field">
<span className="field-label">Server address (base URL)</span> <span className="field-label">Server address (base URL)</span>
<input <input className="ul-input" type="text" value={activeCfg.baseUrl || ""} onChange={(e) => setProviderField(active, "baseUrl", e.target.value)}
type="text" value={activeCfg.baseUrl || ""} placeholder={active === "ollama" ? "http://localhost:11434" : "http://localhost:1234"} />
onChange={(e) => setProviderField(active, "baseUrl", e.target.value)}
placeholder={active === "ollama" ? "http://localhost:11434" : "http://localhost:1234"}
/>
<span className="field-hint"> <span className="field-hint">
{active === "ollama" {active === "ollama"
? <>Where this app should find Ollama. Same computer: the default is right. Ollama on another machine (or this app in Docker on a different box): enter that machine&rsquo;s address, e.g. <code>http://192.168.1.50:11434</code> — and on that machine set <code>OLLAMA_HOST=0.0.0.0</code> so Ollama accepts network connections. Use &ldquo;Test connection&rdquo; below to confirm.</> ? <>Where this app should find Ollama. Same computer: the default is right. On another machine, enter that machine&rsquo;s address, e.g. <code>http://192.168.1.50:11434</code> — and set <code>OLLAMA_HOST=0.0.0.0</code> there. Use &ldquo;Test connection&rdquo; to confirm.</>
: <>Where this app should find LM Studio. Same computer: the default is right. LM Studio on another machine: enter its address, e.g. <code>http://192.168.1.50:1234</code> — and in LM Studio&rsquo;s Developer tab enable &ldquo;Serve on Local Network&rdquo;. Use &ldquo;Test connection&rdquo; below to confirm.</>} : <>Where this app should find LM Studio. Same computer: the default is right. On another machine: enter its address, e.g. <code>http://192.168.1.50:1234</code> — and in LM Studio&rsquo;s Developer tab enable &ldquo;Serve on Local Network&rdquo;.</>}
</span> </span>
</label> </div>
)} )}
{!isLocal && ( {!isLocal && (
<label className="field"> <div className="ul-field">
<span className="field-label">API key</span> <span className="field-label">API key</span>
<input <input className="ul-input" type="password" value={activeCfg.apiKey || ""} onChange={(e) => setProviderField(active, "apiKey", e.target.value)} placeholder="Paste your API key" autoComplete="off" />
type="password" value={activeCfg.apiKey || ""} <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>
onChange={(e) => setProviderField(active, "apiKey", e.target.value)} </div>
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"> <div className="ul-field">
<span className="field-label">Model</span> <span className="field-label">Model</span>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}> <div style={{ display: "flex", gap: 12, flexWrap: "wrap", alignItems: "flex-end" }}>
{modelList.length > 0 ? ( {modelList.length > 0 ? (
<select value={activeCfg.model || ""} onChange={(e) => setProviderField(active, "model", e.target.value)} style={{ flex: 1, minWidth: 220 }}> <select className="ul-input" 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.includes(activeCfg.model) && activeCfg.model && <option value={activeCfg.model}>{activeCfg.model}</option>}
{modelList.map((m) => <option key={m} value={m}>{m}</option>)} {modelList.map((m) => <option key={m} value={m}>{m}</option>)}
</select> </select>
) : ( ) : (
<input <input className="ul-input" type="text" value={activeCfg.model || ""} onChange={(e) => setProviderField(active, "model", e.target.value)}
type="text" value={activeCfg.model || ""} placeholder={active === "ollama" ? "e.g. llama3.1:8b" : "Model name"} style={{ flex: 1, minWidth: 220 }} />
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}> <button className="btn" onClick={() => refreshModels(active)} disabled={modelsBusy === active}>
{modelsBusy === active ? <><span className="spinner" /> Looking</> : "Refresh models"} {modelsBusy === active ? <><span className="spinner" /> Looking</> : <><IconRefresh size={15} /> Refresh models</>}
</button> </button>
</div> </div>
{modelsErr[active] && <span className="field-hint" style={{ color: "var(--redpen)" }}>{modelsErr[active]}</span>} {modelsErr[active]
{!modelsErr[active] && ( ? <span className="field-hint redpen">{modelsErr[active]}</span>
<span className="field-hint"> : <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>}
Accuracy tip: bigger models write noticeably better questions. Locally, prefer an 8B+ model; in the cloud, the default models work well. </div>
</span>
)}
</label>
<div style={{ display: "flex", gap: 10, alignItems: "center", flexWrap: "wrap" }}> <div style={{ display: "flex", gap: 10, alignItems: "center", flexWrap: "wrap" }}>
<button className="btn" onClick={() => testConnection(active)} disabled={t?.busy}> <button className="btn" onClick={() => testConnection(active)} disabled={t?.busy}>
{t?.busy ? <><span className="spinner" /> Testing</> : "Test connection"} {t?.busy ? <><span className="spinner" /> Testing</> : <><IconPlug size={15} /> Test connection</>}
</button> </button>
{t && !t.busy && ( {t && !t.busy && (
<span className={"small " + (t.ok ? "" : "redpen")} style={t.ok ? { color: "var(--board)", fontWeight: 600 } : { fontWeight: 600 }}> <span className="small" style={{ fontWeight: 600, color: t.ok ? "var(--board)" : "var(--redpen)", display: "inline-flex", alignItems: "center", gap: 4 }}>
{t.ok ? "✓ " : "✕ "}{t.message} {t.ok ? <IconCheck size={15} /> : <IconX size={15} />}{t.message}
</span> </span>
)} )}
</div> </div>
</div> </section>
<div className="card"> {/* ---- Canvas LMS ---- */}
<section className="sec" id="canvas">
<h2>Canvas (LMS) integration</h2> <h2>Canvas (LMS) integration</h2>
<p className="field-hint" style={{ marginTop: 4 }}> <p className="sec-hint">Optional. Lets you push a finished assignment straight into a Canvas course from the editor. You can always skip this and use the downloadable Canvas <code>.zip</code> instead.</p>
Optional. Lets you push a finished assignment straight into a Canvas course from the editor. <div className="ul-field">
You can always skip this and use the downloadable Canvas <code>.zip</code> instead.
</p>
<label className="field" style={{ marginTop: 14 }}>
<span className="field-label">Canvas web address</span> <span className="field-label">Canvas web address</span>
<input <input className="ul-input" type="text" value={s.canvas?.baseUrl || ""} onChange={(e) => setCanvas("baseUrl", e.target.value)} placeholder="https://yourschool.instructure.com" autoComplete="off" />
type="text" value={s.canvas?.baseUrl || ""}
onChange={(e) => setCanvas("baseUrl", e.target.value)}
placeholder="https://yourschool.instructure.com"
autoComplete="off"
/>
<span className="field-hint">The address you use to log into Canvas e.g. <code>https://yourschool.instructure.com</code>.</span> <span className="field-hint">The address you use to log into Canvas e.g. <code>https://yourschool.instructure.com</code>.</span>
</label> </div>
<label className="field"> <div className="ul-field">
<span className="field-label">Access token</span> <span className="field-label">Access token</span>
<input <input className="ul-input" type="password" value={s.canvas?.token || ""} onChange={(e) => setCanvas("token", e.target.value)} placeholder="Paste your Canvas access token" autoComplete="off" />
type="password" value={s.canvas?.token || ""} <span className="field-hint">In Canvas: <b>Account Settings Approved Integrations + New Access Token</b>. Stored only on this computer. Some schools restrict tokens if yours does, use the <code>.zip</code> export instead.</span>
onChange={(e) => setCanvas("token", e.target.value)} </div>
placeholder="Paste your Canvas access token"
autoComplete="off"
/>
<span className="field-hint">
In Canvas: <b>Account Settings Approved Integrations + New Access Token</b>. Stored only on this computer.
Some schools restrict tokens if yours does, use the <code>.zip</code> export instead.
</span>
</label>
<div style={{ display: "flex", gap: 10, alignItems: "center", flexWrap: "wrap" }}> <div style={{ display: "flex", gap: 10, alignItems: "center", flexWrap: "wrap" }}>
<button className="btn" onClick={testCanvas} disabled={canvasTest?.busy || !s.canvas?.baseUrl || !s.canvas?.token}> <button className="btn" onClick={testCanvas} disabled={canvasTest?.busy || !s.canvas?.baseUrl || !s.canvas?.token}>
{canvasTest?.busy ? <><span className="spinner" /> Testing</> : "Test connection"} {canvasTest?.busy ? <><span className="spinner" /> Testing</> : <><IconPlug size={15} /> Test connection</>}
</button> </button>
{canvasTest && !canvasTest.busy && ( {canvasTest && !canvasTest.busy && (
<span className="small" style={canvasTest.ok ? { color: "var(--board)", fontWeight: 600 } : { fontWeight: 600 }}> <span className="small" style={{ fontWeight: 600, color: canvasTest.ok ? "var(--board)" : "var(--redpen)", display: "inline-flex", alignItems: "center", gap: 4 }}>
{canvasTest.ok ? "✓ " : "✕ "}{canvasTest.message} {canvasTest.ok ? <IconCheck size={15} /> : <IconX size={15} />}{canvasTest.message}
</span> </span>
)} )}
</div> </div>
</div> </section>
<div className="card"> {/* ---- Generation defaults ---- */}
<section className="sec" id="generation">
<h2>Generation defaults</h2> <h2>Generation defaults</h2>
<label className="check" style={{ marginTop: 14 }}> <label className="check" style={{ marginTop: 14 }}>
<input type="checkbox" checked={autoOn} onChange={(e) => setGen("auto", e.target.checked)} /> <input type="checkbox" checked={autoOn} onChange={(e) => setGen("auto", e.target.checked)} />
<span> <span>Set size limits automatically (recommended)<small>Matches source size and response length to the selected model&rsquo;s real context window local models get safe limits, large cloud models get room for much longer sources and answers.</small></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> </label>
{autoOn && ( {autoOn && (
<p className="field-hint" style={{ margin: "0 0 4px 30px" }}> <p className="field-hint" style={{ margin: "0 0 8px 30px", fontStyle: "italic" }}>
{!activeModel {!activeModel ? "Pick a model above to see its tuned limits."
? "Pick a model above to see its tuned limits."
: autoInfo : 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" : ""}).` ? `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</>} : <><span className="spinner" /> Checking the model&rsquo;s limits</>}
</p> </p>
)} )}
<div className="row" style={{ marginTop: 14 }}> <div className="row" style={{ marginTop: 14 }}>
<label className="field"> <label className="field" style={{ maxWidth: 540 }}>
<span className="field-label">Temperature {Number(s.generation.temperature).toFixed(1)}</span> <span className="field-label">Temperature {Number(s.generation.temperature).toFixed(1)}</span>
<input <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%" }} />
type="range" min="0" max="1" step="0.1" value={s.generation.temperature}
onChange={(e) => setGen("temperature", Number(e.target.value))}
style={{ width: "100%", accentColor: "var(--board)" }}
/>
<span className="field-hint">Lower = more precise and literal (best for accuracy). 0.20.4 recommended.</span> <span className="field-hint">Lower = more precise and literal (best for accuracy). 0.20.4 recommended.</span>
</label> </label>
{!autoOn && ( {!autoOn && (
<label className="field"> <label className="field">
<span className="field-label">Max response length (tokens)</span> <span className="field-label">Max response length (tokens)</span>
<input type="number" min="1000" max="64000" step="500" value={s.generation.maxTokens} <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))} />
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&rsquo;s own output limit.</span>
<span className="field-hint">Raise this if very long assignments come back cut off. Capped to the model's own output limit.</span>
</label> </label>
)} )}
{!autoOn && ( {!autoOn && (
<label className="field"> <label className="field">
<span className="field-label">Max source size (characters)</span> <span className="field-label">Max source size (characters)</span>
<input type="number" min="4000" max="300000" step="1000" value={s.generation.maxSourceChars} <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))} />
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 do better around 16,00024,000.</span>
<span className="field-hint">Longer sources are trimmed to this before being sent to the model. Local models with small context windows do better around 16,00024,000.</span>
</label> </label>
)} )}
</div> </div>
@@ -420,14 +339,15 @@ export default function SettingsPage() {
<input type="checkbox" checked={s.generation.verification !== false} onChange={(e) => setGen("verification", e.target.checked)} /> <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> <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> </label>
</div> </section>
<div style={{ display: "flex", marginTop: 18 }}> <div style={{ display: "flex", justifyContent: "flex-end", paddingTop: 28, borderTop: "1px solid var(--line)", marginTop: 8 }}>
<span className="spacer" />
<button className="btn btn-primary btn-lg" onClick={save} disabled={saving}> <button className="btn btn-primary btn-lg" onClick={save} disabled={saving}>
{saving ? "Saving…" : "Save settings"} {saving ? "Saving…" : <><IconCheck size={16} /> Save settings</>}
</button> </button>
</div> </div>
</div>
</div>
{toast && <div className="toast">{toast}</div>} {toast && <div className="toast">{toast}</div>}
</div> </div>
+5 -11
View File
@@ -3,6 +3,7 @@
// assignment. Auto-fills everything derivable; exposes the optional Canvas quiz settings // assignment. Auto-fills everything derivable; exposes the optional Canvas quiz settings
// a teacher may want to set. Pure client-side (no upload) — see lib/canvas/export.js. // a teacher may want to set. Pure client-side (no upload) — see lib/canvas/export.js.
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { IconX } from "@tabler/icons-react";
import { mapAssignmentToCanvas } from "@/lib/canvas/map"; import { mapAssignmentToCanvas } from "@/lib/canvas/map";
import { exportCanvasZip } from "@/lib/canvas/export"; import { exportCanvasZip } from "@/lib/canvas/export";
@@ -145,18 +146,11 @@ export default function CanvasExportDialog({ assignment, onClose, onDone, onErro
} }
return ( return (
<div <div className="modal-scrim" onClick={onClose}>
onClick={onClose}
style={{
position: "fixed", inset: 0, zIndex: 100, background: "rgba(20,30,26,0.45)",
display: "flex", alignItems: "flex-start", justifyContent: "center",
padding: "40px 16px", overflowY: "auto",
}}
>
<div <div
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
className="card" className="modal"
style={{ width: 560, maxWidth: "100%", padding: 24, animation: "fade-in-up 0.18s ease" }} style={{ width: 560, maxWidth: "100%", padding: 24 }}
> >
<div style={{ display: "flex", alignItems: "flex-start", gap: 12 }}> <div style={{ display: "flex", alignItems: "flex-start", gap: 12 }}>
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
@@ -165,7 +159,7 @@ export default function CanvasExportDialog({ assignment, onClose, onDone, onErro
Downloads a QTI <code>.zip</code>. In Canvas: <b>Settings Import Course Content QTI .zip file</b>. Downloads a QTI <code>.zip</code>. In Canvas: <b>Settings Import Course Content QTI .zip file</b>.
</p> </p>
</div> </div>
<button className="btn btn-sm" onClick={onClose} aria-label="Close"></button> <button className="icon-btn" onClick={onClose} aria-label="Close"><IconX size={16} /></button>
</div> </div>
{/* Summary of what will be exported */} {/* Summary of what will be exported */}
-65
View File
@@ -1,65 +0,0 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
const LINKS = [
{ href: "/", label: "Create" },
{ href: "/library", label: "Library" },
{ href: "/settings", label: "Settings" },
];
export default function Nav() {
const pathname = usePathname();
const [theme, setTheme] = useState(null);
const [scrolled, setScrolled] = useState(false);
useEffect(() => {
setTheme(document.documentElement.dataset.theme === "dark" ? "dark" : "light");
}, []);
useEffect(() => {
function onScroll() {
setScrolled(window.scrollY > 8);
}
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
function toggleTheme() {
const next = document.documentElement.dataset.theme === "dark" ? "light" : "dark";
document.documentElement.dataset.theme = next;
try { localStorage.setItem("theme", next); } catch {}
setTheme(next);
}
return (
<header className={`topnav${scrolled ? " nav-scrolled" : ""}`}>
<div className="topnav-inner">
<Link href="/" className="brand">
<span className="brand-mark" aria-hidden="true"></span>
<span className="brand-text">Mr. Drew&rsquo;s Assignment Creator</span>
</Link>
<nav className="navlinks" aria-label="Main">
{LINKS.map((l) => {
const active = l.href === "/" ? pathname === "/" : pathname.startsWith(l.href);
return (
<Link key={l.href} href={l.href} className={`navlink${active ? " active" : ""}`}>
{l.label}
</Link>
);
})}
</nav>
<button
type="button"
className="icon-btn theme-toggle"
onClick={toggleTheme}
aria-label={theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
title={theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
>
{theme === "dark" ? "☀" : "☾"}
</button>
</div>
</header>
);
}
+112 -82
View File
@@ -1,7 +1,12 @@
"use client"; "use client";
// components/QuestionCard.jsx — edit any question inline, with the answer key // components/QuestionCard.jsx — one question rendered as a row in the editor
// styled in red pen and grading-stamp verification badges. // document: a left rail (drag grip + number), the prompt/options in the center,
// and the answer key in the right margin, styled in red pen.
import { useState } from "react"; import { useState } from "react";
import {
IconGripVertical, IconArrowUp, IconArrowDown, IconRefresh, IconX,
IconCircleCheck, IconAlertTriangle, IconArrowRight,
} from "@tabler/icons-react";
import { questionTypeLabel } from "@/lib/schema"; import { questionTypeLabel } from "@/lib/schema";
const LETTERS = "ABCDEFGHIJ"; const LETTERS = "ABCDEFGHIJ";
@@ -10,47 +15,73 @@ function AutoTextarea({ value, onChange, rows = 2, ...rest }) {
return <textarea value={value || ""} rows={rows} onChange={(e) => onChange(e.target.value)} {...rest} />; return <textarea value={value || ""} rows={rows} onChange={(e) => onChange(e.target.value)} {...rest} />;
} }
export default function QuestionCard({ q, index, count, onChange, onMove, onDelete, onRegenerate, busy }) { export default function QuestionCard({
q, index, count, onChange, onMove, onDelete, onRegenerate, busy,
dragging, over, onDragStart, onDragEnter, onDrop, onDragEnd,
}) {
const [regenOpen, setRegenOpen] = useState(false); const [regenOpen, setRegenOpen] = useState(false);
const [note, setNote] = useState(""); const [note, setNote] = useState("");
const [grabbed, setGrabbed] = useState(false);
function set(patch) { function set(patch) {
onChange({ ...q, ...patch, verification: patch.verification || { status: "unchecked", note: "" } }); onChange({ ...q, ...patch, verification: patch.verification || { status: "unchecked", note: "" } });
} }
// Editing content invalidates the old verification stamp (set() above resets it), function setPoints(points) { onChange({ ...q, points }); }
// but pure point changes shouldn't:
function setPoints(points) {
onChange({ ...q, points });
}
const v = q.verification || { status: "unchecked" }; const v = q.verification || { status: "unchecked" };
const hasMargin = q.type !== "matching"; // matching edits pairs in the center; others use the margin
return ( return (
<div className="card qcard"> <div
className={`qrow${dragging ? " dragging" : ""}${over ? " drag-over" : ""}`}
draggable={grabbed}
onDragStart={(e) => { e.dataTransfer.effectAllowed = "move"; onDragStart?.(); }}
onDragEnter={(e) => { e.preventDefault(); onDragEnter?.(); }}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => { e.preventDefault(); onDrop?.(); }}
onDragEnd={() => { setGrabbed(false); onDragEnd?.(); }}
>
{/* ---- left rail: drag grip + number ---- */}
<div className="qrail">
<button
className="qgrip"
title="Drag to reorder"
aria-label={`Drag question ${index + 1} to reorder`}
onMouseDown={() => setGrabbed(true)}
onMouseUp={() => setGrabbed(false)}
onBlur={() => setGrabbed(false)}
disabled={busy}
>
<IconGripVertical size={18} />
</button>
<span className="qnum">{index + 1}</span>
</div>
{/* ---- center: meta, prompt, student-facing body ---- */}
<div className="qmain">
<div className="qcard-head"> <div className="qcard-head">
<span className="qnum">{index + 1}.</span> <span className="chip chip-pill chip-neutral">{questionTypeLabel(q.type)}</span>
<span className="chip chip-neutral">{questionTypeLabel(q.type)}</span> {v.status === "pass" && <span className="stamp stamp-pass" title="The accuracy check confirmed this answer against your source."><IconCircleCheck size={13} /> Verified</span>}
{v.status === "pass" && <span className="stamp stamp-pass" title="The accuracy check confirmed this answer against your source."> Verified</span>} {v.status === "warn" && <span className="stamp stamp-warn" title={v.note}><IconAlertTriangle size={13} /> Check this</span>}
{v.status === "warn" && <span className="stamp stamp-warn" title={v.note}> Check this</span>}
<span className="qcard-actions"> <span className="qcard-actions">
<label className="small muted" style={{ display: "inline-flex", alignItems: "center", gap: 5 }}> <label className="small muted" style={{ display: "inline-flex", alignItems: "center", gap: 5 }}>
<input className="points-input" type="number" min="0" max="100" value={q.points} <input className="points-input" type="number" min="0" max="100" value={q.points}
onChange={(e) => setPoints(Math.max(0, Number(e.target.value) || 0))} aria-label="Points" /> onChange={(e) => setPoints(Math.max(0, Number(e.target.value) || 0))} aria-label="Points" />
pts pts
</label> </label>
<button className="icon-btn" title="Move up" disabled={index === 0 || busy} onClick={() => onMove(-1)}></button> <button className="icon-btn" title="Move up" aria-label="Move up" disabled={index === 0 || busy} onClick={() => onMove(-1)}><IconArrowUp size={16} /></button>
<button className="icon-btn" title="Move down" disabled={index === count - 1 || busy} onClick={() => onMove(1)}></button> <button className="icon-btn" title="Move down" aria-label="Move down" disabled={index === count - 1 || busy} onClick={() => onMove(1)}><IconArrowDown size={16} /></button>
<button className="icon-btn" title="Regenerate this question" disabled={busy} onClick={() => setRegenOpen((o) => !o)}></button> <button className="icon-btn" title="Regenerate this question" aria-label="Regenerate" disabled={busy} onClick={() => setRegenOpen((o) => !o)}><IconRefresh size={16} /></button>
<button className="icon-btn danger" title="Delete question" disabled={busy} onClick={onDelete}></button> <button className="icon-btn danger" title="Delete question" aria-label="Delete" disabled={busy} onClick={onDelete}><IconX size={16} /></button>
</span> </span>
</div> </div>
{v.status === "warn" && v.note && ( {v.status === "warn" && v.note && (
<div className="alert alert-warn" style={{ marginTop: 0 }}><b>Accuracy reviewer:</b> {v.note}</div> <div className="alert alert-warn" style={{ marginTop: 0 }}><IconAlertTriangle size={17} /> <span><b>Accuracy reviewer:</b> {v.note}</span></div>
)} )}
{regenOpen && ( {regenOpen && (
<div className="alert alert-info" style={{ marginTop: 0 }}> <div className="alert alert-info" style={{ marginTop: 0, display: "block" }}>
<div className="field-label">Regenerate this question from your source</div> <div className="field-label">Regenerate this question from your source</div>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}> <div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
<input type="text" placeholder="Optional note to steer it — e.g. make it harder, focus on the causes" value={note} <input type="text" placeholder="Optional note to steer it — e.g. make it harder, focus on the causes" value={note}
@@ -63,8 +94,8 @@ export default function QuestionCard({ q, index, count, onChange, onMove, onDele
</div> </div>
)} )}
{/* Question prompt */}
<AutoTextarea <AutoTextarea
className="q-prompt"
value={q.question} value={q.question}
onChange={(question) => set({ question })} onChange={(question) => set({ question })}
rows={q.type === "essay" || q.type === "discussion" ? 3 : 2} rows={q.type === "essay" || q.type === "discussion" ? 3 : 2}
@@ -72,37 +103,67 @@ export default function QuestionCard({ q, index, count, onChange, onMove, onDele
placeholder={q.type === "fill_blank" ? "Sentence with ______ (six underscores) for each blank" : "Question text…"} placeholder={q.type === "fill_blank" ? "Sentence with ______ (six underscores) for each blank" : "Question text…"}
/> />
{/* ---- type-specific bodies ---- */}
{q.type === "multiple_choice" && ( {q.type === "multiple_choice" && (
<div style={{ marginTop: 8 }}> <div style={{ marginTop: 10 }}>
{(q.options || []).map((opt, i) => ( {(q.options || []).map((opt, i) => (
<div className="opt-row" key={i}> <div className="opt-row" key={i}>
<input <input type="radio" name={"correct-" + q.id} checked={q.correctIndex === i}
type="radio" name={"correct-" + q.id} checked={q.correctIndex === i} onChange={() => set({ correctIndex: i })} title="Mark as the correct answer" />
onChange={() => set({ correctIndex: i })}
title="Mark as the correct answer"
/>
<span className="opt-letter">{LETTERS[i]}.</span> <span className="opt-letter">{LETTERS[i]}.</span>
<input type="text" value={opt} onChange={(e) => { <input type="text" value={opt} onChange={(e) => { const options = [...q.options]; options[i] = e.target.value; set({ options }); }} placeholder={"Option " + LETTERS[i]} />
const options = [...q.options]; options[i] = e.target.value; set({ options }); <button className="icon-btn danger" title="Remove option" aria-label="Remove option" disabled={q.options.length <= 2}
}} placeholder={"Option " + LETTERS[i]} />
<button className="icon-btn danger" title="Remove option" disabled={q.options.length <= 2}
onClick={() => { onClick={() => {
const options = q.options.filter((_, j) => j !== i); const options = q.options.filter((_, j) => j !== i);
let correctIndex = q.correctIndex; let correctIndex = q.correctIndex;
if (correctIndex === i) correctIndex = 0; if (correctIndex === i) correctIndex = 0;
else if (correctIndex > i) correctIndex -= 1; else if (correctIndex > i) correctIndex -= 1;
set({ options, correctIndex }); set({ options, correctIndex });
}}></button> }}><IconX size={15} /></button>
</div> </div>
))} ))}
{(q.options || []).length < 6 && ( {(q.options || []).length < 6 && (
<button className="btn btn-sm" style={{ marginTop: 4 }} onClick={() => set({ options: [...q.options, ""] })}>+ Add option</button> <button className="btn btn-sm" style={{ marginTop: 6 }} onClick={() => set({ options: [...q.options, ""] })}>+ Add option</button>
)} )}
<div className="field-hint">The <span className="redpen">red radio</span> marks the correct answer.</div> <div className="field-hint">The <span className="redpen">red radio</span> marks the correct answer.</div>
</div> </div>
)} )}
{q.type === "matching" && (
<div className="answer-key" style={{ marginTop: 12 }}>
<span className="ak-label">Answer key correct pairs (the student copy shuffles the right column)</span>
{(q.pairs || []).map((p, i) => (
<div key={i} style={{ display: "flex", gap: 7, margin: "5px 0", alignItems: "center", flexWrap: "wrap" }}>
<input type="text" value={p.left} placeholder="Left item" style={{ flex: 1, minWidth: 130 }}
onChange={(e) => { const pairs = q.pairs.map((x, j) => j === i ? { ...x, left: e.target.value } : x); set({ pairs }); }} />
<IconArrowRight size={16} className="muted" />
<input type="text" value={p.right} placeholder="Matches with" style={{ flex: 1, minWidth: 130 }}
onChange={(e) => { const pairs = q.pairs.map((x, j) => j === i ? { ...x, right: e.target.value } : x); set({ pairs }); }} />
<button className="icon-btn danger" aria-label="Remove pair" disabled={(q.pairs || []).length <= 2} onClick={() => set({ pairs: q.pairs.filter((_, j) => j !== i) })}><IconX size={15} /></button>
</div>
))}
{(q.pairs || []).length < 10 && (
<button className="btn btn-sm" onClick={() => set({ pairs: [...q.pairs, { left: "", right: "" }] })}>+ Add pair</button>
)}
</div>
)}
{q.sourceRef ? (
<p className="small muted" style={{ margin: "12px 0 0" }}><b style={{ fontWeight: 600 }}>Source:</b> &ldquo;{q.sourceRef}&rdquo;</p>
) : null}
</div>
{/* ---- right margin: the answer key, in red pen ---- */}
{hasMargin && (
<div className="qmargin">
{q.type === "multiple_choice" && (
<div className="answer-key">
<span className="ak-label">Answer key</span>
<p style={{ margin: 0, fontWeight: 600, color: "var(--redpen-ink)" }}>
Correct: <span className="redpen">{LETTERS[q.correctIndex] || "?"}</span>
</p>
</div>
)}
{q.type === "true_false" && ( {q.type === "true_false" && (
<div className="answer-key"> <div className="answer-key">
<span className="ak-label">Answer key</span> <span className="ak-label">Answer key</span>
@@ -122,11 +183,9 @@ export default function QuestionCard({ q, index, count, onChange, onMove, onDele
<span className="ak-label">Answer key sample answer</span> <span className="ak-label">Answer key sample answer</span>
<AutoTextarea value={q.sampleAnswer} onChange={(sampleAnswer) => set({ sampleAnswer })} rows={2} placeholder="A model answer…" /> <AutoTextarea value={q.sampleAnswer} onChange={(sampleAnswer) => set({ sampleAnswer })} rows={2} placeholder="A model answer…" />
<span className="ak-label" style={{ marginTop: 8 }}>Must-include points (one per line)</span> <span className="ak-label" style={{ marginTop: 8 }}>Must-include points (one per line)</span>
<AutoTextarea <AutoTextarea value={(q.keyPoints || []).join("\n")}
value={(q.keyPoints || []).join("\n")}
onChange={(text) => set({ keyPoints: text.split("\n").map((s) => s.trim()).filter(Boolean) })} onChange={(text) => set({ keyPoints: text.split("\n").map((s) => s.trim()).filter(Boolean) })}
rows={2} placeholder={"point 1\npoint 2"} rows={2} placeholder={"point 1\npoint 2"} />
/>
</div> </div>
)} )}
@@ -137,13 +196,13 @@ export default function QuestionCard({ q, index, count, onChange, onMove, onDele
<span className="ak-label" style={{ marginTop: 8 }}>Rubric</span> <span className="ak-label" style={{ marginTop: 8 }}>Rubric</span>
{(q.rubric || []).map((r, i) => ( {(q.rubric || []).map((r, i) => (
<div key={i} style={{ display: "flex", gap: 7, margin: "5px 0", flexWrap: "wrap" }}> <div key={i} style={{ display: "flex", gap: 7, margin: "5px 0", flexWrap: "wrap" }}>
<input type="text" value={r.criterion} placeholder="Criterion" style={{ flex: 2, minWidth: 140 }} <input type="text" value={r.criterion} placeholder="Criterion" style={{ flex: 2, minWidth: 120 }}
onChange={(e) => { const rubric = q.rubric.map((x, j) => j === i ? { ...x, criterion: e.target.value } : x); set({ rubric }); }} /> onChange={(e) => { const rubric = q.rubric.map((x, j) => j === i ? { ...x, criterion: e.target.value } : x); set({ rubric }); }} />
<input type="number" className="points-input" value={r.points} min="0" title="Points" <input type="number" className="points-input" value={r.points} min="0" title="Points"
onChange={(e) => { const rubric = q.rubric.map((x, j) => j === i ? { ...x, points: Math.max(0, Number(e.target.value) || 0) } : x); set({ rubric }); }} /> onChange={(e) => { const rubric = q.rubric.map((x, j) => j === i ? { ...x, points: Math.max(0, Number(e.target.value) || 0) } : x); set({ rubric }); }} />
<input type="text" value={r.description} placeholder="What earns full points" style={{ flex: 3, minWidth: 160 }} <input type="text" value={r.description} placeholder="What earns full points" style={{ flex: 3, minWidth: 140 }}
onChange={(e) => { const rubric = q.rubric.map((x, j) => j === i ? { ...x, description: e.target.value } : x); set({ rubric }); }} /> onChange={(e) => { const rubric = q.rubric.map((x, j) => j === i ? { ...x, description: e.target.value } : x); set({ rubric }); }} />
<button className="icon-btn danger" onClick={() => set({ rubric: q.rubric.filter((_, j) => j !== i) })}></button> <button className="icon-btn danger" aria-label="Remove criterion" onClick={() => set({ rubric: q.rubric.filter((_, j) => j !== i) })}><IconX size={15} /></button>
</div> </div>
))} ))}
<button className="btn btn-sm" onClick={() => set({ rubric: [...(q.rubric || []), { criterion: "", points: 0, description: "" }] })}>+ Add criterion</button> <button className="btn btn-sm" onClick={() => set({ rubric: [...(q.rubric || []), { criterion: "", points: 0, description: "" }] })}>+ Add criterion</button>
@@ -155,7 +214,7 @@ export default function QuestionCard({ q, index, count, onChange, onMove, onDele
{q.type === "fill_blank" && ( {q.type === "fill_blank" && (
<div className="answer-key"> <div className="answer-key">
<span className="ak-label">Answer key one answer per blank, in order</span> <span className="ak-label">Answer key one per blank, in order</span>
{(q.answers || []).map((a, i) => ( {(q.answers || []).map((a, i) => (
<div key={i} style={{ display: "flex", gap: 7, alignItems: "center", margin: "5px 0" }}> <div key={i} style={{ display: "flex", gap: 7, alignItems: "center", margin: "5px 0" }}>
<span className="opt-letter">{i + 1}.</span> <span className="opt-letter">{i + 1}.</span>
@@ -163,65 +222,36 @@ export default function QuestionCard({ q, index, count, onChange, onMove, onDele
</div> </div>
))} ))}
<div className="field-hint"> <div className="field-hint">
Blanks found in the question: {(String(q.question).match(/_{3,}/g) || []).length}. Blanks in the question: {(String(q.question).match(/_{3,}/g) || []).length}.{" "}
{" "}
<button className="btn btn-sm" onClick={() => { <button className="btn btn-sm" onClick={() => {
const blanks = (String(q.question).match(/_{3,}/g) || []).length || 1; const blanks = (String(q.question).match(/_{3,}/g) || []).length || 1;
const answers = Array.from({ length: blanks }, (_, i) => q.answers?.[i] || ""); const answers = Array.from({ length: blanks }, (_, i) => q.answers?.[i] || "");
set({ answers }); set({ answers });
}}>Match answer slots to blanks</button> }}>Match slots to blanks</button>
</div> </div>
</div> </div>
)} )}
{q.type === "matching" && (
<div className="answer-key">
<span className="ak-label">Answer key correct pairs (the student copy shuffles the right column)</span>
{(q.pairs || []).map((p, i) => (
<div key={i} style={{ display: "flex", gap: 7, margin: "5px 0", alignItems: "center", flexWrap: "wrap" }}>
<input type="text" value={p.left} placeholder="Left item" style={{ flex: 1, minWidth: 130 }}
onChange={(e) => { const pairs = q.pairs.map((x, j) => j === i ? { ...x, left: e.target.value } : x); set({ pairs }); }} />
<span className="muted"></span>
<input type="text" value={p.right} placeholder="Matches with" style={{ flex: 1, minWidth: 130 }}
onChange={(e) => { const pairs = q.pairs.map((x, j) => j === i ? { ...x, right: e.target.value } : x); set({ pairs }); }} />
<button className="icon-btn danger" disabled={(q.pairs || []).length <= 2} onClick={() => set({ pairs: q.pairs.filter((_, j) => j !== i) })}></button>
</div>
))}
{(q.pairs || []).length < 10 && (
<button className="btn btn-sm" onClick={() => set({ pairs: [...q.pairs, { left: "", right: "" }] })}>+ Add pair</button>
)}
</div>
)}
{q.type === "discussion" && ( {q.type === "discussion" && (
<div className="answer-key"> <div className="answer-key">
<span className="ak-label">Facilitator notes key talking points (one per line)</span> <span className="ak-label">Facilitator notes key talking points</span>
<AutoTextarea <AutoTextarea value={(q.talkingPoints || []).join("\n")}
value={(q.talkingPoints || []).join("\n")} onChange={(text) => set({ talkingPoints: text.split("\n").map((s) => s.trim()).filter(Boolean) })} rows={3} />
onChange={(text) => set({ talkingPoints: text.split("\n").map((s) => s.trim()).filter(Boolean) })}
rows={3}
/>
<span className="ak-label" style={{ marginTop: 8 }}>Follow-up questions (one per line)</span> <span className="ak-label" style={{ marginTop: 8 }}>Follow-up questions (one per line)</span>
<AutoTextarea <AutoTextarea value={(q.followUps || []).join("\n")}
value={(q.followUps || []).join("\n")} onChange={(text) => set({ followUps: text.split("\n").map((s) => s.trim()).filter(Boolean) })} rows={2} />
onChange={(text) => set({ followUps: text.split("\n").map((s) => s.trim()).filter(Boolean) })}
rows={2}
/>
</div> </div>
)} )}
{/* Explanation + source ref, present for all types */} {/* Explanation, present for all types except discussion */}
{q.type !== "discussion" && ( {q.type !== "discussion" && (
<div className="answer-key" style={{ background: "#fff7f5" }}> <div className="answer-key">
<span className="ak-label">Explanation (teacher key)</span> <span className="ak-label">Explanation (teacher key)</span>
<AutoTextarea value={q.explanation} onChange={(explanation) => set({ explanation })} rows={2} placeholder="Why this answer is correct…" /> <AutoTextarea value={q.explanation} onChange={(explanation) => set({ explanation })} rows={3} placeholder="Why this answer is correct…" />
</div>
)}
</div> </div>
)} )}
{q.sourceRef ? (
<p className="small muted" style={{ margin: "9px 0 0" }}>
<b>Source:</b> &ldquo;{q.sourceRef}&rdquo;
</p>
) : null}
</div> </div>
); );
} }
+124
View File
@@ -0,0 +1,124 @@
"use client";
// components/Sidebar.jsx — the Open Workspace shell: brand, primary nav, a live
// library folder tree grouped by subject, and a pinned theme toggle + New button.
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
IconPencil, IconPlus, IconSettings, IconMoon, IconSun,
IconChevronDown, IconChevronRight, IconFileText, IconPencilPlus,
} from "@tabler/icons-react";
import { groupBySubject } from "@/lib/group";
const NAV = [
{ href: "/", label: "Create", icon: IconPlus },
{ href: "/settings", label: "Settings", icon: IconSettings },
];
export default function Sidebar() {
const pathname = usePathname();
const [theme, setTheme] = useState(null);
const [items, setItems] = useState([]);
const [collapsed, setCollapsed] = useState({}); // subject -> true when collapsed
useEffect(() => {
setTheme(document.documentElement.dataset.theme === "dark" ? "dark" : "light");
}, []);
const load = useCallback(() => {
fetch("/api/assignments")
.then((r) => r.json())
.then((d) => setItems(d.assignments || []))
.catch(() => {});
}, []);
// Reload the tree whenever the route changes (covers create / delete / rename).
useEffect(() => { load(); }, [load, pathname]);
function toggleTheme() {
const next = document.documentElement.dataset.theme === "dark" ? "light" : "dark";
if (next === "dark") document.documentElement.dataset.theme = "dark";
else delete document.documentElement.dataset.theme;
try { localStorage.setItem("theme", next); } catch {}
setTheme(next);
}
const groups = useMemo(() => groupBySubject(items), [items]);
const activeId = pathname.startsWith("/editor/") ? decodeURIComponent(pathname.split("/editor/")[1] || "") : "";
const isNavActive = (href) => (href === "/" ? pathname === "/" : pathname.startsWith(href));
return (
<aside className="sidebar" aria-label="Sidebar">
<Link href="/" className="sidebar-brand">
<span className="brand-mark" aria-hidden="true"><IconPencil size={19} stroke={2} /></span>
<span>
<span className="brand-name" style={{ display: "block" }}>Mr.&nbsp;Drew&rsquo;s</span>
<span className="brand-sub">Assignment Creator</span>
</span>
</Link>
<nav className="sidebar-nav" aria-label="Main">
{NAV.map(({ href, label, icon: Icon }) => (
<Link key={href} href={href} className={`navrow${isNavActive(href) ? " active" : ""}`}>
<Icon size={19} stroke={2} />
<span className="navrow-label">{label}</span>
</Link>
))}
</nav>
<div className="sidebar-scroll">
<div className="sidebar-label">
<span>Library</span>
<Link href="/library" className="navrow-label" style={{ fontSize: "0.66rem", color: "var(--board-deep)", fontWeight: 700 }}>
All
</Link>
</div>
{groups.length === 0 && (
<p className="faint" style={{ fontSize: "0.8rem", padding: "4px 10px", margin: 0 }}>
No assignments yet.
</p>
)}
{groups.map((g) => {
const open = !collapsed[g.key];
return (
<div key={g.key} className="tree-group">
<button
className="tree-folder"
onClick={() => setCollapsed((c) => ({ ...c, [g.key]: open }))}
aria-expanded={open}
>
{open ? <IconChevronDown size={15} className="chev" /> : <IconChevronRight size={15} className="chev" />}
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{g.key}</span>
<span className="tree-count">{g.items.length}</span>
</button>
{open && g.items.map((a) => (
<Link key={a.id} href={"/editor/" + a.id} className={`tree-leaf${activeId === a.id ? " active" : ""}`} title={a.title}>
<IconFileText size={15} style={{ flex: "none" }} />
<span>{a.title}</span>
</Link>
))}
</div>
);
})}
</div>
<div className="sidebar-foot">
<button
type="button"
className="icon-btn theme-toggle"
onClick={toggleTheme}
aria-label={theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
title={theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
>
{theme === "dark" ? <IconSun size={18} /> : <IconMoon size={18} />}
</button>
<Link href="/" className="btn btn-primary btn-block">
<IconPencilPlus size={17} /> <span className="navrow-label">New assignment</span>
</Link>
</div>
</aside>
);
}
+22
View File
@@ -0,0 +1,22 @@
// lib/group.js — group saved assignments by subject for the sidebar tree and
// the Library page. Shared so both stay in sync.
export function groupBySubject(items) {
const map = new Map();
for (const a of items || []) {
const key = (a.subject && a.subject.trim()) || a.gradeLevel || "Other";
if (!map.has(key)) map.set(key, []);
map.get(key).push(a);
}
const groups = [...map.entries()].map(([key, list]) => ({
key,
items: list.slice().sort((x, y) => String(y.updatedAt || "").localeCompare(String(x.updatedAt || ""))),
}));
// Stable, friendly order: alphabetical by subject, "Other" last.
groups.sort((a, b) => {
if (a.key === "Other") return 1;
if (b.key === "Other") return -1;
return a.key.localeCompare(b.key);
});
return groups;
}
+27
View File
@@ -8,6 +8,7 @@
"name": "mr-drews-assignment-creator", "name": "mr-drews-assignment-creator",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@tabler/icons-react": "^3.44.0",
"@tailwindcss/postcss": "^4.3.1", "@tailwindcss/postcss": "^4.3.1",
"next": "14.2.18", "next": "14.2.18",
"postcss": "^8.5.15", "postcss": "^8.5.15",
@@ -242,6 +243,32 @@
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@tabler/icons": {
"version": "3.44.0",
"resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.44.0.tgz",
"integrity": "sha512-Wn0AOZG9sg0L+bjfMqq4eNhC6pQjIrk94LvvWYNYkY8KH8wC3YILRzQlrnVJc4FUeMxH/AK97QsYCX35H3LndA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/codecalm"
}
},
"node_modules/@tabler/icons-react": {
"version": "3.44.0",
"resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.44.0.tgz",
"integrity": "sha512-8+rvzBbVm/1Z3sG3x7GUNAaxIKxwgz8xaMhRs23nrCnMTKRFAhEC+82zAIFeAA0seXdrAGX5HFCkaLpGK2rVHg==",
"license": "MIT",
"dependencies": {
"@tabler/icons": "3.44.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/codecalm"
},
"peerDependencies": {
"react": ">= 16"
}
},
"node_modules/@tailwindcss/node": { "node_modules/@tailwindcss/node": {
"version": "4.3.1", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.1.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.1.tgz",
+1
View File
@@ -9,6 +9,7 @@
"start": "next start" "start": "next start"
}, },
"dependencies": { "dependencies": {
"@tabler/icons-react": "^3.44.0",
"@tailwindcss/postcss": "^4.3.1", "@tailwindcss/postcss": "^4.3.1",
"next": "14.2.18", "next": "14.2.18",
"postcss": "^8.5.15", "postcss": "^8.5.15",