Sync Canvas LMS export feature + refreshed Settings screenshot
Brings the share build up to date with the main project: QTI .zip export and optional direct Canvas API push, plus the Canvas settings card. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { mergeSettings, getSettings } from "@/lib/store";
|
||||
import { mapAssignmentToCanvas } from "@/lib/canvas/map";
|
||||
import { normalizeBaseUrl, quizPayload, questionPayloads } from "@/lib/canvas/api";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Browser→Canvas is blocked by CORS, so all Canvas REST calls go through this server route.
|
||||
// Token + base URL come from the saved local settings (data/db.json), like the AI keys.
|
||||
function creds(settings) {
|
||||
const c = settings?.canvas || {};
|
||||
const baseUrl = normalizeBaseUrl(c.baseUrl);
|
||||
const token = String(c.token || "").trim();
|
||||
if (!baseUrl) throw new Error("Add your Canvas web address in Settings first.");
|
||||
if (!token) throw new Error("Add your Canvas access token in Settings first.");
|
||||
return { baseUrl, token };
|
||||
}
|
||||
|
||||
async function canvas(baseUrl, token, path, init = {}) {
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(baseUrl + path, {
|
||||
...init,
|
||||
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", ...(init.headers || {}) },
|
||||
});
|
||||
} catch {
|
||||
throw new Error("Couldn't reach Canvas — check the web address and your network.");
|
||||
}
|
||||
const text = await res.text();
|
||||
let data;
|
||||
try { data = text ? JSON.parse(text) : {}; } catch { data = { raw: text }; }
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) throw new Error("Canvas rejected the access token (401). Check or regenerate it.");
|
||||
const msg = data?.errors?.[0]?.message || data?.message ||
|
||||
(Array.isArray(data?.errors) ? JSON.stringify(data.errors) : "") || `Canvas returned ${res.status}.`;
|
||||
throw new Error(typeof msg === "string" ? msg : `Canvas error ${res.status}.`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
// Accept settings from the client (test-before-save) or fall back to what's saved.
|
||||
const settings = mergeSettings(body.settings || getSettings());
|
||||
const { baseUrl, token } = creds(settings);
|
||||
const action = body.action;
|
||||
|
||||
if (action === "test") {
|
||||
const me = await canvas(baseUrl, token, "/api/v1/users/self");
|
||||
return NextResponse.json({ ok: true, message: `Connected as ${me.name || me.short_name || "Canvas user"}` });
|
||||
}
|
||||
|
||||
if (action === "courses") {
|
||||
const list = await canvas(baseUrl, token,
|
||||
"/api/v1/courses?enrollment_type=teacher&enrollment_state=active&per_page=100");
|
||||
const courses = (Array.isArray(list) ? list : [])
|
||||
.filter((c) => c?.id && c?.name && !c.access_restricted_by_date)
|
||||
.map((c) => ({ id: c.id, name: c.name }));
|
||||
return NextResponse.json({ courses });
|
||||
}
|
||||
|
||||
if (action === "push") {
|
||||
const { assignment, opts, courseId } = body;
|
||||
if (!courseId) throw new Error("Pick a course first.");
|
||||
const ir = mapAssignmentToCanvas(assignment, opts || {});
|
||||
if (ir.gradedCount === 0) throw new Error("This assignment has no Canvas-compatible questions.");
|
||||
|
||||
const quiz = await canvas(baseUrl, token, `/api/v1/courses/${courseId}/quizzes`, {
|
||||
method: "POST", body: JSON.stringify(quizPayload(ir)),
|
||||
});
|
||||
const quizId = quiz.id;
|
||||
|
||||
let added = 0;
|
||||
for (const q of questionPayloads(ir)) {
|
||||
await canvas(baseUrl, token, `/api/v1/courses/${courseId}/quizzes/${quizId}/questions`, {
|
||||
method: "POST", body: JSON.stringify(q),
|
||||
});
|
||||
added++;
|
||||
}
|
||||
|
||||
const url = quiz.html_url || `${baseUrl}/courses/${courseId}/quizzes/${quizId}`;
|
||||
return NextResponse.json({ ok: true, url, quizId, added });
|
||||
}
|
||||
|
||||
throw new Error("Unknown action.");
|
||||
} catch (e) {
|
||||
return NextResponse.json({ error: String(e.message || e) }, { status: 400 });
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import QuestionCard from "@/components/QuestionCard";
|
||||
import CanvasExportDialog from "@/components/CanvasExportDialog";
|
||||
import { QUESTION_TYPES, blankQuestion, totalPoints } from "@/lib/schema";
|
||||
import { exportTxt, exportDoc, exportClipboard, exportPrint } from "@/lib/exporter";
|
||||
|
||||
@@ -19,6 +20,7 @@ export default function EditorPage() {
|
||||
const [verifying, setVerifying] = useState(false);
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [exportOpen, setExportOpen] = useState(false);
|
||||
const [canvasOpen, setCanvasOpen] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [profile, setProfile] = useState({});
|
||||
const toastTimer = useRef(null);
|
||||
@@ -272,10 +274,14 @@ export default function EditorPage() {
|
||||
<button className="btn btn-sm" onClick={() => doExport("copy", true)}>Copy</button>
|
||||
</div>
|
||||
<div className="field-label">Complete packet — student + answer key</div>
|
||||
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
|
||||
<div style={{ display: "flex", gap: 6, flexWrap: "wrap", marginBottom: 12 }}>
|
||||
<button className="btn btn-sm" onClick={() => doExport("print", "packet")}>Print / PDF</button>
|
||||
<button className="btn btn-sm" onClick={() => doExport("doc", "packet")}>Word</button>
|
||||
</div>
|
||||
<div className="field-label">Canvas (LMS)</div>
|
||||
<button className="btn btn-sm" onClick={() => { setExportOpen(false); setCanvasOpen(true); }}>
|
||||
Set up & download .zip…
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -350,6 +356,15 @@ export default function EditorPage() {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{canvasOpen && (
|
||||
<CanvasExportDialog
|
||||
assignment={a}
|
||||
onClose={() => setCanvasOpen(false)}
|
||||
onDone={() => { setCanvasOpen(false); showToast("Downloaded Canvas .zip"); }}
|
||||
onError={(msg) => { setCanvasOpen(false); setError(msg); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{toast && <div className="toast">{toast}</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -22,6 +22,7 @@ export default function SettingsPage() {
|
||||
const [modelsBusy, setModelsBusy] = useState("");
|
||||
const [modelsErr, setModelsErr] = useState({}); // provider -> error
|
||||
const [test, setTest] = useState({}); // provider -> {busy, ok, message}
|
||||
const [canvasTest, setCanvasTest] = useState(null); // {busy, ok, message}
|
||||
const [autoInfo, setAutoInfo] = useState(null); // resolved auto limits for the active model
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [toast, setToast] = useState("");
|
||||
@@ -78,6 +79,27 @@ export default function SettingsPage() {
|
||||
setS((cur) => ({ ...cur, profile: { ...(cur.profile || {}), [field]: value } }));
|
||||
}
|
||||
|
||||
function setCanvas(field, value) {
|
||||
setCanvasTest(null);
|
||||
setS((cur) => ({ ...cur, canvas: { ...(cur.canvas || {}), [field]: value } }));
|
||||
}
|
||||
|
||||
async function testCanvas() {
|
||||
setCanvasTest({ busy: true });
|
||||
try {
|
||||
const res = await fetch("/api/canvas", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "test", settings: s }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || "Test failed.");
|
||||
setCanvasTest({ ok: true, message: data.message });
|
||||
} catch (e) {
|
||||
setCanvasTest({ ok: false, message: String(e.message || e) });
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -308,6 +330,47 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h2>Canvas (LMS) integration</h2>
|
||||
<p className="field-hint" style={{ marginTop: 4 }}>
|
||||
Optional. Lets you push a finished assignment straight into a Canvas course from the editor.
|
||||
You can always skip this and use the downloadable Canvas <code>.zip</code> instead.
|
||||
</p>
|
||||
<label className="field" style={{ marginTop: 14 }}>
|
||||
<span className="field-label">Canvas web address</span>
|
||||
<input
|
||||
type="text" value={s.canvas?.baseUrl || ""}
|
||||
onChange={(e) => setCanvas("baseUrl", e.target.value)}
|
||||
placeholder="https://yourschool.instructure.com"
|
||||
autoComplete="off"
|
||||
/>
|
||||
<span className="field-hint">The address you use to log into Canvas — e.g. <code>https://yourschool.instructure.com</code>.</span>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="field-label">Access token</span>
|
||||
<input
|
||||
type="password" value={s.canvas?.token || ""}
|
||||
onChange={(e) => setCanvas("token", e.target.value)}
|
||||
placeholder="Paste your Canvas access token"
|
||||
autoComplete="off"
|
||||
/>
|
||||
<span className="field-hint">
|
||||
In Canvas: <b>Account → Settings → Approved Integrations → + New Access Token</b>. Stored only on this computer.
|
||||
Some schools restrict tokens — if yours does, use the <code>.zip</code> export instead.
|
||||
</span>
|
||||
</label>
|
||||
<div style={{ display: "flex", gap: 10, alignItems: "center", flexWrap: "wrap" }}>
|
||||
<button className="btn" onClick={testCanvas} disabled={canvasTest?.busy || !s.canvas?.baseUrl || !s.canvas?.token}>
|
||||
{canvasTest?.busy ? <><span className="spinner" /> Testing…</> : "Test connection"}
|
||||
</button>
|
||||
{canvasTest && !canvasTest.busy && (
|
||||
<span className="small" style={canvasTest.ok ? { color: "var(--board)", fontWeight: 600 } : { fontWeight: 600 }}>
|
||||
{canvasTest.ok ? "✓ " : "✕ "}{canvasTest.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h2>Generation defaults</h2>
|
||||
<label className="check" style={{ marginTop: 14 }}>
|
||||
|
||||
Reference in New Issue
Block a user