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:
@@ -3,6 +3,7 @@
|
||||
// assignment. Auto-fills everything derivable; exposes the optional Canvas quiz settings
|
||||
// a teacher may want to set. Pure client-side (no upload) — see lib/canvas/export.js.
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { IconX } from "@tabler/icons-react";
|
||||
import { mapAssignmentToCanvas } from "@/lib/canvas/map";
|
||||
import { exportCanvasZip } from "@/lib/canvas/export";
|
||||
|
||||
@@ -145,18 +146,11 @@ export default function CanvasExportDialog({ assignment, onClose, onDone, onErro
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: "fixed", inset: 0, zIndex: 100, background: "rgba(20,30,26,0.45)",
|
||||
display: "flex", alignItems: "flex-start", justifyContent: "center",
|
||||
padding: "40px 16px", overflowY: "auto",
|
||||
}}
|
||||
>
|
||||
<div className="modal-scrim" onClick={onClose}>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="card"
|
||||
style={{ width: 560, maxWidth: "100%", padding: 24, animation: "fade-in-up 0.18s ease" }}
|
||||
className="modal"
|
||||
style={{ width: 560, maxWidth: "100%", padding: 24 }}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "flex-start", gap: 12 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
@@ -165,7 +159,7 @@ export default function CanvasExportDialog({ assignment, onClose, onDone, onErro
|
||||
Downloads a QTI <code>.zip</code>. In Canvas: <b>Settings → Import Course Content → QTI .zip file</b>.
|
||||
</p>
|
||||
</div>
|
||||
<button className="btn btn-sm" onClick={onClose} aria-label="Close">✕</button>
|
||||
<button className="icon-btn" onClick={onClose} aria-label="Close"><IconX size={16} /></button>
|
||||
</div>
|
||||
|
||||
{/* Summary of what will be exported */}
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
"use client";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const LINKS = [
|
||||
{ href: "/", label: "Create" },
|
||||
{ href: "/library", label: "Library" },
|
||||
{ href: "/settings", label: "Settings" },
|
||||
];
|
||||
|
||||
export default function Nav() {
|
||||
const pathname = usePathname();
|
||||
const [theme, setTheme] = useState(null);
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setTheme(document.documentElement.dataset.theme === "dark" ? "dark" : "light");
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function onScroll() {
|
||||
setScrolled(window.scrollY > 8);
|
||||
}
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => window.removeEventListener("scroll", onScroll);
|
||||
}, []);
|
||||
|
||||
function toggleTheme() {
|
||||
const next = document.documentElement.dataset.theme === "dark" ? "light" : "dark";
|
||||
document.documentElement.dataset.theme = next;
|
||||
try { localStorage.setItem("theme", next); } catch {}
|
||||
setTheme(next);
|
||||
}
|
||||
|
||||
return (
|
||||
<header className={`topnav${scrolled ? " nav-scrolled" : ""}`}>
|
||||
<div className="topnav-inner">
|
||||
<Link href="/" className="brand">
|
||||
<span className="brand-mark" aria-hidden="true">✎</span>
|
||||
<span className="brand-text">Mr. Drew’s Assignment Creator</span>
|
||||
</Link>
|
||||
<nav className="navlinks" aria-label="Main">
|
||||
{LINKS.map((l) => {
|
||||
const active = l.href === "/" ? pathname === "/" : pathname.startsWith(l.href);
|
||||
return (
|
||||
<Link key={l.href} href={l.href} className={`navlink${active ? " active" : ""}`}>
|
||||
{l.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-btn theme-toggle"
|
||||
onClick={toggleTheme}
|
||||
aria-label={theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
|
||||
title={theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
|
||||
>
|
||||
{theme === "dark" ? "☀" : "☾"}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
+222
-192
@@ -1,7 +1,12 @@
|
||||
"use client";
|
||||
// components/QuestionCard.jsx — edit any question inline, with the answer key
|
||||
// styled in red pen and grading-stamp verification badges.
|
||||
// components/QuestionCard.jsx — one question rendered as a row in the editor
|
||||
// document: a left rail (drag grip + number), the prompt/options in the center,
|
||||
// and the answer key in the right margin, styled in red pen.
|
||||
import { useState } from "react";
|
||||
import {
|
||||
IconGripVertical, IconArrowUp, IconArrowDown, IconRefresh, IconX,
|
||||
IconCircleCheck, IconAlertTriangle, IconArrowRight,
|
||||
} from "@tabler/icons-react";
|
||||
import { questionTypeLabel } from "@/lib/schema";
|
||||
|
||||
const LETTERS = "ABCDEFGHIJ";
|
||||
@@ -10,218 +15,243 @@ function AutoTextarea({ value, onChange, rows = 2, ...rest }) {
|
||||
return <textarea value={value || ""} rows={rows} onChange={(e) => onChange(e.target.value)} {...rest} />;
|
||||
}
|
||||
|
||||
export default function QuestionCard({ q, index, count, onChange, onMove, onDelete, onRegenerate, busy }) {
|
||||
export default function QuestionCard({
|
||||
q, index, count, onChange, onMove, onDelete, onRegenerate, busy,
|
||||
dragging, over, onDragStart, onDragEnter, onDrop, onDragEnd,
|
||||
}) {
|
||||
const [regenOpen, setRegenOpen] = useState(false);
|
||||
const [note, setNote] = useState("");
|
||||
const [grabbed, setGrabbed] = useState(false);
|
||||
|
||||
function set(patch) {
|
||||
onChange({ ...q, ...patch, verification: patch.verification || { status: "unchecked", note: "" } });
|
||||
}
|
||||
// Editing content invalidates the old verification stamp (set() above resets it),
|
||||
// but pure point changes shouldn't:
|
||||
function setPoints(points) {
|
||||
onChange({ ...q, points });
|
||||
}
|
||||
function setPoints(points) { onChange({ ...q, points }); }
|
||||
|
||||
const v = q.verification || { status: "unchecked" };
|
||||
const hasMargin = q.type !== "matching"; // matching edits pairs in the center; others use the margin
|
||||
|
||||
return (
|
||||
<div className="card qcard">
|
||||
<div className="qcard-head">
|
||||
<span className="qnum">{index + 1}.</span>
|
||||
<span className="chip chip-neutral">{questionTypeLabel(q.type)}</span>
|
||||
{v.status === "pass" && <span className="stamp stamp-pass" title="The accuracy check confirmed this answer against your source.">✓ Verified</span>}
|
||||
{v.status === "warn" && <span className="stamp stamp-warn" title={v.note}>⚠ Check this</span>}
|
||||
<span className="qcard-actions">
|
||||
<label className="small muted" style={{ display: "inline-flex", alignItems: "center", gap: 5 }}>
|
||||
<input className="points-input" type="number" min="0" max="100" value={q.points}
|
||||
onChange={(e) => setPoints(Math.max(0, Number(e.target.value) || 0))} aria-label="Points" />
|
||||
pts
|
||||
</label>
|
||||
<button className="icon-btn" title="Move up" disabled={index === 0 || busy} onClick={() => onMove(-1)}>↑</button>
|
||||
<button className="icon-btn" title="Move down" disabled={index === count - 1 || busy} onClick={() => onMove(1)}>↓</button>
|
||||
<button className="icon-btn" title="Regenerate this question" disabled={busy} onClick={() => setRegenOpen((o) => !o)}>↻</button>
|
||||
<button className="icon-btn danger" title="Delete question" disabled={busy} onClick={onDelete}>✕</button>
|
||||
</span>
|
||||
<div
|
||||
className={`qrow${dragging ? " dragging" : ""}${over ? " drag-over" : ""}`}
|
||||
draggable={grabbed}
|
||||
onDragStart={(e) => { e.dataTransfer.effectAllowed = "move"; onDragStart?.(); }}
|
||||
onDragEnter={(e) => { e.preventDefault(); onDragEnter?.(); }}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => { e.preventDefault(); onDrop?.(); }}
|
||||
onDragEnd={() => { setGrabbed(false); onDragEnd?.(); }}
|
||||
>
|
||||
{/* ---- left rail: drag grip + number ---- */}
|
||||
<div className="qrail">
|
||||
<button
|
||||
className="qgrip"
|
||||
title="Drag to reorder"
|
||||
aria-label={`Drag question ${index + 1} to reorder`}
|
||||
onMouseDown={() => setGrabbed(true)}
|
||||
onMouseUp={() => setGrabbed(false)}
|
||||
onBlur={() => setGrabbed(false)}
|
||||
disabled={busy}
|
||||
>
|
||||
<IconGripVertical size={18} />
|
||||
</button>
|
||||
<span className="qnum">{index + 1}</span>
|
||||
</div>
|
||||
|
||||
{v.status === "warn" && v.note && (
|
||||
<div className="alert alert-warn" style={{ marginTop: 0 }}><b>Accuracy reviewer:</b> {v.note}</div>
|
||||
)}
|
||||
{/* ---- center: meta, prompt, student-facing body ---- */}
|
||||
<div className="qmain">
|
||||
<div className="qcard-head">
|
||||
<span className="chip chip-pill chip-neutral">{questionTypeLabel(q.type)}</span>
|
||||
{v.status === "pass" && <span className="stamp stamp-pass" title="The accuracy check confirmed this answer against your source."><IconCircleCheck size={13} /> Verified</span>}
|
||||
{v.status === "warn" && <span className="stamp stamp-warn" title={v.note}><IconAlertTriangle size={13} /> Check this</span>}
|
||||
<span className="qcard-actions">
|
||||
<label className="small muted" style={{ display: "inline-flex", alignItems: "center", gap: 5 }}>
|
||||
<input className="points-input" type="number" min="0" max="100" value={q.points}
|
||||
onChange={(e) => setPoints(Math.max(0, Number(e.target.value) || 0))} aria-label="Points" />
|
||||
pts
|
||||
</label>
|
||||
<button className="icon-btn" title="Move up" aria-label="Move up" disabled={index === 0 || busy} onClick={() => onMove(-1)}><IconArrowUp size={16} /></button>
|
||||
<button className="icon-btn" title="Move down" aria-label="Move down" disabled={index === count - 1 || busy} onClick={() => onMove(1)}><IconArrowDown size={16} /></button>
|
||||
<button className="icon-btn" title="Regenerate this question" aria-label="Regenerate" disabled={busy} onClick={() => setRegenOpen((o) => !o)}><IconRefresh size={16} /></button>
|
||||
<button className="icon-btn danger" title="Delete question" aria-label="Delete" disabled={busy} onClick={onDelete}><IconX size={16} /></button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{regenOpen && (
|
||||
<div className="alert alert-info" style={{ marginTop: 0 }}>
|
||||
<div className="field-label">Regenerate this question from your source</div>
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
<input type="text" placeholder="Optional note to steer it — e.g. make it harder, focus on the causes" value={note}
|
||||
onChange={(e) => setNote(e.target.value)} style={{ flex: 1, minWidth: 200 }} />
|
||||
<button className="btn btn-primary btn-sm" disabled={busy} onClick={() => { onRegenerate(note); setRegenOpen(false); setNote(""); }}>
|
||||
{busy ? <><span className="spinner" /> Working…</> : "Regenerate"}
|
||||
</button>
|
||||
<button className="btn btn-sm" onClick={() => setRegenOpen(false)}>Cancel</button>
|
||||
{v.status === "warn" && v.note && (
|
||||
<div className="alert alert-warn" style={{ marginTop: 0 }}><IconAlertTriangle size={17} /> <span><b>Accuracy reviewer:</b> {v.note}</span></div>
|
||||
)}
|
||||
|
||||
{regenOpen && (
|
||||
<div className="alert alert-info" style={{ marginTop: 0, display: "block" }}>
|
||||
<div className="field-label">Regenerate this question from your source</div>
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
<input type="text" placeholder="Optional note to steer it — e.g. make it harder, focus on the causes" value={note}
|
||||
onChange={(e) => setNote(e.target.value)} style={{ flex: 1, minWidth: 200 }} />
|
||||
<button className="btn btn-primary btn-sm" disabled={busy} onClick={() => { onRegenerate(note); setRegenOpen(false); setNote(""); }}>
|
||||
{busy ? <><span className="spinner" /> Working…</> : "Regenerate"}
|
||||
</button>
|
||||
<button className="btn btn-sm" onClick={() => setRegenOpen(false)}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* Question prompt */}
|
||||
<AutoTextarea
|
||||
value={q.question}
|
||||
onChange={(question) => set({ question })}
|
||||
rows={q.type === "essay" || q.type === "discussion" ? 3 : 2}
|
||||
aria-label="Question text"
|
||||
placeholder={q.type === "fill_blank" ? "Sentence with ______ (six underscores) for each blank" : "Question text…"}
|
||||
/>
|
||||
<AutoTextarea
|
||||
className="q-prompt"
|
||||
value={q.question}
|
||||
onChange={(question) => set({ question })}
|
||||
rows={q.type === "essay" || q.type === "discussion" ? 3 : 2}
|
||||
aria-label="Question text"
|
||||
placeholder={q.type === "fill_blank" ? "Sentence with ______ (six underscores) for each blank" : "Question text…"}
|
||||
/>
|
||||
|
||||
{/* ---- type-specific bodies ---- */}
|
||||
{q.type === "multiple_choice" && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
{(q.options || []).map((opt, i) => (
|
||||
<div className="opt-row" key={i}>
|
||||
<input
|
||||
type="radio" name={"correct-" + q.id} checked={q.correctIndex === i}
|
||||
onChange={() => set({ correctIndex: i })}
|
||||
title="Mark as the correct answer"
|
||||
/>
|
||||
<span className="opt-letter">{LETTERS[i]}.</span>
|
||||
<input type="text" value={opt} onChange={(e) => {
|
||||
const options = [...q.options]; options[i] = e.target.value; set({ options });
|
||||
}} placeholder={"Option " + LETTERS[i]} />
|
||||
<button className="icon-btn danger" title="Remove option" disabled={q.options.length <= 2}
|
||||
onClick={() => {
|
||||
const options = q.options.filter((_, j) => j !== i);
|
||||
let correctIndex = q.correctIndex;
|
||||
if (correctIndex === i) correctIndex = 0;
|
||||
else if (correctIndex > i) correctIndex -= 1;
|
||||
set({ options, correctIndex });
|
||||
}}>✕</button>
|
||||
</div>
|
||||
))}
|
||||
{(q.options || []).length < 6 && (
|
||||
<button className="btn btn-sm" style={{ marginTop: 4 }} onClick={() => set({ options: [...q.options, ""] })}>+ Add option</button>
|
||||
)}
|
||||
<div className="field-hint">The <span className="redpen">red radio</span> marks the correct answer.</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{q.type === "true_false" && (
|
||||
<div className="answer-key">
|
||||
<span className="ak-label">Answer key</span>
|
||||
<label className="check" style={{ margin: "2px 0", display: "inline-flex", marginRight: 18 }}>
|
||||
<input type="radio" name={"tf-" + q.id} checked={q.correctAnswer === true} onChange={() => set({ correctAnswer: true })} />
|
||||
<span>True</span>
|
||||
</label>
|
||||
<label className="check" style={{ margin: "2px 0", display: "inline-flex" }}>
|
||||
<input type="radio" name={"tf-" + q.id} checked={q.correctAnswer === false} onChange={() => set({ correctAnswer: false })} />
|
||||
<span>False</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{q.type === "short_answer" && (
|
||||
<div className="answer-key">
|
||||
<span className="ak-label">Answer key — sample answer</span>
|
||||
<AutoTextarea value={q.sampleAnswer} onChange={(sampleAnswer) => set({ sampleAnswer })} rows={2} placeholder="A model answer…" />
|
||||
<span className="ak-label" style={{ marginTop: 8 }}>Must-include points (one per line)</span>
|
||||
<AutoTextarea
|
||||
value={(q.keyPoints || []).join("\n")}
|
||||
onChange={(text) => set({ keyPoints: text.split("\n").map((s) => s.trim()).filter(Boolean) })}
|
||||
rows={2} placeholder={"point 1\npoint 2"}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{q.type === "essay" && (
|
||||
<div className="answer-key">
|
||||
<span className="ak-label">Answer key — sample response</span>
|
||||
<AutoTextarea value={q.sampleResponse} onChange={(sampleResponse) => set({ sampleResponse })} rows={4} placeholder="A strong model response or outline…" />
|
||||
<span className="ak-label" style={{ marginTop: 8 }}>Rubric</span>
|
||||
{(q.rubric || []).map((r, i) => (
|
||||
<div key={i} style={{ display: "flex", gap: 7, margin: "5px 0", flexWrap: "wrap" }}>
|
||||
<input type="text" value={r.criterion} placeholder="Criterion" style={{ flex: 2, minWidth: 140 }}
|
||||
onChange={(e) => { const rubric = q.rubric.map((x, j) => j === i ? { ...x, criterion: e.target.value } : x); set({ rubric }); }} />
|
||||
<input type="number" className="points-input" value={r.points} min="0" title="Points"
|
||||
onChange={(e) => { const rubric = q.rubric.map((x, j) => j === i ? { ...x, points: Math.max(0, Number(e.target.value) || 0) } : x); set({ rubric }); }} />
|
||||
<input type="text" value={r.description} placeholder="What earns full points" style={{ flex: 3, minWidth: 160 }}
|
||||
onChange={(e) => { const rubric = q.rubric.map((x, j) => j === i ? { ...x, description: e.target.value } : x); set({ rubric }); }} />
|
||||
<button className="icon-btn danger" onClick={() => set({ rubric: q.rubric.filter((_, j) => j !== i) })}>✕</button>
|
||||
</div>
|
||||
))}
|
||||
<button className="btn btn-sm" onClick={() => set({ rubric: [...(q.rubric || []), { criterion: "", points: 0, description: "" }] })}>+ Add criterion</button>
|
||||
{(q.rubric || []).length > 0 && (
|
||||
<div className="field-hint">Rubric total: {(q.rubric || []).reduce((s, r) => s + (Number(r.points) || 0), 0)} / question points: {q.points}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{q.type === "fill_blank" && (
|
||||
<div className="answer-key">
|
||||
<span className="ak-label">Answer key — one answer per blank, in order</span>
|
||||
{(q.answers || []).map((a, i) => (
|
||||
<div key={i} style={{ display: "flex", gap: 7, alignItems: "center", margin: "5px 0" }}>
|
||||
<span className="opt-letter">{i + 1}.</span>
|
||||
<input type="text" value={a} onChange={(e) => { const answers = [...q.answers]; answers[i] = e.target.value; set({ answers }); }} />
|
||||
</div>
|
||||
))}
|
||||
<div className="field-hint">
|
||||
Blanks found in the question: {(String(q.question).match(/_{3,}/g) || []).length}.
|
||||
{" "}
|
||||
<button className="btn btn-sm" onClick={() => {
|
||||
const blanks = (String(q.question).match(/_{3,}/g) || []).length || 1;
|
||||
const answers = Array.from({ length: blanks }, (_, i) => q.answers?.[i] || "");
|
||||
set({ answers });
|
||||
}}>Match answer slots to blanks</button>
|
||||
{q.type === "multiple_choice" && (
|
||||
<div style={{ marginTop: 10 }}>
|
||||
{(q.options || []).map((opt, i) => (
|
||||
<div className="opt-row" key={i}>
|
||||
<input type="radio" name={"correct-" + q.id} checked={q.correctIndex === i}
|
||||
onChange={() => set({ correctIndex: i })} title="Mark as the correct answer" />
|
||||
<span className="opt-letter">{LETTERS[i]}.</span>
|
||||
<input type="text" value={opt} onChange={(e) => { const options = [...q.options]; options[i] = e.target.value; set({ options }); }} placeholder={"Option " + LETTERS[i]} />
|
||||
<button className="icon-btn danger" title="Remove option" aria-label="Remove option" disabled={q.options.length <= 2}
|
||||
onClick={() => {
|
||||
const options = q.options.filter((_, j) => j !== i);
|
||||
let correctIndex = q.correctIndex;
|
||||
if (correctIndex === i) correctIndex = 0;
|
||||
else if (correctIndex > i) correctIndex -= 1;
|
||||
set({ options, correctIndex });
|
||||
}}><IconX size={15} /></button>
|
||||
</div>
|
||||
))}
|
||||
{(q.options || []).length < 6 && (
|
||||
<button className="btn btn-sm" style={{ marginTop: 6 }} onClick={() => set({ options: [...q.options, ""] })}>+ Add option</button>
|
||||
)}
|
||||
<div className="field-hint">The <span className="redpen">red radio</span> marks the correct answer.</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{q.type === "matching" && (
|
||||
<div className="answer-key">
|
||||
<span className="ak-label">Answer key — correct pairs (the student copy shuffles the right column)</span>
|
||||
{(q.pairs || []).map((p, i) => (
|
||||
<div key={i} style={{ display: "flex", gap: 7, margin: "5px 0", alignItems: "center", flexWrap: "wrap" }}>
|
||||
<input type="text" value={p.left} placeholder="Left item" style={{ flex: 1, minWidth: 130 }}
|
||||
onChange={(e) => { const pairs = q.pairs.map((x, j) => j === i ? { ...x, left: e.target.value } : x); set({ pairs }); }} />
|
||||
<span className="muted">→</span>
|
||||
<input type="text" value={p.right} placeholder="Matches with" style={{ flex: 1, minWidth: 130 }}
|
||||
onChange={(e) => { const pairs = q.pairs.map((x, j) => j === i ? { ...x, right: e.target.value } : x); set({ pairs }); }} />
|
||||
<button className="icon-btn danger" disabled={(q.pairs || []).length <= 2} onClick={() => set({ pairs: q.pairs.filter((_, j) => j !== i) })}>✕</button>
|
||||
{q.type === "matching" && (
|
||||
<div className="answer-key" style={{ marginTop: 12 }}>
|
||||
<span className="ak-label">Answer key — correct pairs (the student copy shuffles the right column)</span>
|
||||
{(q.pairs || []).map((p, i) => (
|
||||
<div key={i} style={{ display: "flex", gap: 7, margin: "5px 0", alignItems: "center", flexWrap: "wrap" }}>
|
||||
<input type="text" value={p.left} placeholder="Left item" style={{ flex: 1, minWidth: 130 }}
|
||||
onChange={(e) => { const pairs = q.pairs.map((x, j) => j === i ? { ...x, left: e.target.value } : x); set({ pairs }); }} />
|
||||
<IconArrowRight size={16} className="muted" />
|
||||
<input type="text" value={p.right} placeholder="Matches with" style={{ flex: 1, minWidth: 130 }}
|
||||
onChange={(e) => { const pairs = q.pairs.map((x, j) => j === i ? { ...x, right: e.target.value } : x); set({ pairs }); }} />
|
||||
<button className="icon-btn danger" aria-label="Remove pair" disabled={(q.pairs || []).length <= 2} onClick={() => set({ pairs: q.pairs.filter((_, j) => j !== i) })}><IconX size={15} /></button>
|
||||
</div>
|
||||
))}
|
||||
{(q.pairs || []).length < 10 && (
|
||||
<button className="btn btn-sm" onClick={() => set({ pairs: [...q.pairs, { left: "", right: "" }] })}>+ Add pair</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{q.sourceRef ? (
|
||||
<p className="small muted" style={{ margin: "12px 0 0" }}><b style={{ fontWeight: 600 }}>Source:</b> “{q.sourceRef}”</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* ---- right margin: the answer key, in red pen ---- */}
|
||||
{hasMargin && (
|
||||
<div className="qmargin">
|
||||
{q.type === "multiple_choice" && (
|
||||
<div className="answer-key">
|
||||
<span className="ak-label">Answer key</span>
|
||||
<p style={{ margin: 0, fontWeight: 600, color: "var(--redpen-ink)" }}>
|
||||
Correct: <span className="redpen">{LETTERS[q.correctIndex] || "?"}</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{q.type === "true_false" && (
|
||||
<div className="answer-key">
|
||||
<span className="ak-label">Answer key</span>
|
||||
<label className="check" style={{ margin: "2px 0", display: "inline-flex", marginRight: 18 }}>
|
||||
<input type="radio" name={"tf-" + q.id} checked={q.correctAnswer === true} onChange={() => set({ correctAnswer: true })} />
|
||||
<span>True</span>
|
||||
</label>
|
||||
<label className="check" style={{ margin: "2px 0", display: "inline-flex" }}>
|
||||
<input type="radio" name={"tf-" + q.id} checked={q.correctAnswer === false} onChange={() => set({ correctAnswer: false })} />
|
||||
<span>False</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{q.type === "short_answer" && (
|
||||
<div className="answer-key">
|
||||
<span className="ak-label">Answer key — sample answer</span>
|
||||
<AutoTextarea value={q.sampleAnswer} onChange={(sampleAnswer) => set({ sampleAnswer })} rows={2} placeholder="A model answer…" />
|
||||
<span className="ak-label" style={{ marginTop: 8 }}>Must-include points (one per line)</span>
|
||||
<AutoTextarea value={(q.keyPoints || []).join("\n")}
|
||||
onChange={(text) => set({ keyPoints: text.split("\n").map((s) => s.trim()).filter(Boolean) })}
|
||||
rows={2} placeholder={"point 1\npoint 2"} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{q.type === "essay" && (
|
||||
<div className="answer-key">
|
||||
<span className="ak-label">Answer key — sample response</span>
|
||||
<AutoTextarea value={q.sampleResponse} onChange={(sampleResponse) => set({ sampleResponse })} rows={4} placeholder="A strong model response or outline…" />
|
||||
<span className="ak-label" style={{ marginTop: 8 }}>Rubric</span>
|
||||
{(q.rubric || []).map((r, i) => (
|
||||
<div key={i} style={{ display: "flex", gap: 7, margin: "5px 0", flexWrap: "wrap" }}>
|
||||
<input type="text" value={r.criterion} placeholder="Criterion" style={{ flex: 2, minWidth: 120 }}
|
||||
onChange={(e) => { const rubric = q.rubric.map((x, j) => j === i ? { ...x, criterion: e.target.value } : x); set({ rubric }); }} />
|
||||
<input type="number" className="points-input" value={r.points} min="0" title="Points"
|
||||
onChange={(e) => { const rubric = q.rubric.map((x, j) => j === i ? { ...x, points: Math.max(0, Number(e.target.value) || 0) } : x); set({ rubric }); }} />
|
||||
<input type="text" value={r.description} placeholder="What earns full points" style={{ flex: 3, minWidth: 140 }}
|
||||
onChange={(e) => { const rubric = q.rubric.map((x, j) => j === i ? { ...x, description: e.target.value } : x); set({ rubric }); }} />
|
||||
<button className="icon-btn danger" aria-label="Remove criterion" onClick={() => set({ rubric: q.rubric.filter((_, j) => j !== i) })}><IconX size={15} /></button>
|
||||
</div>
|
||||
))}
|
||||
<button className="btn btn-sm" onClick={() => set({ rubric: [...(q.rubric || []), { criterion: "", points: 0, description: "" }] })}>+ Add criterion</button>
|
||||
{(q.rubric || []).length > 0 && (
|
||||
<div className="field-hint">Rubric total: {(q.rubric || []).reduce((s, r) => s + (Number(r.points) || 0), 0)} / question points: {q.points}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{q.type === "fill_blank" && (
|
||||
<div className="answer-key">
|
||||
<span className="ak-label">Answer key — one per blank, in order</span>
|
||||
{(q.answers || []).map((a, i) => (
|
||||
<div key={i} style={{ display: "flex", gap: 7, alignItems: "center", margin: "5px 0" }}>
|
||||
<span className="opt-letter">{i + 1}.</span>
|
||||
<input type="text" value={a} onChange={(e) => { const answers = [...q.answers]; answers[i] = e.target.value; set({ answers }); }} />
|
||||
</div>
|
||||
))}
|
||||
<div className="field-hint">
|
||||
Blanks in the question: {(String(q.question).match(/_{3,}/g) || []).length}.{" "}
|
||||
<button className="btn btn-sm" onClick={() => {
|
||||
const blanks = (String(q.question).match(/_{3,}/g) || []).length || 1;
|
||||
const answers = Array.from({ length: blanks }, (_, i) => q.answers?.[i] || "");
|
||||
set({ answers });
|
||||
}}>Match slots to blanks</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{q.type === "discussion" && (
|
||||
<div className="answer-key">
|
||||
<span className="ak-label">Facilitator notes — key talking points</span>
|
||||
<AutoTextarea value={(q.talkingPoints || []).join("\n")}
|
||||
onChange={(text) => set({ talkingPoints: text.split("\n").map((s) => s.trim()).filter(Boolean) })} rows={3} />
|
||||
<span className="ak-label" style={{ marginTop: 8 }}>Follow-up questions (one per line)</span>
|
||||
<AutoTextarea value={(q.followUps || []).join("\n")}
|
||||
onChange={(text) => set({ followUps: text.split("\n").map((s) => s.trim()).filter(Boolean) })} rows={2} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Explanation, present for all types except discussion */}
|
||||
{q.type !== "discussion" && (
|
||||
<div className="answer-key">
|
||||
<span className="ak-label">Explanation (teacher key)</span>
|
||||
<AutoTextarea value={q.explanation} onChange={(explanation) => set({ explanation })} rows={3} placeholder="Why this answer is correct…" />
|
||||
</div>
|
||||
))}
|
||||
{(q.pairs || []).length < 10 && (
|
||||
<button className="btn btn-sm" onClick={() => set({ pairs: [...q.pairs, { left: "", right: "" }] })}>+ Add pair</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{q.type === "discussion" && (
|
||||
<div className="answer-key">
|
||||
<span className="ak-label">Facilitator notes — key talking points (one per line)</span>
|
||||
<AutoTextarea
|
||||
value={(q.talkingPoints || []).join("\n")}
|
||||
onChange={(text) => set({ talkingPoints: text.split("\n").map((s) => s.trim()).filter(Boolean) })}
|
||||
rows={3}
|
||||
/>
|
||||
<span className="ak-label" style={{ marginTop: 8 }}>Follow-up questions (one per line)</span>
|
||||
<AutoTextarea
|
||||
value={(q.followUps || []).join("\n")}
|
||||
onChange={(text) => set({ followUps: text.split("\n").map((s) => s.trim()).filter(Boolean) })}
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Explanation + source ref, present for all types */}
|
||||
{q.type !== "discussion" && (
|
||||
<div className="answer-key" style={{ background: "#fff7f5" }}>
|
||||
<span className="ak-label">Explanation (teacher key)</span>
|
||||
<AutoTextarea value={q.explanation} onChange={(explanation) => set({ explanation })} rows={2} placeholder="Why this answer is correct…" />
|
||||
</div>
|
||||
)}
|
||||
{q.sourceRef ? (
|
||||
<p className="small muted" style={{ margin: "9px 0 0" }}>
|
||||
<b>Source:</b> “{q.sourceRef}”
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
// components/Sidebar.jsx — the Open Workspace shell: brand, primary nav, a live
|
||||
// library folder tree grouped by subject, and a pinned theme toggle + New button.
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
IconPencil, IconPlus, IconSettings, IconMoon, IconSun,
|
||||
IconChevronDown, IconChevronRight, IconFileText, IconPencilPlus,
|
||||
} from "@tabler/icons-react";
|
||||
import { groupBySubject } from "@/lib/group";
|
||||
|
||||
const NAV = [
|
||||
{ href: "/", label: "Create", icon: IconPlus },
|
||||
{ href: "/settings", label: "Settings", icon: IconSettings },
|
||||
];
|
||||
|
||||
export default function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
const [theme, setTheme] = useState(null);
|
||||
const [items, setItems] = useState([]);
|
||||
const [collapsed, setCollapsed] = useState({}); // subject -> true when collapsed
|
||||
|
||||
useEffect(() => {
|
||||
setTheme(document.documentElement.dataset.theme === "dark" ? "dark" : "light");
|
||||
}, []);
|
||||
|
||||
const load = useCallback(() => {
|
||||
fetch("/api/assignments")
|
||||
.then((r) => r.json())
|
||||
.then((d) => setItems(d.assignments || []))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Reload the tree whenever the route changes (covers create / delete / rename).
|
||||
useEffect(() => { load(); }, [load, pathname]);
|
||||
|
||||
function toggleTheme() {
|
||||
const next = document.documentElement.dataset.theme === "dark" ? "light" : "dark";
|
||||
if (next === "dark") document.documentElement.dataset.theme = "dark";
|
||||
else delete document.documentElement.dataset.theme;
|
||||
try { localStorage.setItem("theme", next); } catch {}
|
||||
setTheme(next);
|
||||
}
|
||||
|
||||
const groups = useMemo(() => groupBySubject(items), [items]);
|
||||
const activeId = pathname.startsWith("/editor/") ? decodeURIComponent(pathname.split("/editor/")[1] || "") : "";
|
||||
|
||||
const isNavActive = (href) => (href === "/" ? pathname === "/" : pathname.startsWith(href));
|
||||
|
||||
return (
|
||||
<aside className="sidebar" aria-label="Sidebar">
|
||||
<Link href="/" className="sidebar-brand">
|
||||
<span className="brand-mark" aria-hidden="true"><IconPencil size={19} stroke={2} /></span>
|
||||
<span>
|
||||
<span className="brand-name" style={{ display: "block" }}>Mr. Drew’s</span>
|
||||
<span className="brand-sub">Assignment Creator</span>
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<nav className="sidebar-nav" aria-label="Main">
|
||||
{NAV.map(({ href, label, icon: Icon }) => (
|
||||
<Link key={href} href={href} className={`navrow${isNavActive(href) ? " active" : ""}`}>
|
||||
<Icon size={19} stroke={2} />
|
||||
<span className="navrow-label">{label}</span>
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="sidebar-scroll">
|
||||
<div className="sidebar-label">
|
||||
<span>Library</span>
|
||||
<Link href="/library" className="navrow-label" style={{ fontSize: "0.66rem", color: "var(--board-deep)", fontWeight: 700 }}>
|
||||
All
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{groups.length === 0 && (
|
||||
<p className="faint" style={{ fontSize: "0.8rem", padding: "4px 10px", margin: 0 }}>
|
||||
No assignments yet.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{groups.map((g) => {
|
||||
const open = !collapsed[g.key];
|
||||
return (
|
||||
<div key={g.key} className="tree-group">
|
||||
<button
|
||||
className="tree-folder"
|
||||
onClick={() => setCollapsed((c) => ({ ...c, [g.key]: open }))}
|
||||
aria-expanded={open}
|
||||
>
|
||||
{open ? <IconChevronDown size={15} className="chev" /> : <IconChevronRight size={15} className="chev" />}
|
||||
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{g.key}</span>
|
||||
<span className="tree-count">{g.items.length}</span>
|
||||
</button>
|
||||
{open && g.items.map((a) => (
|
||||
<Link key={a.id} href={"/editor/" + a.id} className={`tree-leaf${activeId === a.id ? " active" : ""}`} title={a.title}>
|
||||
<IconFileText size={15} style={{ flex: "none" }} />
|
||||
<span>{a.title}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="sidebar-foot">
|
||||
<button
|
||||
type="button"
|
||||
className="icon-btn theme-toggle"
|
||||
onClick={toggleTheme}
|
||||
aria-label={theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
|
||||
title={theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
|
||||
>
|
||||
{theme === "dark" ? <IconSun size={18} /> : <IconMoon size={18} />}
|
||||
</button>
|
||||
<Link href="/" className="btn btn-primary btn-block">
|
||||
<IconPencilPlus size={17} /> <span className="navrow-label">New assignment</span>
|
||||
</Link>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user