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