From b7416cc618bf07fad25a913be5c015bce5a59c02 Mon Sep 17 00:00:00 2001 From: bizzle Date: Thu, 25 Jun 2026 18:33:08 -0400 Subject: [PATCH] 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 --- app/editor/[id]/page.jsx | 259 +++++------ app/globals.css | 711 +++++++++++++++++++----------- app/layout.jsx | 8 +- app/library/page.jsx | 182 ++++---- app/page.jsx | 565 +++++++++++------------- app/settings/page.jsx | 534 ++++++++++------------ components/CanvasExportDialog.jsx | 16 +- components/Nav.jsx | 65 --- components/QuestionCard.jsx | 414 +++++++++-------- components/Sidebar.jsx | 124 ++++++ lib/group.js | 22 + package-lock.json | 27 ++ package.json | 1 + 13 files changed, 1564 insertions(+), 1364 deletions(-) delete mode 100644 components/Nav.jsx create mode 100644 components/Sidebar.jsx create mode 100644 lib/group.js diff --git a/app/editor/[id]/page.jsx b/app/editor/[id]/page.jsx index 170c387..28d133a 100644 --- a/app/editor/[id]/page.jsx +++ b/app/editor/[id]/page.jsx @@ -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 ( -
-

Couldn’t open that assignment

-

{loadErr}

- +
+
+

Couldn’t open that assignment

+

{loadErr}

+ +
); } if (!a) { return ( -
-
+
+
{[0, 1, 2].map((i) => ( -
+
@@ -232,79 +208,69 @@ export default function EditorPage() { const uncheckedCount = a.questions.filter((q) => !q.verification || q.verification.status === "unchecked").length; return ( -
-
+
+

{a.title || "Untitled assignment"}

+
- 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"; }} - /> -

+

+ patch({ title: e.target.value })} aria-label="Assignment title" placeholder="Untitled assignment" /> + +
+

{[a.assignmentType?.replace("_", " "), a.gradeLevel, a.subject].filter(Boolean).join(" · ")} · {a.questions.length} questions · {totalPoints(a.questions)} points {a.source?.name ? <> · from {a.source.name} : null}

- + {exportOpen && ( -
+
Student version
-
+
Teacher version (answer key)
-
+
Complete packet — student + answer key
-
+
Canvas (LMS)
- +
)}
- {error &&
{error}
} + {error &&
{error}
} {warnCount > 0 && (
- {warnCount} question{warnCount === 1 ? "" : "s"} flagged by the accuracy check. Look for the ⚠ stamps below — each has a reviewer note. Edit or regenerate those questions, then re-run the check. + + {warnCount} question{warnCount === 1 ? "" : "s"} flagged by the accuracy check. Look for the ⚠ stamps below — each has a reviewer note. Edit or regenerate those questions, then re-run the check.
)} {warnCount === 0 && uncheckedCount === 0 && a.questions.length > 0 && ( -
✓ Every question passed the accuracy check against your source.
+
Every question passed the accuracy check against your source.
)} -