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;
|
||||
|
||||
Reference in New Issue
Block a user