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.
import { useEffect, useRef, useState } from "react";
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 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";
export default function EditorPage() {
@@ -23,6 +27,8 @@ export default function EditorPage() {
const [canvasOpen, setCanvasOpen] = useState(false);
const [error, setError] = useState("");
const [profile, setProfile] = useState({});
const [dragIndex, setDragIndex] = useState(null);
const [overIndex, setOverIndex] = useState(null);
const toastTimer = useRef(null);
useEffect(() => {
@@ -30,19 +36,17 @@ export default function EditorPage() {
.then(async (r) => {
const data = await r.json();
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);
})
.catch((e) => setLoadErr(String(e.message || e)));
fetch("/api/settings")
.then((r) => r.json())
.then((s) => setProfile(s?.profile || {}))
.catch(() => {});
fetch("/api/settings").then((r) => r.json()).then((s) => setProfile(s?.profile || {})).catch(() => {});
}, [id]);
useEffect(() => {
function onBeforeUnload(e) {
if (dirty) { e.preventDefault(); e.returnValue = ""; }
}
function onBeforeUnload(e) { if (dirty) { e.preventDefault(); e.returnValue = ""; } }
window.addEventListener("beforeunload", onBeforeUnload);
return () => window.removeEventListener("beforeunload", onBeforeUnload);
}, [dirty]);
@@ -53,17 +57,10 @@ export default function EditorPage() {
toastTimer.current = setTimeout(() => setToast(""), 2400);
}
function patch(p) {
setA((cur) => ({ ...cur, ...p }));
setDirty(true);
}
function patch(p) { setA((cur) => ({ ...cur, ...p })); setDirty(true); }
function setQuestion(i, q) {
setA((cur) => {
const questions = [...cur.questions];
questions[i] = q;
return { ...cur, questions };
});
setA((cur) => { const questions = [...cur.questions]; questions[i] = q; return { ...cur, questions }; });
setDirty(true);
}
@@ -78,6 +75,19 @@ export default function EditorPage() {
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) {
if (!confirm("Delete question " + (i + 1) + "?")) return;
setA((cur) => ({ ...cur, questions: cur.questions.filter((_, j) => j !== i) }));
@@ -85,48 +95,31 @@ export default function EditorPage() {
}
async function save(silent) {
setSaving(true);
setError("");
setSaving(true); setError("");
try {
const res = await fetch("/api/assignments/" + id, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(a),
});
const res = await fetch("/api/assignments/" + id, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(a) });
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Save failed.");
setA(data);
setDirty(false);
setA(data); setDirty(false);
if (!silent) showToast("Saved");
} catch (e) {
setError(String(e.message || e));
} finally {
setSaving(false);
}
} catch (e) { setError(String(e.message || e)); }
finally { setSaving(false); }
}
async function regenerateQuestion(i, note) {
const q = a.questions[i];
setBusyQ(q.id);
setError("");
setBusyQ(q.id); setError("");
try {
const data = await postJson("/api/generate", {
stage: "question",
source: a.source?.text || "",
stage: "question", source: a.source?.text || "",
config: a.config || { assignmentType: a.assignmentType, gradeLevel: a.gradeLevel, subject: a.subject, difficulty: a.difficulty },
type: q.type,
note,
replacing: { question: q.question },
type: q.type, note, replacing: { question: q.question },
existingQuestions: a.questions.filter((_, j) => j !== i).map((x) => ({ question: x.question })),
});
const next = { ...data.question, points: q.points };
setQuestion(i, next);
setQuestion(i, { ...data.question, points: q.points });
showToast("Question " + (i + 1) + " regenerated");
} catch (e) {
setError(String(e.message || e));
} finally {
setBusyQ(null);
}
} catch (e) { setError(String(e.message || e)); }
finally { setBusyQ(null); }
}
async function addQuestion(type, withAI) {
@@ -136,55 +129,38 @@ export default function EditorPage() {
setDirty(true);
return;
}
setBusyQ("__new__");
setError("");
setBusyQ("__new__"); setError("");
try {
const data = await postJson("/api/generate", {
stage: "question",
source: a.source?.text || "",
stage: "question", source: a.source?.text || "",
config: a.config || { assignmentType: a.assignmentType, gradeLevel: a.gradeLevel, subject: a.subject, difficulty: a.difficulty },
type,
existingQuestions: a.questions.map((x) => ({ question: x.question })),
type, existingQuestions: a.questions.map((x) => ({ question: x.question })),
});
setA((cur) => ({ ...cur, questions: [...cur.questions, data.question] }));
setDirty(true);
showToast("Question added");
} catch (e) {
setError(String(e.message || e));
} finally {
setBusyQ(null);
}
} catch (e) { setError(String(e.message || e)); }
finally { setBusyQ(null); }
}
async function reverify() {
setVerifying(true);
setError("");
setVerifying(true); setError("");
try {
const data = await postJson("/api/generate", {
stage: "verify",
source: a.source?.text || "",
stage: "verify", source: a.source?.text || "",
config: a.config || { assignmentType: a.assignmentType, gradeLevel: a.gradeLevel, subject: a.subject, difficulty: a.difficulty },
questions: a.questions,
});
setA((cur) => ({
...cur,
questions: cur.questions.map((q) =>
data.verifications[q.id]
? { ...q, verification: data.verifications[q.id] }
: { ...q, verification: { status: "unchecked", note: "" } }
),
data.verifications[q.id] ? { ...q, verification: data.verifications[q.id] } : { ...q, verification: { status: "unchecked", note: "" } }),
}));
setDirty(true);
const warns = Object.values(data.verifications).filter((v) => v.status === "warn").length;
showToast(warns
? `Accuracy check done — ${warns} question${warns === 1 ? "" : "s"} flagged`
: "Accuracy check done — all clear"
);
} catch (e) {
setError(String(e.message || e));
} finally {
setVerifying(false);
}
showToast(warns ? `Accuracy check done — ${warns} question${warns === 1 ? "" : "s"} flagged` : "Accuracy check done — all clear");
} catch (e) { setError(String(e.message || e)); }
finally { setVerifying(false); }
}
function doExport(kind, who) {
@@ -195,30 +171,30 @@ export default function EditorPage() {
if (kind === "doc") { exportDoc(a, opts); showToast("Downloaded Word file"); }
if (kind === "print") { exportPrint(a, opts); }
if (kind === "copy") { exportClipboard(a, opts).then(() => showToast("Copied to clipboard")); }
} catch (e) {
setError(String(e.message || e));
}
} catch (e) { setError(String(e.message || e)); }
}
if (loadErr) {
return (
<div className="page page-narrow">
<div className="empty">
<h3>Couldn&rsquo;t open that assignment</h3>
<p>{loadErr}</p>
<button className="btn btn-primary" style={{ marginTop: 12 }} onClick={() => router.push("/library")}>Go to Library</button>
</div>
</div>
);
}
if (!a) {
return (
<div style={{ padding: "40px 0" }}>
<div className="card skeleton-card" style={{ marginBottom: 16 }}>
<div className="page page-narrow">
<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 short" />
</div>
{[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-line full" />
<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;
return (
<div>
<div className="page-head" style={{ display: "flex", alignItems: "flex-start", gap: 12, flexWrap: "wrap" }}>
<div className="page page-narrow">
<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 }}>
<input
type="text"
value={a.title}
onChange={(e) => patch({ title: e.target.value })}
aria-label="Assignment title"
style={{
fontFamily: "var(--font-display)", fontSize: "1.6rem", fontWeight: 700,
border: "1.5px solid transparent", background: "transparent",
padding: "4px 8px", marginLeft: -8, borderRadius: 8, width: "100%",
transition: "border-color 0.15s, background 0.15s",
}}
onFocus={(e) => { e.target.style.borderColor = "var(--line-strong)"; e.target.style.background = "var(--field-bg)"; }}
onBlur={(e) => { e.target.style.borderColor = "transparent"; e.target.style.background = "transparent"; }}
/>
<p className="muted small" style={{ margin: "4px 0 0 2px" }}>
<div className="title-edit">
<input type="text" value={a.title} onChange={(e) => patch({ title: e.target.value })} aria-label="Assignment title" placeholder="Untitled assignment" />
<IconPencil size={17} className="pen" />
</div>
<p className="muted small" style={{ margin: "6px 0 0 2px" }}>
{[a.assignmentType?.replace("_", " "), a.gradeLevel, a.subject].filter(Boolean).join(" · ")} · {a.questions.length} questions · {totalPoints(a.questions)} points
{a.source?.name ? <> · from <i>{a.source.name}</i></> : null}
</p>
</div>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
<div style={{ position: "relative" }}>
<button className="btn" onClick={() => { setExportOpen((o) => !o); setAddOpen(false); }}>Export </button>
<button className="btn" onClick={() => { setExportOpen((o) => !o); setAddOpen(false); }}>Export <IconChevronDown size={15} /></button>
{exportOpen && (
<div className="card" style={{ position: "absolute", right: 0, top: "calc(100% + 6px)", zIndex: 30, width: 295, padding: 16, animation: "fade-in-up 0.18s ease" }}>
<div className="pop" style={{ position: "absolute", right: 0, top: "calc(100% + 6px)", zIndex: 30, width: 300, padding: 16 }}>
<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("doc", false)}>Word</button>
<button className="btn btn-sm" onClick={() => doExport("txt", false)}>Text</button>
<button className="btn btn-sm" onClick={() => doExport("copy", false)}>Copy</button>
</div>
<div className="field-label" style={{ color: "var(--redpen)" }}>Teacher version (answer key)</div>
<div style={{ display: "flex", gap: 6, flexWrap: "wrap", marginBottom: 12 }}>
<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("doc", true)}>Word</button>
<button className="btn btn-sm" onClick={() => doExport("txt", true)}>Text</button>
<button className="btn btn-sm" onClick={() => doExport("copy", true)}>Copy</button>
</div>
<div className="field-label">Complete packet student + answer key</div>
<div style={{ display: "flex", gap: 6, flexWrap: "wrap", 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("doc", "packet")}>Word</button>
</div>
<div className="field-label">Canvas (LMS)</div>
<button className="btn btn-sm" onClick={() => { setExportOpen(false); setCanvasOpen(true); }}>
Set up &amp; download .zip
</button>
<button className="btn btn-sm" onClick={() => { setExportOpen(false); setCanvasOpen(true); }}>Set up &amp; download .zip</button>
</div>
)}
</div>
<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 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>
</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 && (
<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>
)}
{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>
<textarea rows={2} value={a.instructions || ""} onChange={(e) => patch({ instructions: e.target.value })} placeholder="Instructions students see at the top…" />
</label>
@@ -316,6 +282,8 @@ export default function EditorPage() {
</label>
) : null}
{/* the questions as one continuous document */}
<div className="panel doc" style={{ marginTop: 16 }}>
{a.questions.map((q, i) => (
<QuestionCard
key={q.id}
@@ -323,28 +291,31 @@ export default function EditorPage() {
index={i}
count={a.questions.length}
busy={busyQ === q.id}
dragging={dragIndex === i}
over={overIndex === i && dragIndex !== i}
onChange={(next) => setQuestion(i, next)}
onMove={(dir) => moveQuestion(i, dir)}
onDelete={() => deleteQuestion(i)}
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__"}>
{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>
{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) => (
<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>
<button
className="btn btn-sm btn-primary"
onClick={() => addQuestion(t.id, true)}
disabled={!a.source?.text}
title={a.source?.text ? "Generate from your source" : "No source stored with this assignment"}
>AI</button>
<button className="btn btn-sm btn-primary" onClick={() => addQuestion(t.id, true)} disabled={!a.source?.text}
title={a.source?.text ? "Generate from your source" : "No source stored with this assignment"}>AI</button>
<button className="btn btn-sm" onClick={() => addQuestion(t.id, false)}>Blank</button>
</div>
))}
@@ -352,7 +323,7 @@ export default function EditorPage() {
)}
<span className="spacer" />
<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>
</div>
@@ -371,11 +342,7 @@ export default function EditorPage() {
}
async function postJson(url, body) {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const res = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || `Request failed (${res.status}).`);
return data;
+457 -252
View File
@@ -1,13 +1,14 @@
/* 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";
/* =============================================================================
Mr. Drew's Assignment Creator — design system
Identity: a well-kept teacher's desk. Lora serif headings, chalkboard
green for primary actions, and the signature: everything answer-key wears
red pen — the color teachers actually grade in.
Mr. Drew's Assignment Creator — design system ("Open Workspace")
Identity: a well-kept teacher's desk, now laid out as a workspace.
A solid chalkboard-green sidebar, flat ruled surfaces (elevation is earned,
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 {
@@ -17,13 +18,16 @@
--panel: #ffffff;
--ink: #1e2d28;
--ink-soft: #58706a;
--ink-faint: #8aa099;
--board: #2f6b58;
--board-deep: #245546;
--board-tint: #e4eeea;
--board-glow: rgba(47, 107, 88, 0.12);
--redpen: #b8412f;
--redpen-ink: #8c3022;
--redpen-tint: #faece9;
--gold: #b98a23;
--gold-ink: #7a5a14;
--gold-tint: #faf3e2;
--line: #dde4df;
--line-strong: #c5d0cb;
@@ -32,9 +36,24 @@
--tab-track: #e6ecea;
--chip-neutral-bg: #eaeeec;
--empty-bg: #fafcfb;
--shadow-sm: 0 1px 3px rgba(34, 49, 44, 0.07), 0 2px 8px rgba(34, 49, 44, 0.05);
--shadow: 0 2px 6px rgba(34, 49, 44, 0.06), 0 6px 20px rgba(34, 49, 44, 0.07);
--shadow-lg: 0 8px 32px rgba(34, 49, 44, 0.12), 0 2px 8px rgba(34, 49, 44, 0.06);
--disabled-bg: #eaeeec;
--disabled-ink: #97a8a2;
/* 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;
/* type */
@@ -47,6 +66,8 @@
--radius-xs: 5px;
--transition: 0.18s ease;
--transition-fast: 0.1s ease;
--sidebar-w: 264px;
}
/* The same desk after dark */
@@ -55,13 +76,16 @@
--panel: #1b2320;
--ink: #e2eae5;
--ink-soft: #92aaa2;
--ink-faint: #6c847c;
--board: #4d9c82;
--board-deep: #7bc0a8;
--board-tint: #1e3028;
--board-glow: rgba(77, 156, 130, 0.15);
--redpen: #e07a63;
--redpen-ink: #f0a795;
--redpen-tint: #38221e;
--gold: #d3a94c;
--gold-ink: #e2c47e;
--gold-tint: #342d1a;
--line: #263028;
--line-strong: #374440;
@@ -70,18 +94,27 @@
--tab-track: #161d1a;
--chip-neutral-bg: #262f2b;
--empty-bg: #171e1a;
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3), 0 2px 8px rgba(0, 0, 0, 0.25);
--shadow: 0 2px 6px rgba(0, 0, 0, 0.3), 0 6px 20px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.4), 0 2px 8px rgba(0, 0, 0, 0.3);
--disabled-bg: #222b27;
--disabled-ink: #5d726b;
--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;
}
html[data-theme="dark"] .alert-error { border-color: #5a2f26; color: #f0a795; }
html[data-theme="dark"] .alert-warn { border-color: #564820; color: #e2c47e; }
html[data-theme="dark"] .alert-info { border-color: #2d5040; color: #8dcbb5; }
html[data-theme="dark"] .toast { background: #e2eae5; color: #111815; }
html[data-theme="dark"] .brand-mark { color: #f1f5f2; }
html[data-theme="dark"] .nav-scrolled { box-shadow: 0 4px 24px rgba(0,0,0,0.5); }
* { box-sizing: border-box; }
@@ -91,21 +124,23 @@
background: var(--paper);
color: var(--ink);
font-family: var(--font-body);
font-size: 15.5px;
line-height: 1.58;
font-size: 14.75px;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Wider, clearer type ramp — levels separated by size AND family/weight */
h1, h2, h3 {
font-family: var(--font-display);
font-weight: 700;
letter-spacing: -0.01em;
letter-spacing: -0.012em;
margin: 0;
line-height: 1.25;
line-height: 1.2;
color: var(--ink);
}
h1 { font-size: 1.85rem; }
h2 { font-size: 1.3rem; }
h1 { font-size: 2.1rem; }
h2 { font-size: 1.35rem; }
h3 { font-size: 1.08rem; }
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 fade-in-up {
from { opacity: 0; transform: translateY(10px); }
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@@ -145,7 +180,7 @@
@keyframes step-complete {
0% { transform: scale(1); }
50% { transform: scale(1.2); }
50% { transform: scale(1.18); }
100% { transform: scale(1); }
}
@@ -154,296 +189,393 @@
50% { opacity: 1; }
}
@keyframes progress-fill {
from { width: 0%; }
to { width: 100%; }
}
@keyframes spin-ring {
0% { transform: rotate(0deg); stroke-dashoffset: 60; }
50% { stroke-dashoffset: 15; }
100% { transform: rotate(360deg); stroke-dashoffset: 60; }
}
/* =====================================================================
LAYOUT SHELL
===================================================================== */
@layer components {
.shell { max-width: 1020px; margin: 0 auto; padding: 32px 22px 90px; }
/* ---------- navigation ---------- */
.topnav {
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
border-bottom: 1px solid var(--line);
position: sticky; top: 0; z-index: 50;
transition: box-shadow var(--transition);
/* =====================================================================
APP SHELL — fixed left sidebar + scrolling content
===================================================================== */
.app { display: flex; min-height: 100vh; align-items: stretch; }
.content { flex: 1; min-width: 0; }
/* pages opt into padding; the Create flow renders full-bleed */
.page { padding: 36px 48px 90px; }
.page-narrow { max-width: 1120px; }
/* ---------- sidebar ---------- */
.sidebar {
width: var(--sidebar-w); flex: none;
background: var(--panel);
border-right: 1px solid var(--line);
display: flex; flex-direction: column;
position: sticky; top: 0; height: 100vh;
z-index: 40;
}
html[data-theme="dark"] .topnav {
background: rgba(27, 35, 32, 0.85);
.sidebar-brand {
display: flex; align-items: center; gap: 11px;
padding: 20px 18px 16px;
white-space: nowrap; text-decoration: none;
}
.topnav.nav-scrolled {
box-shadow: 0 4px 24px rgba(34, 49, 44, 0.1);
border-bottom-color: var(--line-strong);
}
.topnav-inner {
max-width: 1020px; margin: 0 auto; padding: 0 22px;
display: flex; align-items: center; gap: 24px; height: 60px;
}
.brand {
font-family: var(--font-display); font-size: 1.08rem; font-weight: 700; color: var(--ink);
display: flex; align-items: center; gap: 10px; white-space: nowrap;
}
.brand:hover { text-decoration: none; }
.sidebar-brand:hover { text-decoration: none; }
.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%);
color: #fff;
display: inline-flex; align-items: center; justify-content: center;
font-size: 15px; flex: none;
box-shadow: 0 2px 6px rgba(47, 107, 88, 0.35);
}
.navlinks { display: flex; gap: 2px; margin-left: auto; }
.theme-toggle {
flex: none; width: 36px; height: 36px; border-radius: 9px;
font-size: 1rem; transition: background var(--transition), transform var(--transition-fast);
}
.theme-toggle:hover { transform: rotate(18deg); }
.navlink {
padding: 7px 14px; border-radius: var(--radius-sm); color: var(--ink-soft);
font-weight: 500; font-size: 0.93rem; transition: background var(--transition), color var(--transition);
}
.navlink:hover { background: var(--hover-bg); color: var(--ink); text-decoration: none; }
.navlink.active { background: var(--board-tint); color: var(--board-deep); font-weight: 600; }
.brand-name { font-family: var(--font-display); font-weight: 700; font-size: 1.02rem; color: var(--ink); line-height: 1.15; }
.brand-sub { font-size: 0.72rem; color: var(--ink-soft); font-weight: 500; }
/* ---------- 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 {
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
padding: 24px;
transition: box-shadow var(--transition), border-color var(--transition), transform 0.2s ease;
animation: fade-in-up 0.3s ease both;
transition: border-color var(--transition);
}
.card + .card { margin-top: 16px; }
.card:hover { box-shadow: var(--shadow); }
.card-lift:hover {
box-shadow: var(--shadow-lg);
transform: translateY(-2px);
border-color: var(--line-strong);
/* The only things that float get real elevation */
.pop {
background: var(--panel);
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 ---------- */
.btn {
appearance: none;
border: 1px solid var(--line-strong);
background: var(--panel);
color: var(--ink);
font: inherit; font-family: var(--font-body); font-weight: 600; font-size: 0.92rem;
padding: 9px 17px; border-radius: var(--radius-sm); cursor: pointer;
font: inherit; font-family: var(--font-body); font-weight: 600; font-size: 0.9rem;
padding: 9px 16px; border-radius: var(--radius-sm); cursor: pointer;
display: inline-flex; align-items: center; gap: 7px;
transition: background var(--transition), border-color var(--transition), box-shadow var(--transition), transform var(--transition-fast);
white-space: nowrap; user-select: none;
}
.btn:hover {
background: var(--hover-bg);
border-color: var(--board);
box-shadow: 0 1px 4px var(--board-glow);
.btn:hover { background: var(--hover-bg); border-color: var(--board); }
.btn:active { transform: translateY(1px); }
.btn:disabled {
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 {
background: var(--board);
border-color: var(--board);
color: #fff;
background: var(--board); border-color: var(--board); color: #fff;
box-shadow: 0 2px 6px rgba(47, 107, 88, 0.25);
}
.btn-primary:hover {
background: var(--board-deep);
border-color: var(--board-deep);
box-shadow: 0 4px 14px rgba(47, 107, 88, 0.35);
.btn-primary:hover { background: var(--board-deep); border-color: var(--board-deep); box-shadow: 0 4px 14px rgba(47, 107, 88, 0.32); }
html[data-theme="dark"] .btn-primary { color: #0d1512; }
.btn-primary:disabled { background: var(--disabled-bg); color: var(--disabled-ink); border-color: var(--line); box-shadow: none; }
.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);
}
.btn-danger { color: var(--redpen); border-color: var(--line-strong); }
.btn-danger:hover { background: var(--redpen-tint); border-color: var(--redpen); box-shadow: none; }
.btn-sm { padding: 5px 11px; font-size: 0.84rem; border-radius: var(--radius-xs); }
.btn-lg { padding: 12px 26px; font-size: 1rem; border-radius: var(--radius); }
.icon-btn:hover { border-color: var(--board); color: var(--ink); background: var(--board-tint); }
.icon-btn:disabled { background: var(--disabled-bg); color: var(--disabled-ink); border-color: var(--line); cursor: default; }
.icon-btn.danger:hover { border-color: var(--redpen); color: var(--redpen); background: var(--redpen-tint); }
.theme-toggle { width: 40px; height: 40px; border-radius: 9px; }
/* ---------- forms ---------- */
label.field { display: block; margin-bottom: 14px; }
.field-label { display: block; font-weight: 600; font-size: 0.87rem; margin-bottom: 5px; color: var(--ink); letter-spacing: 0.01em; }
.field-hint { font-size: 0.82rem; color: var(--ink-soft); margin-top: 5px; line-height: 1.5; }
label.field { display: block; margin-bottom: 16px; }
.field-label {
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 {
width: 100%;
font: inherit;
font-family: var(--font-body);
color: var(--ink);
background: var(--field-bg);
border: 1.5px solid var(--line-strong);
border-radius: var(--radius-sm);
width: 100%; font: inherit; font-family: var(--font-body);
color: var(--ink); background: var(--field-bg);
border: 1.5px solid var(--line-strong); border-radius: var(--radius-sm);
padding: 9px 12px;
transition: border-color var(--transition), box-shadow var(--transition), background var(--transition);
}
input:focus, select:focus, textarea:focus {
border-color: var(--board);
outline: none;
box-shadow: 0 0 0 3px var(--board-glow);
background: var(--field-bg);
border-color: var(--board); outline: none; box-shadow: 0 0 0 3px var(--board-glow);
}
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; }
.check {
display: flex; align-items: flex-start; gap: 10px; margin: 10px 0; cursor: pointer;
padding: 8px 10px; border-radius: var(--radius-xs); transition: background var(--transition);
display: flex; align-items: flex-start; gap: 11px; margin: 10px 0; cursor: pointer;
}
.check:hover { background: var(--hover-bg); }
.check input { width: 16px; height: 16px; margin-top: 3px; accent-color: var(--board); cursor: pointer; flex: none; }
.check input { width: 18px; height: 18px; margin-top: 2px; accent-color: var(--board); cursor: pointer; flex: none; }
.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) ---------- */
.steps {
display: flex; gap: 0;
border-bottom: 1.5px solid var(--line);
margin-bottom: 24px;
overflow: hidden;
/* ---------- create flow: split workspace ---------- */
.create-split { display: flex; min-height: 100vh; align-items: stretch; }
.create-source {
flex: 0 0 58%; max-width: 58%;
background: var(--empty-bg); border-right: 1px solid var(--line);
padding: 36px 44px; display: flex; flex-direction: column;
}
.step {
display: flex; align-items: center; gap: 10px;
padding: 12px 20px 13px; margin-bottom: -1.5px;
border-bottom: 2.5px solid transparent;
color: var(--ink-soft); font-weight: 500; font-size: 0.93rem;
background: none; border-top: 0; border-left: 0; border-right: 0;
cursor: pointer; font-family: var(--font-body);
transition: color var(--transition), border-color var(--transition);
.create-aside {
flex: 1; background: var(--paper);
padding: 36px 38px; display: flex; flex-direction: column;
}
.step .step-n {
width: 24px; height: 24px; border-radius: 50%; flex: none;
border: 2px solid currentColor;
.source-textarea {
flex: 1; min-height: 360px; width: 100%; resize: none;
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;
font-size: 0.78rem; font-weight: 700;
transition: background var(--transition), border-color var(--transition), transform 0.2s ease;
font-weight: 700; font-size: 0.9rem;
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; }
.step.done { color: var(--board); }
.step.done .step-n {
background: var(--board); border-color: var(--board); color: #fff;
animation: step-complete 0.3s ease;
.vstep.active .vstep-n { background: var(--board); border-color: var(--board); color: #fff; box-shadow: 0 0 0 4px var(--board-tint); }
html[data-theme="dark"] .vstep.active .vstep-n { color: #0d1512; }
.vstep.done .vstep-n { background: var(--board); border-color: var(--board); color: #fff; animation: step-complete 0.3s ease; }
.vstep-title { font-family: var(--font-body); font-weight: 600; font-size: 0.98rem; color: var(--ink-soft); }
.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-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 {
border: 1.5px solid var(--line-strong); border-radius: var(--radius); background: var(--field-bg);
padding: 14px 15px; cursor: pointer; text-align: left; font: inherit; font-family: var(--font-body);
transition: border-color var(--transition), background var(--transition), box-shadow var(--transition), transform 0.15s ease;
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.selected { border-color: var(--board); background: var(--board-tint); box-shadow: 0 2px 10px var(--board-glow); }
.choice:hover { border-color: var(--board); transform: translateY(-1px); }
.choice.selected { border-color: var(--board); background: var(--board-tint); }
.choice b { display: block; font-size: 0.93rem; font-weight: 600; }
.choice small { color: var(--ink-soft); font-size: 0.79rem; line-height: 1.35; display: block; margin-top: 4px; }
/* ---------- tabs (source input) ---------- */
/* ---------- segmented tabs (source input) ---------- */
.tabs {
display: inline-flex; background: var(--tab-track);
border-radius: var(--radius-sm); padding: 3px; gap: 2px; margin-bottom: 16px;
}
.tab {
border: 0; background: none; font: inherit; font-family: var(--font-body);
font-weight: 600; font-size: 0.88rem;
font-weight: 600; font-size: 0.87rem;
padding: 7px 16px; border-radius: 6px; cursor: pointer; color: var(--ink-soft);
transition: background var(--transition), color var(--transition), box-shadow var(--transition);
}
.tab.active {
background: var(--panel); color: var(--ink);
box-shadow: var(--shadow-sm);
transition: background var(--transition), color var(--transition);
}
.tab.active { background: var(--panel); color: var(--ink); box-shadow: var(--shadow-sm); }
/* ---------- badges & chips ---------- */
.chip {
display: inline-flex; align-items: center; gap: 5px;
font-size: 0.73rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase;
padding: 3px 10px; border-radius: 99px;
font-family: var(--font-mono); font-size: 0.68rem; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase;
padding: 3px 9px; border-radius: 6px; flex: none;
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-gold { background: var(--gold-tint); color: var(--gold-ink); }
.chip-red { background: var(--redpen-tint); color: var(--redpen); }
.stamp {
display: inline-flex; align-items: center; gap: 5px;
font-family: var(--font-mono); font-size: 0.71rem; font-weight: 700;
letter-spacing: 0.08em; text-transform: uppercase;
display: inline-flex; align-items: center; gap: 4px;
font-family: var(--font-mono); font-size: 0.68rem; font-weight: 700;
letter-spacing: 0.07em; text-transform: uppercase;
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); }
/* ---------- answer key (red pen) ---------- */
.answer-key {
margin-top: 14px; padding: 13px 15px;
padding: 13px 15px;
background: var(--redpen-tint);
border-left: 3px solid var(--redpen);
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
}
.answer-key + .answer-key { margin-top: 10px; }
.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);
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; }
/* ---------- question cards ---------- */
.qcard { position: relative; }
.qcard-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 14px; }
.qnum { font-family: var(--font-display); font-size: 1.15rem; font-weight: 700; color: var(--board-deep); min-width: 28px; }
.qcard-actions { margin-left: auto; display: flex; gap: 6px; flex-wrap: wrap; }
.icon-btn {
border: 1px solid var(--line); background: var(--field-bg); border-radius: 7px;
width: 32px; height: 32px; cursor: pointer; font-size: 0.93rem; line-height: 1;
display: inline-flex; align-items: center; justify-content: center; color: var(--ink-soft);
transition: background var(--transition), border-color var(--transition), color var(--transition), transform var(--transition-fast);
/* ---------- editor: continuous document ---------- */
.doc { overflow: hidden; } /* a .panel that holds the question rows */
.qrow {
display: grid;
grid-template-columns: 44px minmax(0, 1fr) minmax(300px, 360px);
gap: 22px;
padding: 24px 26px;
}
.icon-btn:hover { border-color: var(--board); color: var(--ink); background: var(--board-tint); transform: scale(1.05); }
.icon-btn:disabled { opacity: 0.3; cursor: default; transform: none; }
.icon-btn.danger:hover { border-color: var(--redpen); color: var(--redpen); background: var(--redpen-tint); }
.qrow + .qrow { border-top: 1px solid var(--line); }
.qrow.dragging { opacity: 0.4; }
.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 input[type="radio"] { accent-color: var(--redpen); width: 16px; height: 16px; flex: none; cursor: pointer; }
.opt-letter { font-weight: 700; font-size: 0.84rem; color: var(--ink-soft); width: 18px; flex: none; }
.points-input { width: 64px !important; text-align: center; }
.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.82rem; color: var(--ink-soft); width: 18px; flex: none; }
.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 {
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;
}
.alert-error { background: var(--redpen-tint); border-color: #e8c5be; color: #8c3022; }
.alert-warn { background: var(--gold-tint); border-color: #e9d8a6; color: #7a5a14; }
.alert-info { background: var(--board-tint); border-color: #cde0d8; color: var(--board-deep); }
.alert svg { flex: none; margin-top: 1px; }
.alert-error { background: var(--alert-error-bg); border-color: var(--alert-error-border); color: var(--alert-error-ink); }
.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 {
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
background: var(--ink); color: #fff;
padding: 11px 22px; border-radius: 99px;
font-size: 0.91rem; font-weight: 600;
box-shadow: 0 8px 30px rgba(0,0,0,0.3);
z-index: 100;
background: var(--ink); color: var(--panel);
padding: 11px 22px; border-radius: 99px; font-size: 0.91rem; font-weight: 600;
box-shadow: var(--shadow-modal); z-index: 100;
animation: slide-up 0.22s cubic-bezier(0.34, 1.56, 0.64, 1) both;
white-space: nowrap;
}
@@ -451,10 +583,8 @@
/* ---------- spinner ---------- */
.spinner {
width: 16px; height: 16px; border-radius: 50%; flex: none;
border: 2px solid rgba(47, 107, 88, 0.22);
border-top-color: var(--board);
animation: spin 0.7s linear infinite;
display: inline-block; vertical-align: -3px;
border: 2px solid var(--board-glow); border-top-color: var(--board);
animation: spin 0.7s linear infinite; display: inline-block; vertical-align: -3px;
}
/* ---------- generation progress ---------- */
@@ -463,75 +593,150 @@
display: flex; align-items: center; gap: 12px;
padding: 11px 0; font-size: 0.97rem; color: var(--ink-soft);
border-bottom: 1px solid var(--line);
opacity: 0;
animation: fade-in-up 0.35s ease forwards;
}
.progress-list li:last-child { border-bottom: none; }
.progress-list li:nth-child(1) { animation-delay: 0.0s; }
.progress-list li:nth-child(2) { animation-delay: 0.08s; }
.progress-list li:nth-child(3) { animation-delay: 0.16s; }
.progress-list li:nth-child(4) { animation-delay: 0.24s; }
.progress-list li.active { color: var(--ink); font-weight: 600; }
.progress-list li.done { color: var(--board); }
.progress-dot { width: 20px; text-align: center; flex: none; font-size: 1rem; }
.progress-dot { width: 20px; text-align: center; flex: none; }
/* ---------- library ---------- */
.lib-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(276px, 1fr)); gap: 16px; }
.lib-card { animation: fade-in-up 0.35s ease both; }
.lib-card:nth-child(2) { animation-delay: 0.06s; }
.lib-card:nth-child(3) { animation-delay: 0.12s; }
.lib-card:nth-child(4) { animation-delay: 0.18s; }
.lib-card:nth-child(5) { animation-delay: 0.24s; }
.lib-card:nth-child(6) { animation-delay: 0.30s; }
.lib-card h3 { margin-bottom: 6px; }
.lib-meta { color: var(--ink-soft); font-size: 0.83rem; margin: 3px 0 14px; line-height: 1.55; }
.lib-actions { display: flex; gap: 7px; }
/* Skeleton loader */
.skeleton-card { animation: skeleton-pulse 1.5s ease-in-out infinite; }
.skeleton-line {
background: var(--line); border-radius: 5px; height: 14px; margin-bottom: 10px;
/* ---------- library: search + filters ---------- */
.lib-toolbar { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; margin-bottom: 28px; }
.search-wrap { position: relative; flex: 1; max-width: 480px; min-width: 240px; }
.search-wrap svg { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); color: var(--ink-soft); }
.search-wrap input { padding-left: 38px; }
.fchip {
font-size: 0.79rem; font-weight: 600; padding: 7px 14px; border-radius: 99px; cursor: pointer;
border: 1.5px solid var(--line-strong); background: var(--panel); color: var(--ink-soft);
transition: border-color var(--transition), color var(--transition), background var(--transition);
}
.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.medium { width: 75%; }
.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 {
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);
background: var(--empty-bg);
animation: fade-in 0.3s ease;
}
.empty h3 { color: var(--ink); margin-bottom: 8px; }
/* ---------- provider cards on settings ---------- */
.provider-row {
display: flex; align-items: center; gap: 12px;
padding: 13px 15px; border: 1.5px solid var(--line-strong);
border-radius: var(--radius); cursor: pointer; background: var(--field-bg);
margin-bottom: 10px;
transition: border-color var(--transition), background var(--transition), box-shadow var(--transition);
/* ---------- settings: flush two-column ---------- */
.settings-grid { display: flex; gap: 48px; align-items: flex-start; }
.settings-toc {
width: 210px; flex: none; position: sticky; top: 36px;
display: flex; flex-direction: column; gap: 1px;
}
.provider-row:hover { border-color: var(--board); background: var(--hover-bg); }
.provider-row.selected { border-color: var(--board); background: var(--board-tint); box-shadow: 0 2px 8px var(--board-glow); }
.provider-row input { accent-color: var(--board); width: 17px; height: 17px; flex: none; }
.provider-row b { font-size: 0.96rem; }
.toc-link {
display: flex; align-items: center; gap: 9px; padding: 8px 12px;
border-left: 2px solid transparent; color: var(--ink-soft);
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; }
.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 ---------- */
.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); }
.faint { color: var(--ink-faint); }
.small { font-size: 0.84rem; }
.spacer { flex: 1; }
.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; }
.topnav-inner { gap: 10px; padding: 0 15px; }
.brand span.brand-text { display: none; }
.card { padding: 18px; }
.steps { overflow-x: auto; }
h1 { font-size: 1.5rem; }
/* =====================================================================
RESPONSIVE — collapse the sidebar into a top bar on small screens
===================================================================== */
@media (max-width: 880px) {
.app { flex-direction: column; }
.sidebar {
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 Nav from "@/components/Nav";
import Sidebar from "@/components/Sidebar";
export const metadata = {
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" />
</head>
<body>
<Nav />
<main className="shell">{children}</main>
<div className="app">
<Sidebar />
<main className="content">{children}</main>
</div>
</body>
</html>
);
+82 -78
View File
@@ -1,29 +1,38 @@
"use client";
// app/library/page.jsx — everything you've made, saved locally in data/db.json.
import { useEffect, useState } from "react";
// app/library/page.jsx — everything you've made, grouped by subject, saved locally.
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { IconSearch, IconPlus, IconCopy, IconTrash, IconArrowRight } from "@tabler/icons-react";
import { groupBySubject } from "@/lib/group";
const TYPE_LABELS = {
quiz: "Quiz",
test: "Test",
worksheet: "Worksheet",
discussion: "Discussion",
case_study: "Case study",
quiz: "Quiz", 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 (
<div className="card skeleton-card" aria-hidden="true">
<div className="skeleton-chip" />
<div className="skeleton-line medium" />
<div className="skeleton-line short" style={{ marginBottom: 18 }} />
<div className="skeleton-line full" />
<div className="skeleton-line" style={{ width: "40%", marginBottom: 16 }} />
<div style={{ display: "flex", gap: 7 }}>
<div className="skeleton-line" style={{ width: 64, height: 30, borderRadius: 7, marginBottom: 0 }} />
<div className="skeleton-line" style={{ width: 80, height: 30, borderRadius: 7, marginBottom: 0 }} />
<div className="skeleton-line" style={{ width: 64, height: 30, borderRadius: 7, marginBottom: 0 }} />
<div className="lib-group">
<div className="skeleton-line short" style={{ height: 22, marginBottom: 14 }} />
<div className="panel lib-rows skeleton-card">
{[0, 1].map((i) => (
<div key={i} className="lib-row" style={{ borderTop: i ? "1px solid var(--line)" : "none" }}>
<div className="skeleton-chip" style={{ marginBottom: 0 }} />
<div style={{ flex: 1 }}>
<div className="skeleton-line medium" style={{ marginBottom: 6 }} />
<div className="skeleton-line short" style={{ marginBottom: 0 }} />
</div>
</div>
))}
</div>
</div>
);
@@ -33,6 +42,7 @@ export default function LibraryPage() {
const router = useRouter();
const [items, setItems] = useState(null);
const [query, setQuery] = useState("");
const [filter, setFilter] = useState("all");
const [error, setError] = useState("");
const [busy, setBusy] = useState("");
@@ -45,70 +55,53 @@ export default function LibraryPage() {
useEffect(load, []);
async function duplicate(id) {
setBusy(id);
setError("");
setBusy(id); setError("");
try {
const res = await fetch("/api/assignments/" + id);
const full = await res.json();
if (!res.ok) throw new Error(full.error || "Could not load that assignment.");
const { id: _id, createdAt, updatedAt, ...copy } = full;
copy.title = (copy.title || "Untitled") + " (copy)";
const res2 = await fetch("/api/assignments", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(copy),
});
const res2 = await fetch("/api/assignments", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(copy) });
const created = await res2.json();
if (!res2.ok) throw new Error(created.error || "Could not duplicate.");
load();
} catch (e) {
setError(String(e.message || e));
} finally {
setBusy("");
}
} catch (e) { setError(String(e.message || e)); }
finally { setBusy(""); }
}
async function remove(id, title) {
if (!confirm(`Delete "${title}"? This can't be undone.`)) return;
setBusy(id);
try {
await fetch("/api/assignments/" + id, { method: "DELETE" });
load();
} finally {
setBusy("");
}
try { await fetch("/api/assignments/" + id, { method: "DELETE" }); load(); }
finally { setBusy(""); }
}
const filtered = (items || []).filter((a) => {
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
return (items || []).filter((a) => {
if (filter !== "all" && a.assignmentType !== filter) return false;
if (!q) return true;
return [a.title, a.subject, a.gradeLevel, TYPE_LABELS[a.assignmentType]]
.filter(Boolean)
.join(" ")
.toLowerCase()
.includes(q);
.filter(Boolean).join(" ").toLowerCase().includes(q);
});
}, [items, query, filter]);
const groups = useMemo(() => groupBySubject(filtered), [filtered]);
return (
<div>
<div className="page-head" style={{ display: "flex", alignItems: "center", gap: 14, flexWrap: "wrap" }}>
<div className="page page-narrow">
<div className="page-head" style={{ display: "flex", alignItems: "flex-start", gap: 14, flexWrap: "wrap", marginBottom: 22 }}>
<div style={{ flex: 1 }}>
<h1>Library</h1>
<p>Everything you&rsquo;ve created, stored locally on this computer.</p>
</div>
<Link href="/" className="btn btn-primary"> New assignment</Link>
<Link href="/" className="btn btn-primary"><IconPlus size={17} /> New assignment</Link>
</div>
{error && <div className="alert alert-error">{error}</div>}
{/* Skeleton loading state */}
{items === null && (
<div className="lib-grid">
<SkeletonCard />
<SkeletonCard />
<SkeletonCard />
</div>
)}
{items === null && (<><SkeletonGroup /><SkeletonGroup /></>)}
{items !== null && items.length === 0 && (
<div className="empty">
@@ -120,36 +113,50 @@ export default function LibraryPage() {
{items !== null && items.length > 0 && (
<>
<input
type="text"
placeholder="Search by title, subject, grade, or type…"
value={query}
onChange={(e) => setQuery(e.target.value)}
style={{ marginBottom: 18, maxWidth: 440 }}
aria-label="Search library"
/>
{filtered.length === 0 && <p className="muted">No matches for &ldquo;{query}&rdquo;.</p>}
<div className="lib-grid">
{filtered.map((a) => (
<div key={a.id} className="card lib-card card-lift">
<span className="chip">{TYPE_LABELS[a.assignmentType] || a.assignmentType}</span>
<h3 style={{ marginTop: 10 }}>
<Link href={"/editor/" + a.id} style={{ color: "inherit" }}>{a.title}</Link>
</h3>
<div className="lib-toolbar">
<div className="search-wrap">
<IconSearch size={16} />
<input type="text" placeholder="Search by title, subject, grade, or type…" value={query} onChange={(e) => setQuery(e.target.value)} aria-label="Search library" />
</div>
<div style={{ display: "flex", gap: 7, flexWrap: "wrap" }}>
{FILTERS.map((f) => (
<button key={f.id} className={`fchip${filter === f.id ? " active" : ""}`} onClick={() => setFilter(f.id)}>{f.label}</button>
))}
</div>
</div>
{groups.length === 0 && <p className="muted">No matches{query ? <> for &ldquo;{query}&rdquo;</> : ""}.</p>}
{groups.map((g) => (
<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">
{[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)}
</p>
</div>
<div className="lib-actions">
<button className="btn btn-sm btn-primary" onClick={() => router.push("/editor/" + a.id)}>Open</button>
<button className="btn btn-sm" disabled={busy === a.id} onClick={() => duplicate(a.id)}>
{busy === a.id ? <span className="spinner" /> : "Duplicate"}
<button className="btn btn-sm btn-primary" onClick={() => router.push("/editor/" + a.id)}><IconArrowRight size={14} /> Open</button>
<button className="icon-btn" title="Duplicate" aria-label="Duplicate" disabled={busy === a.id} onClick={() => duplicate(a.id)}>
{busy === a.id ? <span className="spinner" /> : <IconCopy size={16} />}
</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>
</section>
))}
</>
)}
</div>
@@ -157,9 +164,6 @@ export default function LibraryPage() {
}
function formatDate(iso) {
try {
return new Date(iso).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
} catch {
return "";
}
try { return new Date(iso).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" }); }
catch { return ""; }
}
+155 -186
View File
@@ -1,12 +1,20 @@
"use client";
// app/page.jsx — the Create flow: Source -> Configure -> Generate.
// app/page.jsx — the Create flow as an Open Workspace split screen:
// left pane = the current step's content, right pane = step tracker + actions.
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import {
IconArrowRight, IconArrowLeft, IconBulb, IconPencil, IconAlertTriangle,
IconCircleCheck, IconCircle, IconX,
} from "@tabler/icons-react";
import { ASSIGNMENT_TYPES, QUESTION_TYPES, GRADE_LEVELS, DIFFICULTIES } from "@/lib/schema";
const MAX_PASTE = 120000;
// Maps a generation phase to its 0-based order index
const STEPS = [
{ label: "Source", desc: "Add your reading material" },
{ label: "Configure", desc: "Set type, grade, questions" },
{ label: "Generate", desc: "Review the verified key" },
];
const PHASE_ORDER = ["analyze", "generate", "verify", "save"];
function ProgressStep({ phase, label, currentPhase, hasVerify }) {
@@ -15,9 +23,9 @@ function ProgressStep({ phase, label, currentPhase, hasVerify }) {
const me = PHASE_ORDER.indexOf(phase);
const state = me < cur ? "done" : me === cur ? "active" : "";
return (
<li className={state} style={{ animationDelay: `${me * 0.08}s` }}>
<li className={state}>
<span className="progress-dot">
{state === "done" ? "✓" : state === "active" ? <span className="spinner" /> : "·"}
{state === "done" ? <IconCircleCheck size={18} /> : state === "active" ? <span className="spinner" /> : <IconCircle size={16} />}
</span>
{label}{state === "active" ? "…" : ""}
</li>
@@ -58,13 +66,9 @@ export default function CreatePage() {
.then((r) => r.json())
.then((s) => {
const cfg = s.providers?.[s.provider] || {};
if (!cfg.model) {
setProviderNote({ provider: s.provider, missing: "model" });
} else if (["openai", "anthropic", "google"].includes(s.provider) && !cfg.apiKey) {
setProviderNote({ provider: s.provider, missing: "key" });
} else {
setProviderNote(null);
}
if (!cfg.model) setProviderNote({ provider: s.provider, missing: "model" });
else if (["openai", "anthropic", "google"].includes(s.provider) && !cfg.apiKey) setProviderNote({ provider: s.provider, missing: "key" });
else setProviderNote(null);
setConfig((c) => ({ ...c, verify: s.generation?.verification !== false }));
})
.catch(() => {});
@@ -74,9 +78,7 @@ export default function CreatePage() {
const [genState, setGenState] = useState(null);
const generating = !!genState && !genState.error;
function update(patch) {
setConfig((c) => ({ ...c, ...patch }));
}
function update(patch) { setConfig((c) => ({ ...c, ...patch })); }
function toggleQType(id) {
setConfig((c) => {
@@ -99,9 +101,7 @@ export default function CreatePage() {
setText(content.slice(0, MAX_PASTE));
setSourceName(file.name);
setSourceTab("upload");
} catch {
setError("Could not read that file.");
}
} catch { setError("Could not read that file."); }
}
async function fetchUrl() {
@@ -117,17 +117,19 @@ export default function CreatePage() {
if (!res.ok) throw new Error(data.error || "Could not fetch that page.");
setText(data.text.slice(0, MAX_PASTE));
setSourceName(data.title || url);
} catch (e) {
setError(String(e.message || e));
} finally {
setFetching(false);
}
} catch (e) { setError(String(e.message || e)); }
finally { setFetching(false); }
}
const sourceReady = text.trim().length >= 100;
const configReady =
config.subject.trim().length > 0 &&
(["discussion", "case_study"].includes(config.assignmentType) || config.questionTypes.length > 0);
const isDiscussionOrCase = ["discussion", "case_study"].includes(config.assignmentType);
function canGoTo(i) {
return i === 0 || (i === 1 && sourceReady) || (i === 2 && sourceReady && configReady);
}
async function generate() {
setGenState({ phase: "analyze" });
@@ -138,9 +140,7 @@ export default function CreatePage() {
try {
const r1 = await postJson("/api/generate", { stage: "analyze", source, config: cfg });
analysis = r1.analysis;
} catch (e) {
console.warn("Analysis stage failed, continuing:", e);
}
} catch (e) { console.warn("Analysis stage failed, continuing:", e); }
setGenState({ phase: "generate" });
const r2 = await postJson("/api/generate", { stage: "generate", source, analysis, config: cfg });
@@ -149,18 +149,11 @@ export default function CreatePage() {
if (cfg.verify) {
setGenState({ phase: "verify" });
try {
const r3 = await postJson("/api/generate", {
stage: "verify",
source,
config: cfg,
questions: assignment.questions,
});
const r3 = await postJson("/api/generate", { stage: "verify", source, config: cfg, questions: assignment.questions });
for (const q of assignment.questions) {
if (r3.verifications[q.id]) q.verification = r3.verifications[q.id];
}
} catch (e) {
console.warn("Verification stage failed, continuing:", e);
}
} catch (e) { console.warn("Verification stage failed, continuing:", e); }
}
setGenState({ phase: "save" });
@@ -175,60 +168,22 @@ export default function CreatePage() {
}
}
const isDiscussionOrCase = ["discussion", "case_study"].includes(config.assignmentType);
return (
<div>
<div className="page-head">
<h1>Create an assignment</h1>
<p>Give it your source material, set the parameters, and get a classroom-ready assignment with a verified answer key all on your own machine.</p>
</div>
{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 ============ */}
<div className="create-split">
<h1 className="sr-only">Create an assignment</h1>
{/* ============ LEFT: current step content ============ */}
<section className="create-source">
{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>
<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.
</p>
<div className="tabs">
<div className="tabs" style={{ marginTop: 20 }}>
{[["paste", "Paste text"], ["upload", "Upload file"], ["url", "From a web page"]].map(([id, label]) => (
<button
key={id}
className={`tab${sourceTab === id ? " active" : ""}`}
onClick={() => { setSourceTab(id); setError(""); }}
>
<button key={id} className={`tab${sourceTab === id ? " active" : ""}`} onClick={() => { setSourceTab(id); setError(""); }}>
{label}
</button>
))}
@@ -244,65 +199,47 @@ export default function CreatePage() {
{sourceTab === "url" && (
<div style={{ display: "flex", gap: 10, marginBottom: 14, flexWrap: "wrap" }}>
<input
type="url"
placeholder="https://example.com/article"
value={url}
<input type="url" placeholder="https://example.com/article" value={url}
onChange={(e) => setUrl(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter" && url.trim()) fetchUrl(); }}
style={{ flex: 1, minWidth: 240 }}
/>
style={{ flex: 1, minWidth: 240 }} />
<button className="btn btn-primary" onClick={fetchUrl} disabled={fetching || !url.trim()}>
{fetching ? <><span className="spinner" /> Fetching</> : "Fetch page"}
</button>
</div>
)}
<div style={{ flex: 1, display: "flex", flexDirection: "column", marginTop: sourceTab === "paste" ? 4 : 0 }}>
<textarea
className="source-textarea"
value={text}
onChange={(e) => { setText(e.target.value.slice(0, MAX_PASTE)); if (sourceTab === "paste") setSourceName(""); }}
placeholder={
sourceTab === "paste"
placeholder={sourceTab === "paste"
? "Paste your reading passage, chapter, article, or lecture notes here…"
: "The file or page content will appear here — you can trim or edit it before generating."
}
rows={12}
: "The file or page content will appear here — you can trim or edit it before generating."}
aria-label="Source material"
/>
<div className="small muted" style={{ display: "flex", marginTop: 7 }}>
<span>
{text.length.toLocaleString()} / {MAX_PASTE.toLocaleString()} characters
{sourceReady ? "" : " — at least 100 needed"}
</span>
<div className="small muted" style={{ display: "flex", alignItems: "center", marginTop: 12, paddingTop: 12, borderTop: "1px solid var(--line)" }}>
<span>{text.length.toLocaleString()} / {MAX_PASTE.toLocaleString()} characters{sourceReady ? "" : " — at least 100 needed"}</span>
<span className="spacer" />
{text && <button className="btn btn-sm" onClick={() => { setText(""); setSourceName(""); }}>Clear</button>}
</div>
{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>
{error && <div className="alert alert-error"><IconAlertTriangle size={17} /> <span>{error}</span></div>}
</>
)}
{/* ============ STEP 2: CONFIGURE ============ */}
{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>
<div style={{ margin: "18px 0" }}>
<div style={{ margin: "20px 0" }}>
<span className="field-label">Assignment type</span>
<div className="choice-grid">
{ASSIGNMENT_TYPES.map((t) => (
<button
key={t.id}
className={`choice${config.assignmentType === t.id ? " selected" : ""}`}
onClick={() => update({ assignmentType: t.id })}
>
<button key={t.id} className={`choice${config.assignmentType === t.id ? " selected" : ""}`} onClick={() => update({ assignmentType: t.id })}>
<b>{t.label}</b>
<small>{t.hint}</small>
</button>
@@ -319,13 +256,8 @@ export default function CreatePage() {
</label>
<label className="field">
<span className="field-label">Subject</span>
<input
type="text"
list="subjects"
placeholder="e.g. U.S. History, Biology, English Language Arts"
value={config.subject}
onChange={(e) => update({ subject: e.target.value })}
/>
<input type="text" list="subjects" placeholder="e.g. U.S. History, Biology, English Language Arts"
value={config.subject} onChange={(e) => update({ subject: e.target.value })} />
<datalist id="subjects">
{["English Language Arts","U.S. History","World History","Civics / Government","Biology","Chemistry","Physics","Earth Science","Mathematics","Geography","Economics","Health","Computer Science","Spanish","Art History"].map((s) => (
<option key={s} value={s} />
@@ -336,14 +268,9 @@ export default function CreatePage() {
<div className="row">
<label className="field">
<span className="field-label">
{isDiscussionOrCase ? "Number of prompts/questions" : "Number of questions"} {config.questionCount}
</span>
<input
type="range" min="1" max="30" value={config.questionCount}
onChange={(e) => update({ questionCount: Number(e.target.value) })}
style={{ width: "100%", accentColor: "var(--board)" }}
/>
<span className="field-label">{isDiscussionOrCase ? "Number of prompts/questions" : "Number of questions"} {config.questionCount}</span>
<input type="range" min="1" max="30" value={config.questionCount}
onChange={(e) => update({ questionCount: Number(e.target.value) })} style={{ width: "100%" }} />
</label>
<label className="field">
<span className="field-label">Difficulty</span>
@@ -354,22 +281,18 @@ export default function CreatePage() {
</div>
{!isDiscussionOrCase && (
<div style={{ margin: "4px 0 12px" }}>
<div style={{ margin: "4px 0 14px" }}>
<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) => (
<label key={t.id} className="check" style={{ minWidth: 150, flex: "0 0 auto" }}>
<input
type="checkbox"
checked={config.questionTypes.includes(t.id)}
onChange={() => toggleQType(t.id)}
/>
<label key={t.id} className="check" style={{ minWidth: 150 }}>
<input type="checkbox" checked={config.questionTypes.includes(t.id)} onChange={() => toggleQType(t.id)} />
<span>{t.label}</span>
</label>
))}
</div>
{config.questionTypes.length === 0 && (
<div className="field-hint" style={{ color: "var(--redpen)" }}>Pick at least one question type.</div>
<div className="field-hint redpen">Pick at least one question type.</div>
)}
</div>
)}
@@ -386,51 +309,27 @@ export default function CreatePage() {
)}
<label className="check">
<input type="checkbox" checked={config.verify} onChange={(e) => update({ verify: e.target.checked })} />
<span>Run the accuracy check<small>A second AI pass reviews every question and answer against your source. Strongly recommended adds a little time.</small></span>
<span>Run the accuracy check<small>A second AI pass reviews every question and answer against your source. Strongly recommended.</small></span>
</label>
<label className="field" style={{ marginTop: 12 }}>
<span className="field-label">Anything to focus on? <span className="muted" style={{ fontWeight: 400 }}>(optional)</span></span>
<input
type="text"
placeholder="e.g. focus on causes rather than dates; include the vocabulary terms"
value={config.focusNote}
onChange={(e) => update({ focusNote: e.target.value })}
/>
<label className="field" style={{ marginTop: 14 }}>
<span className="field-label">Anything to focus on? <span className="faint" style={{ textTransform: "none", letterSpacing: 0, fontWeight: 400 }}>(optional)</span></span>
<input type="text" placeholder="e.g. focus on causes rather than dates; include the vocabulary terms"
value={config.focusNote} onChange={(e) => update({ focusNote: e.target.value })} />
</label>
<div style={{ display: "flex", marginTop: 20, gap: 10 }}>
<button className="btn" onClick={() => setStep(0)}> Back</button>
<span className="spacer" />
<button className="btn btn-primary btn-lg" disabled={!configReady} onClick={() => setStep(2)}>
Next: Generate
</button>
</div>
</div>
</>
)}
{/* ============ STEP 3: GENERATE ============ */}
{step === 2 && (
<div className="card">
<>
<span className="field-label" style={{ color: "var(--board)", marginBottom: 8 }}>Step 3 · Generate</span>
<h2>Ready to generate</h2>
<p className="muted" style={{ margin: "10px 0 4px", fontSize: "0.96rem" }}>
<b style={{ color: "var(--ink)" }}>
{ASSIGNMENT_TYPES.find((t) => t.id === config.assignmentType)?.label}
</b>{" "}
<p className="muted" style={{ margin: "12px 0 4px", fontSize: "0.96rem" }}>
<b style={{ color: "var(--ink)" }}>{ASSIGNMENT_TYPES.find((t) => t.id === config.assignmentType)?.label}</b>{" "}
· {config.gradeLevel} · {config.subject || "—"} · {config.questionCount} question{config.questionCount === 1 ? "" : "s"} · {config.difficulty} difficulty
</p>
<p className="muted small">Source: {sourceName || "Pasted text"} ({text.length.toLocaleString()} characters)</p>
{!genState && (
<div style={{ display: "flex", marginTop: 20, gap: 10 }}>
<button className="btn" onClick={() => setStep(1)}> Back</button>
<span className="spacer" />
<button className="btn btn-primary btn-lg" onClick={generate} disabled={!!providerNote}>
Generate assignment
</button>
</div>
)}
{genState && !genState.error && (
<>
<ul className="progress-list" aria-live="polite">
@@ -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} />}
<ProgressStep phase="save" label="Saving and opening the editor" currentPhase={genState.phase} hasVerify={config.verify} />
</ul>
<p className="small muted" style={{ marginTop: 16 }}>
Local models can take a few minutes for large assignments. Leave this tab open.
</p>
<p className="small muted" style={{ marginTop: 16 }}>Local models can take a few minutes for large assignments. Leave this tab open.</p>
</>
)}
{genState?.error && (
<>
<div className="alert alert-error"><b>Generation failed.</b> {genState.error}</div>
<div style={{ display: "flex", gap: 10 }}>
<button className="btn" onClick={() => setGenState(null)}>Adjust and retry</button>
<button className="btn btn-primary" onClick={generate}>Try again</button>
<div className="alert alert-error" style={{ marginTop: 20 }}>
<IconAlertTriangle size={17} /> <span><b>Generation failed.</b> {genState.error}</span>
</div>
)}
</>
)}
</section>
{/* ============ RIGHT: step tracker + actions ============ */}
<aside className="create-aside">
<span className="field-label" style={{ color: "var(--ink-soft)" }}>Progress</span>
<h2 style={{ fontSize: "1.2rem", margin: "8px 0 24px" }}>Create an assignment</h2>
{providerNote && (
<div className="alert alert-warn" style={{ marginTop: 0 }}>
<IconAlertTriangle size={17} />
<span>
{providerNote.missing === "key" ? "Your selected AI provider needs an API key." : "No AI model is selected yet."}{" "}
<a href="/settings">Open Settings</a> to finish setup.
</span>
</div>
)}
<div className="vsteps">
{STEPS.map((s, i) => {
const state = step === i ? "active" : step > i ? "done" : "";
return (
<button
key={s.label}
className={`vstep ${state}`}
style={{ border: 0, background: "none", textAlign: "left", cursor: canGoTo(i) && !generating ? "pointer" : "default", padding: 0, paddingBottom: i === STEPS.length - 1 ? 0 : 22, font: "inherit", width: "100%" }}
onClick={() => { if (canGoTo(i) && !generating) setStep(i); }}
disabled={!canGoTo(i) || generating}
>
<span className="vstep-n">{step > i ? <IconCircleCheck size={20} /> : i + 1}</span>
<span>
<span className="vstep-title" style={{ display: "block" }}>{s.label}</span>
<span className="vstep-desc">{s.desc}</span>
</span>
</button>
);
})}
</div>
<hr className="hr" style={{ margin: "26px 0" }} />
<div className="tip">
<IconBulb size={18} />
<p style={{ margin: 0 }}>
{step === 0 && <><b>Tip:</b> 300800 words of clean source text produces the most accurate questions.</>}
{step === 1 && <><b>Tip:</b> Mixing question types gives a more rounded check for understanding.</>}
{step === 2 && <><b>Tip:</b> Every question keeps a source quote so you can verify it against your material.</>}
</p>
</div>
<div style={{ marginTop: "auto", paddingTop: 28, display: "flex", flexDirection: "column", gap: 10 }}>
{step === 0 && (
<button className="btn btn-primary btn-lg btn-block" disabled={!sourceReady} onClick={() => setStep(1)}>
Next: Configure <IconArrowRight size={17} />
</button>
)}
{step === 1 && (
<>
<button className="btn btn-primary btn-lg btn-block" disabled={!configReady} onClick={() => setStep(2)}>
Next: Generate <IconArrowRight size={17} />
</button>
<button className="btn btn-block" onClick={() => setStep(0)}><IconArrowLeft size={16} /> Back</button>
</>
)}
{step === 2 && !genState && (
<>
<button className="btn btn-primary btn-lg btn-block" onClick={generate} disabled={!!providerNote}>
<IconPencil size={17} /> Generate assignment
</button>
<button className="btn btn-block" onClick={() => setStep(1)}><IconArrowLeft size={16} /> Back</button>
</>
)}
{step === 2 && genState?.error && (
<>
<button className="btn btn-primary btn-block" onClick={generate}>Try again</button>
<button className="btn btn-block" onClick={() => setGenState(null)}>Adjust and retry</button>
</>
)}
{step === 0 && !sourceReady && <p className="small faint" style={{ textAlign: "center", margin: 0 }}>Add at least 100 characters to continue</p>}
</div>
</aside>
</div>
);
}
async function postJson(url, body) {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const res = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || `Request failed (${res.status}).`);
return data;
+141 -221
View File
@@ -1,6 +1,11 @@
"use client";
// 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 {
IconUserCircle, IconCpu, IconSchool, IconAdjustments,
IconPhoto, IconRefresh, IconPlug, IconCheck, IconX,
} from "@tabler/icons-react";
const PROVIDERS = [
{ 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: "google", name: "Google AI (Gemini)", desc: "Cloud API — needs an API key", local: false },
];
const KEY_LINKS = {
anthropic: "https://console.anthropic.com/",
openai: "https://platform.openai.com/api-keys",
google: "https://aistudio.google.com/apikey",
};
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() {
const [s, setS] = useState(null);
const [models, setModels] = useState({}); // provider -> string[]
const [models, setModels] = useState({});
const [modelsBusy, setModelsBusy] = useState("");
const [modelsErr, setModelsErr] = useState({}); // provider -> error
const [test, setTest] = useState({}); // provider -> {busy, ok, message}
const [canvasTest, setCanvasTest] = useState(null); // {busy, ok, message}
const [autoInfo, setAutoInfo] = useState(null); // resolved auto limits for the active model
const [modelsErr, setModelsErr] = useState({});
const [test, setTest] = useState({});
const [canvasTest, setCanvasTest] = useState(null);
const [autoInfo, setAutoInfo] = useState(null);
const [saving, setSaving] = useState(false);
const [toast, setToast] = useState("");
const [error, setError] = useState("");
const [activeToc, setActiveToc] = useState("teacher");
const toastTimer = useRef(null);
useEffect(() => {
@@ -38,18 +49,12 @@ export default function SettingsPage() {
const activeKey = s?.providers?.[activeProvider]?.apiKey || "";
const autoOn = s ? s.generation?.auto !== false : true;
// Preview the auto-tuned limits whenever the model selection (or key) changes.
// Debounced so typing an API key doesn't fire a request per keystroke.
useEffect(() => {
if (!s || !autoOn || !activeModel) { setAutoInfo(null); return; }
let cancelled = false;
setAutoInfo(null);
const t = setTimeout(() => {
fetch("/api/providers", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "defaults", provider: activeProvider, settings: s }),
})
fetch("/api/providers", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "defaults", provider: activeProvider, settings: s }) })
.then((r) => r.json())
.then((d) => { if (!cancelled && !d.error && d.maxTokens) setAutoInfo(d); })
.catch(() => {});
@@ -58,50 +63,33 @@ export default function SettingsPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeProvider, activeModel, activeKey, autoOn]);
function showToast(msg) {
setToast(msg);
clearTimeout(toastTimer.current);
toastTimer.current = setTimeout(() => setToast(""), 2400);
}
// Highlight the TOC entry for the section currently in view.
useEffect(() => {
if (!s) return;
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) {
setS((cur) => ({
...cur,
providers: { ...cur.providers, [provider]: { ...cur.providers[provider], [field]: value } },
}));
}
function setGen(field, value) {
setS((cur) => ({ ...cur, generation: { ...cur.generation, [field]: value } }));
}
function setProfile(field, value) {
setS((cur) => ({ ...cur, profile: { ...(cur.profile || {}), [field]: value } }));
}
function setCanvas(field, value) {
setCanvasTest(null);
setS((cur) => ({ ...cur, canvas: { ...(cur.canvas || {}), [field]: value } }));
}
function showToast(msg) { setToast(msg); clearTimeout(toastTimer.current); toastTimer.current = setTimeout(() => setToast(""), 2400); }
function setProviderField(provider, field, value) { setS((cur) => ({ ...cur, providers: { ...cur.providers, [provider]: { ...cur.providers[provider], [field]: value } } })); }
function setGen(field, value) { setS((cur) => ({ ...cur, generation: { ...cur.generation, [field]: value } })); }
function setProfile(field, value) { setS((cur) => ({ ...cur, profile: { ...(cur.profile || {}), [field]: value } })); }
function setCanvas(field, value) { setCanvasTest(null); setS((cur) => ({ ...cur, canvas: { ...(cur.canvas || {}), [field]: value } })); }
async function testCanvas() {
setCanvasTest({ busy: true });
try {
const res = await fetch("/api/canvas", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "test", settings: s }),
});
const res = await fetch("/api/canvas", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "test", settings: s }) });
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Test failed.");
setCanvasTest({ ok: true, message: data.message });
} catch (e) {
setCanvasTest({ ok: false, message: String(e.message || e) });
}
} catch (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) {
const file = e.target.files?.[0];
e.target.value = "";
@@ -123,64 +111,39 @@ export default function SettingsPage() {
}
async function refreshModels(provider) {
setModelsBusy(provider);
setModelsErr((e) => ({ ...e, [provider]: "" }));
setModelsBusy(provider); setModelsErr((e) => ({ ...e, [provider]: "" }));
try {
const res = await fetch("/api/providers", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "models", provider, settings: s }),
});
const res = await fetch("/api/providers", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "models", provider, settings: s }) });
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Could not list models.");
setModels((m) => ({ ...m, [provider]: data.models }));
if (data.models.length && !data.models.includes(s.providers[provider].model)) {
setProviderField(provider, "model", data.models[0]);
}
} catch (e) {
setModelsErr((er) => ({ ...er, [provider]: String(e.message || e) }));
} finally {
setModelsBusy("");
}
if (data.models.length && !data.models.includes(s.providers[provider].model)) setProviderField(provider, "model", data.models[0]);
} catch (e) { setModelsErr((er) => ({ ...er, [provider]: String(e.message || e) })); }
finally { setModelsBusy(""); }
}
async function testConnection(provider) {
setTest((t) => ({ ...t, [provider]: { busy: true } }));
try {
const res = await fetch("/api/providers", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "test", provider, settings: s }),
});
const res = await fetch("/api/providers", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "test", provider, settings: s }) });
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Test failed.");
setTest((t) => ({ ...t, [provider]: { ok: true, message: data.message } }));
} catch (e) {
setTest((t) => ({ ...t, [provider]: { ok: false, message: String(e.message || e) } }));
}
} catch (e) { setTest((t) => ({ ...t, [provider]: { ok: false, message: String(e.message || e) } })); }
}
async function save() {
setSaving(true);
setError("");
setSaving(true); setError("");
try {
const res = await fetch("/api/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(s),
});
const res = await fetch("/api/settings", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(s) });
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Save failed.");
setS(data);
showToast("Settings saved");
} catch (e) {
setError(String(e.message || e));
} finally {
setSaving(false);
}
setS(data); showToast("Settings saved");
} catch (e) { setError(String(e.message || e)); }
finally { setSaving(false); }
}
if (!s) return <p className="muted"><span className="spinner" /> Loading</p>;
if (!s) return <div className="page page-narrow"><p className="muted"><span className="spinner" /> Loading</p></div>;
const active = s.provider;
const activeCfg = s.providers[active] || {};
@@ -189,7 +152,7 @@ export default function SettingsPage() {
const t = test[active];
return (
<div>
<div className="page page-narrow">
<div className="page-head">
<h1>Settings</h1>
<p>Pick the AI that powers generation. Local options keep everything source material, questions, API traffic on this computer. Keys and settings are stored only in your local <code>data/db.json</code> file.</p>
@@ -197,222 +160,178 @@ export default function SettingsPage() {
{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>
<p className="field-hint" style={{ marginTop: 4 }}>
Shown in the header of every printed and exported assignment leave anything blank to omit it.
</p>
<div className="row" style={{ marginTop: 14 }}>
<label className="field">
<p className="sec-hint">Shown in the header of every printed and exported assignment leave anything blank to omit it.</p>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))", gap: 26, marginBottom: 26 }}>
<div className="ul-field" style={{ marginBottom: 0 }}>
<span className="field-label">Teacher name</span>
<input type="text" value={s.profile?.teacherName || ""}
onChange={(e) => setProfile("teacherName", e.target.value)}
placeholder="e.g. Mr. Drew" />
</label>
<label className="field">
<span className="field-label">Class / course</span>
<input type="text" value={s.profile?.className || ""}
onChange={(e) => setProfile("className", e.target.value)}
placeholder="e.g. 7th Grade Science — Period 3" />
</label>
<label className="field">
<span className="field-label">School name</span>
<input type="text" value={s.profile?.schoolName || ""}
onChange={(e) => setProfile("schoolName", e.target.value)}
placeholder="e.g. Lincoln Middle School" />
</label>
<input className="ul-input" type="text" value={s.profile?.teacherName || ""} onChange={(e) => setProfile("teacherName", e.target.value)} placeholder="e.g. Mr. Drew" />
</div>
<div className="field">
<span className="field-label">School logo or mascot (optional)</span>
<div style={{ display: "flex", gap: 12, alignItems: "center", flexWrap: "wrap" }}>
<div className="ul-field" style={{ marginBottom: 0 }}>
<span className="field-label">Class / course</span>
<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 && (
<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 }} />
<img src={s.profile.logo} alt="School logo preview" style={{ height: 52, maxWidth: 140, objectFit: "contain", background: "#fff", border: "1px solid var(--line)", borderRadius: 6, padding: 4 }} />
)}
<label className="btn" style={{ cursor: "pointer" }}>
{s.profile?.logo ? "Replace image" : "Upload image"}
<IconPhoto size={16} /> {s.profile?.logo ? "Replace image" : "Upload image"}
<input type="file" accept="image/*" onChange={onLogoFile} style={{ display: "none" }} />
</label>
{s.profile?.logo && (
<button className="btn btn-danger" onClick={() => setProfile("logo", "")}>Remove</button>
)}
{s.profile?.logo && <button className="btn btn-ghost btn-danger" style={{ borderColor: "transparent" }} onClick={() => setProfile("logo", "")}>Remove</button>}
</div>
<span className="field-hint">Appears beside the school name on printed pages. PNG with transparency looks best; the image is stored locally and shrunk automatically.</span>
</div>
</div>
</section>
<div className="card">
{/* ---- AI provider ---- */}
<section className="sec" id="provider">
<h2>AI provider</h2>
<div style={{ marginTop: 14 }}>
<div className="provider-list" style={{ marginTop: 16 }}>
{PROVIDERS.map((p) => (
<label key={p.id} className={"provider-row" + (active === p.id ? " selected" : "")}>
<input type="radio" name="provider" checked={active === p.id} onChange={() => setS((cur) => ({ ...cur, provider: p.id }))} />
<span>
<b>{p.name}</b>
<span style={{ flex: 1 }}>
<b style={active === p.id ? { color: "var(--board-deep)" } : undefined}>{p.name}</b>
<small>{p.desc}</small>
</span>
{p.local && <span className="chip local-tag">Private · local</span>}
{p.local && <span className="chip chip-pill local-tag">Private · local</span>}
</label>
))}
</div>
<hr className="hr" />
<h3 style={{ marginBottom: 12 }}>{PROVIDERS.find((p) => p.id === active)?.name} setup</h3>
<h3 style={{ margin: "26px 0 16px" }}>{PROVIDERS.find((p) => p.id === active)?.name} setup</h3>
{isLocal && (
<label className="field">
<div className="ul-field">
<span className="field-label">Server address (base URL)</span>
<input
type="text" value={activeCfg.baseUrl || ""}
onChange={(e) => setProviderField(active, "baseUrl", e.target.value)}
placeholder={active === "ollama" ? "http://localhost:11434" : "http://localhost:1234"}
/>
<input className="ul-input" type="text" value={activeCfg.baseUrl || ""} onChange={(e) => setProviderField(active, "baseUrl", e.target.value)}
placeholder={active === "ollama" ? "http://localhost:11434" : "http://localhost:1234"} />
<span className="field-hint">
{active === "ollama"
? <>Where this app should find Ollama. Same computer: the default is right. Ollama on another machine (or this app in Docker on a different box): enter that machine&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 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 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. 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>
</label>
</div>
)}
{!isLocal && (
<label className="field">
<div className="ul-field">
<span className="field-label">API key</span>
<input
type="password" value={activeCfg.apiKey || ""}
onChange={(e) => setProviderField(active, "apiKey", e.target.value)}
placeholder="Paste your API key"
autoComplete="off"
/>
<span className="field-hint">
Get a key at <a href={KEY_LINKS[active]} target="_blank" rel="noreferrer">{KEY_LINKS[active]}</a>. It is stored only on this computer.
</span>
</label>
<input className="ul-input" type="password" value={activeCfg.apiKey || ""} onChange={(e) => setProviderField(active, "apiKey", e.target.value)} placeholder="Paste your API key" autoComplete="off" />
<span className="field-hint">Get a key at <a href={KEY_LINKS[active]} target="_blank" rel="noreferrer">{KEY_LINKS[active]}</a>. It is stored only on this computer.</span>
</div>
)}
<label className="field">
<div className="ul-field">
<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 ? (
<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.map((m) => <option key={m} value={m}>{m}</option>)}
</select>
) : (
<input
type="text" value={activeCfg.model || ""}
onChange={(e) => setProviderField(active, "model", e.target.value)}
placeholder={active === "ollama" ? "e.g. llama3.1:8b" : "Model name"}
style={{ flex: 1, minWidth: 220 }}
/>
<input className="ul-input" type="text" value={activeCfg.model || ""} onChange={(e) => setProviderField(active, "model", e.target.value)}
placeholder={active === "ollama" ? "e.g. llama3.1:8b" : "Model name"} style={{ flex: 1, minWidth: 220 }} />
)}
<button className="btn" onClick={() => refreshModels(active)} disabled={modelsBusy === active}>
{modelsBusy === active ? <><span className="spinner" /> Looking</> : "Refresh models"}
{modelsBusy === active ? <><span className="spinner" /> Looking</> : <><IconRefresh size={15} /> Refresh models</>}
</button>
</div>
{modelsErr[active] && <span className="field-hint" style={{ color: "var(--redpen)" }}>{modelsErr[active]}</span>}
{!modelsErr[active] && (
<span className="field-hint">
Accuracy tip: bigger models write noticeably better questions. Locally, prefer an 8B+ model; in the cloud, the default models work well.
</span>
)}
</label>
{modelsErr[active]
? <span className="field-hint redpen">{modelsErr[active]}</span>
: <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>}
</div>
<div style={{ display: "flex", gap: 10, alignItems: "center", flexWrap: "wrap" }}>
<button className="btn" onClick={() => testConnection(active)} disabled={t?.busy}>
{t?.busy ? <><span className="spinner" /> Testing</> : "Test connection"}
{t?.busy ? <><span className="spinner" /> Testing</> : <><IconPlug size={15} /> Test connection</>}
</button>
{t && !t.busy && (
<span className={"small " + (t.ok ? "" : "redpen")} style={t.ok ? { color: "var(--board)", fontWeight: 600 } : { fontWeight: 600 }}>
{t.ok ? "✓ " : "✕ "}{t.message}
<span className="small" style={{ fontWeight: 600, color: t.ok ? "var(--board)" : "var(--redpen)", display: "inline-flex", alignItems: "center", gap: 4 }}>
{t.ok ? <IconCheck size={15} /> : <IconX size={15} />}{t.message}
</span>
)}
</div>
</div>
</section>
<div className="card">
{/* ---- Canvas LMS ---- */}
<section className="sec" id="canvas">
<h2>Canvas (LMS) integration</h2>
<p className="field-hint" style={{ marginTop: 4 }}>
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>
<label className="field" style={{ marginTop: 14 }}>
<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>
<div className="ul-field">
<span className="field-label">Canvas web address</span>
<input
type="text" value={s.canvas?.baseUrl || ""}
onChange={(e) => setCanvas("baseUrl", e.target.value)}
placeholder="https://yourschool.instructure.com"
autoComplete="off"
/>
<input className="ul-input" 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>
</label>
<label className="field">
</div>
<div className="ul-field">
<span className="field-label">Access token</span>
<input
type="password" value={s.canvas?.token || ""}
onChange={(e) => setCanvas("token", e.target.value)}
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>
<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" />
<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>
</div>
<div style={{ display: "flex", gap: 10, alignItems: "center", flexWrap: "wrap" }}>
<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>
{canvasTest && !canvasTest.busy && (
<span className="small" style={canvasTest.ok ? { color: "var(--board)", fontWeight: 600 } : { fontWeight: 600 }}>
{canvasTest.ok ? "✓ " : "✕ "}{canvasTest.message}
<span className="small" style={{ fontWeight: 600, color: canvasTest.ok ? "var(--board)" : "var(--redpen)", display: "inline-flex", alignItems: "center", gap: 4 }}>
{canvasTest.ok ? <IconCheck size={15} /> : <IconX size={15} />}{canvasTest.message}
</span>
)}
</div>
</div>
</section>
<div className="card">
{/* ---- Generation defaults ---- */}
<section className="sec" id="generation">
<h2>Generation defaults</h2>
<label className="check" style={{ marginTop: 14 }}>
<input type="checkbox" checked={autoOn} onChange={(e) => setGen("auto", e.target.checked)} />
<span>
Set size limits automatically (recommended)
<small>Matches source size and response length to the selected model's real context window — local models get safe limits, large cloud models get room for much longer sources and answers.</small>
</span>
<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>
</label>
{autoOn && (
<p className="field-hint" style={{ margin: "0 0 4px 30px" }}>
{!activeModel
? "Pick a model above to see its tuned limits."
<p className="field-hint" style={{ margin: "0 0 8px 30px", fontStyle: "italic" }}>
{!activeModel ? "Pick a model above to see its tuned limits."
: autoInfo
? `Tuned for ${activeModel}: sources up to ${autoInfo.maxSourceChars.toLocaleString()} characters, responses up to ${autoInfo.maxTokens.toLocaleString()} tokens (context window ≈ ${Math.round(autoInfo.caps.contextTokens / 1000).toLocaleString()}k tokens${autoInfo.caps.source === "fallback" ? ", estimated — couldn't read the model's limits" : ""}).`
: <><span className="spinner" /> Checking the model's limits</>}
: <><span className="spinner" /> Checking the model&rsquo;s limits</>}
</p>
)}
<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>
<input
type="range" min="0" max="1" step="0.1" value={s.generation.temperature}
onChange={(e) => setGen("temperature", Number(e.target.value))}
style={{ width: "100%", accentColor: "var(--board)" }}
/>
<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%" }} />
<span className="field-hint">Lower = more precise and literal (best for accuracy). 0.20.4 recommended.</span>
</label>
{!autoOn && (
<label className="field">
<span className="field-label">Max response length (tokens)</span>
<input type="number" min="1000" max="64000" step="500" value={s.generation.maxTokens}
onChange={(e) => setGen("maxTokens", Math.max(1000, Number(e.target.value) || 8000))} />
<span className="field-hint">Raise this if very long assignments come back cut off. Capped to the model's own output limit.</span>
<input type="number" min="1000" max="64000" step="500" value={s.generation.maxTokens} onChange={(e) => setGen("maxTokens", Math.max(1000, Number(e.target.value) || 8000))} />
<span className="field-hint">Raise this if very long assignments come back cut off. Capped to the model&rsquo;s own output limit.</span>
</label>
)}
{!autoOn && (
<label className="field">
<span className="field-label">Max source size (characters)</span>
<input type="number" min="4000" max="300000" step="1000" value={s.generation.maxSourceChars}
onChange={(e) => setGen("maxSourceChars", Math.max(4000, Number(e.target.value) || 24000))} />
<span className="field-hint">Longer sources are trimmed to this before being sent to the model. Local models with small context windows do better around 16,00024,000.</span>
<input type="number" min="4000" max="300000" step="1000" value={s.generation.maxSourceChars} onChange={(e) => setGen("maxSourceChars", Math.max(4000, Number(e.target.value) || 24000))} />
<span className="field-hint">Longer sources are trimmed to this before being sent to the model. Local models do better around 16,00024,000.</span>
</label>
)}
</div>
@@ -420,14 +339,15 @@ export default function SettingsPage() {
<input type="checkbox" checked={s.generation.verification !== false} onChange={(e) => setGen("verification", e.target.checked)} />
<span>Run the accuracy check by default<small>A second pass that verifies every answer against the source. You can still toggle it per assignment.</small></span>
</label>
</div>
</section>
<div style={{ display: "flex", marginTop: 18 }}>
<span className="spacer" />
<div style={{ display: "flex", justifyContent: "flex-end", paddingTop: 28, borderTop: "1px solid var(--line)", marginTop: 8 }}>
<button className="btn btn-primary btn-lg" onClick={save} disabled={saving}>
{saving ? "Saving…" : "Save settings"}
{saving ? "Saving…" : <><IconCheck size={16} /> Save settings</>}
</button>
</div>
</div>
</div>
{toast && <div className="toast">{toast}</div>}
</div>
+5 -11
View File
@@ -3,6 +3,7 @@
// 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.
import { useEffect, useMemo, useState } from "react";
import { IconX } from "@tabler/icons-react";
import { mapAssignmentToCanvas } from "@/lib/canvas/map";
import { exportCanvasZip } from "@/lib/canvas/export";
@@ -145,18 +146,11 @@ export default function CanvasExportDialog({ assignment, onClose, onDone, onErro
}
return (
<div
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 className="modal-scrim" onClick={onClose}>
<div
onClick={(e) => e.stopPropagation()}
className="card"
style={{ width: 560, maxWidth: "100%", padding: 24, animation: "fade-in-up 0.18s ease" }}
className="modal"
style={{ width: 560, maxWidth: "100%", padding: 24 }}
>
<div style={{ display: "flex", alignItems: "flex-start", gap: 12 }}>
<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>.
</p>
</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>
{/* 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";
// components/QuestionCard.jsx — edit any question inline, with the answer key
// styled in red pen and grading-stamp verification badges.
// components/QuestionCard.jsx — one question rendered as a row in the editor
// 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 {
IconGripVertical, IconArrowUp, IconArrowDown, IconRefresh, IconX,
IconCircleCheck, IconAlertTriangle, IconArrowRight,
} from "@tabler/icons-react";
import { questionTypeLabel } from "@/lib/schema";
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} />;
}
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 [note, setNote] = useState("");
const [grabbed, setGrabbed] = useState(false);
function set(patch) {
onChange({ ...q, ...patch, verification: patch.verification || { status: "unchecked", note: "" } });
}
// Editing content invalidates the old verification stamp (set() above resets it),
// but pure point changes shouldn't:
function setPoints(points) {
onChange({ ...q, points });
}
function setPoints(points) { onChange({ ...q, points }); }
const v = q.verification || { status: "unchecked" };
const hasMargin = q.type !== "matching"; // matching edits pairs in the center; others use the margin
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">
<span className="qnum">{index + 1}.</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."> Verified</span>}
{v.status === "warn" && <span className="stamp stamp-warn" title={v.note}> Check this</span>}
<span className="chip chip-pill 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 === "warn" && <span className="stamp stamp-warn" title={v.note}><IconAlertTriangle size={13} /> Check this</span>}
<span className="qcard-actions">
<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}
onChange={(e) => setPoints(Math.max(0, Number(e.target.value) || 0))} aria-label="Points" />
pts
</label>
<button className="icon-btn" title="Move up" disabled={index === 0 || busy} onClick={() => onMove(-1)}></button>
<button className="icon-btn" title="Move down" disabled={index === count - 1 || busy} onClick={() => onMove(1)}></button>
<button className="icon-btn" title="Regenerate this question" disabled={busy} onClick={() => setRegenOpen((o) => !o)}></button>
<button className="icon-btn danger" title="Delete question" disabled={busy} onClick={onDelete}></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" aria-label="Move down" disabled={index === count - 1 || busy} onClick={() => onMove(1)}><IconArrowDown size={16} /></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" aria-label="Delete" disabled={busy} onClick={onDelete}><IconX size={16} /></button>
</span>
</div>
{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 && (
<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 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}
@@ -63,8 +94,8 @@ export default function QuestionCard({ q, index, count, onChange, onMove, onDele
</div>
)}
{/* Question prompt */}
<AutoTextarea
className="q-prompt"
value={q.question}
onChange={(question) => set({ question })}
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…"}
/>
{/* ---- type-specific bodies ---- */}
{q.type === "multiple_choice" && (
<div style={{ marginTop: 8 }}>
<div style={{ marginTop: 10 }}>
{(q.options || []).map((opt, i) => (
<div className="opt-row" key={i}>
<input
type="radio" name={"correct-" + q.id} checked={q.correctIndex === i}
onChange={() => set({ correctIndex: i })}
title="Mark as the correct answer"
/>
<input type="radio" name={"correct-" + q.id} checked={q.correctIndex === i}
onChange={() => set({ correctIndex: i })} title="Mark as the correct answer" />
<span className="opt-letter">{LETTERS[i]}.</span>
<input type="text" value={opt} onChange={(e) => {
const options = [...q.options]; options[i] = e.target.value; set({ options });
}} placeholder={"Option " + LETTERS[i]} />
<button className="icon-btn danger" title="Remove option" disabled={q.options.length <= 2}
<input type="text" value={opt} onChange={(e) => { const options = [...q.options]; options[i] = e.target.value; set({ options }); }} placeholder={"Option " + LETTERS[i]} />
<button className="icon-btn danger" title="Remove option" aria-label="Remove option" disabled={q.options.length <= 2}
onClick={() => {
const options = q.options.filter((_, j) => j !== i);
let correctIndex = q.correctIndex;
if (correctIndex === i) correctIndex = 0;
else if (correctIndex > i) correctIndex -= 1;
set({ options, correctIndex });
}}></button>
}}><IconX size={15} /></button>
</div>
))}
{(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>
)}
{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" && (
<div className="answer-key">
<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>
<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>
<AutoTextarea
value={(q.keyPoints || []).join("\n")}
<AutoTextarea value={(q.keyPoints || []).join("\n")}
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>
)}
@@ -137,13 +196,13 @@ export default function QuestionCard({ q, index, count, onChange, onMove, onDele
<span className="ak-label" style={{ marginTop: 8 }}>Rubric</span>
{(q.rubric || []).map((r, i) => (
<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 }); }} />
<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 }); }} />
<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 }); }} />
<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>
))}
<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" && (
<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) => (
<div key={i} style={{ display: "flex", gap: 7, alignItems: "center", margin: "5px 0" }}>
<span className="opt-letter">{i + 1}.</span>
@@ -163,65 +222,36 @@ export default function QuestionCard({ q, index, count, onChange, onMove, onDele
</div>
))}
<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={() => {
const blanks = (String(q.question).match(/_{3,}/g) || []).length || 1;
const answers = Array.from({ length: blanks }, (_, i) => q.answers?.[i] || "");
set({ answers });
}}>Match answer slots to blanks</button>
}}>Match slots to blanks</button>
</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" && (
<div className="answer-key">
<span className="ak-label">Facilitator notes key talking points (one per line)</span>
<AutoTextarea
value={(q.talkingPoints || []).join("\n")}
onChange={(text) => set({ talkingPoints: text.split("\n").map((s) => s.trim()).filter(Boolean) })}
rows={3}
/>
<span className="ak-label">Facilitator notes key talking points</span>
<AutoTextarea value={(q.talkingPoints || []).join("\n")}
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>
<AutoTextarea
value={(q.followUps || []).join("\n")}
onChange={(text) => set({ followUps: text.split("\n").map((s) => s.trim()).filter(Boolean) })}
rows={2}
/>
<AutoTextarea value={(q.followUps || []).join("\n")}
onChange={(text) => set({ followUps: text.split("\n").map((s) => s.trim()).filter(Boolean) })} rows={2} />
</div>
)}
{/* Explanation + source ref, present for all types */}
{/* Explanation, present for all types except discussion */}
{q.type !== "discussion" && (
<div className="answer-key" style={{ background: "#fff7f5" }}>
<div className="answer-key">
<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>
)}
{q.sourceRef ? (
<p className="small muted" style={{ margin: "9px 0 0" }}>
<b>Source:</b> &ldquo;{q.sourceRef}&rdquo;
</p>
) : null}
</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",
"version": "1.0.0",
"dependencies": {
"@tabler/icons-react": "^3.44.0",
"@tailwindcss/postcss": "^4.3.1",
"next": "14.2.18",
"postcss": "^8.5.15",
@@ -242,6 +243,32 @@
"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": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.1.tgz",
+1
View File
@@ -9,6 +9,7 @@
"start": "next start"
},
"dependencies": {
"@tabler/icons-react": "^3.44.0",
"@tailwindcss/postcss": "^4.3.1",
"next": "14.2.18",
"postcss": "^8.5.15",