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:
+113
-146
@@ -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,89 +129,72 @@ 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) {
|
||||
setExportOpen(false);
|
||||
const opts = who === "packet" ? { packet: true, profile } : { teacher: who === true, profile };
|
||||
try {
|
||||
if (kind === "txt") { exportTxt(a, opts); showToast("Downloaded .txt"); }
|
||||
if (kind === "doc") { exportDoc(a, opts); showToast("Downloaded Word file"); }
|
||||
if (kind === "txt") { exportTxt(a, opts); showToast("Downloaded .txt"); }
|
||||
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));
|
||||
}
|
||||
if (kind === "copy") { exportClipboard(a, opts).then(() => showToast("Copied to clipboard")); }
|
||||
} catch (e) { setError(String(e.message || e)); }
|
||||
}
|
||||
|
||||
if (loadErr) {
|
||||
return (
|
||||
<div className="empty">
|
||||
<h3>Couldn’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 className="page page-narrow">
|
||||
<div className="empty">
|
||||
<h3>Couldn’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 & download .zip…
|
||||
</button>
|
||||
<button className="btn btn-sm" onClick={() => { setExportOpen(false); setCanvasOpen(true); }}>Set up & 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,35 +282,40 @@ export default function EditorPage() {
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
{a.questions.map((q, i) => (
|
||||
<QuestionCard
|
||||
key={q.id}
|
||||
q={q}
|
||||
index={i}
|
||||
count={a.questions.length}
|
||||
busy={busyQ === q.id}
|
||||
onChange={(next) => setQuestion(i, next)}
|
||||
onMove={(dir) => moveQuestion(i, dir)}
|
||||
onDelete={() => deleteQuestion(i)}
|
||||
onRegenerate={(note) => regenerateQuestion(i, note)}
|
||||
/>
|
||||
))}
|
||||
{/* the questions as one continuous document */}
|
||||
<div className="panel doc" style={{ marginTop: 16 }}>
|
||||
{a.questions.map((q, i) => (
|
||||
<QuestionCard
|
||||
key={q.id}
|
||||
q={q}
|
||||
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;
|
||||
|
||||
+458
-253
@@ -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,305 +180,402 @@
|
||||
|
||||
@keyframes step-complete {
|
||||
0% { transform: scale(1); }
|
||||
50% { transform: scale(1.2); }
|
||||
50% { transform: scale(1.18); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
|
||||
@keyframes skeleton-pulse {
|
||||
0%, 100% { opacity: 0.55; }
|
||||
50% { opacity: 1; }
|
||||
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
@@ -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>
|
||||
);
|
||||
|
||||
+93
-89
@@ -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();
|
||||
if (!q) return true;
|
||||
return [a.title, a.subject, a.gradeLevel, TYPE_LABELS[a.assignmentType]]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
.includes(q);
|
||||
});
|
||||
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);
|
||||
});
|
||||
}, [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’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 “{query}”.</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>
|
||||
<p className="lib-meta">
|
||||
{[a.gradeLevel, a.subject].filter(Boolean).join(" · ")}<br />
|
||||
{a.questionCount} question{a.questionCount === 1 ? "" : "s"} · {a.totalPoints} pts · updated {formatDate(a.updatedAt)}
|
||||
</p>
|
||||
<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>
|
||||
<button className="btn btn-sm btn-danger" disabled={busy === a.id} onClick={() => remove(a.id, a.title)}>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<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 “{query}”</> : ""}.</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(" · ")}
|
||||
{(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)}><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="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 ""; }
|
||||
}
|
||||
|
||||
+267
-298
@@ -1,12 +1,20 @@
|
||||
"use client";
|
||||
// app/page.jsx — the Create flow: Source -> Configure -> Generate.
|
||||
// app/page.jsx — the Create flow as an Open Workspace split screen:
|
||||
// left pane = the current step's content, right pane = step tracker + actions.
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
IconArrowRight, IconArrowLeft, IconBulb, IconPencil, IconAlertTriangle,
|
||||
IconCircleCheck, IconCircle, IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import { ASSIGNMENT_TYPES, QUESTION_TYPES, GRADE_LEVELS, DIFFICULTIES } from "@/lib/schema";
|
||||
|
||||
const MAX_PASTE = 120000;
|
||||
|
||||
// Maps a generation phase to its 0-based order index
|
||||
const STEPS = [
|
||||
{ label: "Source", desc: "Add your reading material" },
|
||||
{ label: "Configure", desc: "Set type, grade, questions" },
|
||||
{ label: "Generate", desc: "Review the verified key" },
|
||||
];
|
||||
const PHASE_ORDER = ["analyze", "generate", "verify", "save"];
|
||||
|
||||
function ProgressStep({ phase, label, currentPhase, hasVerify }) {
|
||||
@@ -15,9 +23,9 @@ function ProgressStep({ phase, label, currentPhase, hasVerify }) {
|
||||
const me = PHASE_ORDER.indexOf(phase);
|
||||
const state = me < cur ? "done" : me === cur ? "active" : "";
|
||||
return (
|
||||
<li className={state} style={{ animationDelay: `${me * 0.08}s` }}>
|
||||
<li className={state}>
|
||||
<span className="progress-dot">
|
||||
{state === "done" ? "✓" : state === "active" ? <span className="spinner" /> : "·"}
|
||||
{state === "done" ? <IconCircleCheck size={18} /> : state === "active" ? <span className="spinner" /> : <IconCircle size={16} />}
|
||||
</span>
|
||||
{label}{state === "active" ? "…" : ""}
|
||||
</li>
|
||||
@@ -58,13 +66,9 @@ export default function CreatePage() {
|
||||
.then((r) => r.json())
|
||||
.then((s) => {
|
||||
const cfg = s.providers?.[s.provider] || {};
|
||||
if (!cfg.model) {
|
||||
setProviderNote({ provider: s.provider, missing: "model" });
|
||||
} else if (["openai", "anthropic", "google"].includes(s.provider) && !cfg.apiKey) {
|
||||
setProviderNote({ provider: s.provider, missing: "key" });
|
||||
} else {
|
||||
setProviderNote(null);
|
||||
}
|
||||
if (!cfg.model) setProviderNote({ provider: s.provider, missing: "model" });
|
||||
else if (["openai", "anthropic", "google"].includes(s.provider) && !cfg.apiKey) setProviderNote({ provider: s.provider, missing: "key" });
|
||||
else setProviderNote(null);
|
||||
setConfig((c) => ({ ...c, verify: s.generation?.verification !== false }));
|
||||
})
|
||||
.catch(() => {});
|
||||
@@ -74,9 +78,7 @@ export default function CreatePage() {
|
||||
const [genState, setGenState] = useState(null);
|
||||
const generating = !!genState && !genState.error;
|
||||
|
||||
function update(patch) {
|
||||
setConfig((c) => ({ ...c, ...patch }));
|
||||
}
|
||||
function update(patch) { setConfig((c) => ({ ...c, ...patch })); }
|
||||
|
||||
function toggleQType(id) {
|
||||
setConfig((c) => {
|
||||
@@ -99,9 +101,7 @@ export default function CreatePage() {
|
||||
setText(content.slice(0, MAX_PASTE));
|
||||
setSourceName(file.name);
|
||||
setSourceTab("upload");
|
||||
} catch {
|
||||
setError("Could not read that file.");
|
||||
}
|
||||
} catch { setError("Could not read that file."); }
|
||||
}
|
||||
|
||||
async function fetchUrl() {
|
||||
@@ -117,17 +117,19 @@ export default function CreatePage() {
|
||||
if (!res.ok) throw new Error(data.error || "Could not fetch that page.");
|
||||
setText(data.text.slice(0, MAX_PASTE));
|
||||
setSourceName(data.title || url);
|
||||
} catch (e) {
|
||||
setError(String(e.message || e));
|
||||
} finally {
|
||||
setFetching(false);
|
||||
}
|
||||
} catch (e) { setError(String(e.message || e)); }
|
||||
finally { setFetching(false); }
|
||||
}
|
||||
|
||||
const sourceReady = text.trim().length >= 100;
|
||||
const configReady =
|
||||
config.subject.trim().length > 0 &&
|
||||
(["discussion", "case_study"].includes(config.assignmentType) || config.questionTypes.length > 0);
|
||||
const isDiscussionOrCase = ["discussion", "case_study"].includes(config.assignmentType);
|
||||
|
||||
function canGoTo(i) {
|
||||
return i === 0 || (i === 1 && sourceReady) || (i === 2 && sourceReady && configReady);
|
||||
}
|
||||
|
||||
async function generate() {
|
||||
setGenState({ phase: "analyze" });
|
||||
@@ -138,9 +140,7 @@ export default function CreatePage() {
|
||||
try {
|
||||
const r1 = await postJson("/api/generate", { stage: "analyze", source, config: cfg });
|
||||
analysis = r1.analysis;
|
||||
} catch (e) {
|
||||
console.warn("Analysis stage failed, continuing:", e);
|
||||
}
|
||||
} catch (e) { console.warn("Analysis stage failed, continuing:", e); }
|
||||
|
||||
setGenState({ phase: "generate" });
|
||||
const r2 = await postJson("/api/generate", { stage: "generate", source, analysis, config: cfg });
|
||||
@@ -149,18 +149,11 @@ export default function CreatePage() {
|
||||
if (cfg.verify) {
|
||||
setGenState({ phase: "verify" });
|
||||
try {
|
||||
const r3 = await postJson("/api/generate", {
|
||||
stage: "verify",
|
||||
source,
|
||||
config: cfg,
|
||||
questions: assignment.questions,
|
||||
});
|
||||
const r3 = await postJson("/api/generate", { stage: "verify", source, config: cfg, questions: assignment.questions });
|
||||
for (const q of assignment.questions) {
|
||||
if (r3.verifications[q.id]) q.verification = r3.verifications[q.id];
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Verification stage failed, continuing:", e);
|
||||
}
|
||||
} catch (e) { console.warn("Verification stage failed, continuing:", e); }
|
||||
}
|
||||
|
||||
setGenState({ phase: "save" });
|
||||
@@ -175,297 +168,273 @@ export default function CreatePage() {
|
||||
}
|
||||
}
|
||||
|
||||
const isDiscussionOrCase = ["discussion", "case_study"].includes(config.assignmentType);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-head">
|
||||
<h1>Create an assignment</h1>
|
||||
<p>Give it your source material, set the parameters, and get a classroom-ready assignment with a verified answer key — all on your own machine.</p>
|
||||
</div>
|
||||
<div className="create-split">
|
||||
<h1 className="sr-only">Create an assignment</h1>
|
||||
{/* ============ LEFT: current step content ============ */}
|
||||
<section className="create-source">
|
||||
{step === 0 && (
|
||||
<>
|
||||
<span className="field-label" style={{ color: "var(--board)", marginBottom: 8 }}>Step 1 · Source</span>
|
||||
<h2>What should the questions come from?</h2>
|
||||
<p className="muted" style={{ margin: "8px 0 0", fontSize: "0.95rem", maxWidth: "60ch" }}>
|
||||
Questions are grounded strictly in this material — the AI is instructed not to add outside facts.
|
||||
</p>
|
||||
|
||||
{providerNote && (
|
||||
<div className="alert alert-warn">
|
||||
{providerNote.missing === "key"
|
||||
? "Your selected AI provider needs an API key before you can generate."
|
||||
: "No AI model is selected yet."}{" "}
|
||||
<a href="/settings">Open Settings</a> to finish setup.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="steps" role="tablist">
|
||||
{["Source", "Configure", "Generate"].map((label, i) => (
|
||||
<button
|
||||
key={label}
|
||||
className={`step${step === i ? " active" : ""}${step > i ? " done" : ""}`}
|
||||
onClick={() => {
|
||||
if (i === 0 || (i === 1 && sourceReady) || (i === 2 && sourceReady && configReady)) setStep(i);
|
||||
}}
|
||||
disabled={
|
||||
generating ||
|
||||
(i === 1 && !sourceReady) ||
|
||||
(i === 2 && (!sourceReady || !configReady))
|
||||
}
|
||||
role="tab"
|
||||
aria-selected={step === i}
|
||||
>
|
||||
<span className="step-n">{step > i ? "✓" : i + 1}</span> {label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ============ STEP 1: SOURCE ============ */}
|
||||
{step === 0 && (
|
||||
<div className="card">
|
||||
<h2>What should the questions come from?</h2>
|
||||
<p className="muted small" style={{ margin: "6px 0 16px" }}>
|
||||
Questions are grounded strictly in this material — the AI is instructed not to add outside facts.
|
||||
</p>
|
||||
|
||||
<div className="tabs">
|
||||
{[["paste", "Paste text"], ["upload", "Upload file"], ["url", "From a web page"]].map(([id, label]) => (
|
||||
<button
|
||||
key={id}
|
||||
className={`tab${sourceTab === id ? " active" : ""}`}
|
||||
onClick={() => { setSourceTab(id); setError(""); }}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{sourceTab === "upload" && (
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<input ref={fileRef} type="file" accept=".txt,.md,.markdown,.text,.csv" onChange={onFile} style={{ display: "none" }} />
|
||||
<button className="btn" onClick={() => fileRef.current?.click()}>Choose a .txt or .md file</button>
|
||||
{sourceName && <span className="small muted" style={{ marginLeft: 10 }}>{sourceName}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sourceTab === "url" && (
|
||||
<div style={{ display: "flex", gap: 10, marginBottom: 14, flexWrap: "wrap" }}>
|
||||
<input
|
||||
type="url"
|
||||
placeholder="https://example.com/article"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" && url.trim()) fetchUrl(); }}
|
||||
style={{ flex: 1, minWidth: 240 }}
|
||||
/>
|
||||
<button className="btn btn-primary" onClick={fetchUrl} disabled={fetching || !url.trim()}>
|
||||
{fetching ? <><span className="spinner" /> Fetching…</> : "Fetch page"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(e) => { setText(e.target.value.slice(0, MAX_PASTE)); if (sourceTab === "paste") setSourceName(""); }}
|
||||
placeholder={
|
||||
sourceTab === "paste"
|
||||
? "Paste your reading passage, chapter, article, or lecture notes here…"
|
||||
: "The file or page content will appear here — you can trim or edit it before generating."
|
||||
}
|
||||
rows={12}
|
||||
aria-label="Source material"
|
||||
/>
|
||||
<div className="small muted" style={{ display: "flex", marginTop: 7 }}>
|
||||
<span>
|
||||
{text.length.toLocaleString()} / {MAX_PASTE.toLocaleString()} characters
|
||||
{sourceReady ? "" : " — at least 100 needed"}
|
||||
</span>
|
||||
<span className="spacer" />
|
||||
{text && <button className="btn btn-sm" onClick={() => { setText(""); setSourceName(""); }}>Clear</button>}
|
||||
</div>
|
||||
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
|
||||
<div style={{ display: "flex", marginTop: 20 }}>
|
||||
<span className="spacer" />
|
||||
<button className="btn btn-primary btn-lg" disabled={!sourceReady} onClick={() => setStep(1)}>
|
||||
Next: Configure →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ============ STEP 2: CONFIGURE ============ */}
|
||||
{step === 1 && (
|
||||
<div className="card">
|
||||
<h2>Set up the assignment</h2>
|
||||
|
||||
<div style={{ margin: "18px 0" }}>
|
||||
<span className="field-label">Assignment type</span>
|
||||
<div className="choice-grid">
|
||||
{ASSIGNMENT_TYPES.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
className={`choice${config.assignmentType === t.id ? " selected" : ""}`}
|
||||
onClick={() => update({ assignmentType: t.id })}
|
||||
>
|
||||
<b>{t.label}</b>
|
||||
<small>{t.hint}</small>
|
||||
<div className="tabs" style={{ marginTop: 20 }}>
|
||||
{[["paste", "Paste text"], ["upload", "Upload file"], ["url", "From a web page"]].map(([id, label]) => (
|
||||
<button key={id} className={`tab${sourceTab === id ? " active" : ""}`} onClick={() => { setSourceTab(id); setError(""); }}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row">
|
||||
<label className="field">
|
||||
<span className="field-label">Grade level</span>
|
||||
<select value={config.gradeLevel} onChange={(e) => update({ gradeLevel: e.target.value })}>
|
||||
{GRADE_LEVELS.map((g) => <option key={g}>{g}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="field-label">Subject</span>
|
||||
<input
|
||||
type="text"
|
||||
list="subjects"
|
||||
placeholder="e.g. U.S. History, Biology, English Language Arts"
|
||||
value={config.subject}
|
||||
onChange={(e) => update({ subject: e.target.value })}
|
||||
{sourceTab === "upload" && (
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<input ref={fileRef} type="file" accept=".txt,.md,.markdown,.text,.csv" onChange={onFile} style={{ display: "none" }} />
|
||||
<button className="btn" onClick={() => fileRef.current?.click()}>Choose a .txt or .md file</button>
|
||||
{sourceName && <span className="small muted" style={{ marginLeft: 10 }}>{sourceName}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sourceTab === "url" && (
|
||||
<div style={{ display: "flex", gap: 10, marginBottom: 14, flexWrap: "wrap" }}>
|
||||
<input type="url" placeholder="https://example.com/article" value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" && url.trim()) fetchUrl(); }}
|
||||
style={{ flex: 1, minWidth: 240 }} />
|
||||
<button className="btn btn-primary" onClick={fetchUrl} disabled={fetching || !url.trim()}>
|
||||
{fetching ? <><span className="spinner" /> Fetching…</> : "Fetch page"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ flex: 1, display: "flex", flexDirection: "column", marginTop: sourceTab === "paste" ? 4 : 0 }}>
|
||||
<textarea
|
||||
className="source-textarea"
|
||||
value={text}
|
||||
onChange={(e) => { setText(e.target.value.slice(0, MAX_PASTE)); if (sourceTab === "paste") setSourceName(""); }}
|
||||
placeholder={sourceTab === "paste"
|
||||
? "Paste your reading passage, chapter, article, or lecture notes here…"
|
||||
: "The file or page content will appear here — you can trim or edit it before generating."}
|
||||
aria-label="Source material"
|
||||
/>
|
||||
<datalist id="subjects">
|
||||
{["English Language Arts","U.S. History","World History","Civics / Government","Biology","Chemistry","Physics","Earth Science","Mathematics","Geography","Economics","Health","Computer Science","Spanish","Art History"].map((s) => (
|
||||
<option key={s} value={s} />
|
||||
))}
|
||||
</datalist>
|
||||
</label>
|
||||
</div>
|
||||
<div className="small muted" style={{ display: "flex", alignItems: "center", marginTop: 12, paddingTop: 12, borderTop: "1px solid var(--line)" }}>
|
||||
<span>{text.length.toLocaleString()} / {MAX_PASTE.toLocaleString()} characters{sourceReady ? "" : " — at least 100 needed"}</span>
|
||||
<span className="spacer" />
|
||||
{text && <button className="btn btn-sm" onClick={() => { setText(""); setSourceName(""); }}>Clear</button>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row">
|
||||
<label className="field">
|
||||
<span className="field-label">
|
||||
{isDiscussionOrCase ? "Number of prompts/questions" : "Number of questions"} — {config.questionCount}
|
||||
</span>
|
||||
<input
|
||||
type="range" min="1" max="30" value={config.questionCount}
|
||||
onChange={(e) => update({ questionCount: Number(e.target.value) })}
|
||||
style={{ width: "100%", accentColor: "var(--board)" }}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="field-label">Difficulty</span>
|
||||
<select value={config.difficulty} onChange={(e) => update({ difficulty: e.target.value })}>
|
||||
{DIFFICULTIES.map((d) => <option key={d}>{d}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
{error && <div className="alert alert-error"><IconAlertTriangle size={17} /> <span>{error}</span></div>}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!isDiscussionOrCase && (
|
||||
<div style={{ margin: "4px 0 12px" }}>
|
||||
<span className="field-label">Question types to include</span>
|
||||
<div className="row" style={{ gap: 4 }}>
|
||||
{QUESTION_TYPES.map((t) => (
|
||||
<label key={t.id} className="check" style={{ minWidth: 150, flex: "0 0 auto" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.questionTypes.includes(t.id)}
|
||||
onChange={() => toggleQType(t.id)}
|
||||
/>
|
||||
<span>{t.label}</span>
|
||||
</label>
|
||||
{step === 1 && (
|
||||
<>
|
||||
<span className="field-label" style={{ color: "var(--board)", marginBottom: 8 }}>Step 2 · Configure</span>
|
||||
<h2>Set up the assignment</h2>
|
||||
|
||||
<div style={{ margin: "20px 0" }}>
|
||||
<span className="field-label">Assignment type</span>
|
||||
<div className="choice-grid">
|
||||
{ASSIGNMENT_TYPES.map((t) => (
|
||||
<button key={t.id} className={`choice${config.assignmentType === t.id ? " selected" : ""}`} onClick={() => update({ assignmentType: t.id })}>
|
||||
<b>{t.label}</b>
|
||||
<small>{t.hint}</small>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{config.questionTypes.length === 0 && (
|
||||
<div className="field-hint" style={{ color: "var(--redpen)" }}>Pick at least one question type.</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="check">
|
||||
<input type="checkbox" checked={config.includeExplanations} onChange={(e) => update({ includeExplanations: e.target.checked })} />
|
||||
<span>Include explanations in the answer key<small>Why each answer is correct — and for multiple choice, why the others are wrong.</small></span>
|
||||
</label>
|
||||
{!isDiscussionOrCase && (
|
||||
<div className="row">
|
||||
<label className="field">
|
||||
<span className="field-label">Grade level</span>
|
||||
<select value={config.gradeLevel} onChange={(e) => update({ gradeLevel: e.target.value })}>
|
||||
{GRADE_LEVELS.map((g) => <option key={g}>{g}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="field-label">Subject</span>
|
||||
<input type="text" list="subjects" placeholder="e.g. U.S. History, Biology, English Language Arts"
|
||||
value={config.subject} onChange={(e) => update({ subject: e.target.value })} />
|
||||
<datalist id="subjects">
|
||||
{["English Language Arts","U.S. History","World History","Civics / Government","Biology","Chemistry","Physics","Earth Science","Mathematics","Geography","Economics","Health","Computer Science","Spanish","Art History"].map((s) => (
|
||||
<option key={s} value={s} />
|
||||
))}
|
||||
</datalist>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="row">
|
||||
<label className="field">
|
||||
<span className="field-label">{isDiscussionOrCase ? "Number of prompts/questions" : "Number of questions"} — {config.questionCount}</span>
|
||||
<input type="range" min="1" max="30" value={config.questionCount}
|
||||
onChange={(e) => update({ questionCount: Number(e.target.value) })} style={{ width: "100%" }} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="field-label">Difficulty</span>
|
||||
<select value={config.difficulty} onChange={(e) => update({ difficulty: e.target.value })}>
|
||||
{DIFFICULTIES.map((d) => <option key={d}>{d}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{!isDiscussionOrCase && (
|
||||
<div style={{ margin: "4px 0 14px" }}>
|
||||
<span className="field-label">Question types to include</span>
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: "2px 18px" }}>
|
||||
{QUESTION_TYPES.map((t) => (
|
||||
<label key={t.id} className="check" style={{ minWidth: 150 }}>
|
||||
<input type="checkbox" checked={config.questionTypes.includes(t.id)} onChange={() => toggleQType(t.id)} />
|
||||
<span>{t.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{config.questionTypes.length === 0 && (
|
||||
<div className="field-hint redpen">Pick at least one question type.</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="check">
|
||||
<input type="checkbox" checked={config.includeRubrics} onChange={(e) => update({ includeRubrics: e.target.checked })} />
|
||||
<span>Include rubrics for essay questions<small>Point-based criteria that sum to the question total.</small></span>
|
||||
<input type="checkbox" checked={config.includeExplanations} onChange={(e) => update({ includeExplanations: e.target.checked })} />
|
||||
<span>Include explanations in the answer key<small>Why each answer is correct — and for multiple choice, why the others are wrong.</small></span>
|
||||
</label>
|
||||
{!isDiscussionOrCase && (
|
||||
<label className="check">
|
||||
<input type="checkbox" checked={config.includeRubrics} onChange={(e) => update({ includeRubrics: e.target.checked })} />
|
||||
<span>Include rubrics for essay questions<small>Point-based criteria that sum to the question total.</small></span>
|
||||
</label>
|
||||
)}
|
||||
<label className="check">
|
||||
<input type="checkbox" checked={config.verify} onChange={(e) => update({ verify: e.target.checked })} />
|
||||
<span>Run the accuracy check<small>A second AI pass reviews every question and answer against your source. Strongly recommended.</small></span>
|
||||
</label>
|
||||
)}
|
||||
<label className="check">
|
||||
<input type="checkbox" checked={config.verify} onChange={(e) => update({ verify: e.target.checked })} />
|
||||
<span>Run the accuracy check<small>A second AI pass reviews every question and answer against your source. Strongly recommended — adds a little time.</small></span>
|
||||
</label>
|
||||
|
||||
<label className="field" style={{ marginTop: 12 }}>
|
||||
<span className="field-label">Anything to focus on? <span className="muted" style={{ fontWeight: 400 }}>(optional)</span></span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. focus on causes rather than dates; include the vocabulary terms"
|
||||
value={config.focusNote}
|
||||
onChange={(e) => update({ focusNote: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="field" style={{ marginTop: 14 }}>
|
||||
<span className="field-label">Anything to focus on? <span className="faint" style={{ textTransform: "none", letterSpacing: 0, fontWeight: 400 }}>(optional)</span></span>
|
||||
<input type="text" placeholder="e.g. focus on causes rather than dates; include the vocabulary terms"
|
||||
value={config.focusNote} onChange={(e) => update({ focusNote: e.target.value })} />
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div style={{ display: "flex", marginTop: 20, gap: 10 }}>
|
||||
<button className="btn" onClick={() => setStep(0)}>← Back</button>
|
||||
<span className="spacer" />
|
||||
<button className="btn btn-primary btn-lg" disabled={!configReady} onClick={() => setStep(2)}>
|
||||
Next: Generate →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{step === 2 && (
|
||||
<>
|
||||
<span className="field-label" style={{ color: "var(--board)", marginBottom: 8 }}>Step 3 · Generate</span>
|
||||
<h2>Ready to generate</h2>
|
||||
<p className="muted" style={{ margin: "12px 0 4px", fontSize: "0.96rem" }}>
|
||||
<b style={{ color: "var(--ink)" }}>{ASSIGNMENT_TYPES.find((t) => t.id === config.assignmentType)?.label}</b>{" "}
|
||||
· {config.gradeLevel} · {config.subject || "—"} · {config.questionCount} question{config.questionCount === 1 ? "" : "s"} · {config.difficulty} difficulty
|
||||
</p>
|
||||
<p className="muted small">Source: {sourceName || "Pasted text"} ({text.length.toLocaleString()} characters)</p>
|
||||
|
||||
{/* ============ STEP 3: GENERATE ============ */}
|
||||
{step === 2 && (
|
||||
<div className="card">
|
||||
<h2>Ready to generate</h2>
|
||||
<p className="muted" style={{ margin: "10px 0 4px", fontSize: "0.96rem" }}>
|
||||
<b style={{ color: "var(--ink)" }}>
|
||||
{ASSIGNMENT_TYPES.find((t) => t.id === config.assignmentType)?.label}
|
||||
</b>{" "}
|
||||
· {config.gradeLevel} · {config.subject || "—"} · {config.questionCount} question{config.questionCount === 1 ? "" : "s"} · {config.difficulty} difficulty
|
||||
</p>
|
||||
<p className="muted small">Source: {sourceName || "Pasted text"} ({text.length.toLocaleString()} characters)</p>
|
||||
{genState && !genState.error && (
|
||||
<>
|
||||
<ul className="progress-list" aria-live="polite">
|
||||
<ProgressStep phase="analyze" label="Reading the source and mapping key concepts" currentPhase={genState.phase} hasVerify={config.verify} />
|
||||
<ProgressStep phase="generate" label="Writing questions and the answer key" currentPhase={genState.phase} hasVerify={config.verify} />
|
||||
{config.verify && <ProgressStep phase="verify" label="Checking every answer against the source" currentPhase={genState.phase} hasVerify={config.verify} />}
|
||||
<ProgressStep phase="save" label="Saving and opening the editor" currentPhase={genState.phase} hasVerify={config.verify} />
|
||||
</ul>
|
||||
<p className="small muted" style={{ marginTop: 16 }}>Local models can take a few minutes for large assignments. Leave this tab open.</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!genState && (
|
||||
<div style={{ display: "flex", marginTop: 20, gap: 10 }}>
|
||||
<button className="btn" onClick={() => setStep(1)}>← Back</button>
|
||||
<span className="spacer" />
|
||||
<button className="btn btn-primary btn-lg" onClick={generate} disabled={!!providerNote}>
|
||||
✎ Generate assignment
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{genState && !genState.error && (
|
||||
<>
|
||||
<ul className="progress-list" aria-live="polite">
|
||||
<ProgressStep phase="analyze" label="Reading the source and mapping key concepts" currentPhase={genState.phase} hasVerify={config.verify} />
|
||||
<ProgressStep phase="generate" label="Writing questions and the answer key" currentPhase={genState.phase} hasVerify={config.verify} />
|
||||
{config.verify && <ProgressStep phase="verify" label="Checking every answer against the source" currentPhase={genState.phase} hasVerify={config.verify} />}
|
||||
<ProgressStep phase="save" label="Saving and opening the editor" currentPhase={genState.phase} hasVerify={config.verify} />
|
||||
</ul>
|
||||
<p className="small muted" style={{ marginTop: 16 }}>
|
||||
Local models can take a few minutes for large assignments. Leave this tab open.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{genState?.error && (
|
||||
<>
|
||||
<div className="alert alert-error"><b>Generation failed.</b> {genState.error}</div>
|
||||
<div style={{ display: "flex", gap: 10 }}>
|
||||
<button className="btn" onClick={() => setGenState(null)}>Adjust and retry</button>
|
||||
<button className="btn btn-primary" onClick={generate}>Try again</button>
|
||||
{genState?.error && (
|
||||
<div className="alert alert-error" style={{ marginTop: 20 }}>
|
||||
<IconAlertTriangle size={17} /> <span><b>Generation failed.</b> {genState.error}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ============ RIGHT: step tracker + actions ============ */}
|
||||
<aside className="create-aside">
|
||||
<span className="field-label" style={{ color: "var(--ink-soft)" }}>Progress</span>
|
||||
<h2 style={{ fontSize: "1.2rem", margin: "8px 0 24px" }}>Create an assignment</h2>
|
||||
|
||||
{providerNote && (
|
||||
<div className="alert alert-warn" style={{ marginTop: 0 }}>
|
||||
<IconAlertTriangle size={17} />
|
||||
<span>
|
||||
{providerNote.missing === "key" ? "Your selected AI provider needs an API key." : "No AI model is selected yet."}{" "}
|
||||
<a href="/settings">Open Settings</a> to finish setup.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="vsteps">
|
||||
{STEPS.map((s, i) => {
|
||||
const state = step === i ? "active" : step > i ? "done" : "";
|
||||
return (
|
||||
<button
|
||||
key={s.label}
|
||||
className={`vstep ${state}`}
|
||||
style={{ border: 0, background: "none", textAlign: "left", cursor: canGoTo(i) && !generating ? "pointer" : "default", padding: 0, paddingBottom: i === STEPS.length - 1 ? 0 : 22, font: "inherit", width: "100%" }}
|
||||
onClick={() => { if (canGoTo(i) && !generating) setStep(i); }}
|
||||
disabled={!canGoTo(i) || generating}
|
||||
>
|
||||
<span className="vstep-n">{step > i ? <IconCircleCheck size={20} /> : i + 1}</span>
|
||||
<span>
|
||||
<span className="vstep-title" style={{ display: "block" }}>{s.label}</span>
|
||||
<span className="vstep-desc">{s.desc}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<hr className="hr" style={{ margin: "26px 0" }} />
|
||||
|
||||
<div className="tip">
|
||||
<IconBulb size={18} />
|
||||
<p style={{ margin: 0 }}>
|
||||
{step === 0 && <><b>Tip:</b> 300–800 words of clean source text produces the most accurate questions.</>}
|
||||
{step === 1 && <><b>Tip:</b> Mixing question types gives a more rounded check for understanding.</>}
|
||||
{step === 2 && <><b>Tip:</b> Every question keeps a source quote so you can verify it against your material.</>}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: "auto", paddingTop: 28, display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
{step === 0 && (
|
||||
<button className="btn btn-primary btn-lg btn-block" disabled={!sourceReady} onClick={() => setStep(1)}>
|
||||
Next: Configure <IconArrowRight size={17} />
|
||||
</button>
|
||||
)}
|
||||
{step === 1 && (
|
||||
<>
|
||||
<button className="btn btn-primary btn-lg btn-block" disabled={!configReady} onClick={() => setStep(2)}>
|
||||
Next: Generate <IconArrowRight size={17} />
|
||||
</button>
|
||||
<button className="btn btn-block" onClick={() => setStep(0)}><IconArrowLeft size={16} /> Back</button>
|
||||
</>
|
||||
)}
|
||||
{step === 2 && !genState && (
|
||||
<>
|
||||
<button className="btn btn-primary btn-lg btn-block" onClick={generate} disabled={!!providerNote}>
|
||||
<IconPencil size={17} /> Generate assignment
|
||||
</button>
|
||||
<button className="btn btn-block" onClick={() => setStep(1)}><IconArrowLeft size={16} /> Back</button>
|
||||
</>
|
||||
)}
|
||||
{step === 2 && genState?.error && (
|
||||
<>
|
||||
<button className="btn btn-primary btn-block" onClick={generate}>Try again</button>
|
||||
<button className="btn btn-block" onClick={() => setGenState(null)}>Adjust and retry</button>
|
||||
</>
|
||||
)}
|
||||
{step === 0 && !sourceReady && <p className="small faint" style={{ textAlign: "center", margin: 0 }}>Add at least 100 characters to continue</p>}
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function postJson(url, body) {
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const res = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || `Request failed (${res.status}).`);
|
||||
return data;
|
||||
|
||||
+227
-307
@@ -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,238 +160,195 @@ export default function SettingsPage() {
|
||||
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
|
||||
<div className="card">
|
||||
<h2>Teacher & 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">
|
||||
<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>
|
||||
</div>
|
||||
<div className="field">
|
||||
<span className="field-label">School logo or mascot (optional)</span>
|
||||
<div style={{ display: "flex", gap: 12, alignItems: "center", flexWrap: "wrap" }}>
|
||||
{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 }} />
|
||||
)}
|
||||
<label className="btn" style={{ cursor: "pointer" }}>
|
||||
{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>
|
||||
)}
|
||||
</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>
|
||||
|
||||
<div className="card">
|
||||
<h2>AI provider</h2>
|
||||
<div style={{ marginTop: 14 }}>
|
||||
{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>
|
||||
<small>{p.desc}</small>
|
||||
</span>
|
||||
{p.local && <span className="chip local-tag">Private · local</span>}
|
||||
</label>
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<hr className="hr" />
|
||||
<h3 style={{ marginBottom: 12 }}>{PROVIDERS.find((p) => p.id === active)?.name} setup</h3>
|
||||
<div className="settings-body">
|
||||
{/* ---- Teacher & school ---- */}
|
||||
<section className="sec" id="teacher">
|
||||
<h2>Teacher & school</h2>
|
||||
<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 className="ul-input" type="text" value={s.profile?.teacherName || ""} onChange={(e) => setProfile("teacherName", e.target.value)} placeholder="e.g. Mr. Drew" />
|
||||
</div>
|
||||
<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 }} />
|
||||
)}
|
||||
<label className="btn" style={{ cursor: "pointer" }}>
|
||||
<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-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>
|
||||
</section>
|
||||
|
||||
{isLocal && (
|
||||
<label className="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"}
|
||||
/>
|
||||
<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’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 “Test connection” 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’s Developer tab enable “Serve on Local Network”. Use “Test connection” below to confirm.</>}
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
{/* ---- AI provider ---- */}
|
||||
<section className="sec" id="provider">
|
||||
<h2>AI provider</h2>
|
||||
<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 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 chip-pill local-tag">Private · local</span>}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!isLocal && (
|
||||
<label className="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>
|
||||
)}
|
||||
<h3 style={{ margin: "26px 0 16px" }}>{PROVIDERS.find((p) => p.id === active)?.name} setup</h3>
|
||||
|
||||
<label className="field">
|
||||
<span className="field-label">Model</span>
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
{modelList.length > 0 ? (
|
||||
<select 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 }}
|
||||
/>
|
||||
{isLocal && (
|
||||
<div className="ul-field">
|
||||
<span className="field-label">Server address (base URL)</span>
|
||||
<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. On another machine, enter that machine’s address, e.g. <code>http://192.168.1.50:11434</code> — and set <code>OLLAMA_HOST=0.0.0.0</code> there. Use “Test connection” 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’s Developer tab enable “Serve on Local Network”.</>}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<button className="btn" onClick={() => refreshModels(active)} disabled={modelsBusy === active}>
|
||||
{modelsBusy === active ? <><span className="spinner" /> Looking…</> : "Refresh models"}
|
||||
|
||||
{!isLocal && (
|
||||
<div className="ul-field">
|
||||
<span className="field-label">API key</span>
|
||||
<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>
|
||||
)}
|
||||
|
||||
<div className="ul-field">
|
||||
<span className="field-label">Model</span>
|
||||
<div style={{ display: "flex", gap: 12, flexWrap: "wrap", alignItems: "flex-end" }}>
|
||||
{modelList.length > 0 ? (
|
||||
<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 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…</> : <><IconRefresh size={15} /> Refresh models</>}
|
||||
</button>
|
||||
</div>
|
||||
{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…</> : <><IconPlug size={15} /> Test connection</>}
|
||||
</button>
|
||||
{t && !t.busy && (
|
||||
<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>
|
||||
</section>
|
||||
|
||||
{/* ---- Canvas LMS ---- */}
|
||||
<section className="sec" id="canvas">
|
||||
<h2>Canvas (LMS) integration</h2>
|
||||
<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 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>
|
||||
</div>
|
||||
<div className="ul-field">
|
||||
<span className="field-label">Access token</span>
|
||||
<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…</> : <><IconPlug size={15} /> Test connection</>}
|
||||
</button>
|
||||
{canvasTest && !canvasTest.busy && (
|
||||
<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>
|
||||
</section>
|
||||
|
||||
{/* ---- 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>
|
||||
</label>
|
||||
{autoOn && (
|
||||
<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…</>}
|
||||
</p>
|
||||
)}
|
||||
<div className="row" style={{ marginTop: 14 }}>
|
||||
<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%" }} />
|
||||
<span className="field-hint">Lower = more precise and literal (best for accuracy). 0.2–0.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>
|
||||
</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 do better around 16,000–24,000.</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
<label className="check">
|
||||
<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>
|
||||
</section>
|
||||
|
||||
<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…" : <><IconCheck size={16} /> Save settings</>}
|
||||
</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>
|
||||
|
||||
<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"}
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<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 }}>
|
||||
<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"
|
||||
/>
|
||||
<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">
|
||||
<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>
|
||||
<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"}
|
||||
</button>
|
||||
{canvasTest && !canvasTest.busy && (
|
||||
<span className="small" style={canvasTest.ok ? { color: "var(--board)", fontWeight: 600 } : { fontWeight: 600 }}>
|
||||
{canvasTest.ok ? "✓ " : "✕ "}{canvasTest.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<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>
|
||||
</label>
|
||||
{autoOn && (
|
||||
<p className="field-hint" style={{ margin: "0 0 4px 30px" }}>
|
||||
{!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…</>}
|
||||
</p>
|
||||
)}
|
||||
<div className="row" style={{ marginTop: 14 }}>
|
||||
<label className="field">
|
||||
<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)" }}
|
||||
/>
|
||||
<span className="field-hint">Lower = more precise and literal (best for accuracy). 0.2–0.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>
|
||||
</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,000–24,000.</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
<label className="check">
|
||||
<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>
|
||||
|
||||
<div style={{ display: "flex", marginTop: 18 }}>
|
||||
<span className="spacer" />
|
||||
<button className="btn btn-primary btn-lg" onClick={save} disabled={saving}>
|
||||
{saving ? "Saving…" : "Save settings"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{toast && <div className="toast">{toast}</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user