Files
mr-drews-assignment-creator/components/CanvasExportDialog.jsx
T
bizzleandClaude b7416cc618 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>
2026-06-25 18:33:08 -04:00

310 lines
14 KiB
React

"use client";
// components/CanvasExportDialog.jsx — configure and download a Canvas QTI .zip for one
// assignment. Auto-fills everything derivable; exposes the optional Canvas quiz settings
// a teacher may want to set. Pure client-side (no upload) — see lib/canvas/export.js.
import { useEffect, useMemo, useState } from "react";
import { IconX } from "@tabler/icons-react";
import { mapAssignmentToCanvas } from "@/lib/canvas/map";
import { exportCanvasZip } from "@/lib/canvas/export";
const QUIZ_TYPES = [
{ id: "assignment", label: "Graded quiz" },
{ id: "practice_quiz", label: "Practice quiz (ungraded)" },
{ id: "graded_survey", label: "Graded survey" },
{ id: "survey", label: "Ungraded survey" },
];
const TYPE_LABEL = {
multiple_choice_question: "Multiple choice",
true_false_question: "True / False",
essay_question: "Essay (manually graded)",
fill_in_multiple_blanks_question: "Fill in the blank",
matching_question: "Matching",
text_only_question: "Intro text (case study)",
};
// datetime-local "2026-06-25T14:30" -> ISO-8601, or "" if blank/invalid.
function toIso(v) {
if (!v) return "";
const d = new Date(v);
return Number.isNaN(d.getTime()) ? "" : d.toISOString();
}
export default function CanvasExportDialog({ assignment, onClose, onDone, onError }) {
const [form, setForm] = useState({
quizType: "assignment",
timeLimit: "",
allowedAttempts: "1",
scoringPolicy: "keep_highest",
shuffleAnswers: false,
showCorrectAnswers: true,
oneQuestionAtATime: false,
cantGoBack: false,
dueAt: "",
unlockAt: "",
lockAt: "",
accessCode: "",
includeExplanations: true,
});
const [busy, setBusy] = useState(false);
// Direct Canvas push (only offered when credentials are saved in Settings).
const [canvasReady, setCanvasReady] = useState(null); // null = unknown/loading
const [courses, setCourses] = useState(null); // null = not loaded yet
const [courseId, setCourseId] = useState("");
const [coursesErr, setCoursesErr] = useState("");
const [loadingCourses, setLoadingCourses] = useState(false);
const [sending, setSending] = useState(false);
const [sent, setSent] = useState(null); // { url, added }
const [sendErr, setSendErr] = useState("");
useEffect(() => {
fetch("/api/settings")
.then((r) => r.json())
.then((s) => setCanvasReady(!!(s?.canvas?.baseUrl && s?.canvas?.token)))
.catch(() => setCanvasReady(false));
}, []);
const set = (k, v) => setForm((f) => ({ ...f, [k]: v }));
const opts = useMemo(() => ({
quizType: form.quizType,
timeLimit: form.timeLimit === "" ? "" : Math.max(0, parseInt(form.timeLimit, 10) || 0),
allowedAttempts: parseInt(form.allowedAttempts, 10),
scoringPolicy: form.scoringPolicy,
shuffleAnswers: form.shuffleAnswers,
showCorrectAnswers: form.showCorrectAnswers,
oneQuestionAtATime: form.oneQuestionAtATime,
cantGoBack: form.oneQuestionAtATime && form.cantGoBack,
dueAt: toIso(form.dueAt),
unlockAt: toIso(form.unlockAt),
lockAt: toIso(form.lockAt),
accessCode: form.accessCode.trim(),
includeExplanations: form.includeExplanations,
}), [form]);
// Preview what will be in the package (counts + anything left out).
const ir = useMemo(() => mapAssignmentToCanvas(assignment, opts), [assignment, opts]);
const breakdown = useMemo(() => {
const counts = {};
for (const it of ir.items) counts[it.canvasType] = (counts[it.canvasType] || 0) + 1;
return counts;
}, [ir]);
const skippedDiscussion = ir.skipped.filter((s) => s.type === "discussion").length;
const skippedOther = ir.skipped.length - skippedDiscussion;
const nothingToExport = ir.gradedCount === 0;
function doExport() {
setBusy(true);
try {
exportCanvasZip(assignment, opts);
onDone?.();
} catch (e) {
onError?.(String(e?.message || e));
} finally {
setBusy(false);
}
}
async function loadCourses() {
setLoadingCourses(true);
setCoursesErr("");
try {
const res = await fetch("/api/canvas", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "courses" }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Could not load courses.");
setCourses(data.courses || []);
if (data.courses?.length) setCourseId(String(data.courses[0].id));
} catch (e) {
setCoursesErr(String(e.message || e));
} finally {
setLoadingCourses(false);
}
}
async function sendToCanvas() {
setSending(true);
setSendErr("");
try {
const res = await fetch("/api/canvas", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "push", assignment, opts, courseId: Number(courseId) }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Send failed.");
setSent({ url: data.url, added: data.added });
} catch (e) {
setSendErr(String(e.message || e));
} finally {
setSending(false);
}
}
return (
<div className="modal-scrim" onClick={onClose}>
<div
onClick={(e) => e.stopPropagation()}
className="modal"
style={{ width: 560, maxWidth: "100%", padding: 24 }}
>
<div style={{ display: "flex", alignItems: "flex-start", gap: 12 }}>
<div style={{ flex: 1 }}>
<h2 style={{ fontFamily: "var(--font-display)", fontSize: "1.4rem", margin: 0 }}>Export to Canvas</h2>
<p className="muted small" style={{ margin: "4px 0 0" }}>
Downloads a QTI <code>.zip</code>. In Canvas: <b>Settings Import Course Content QTI .zip file</b>.
</p>
</div>
<button className="icon-btn" onClick={onClose} aria-label="Close"><IconX size={16} /></button>
</div>
{/* Summary of what will be exported */}
<div className="alert alert-info" style={{ marginTop: 16 }}>
{nothingToExport ? (
<b>Nothing to export this assignment has no Canvas-compatible questions.</b>
) : (
<>
<b>{ir.gradedCount} question{ir.gradedCount === 1 ? "" : "s"}</b> one Canvas quiz
{" "}({ir.quiz.pointsPossible} pts).
<div className="small" style={{ marginTop: 6 }}>
{Object.entries(breakdown).map(([t, n]) => (
<span key={t} style={{ marginRight: 12, whiteSpace: "nowrap" }}>{TYPE_LABEL[t] || t}: {n}</span>
))}
</div>
</>
)}
</div>
{skippedDiscussion > 0 && (
<div className="alert alert-warn">
{skippedDiscussion} discussion question{skippedDiscussion === 1 ? "" : "s"} left out Canvas quizzes have no discussion type.
</div>
)}
{skippedOther > 0 && (
<div className="alert alert-warn">
{skippedOther} question{skippedOther === 1 ? "" : "s"} couldn&rsquo;t be converted and were left out.
</div>
)}
{/* Optional Canvas settings — auto-filled with sensible defaults */}
<div className="field-label" style={{ marginTop: 8 }}>Quiz settings (optional change in Canvas later too)</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
<label className="field" style={{ marginBottom: 0 }}>
<span className="field-label">Quiz type</span>
<select value={form.quizType} onChange={(e) => set("quizType", e.target.value)}>
{QUIZ_TYPES.map((t) => <option key={t.id} value={t.id}>{t.label}</option>)}
</select>
</label>
<label className="field" style={{ marginBottom: 0 }}>
<span className="field-label">Time limit (minutes)</span>
<input type="number" min="0" value={form.timeLimit} placeholder="No limit"
onChange={(e) => set("timeLimit", e.target.value)} />
</label>
<label className="field" style={{ marginBottom: 0 }}>
<span className="field-label">Allowed attempts</span>
<select value={form.allowedAttempts} onChange={(e) => set("allowedAttempts", e.target.value)}>
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
<option value="-1">Unlimited</option>
</select>
</label>
<label className="field" style={{ marginBottom: 0 }}>
<span className="field-label">Keep which score</span>
<select value={form.scoringPolicy} onChange={(e) => set("scoringPolicy", e.target.value)}
disabled={form.allowedAttempts === "1"}>
<option value="keep_highest">Highest</option>
<option value="keep_latest">Latest</option>
</select>
</label>
<label className="field" style={{ marginBottom: 0 }}>
<span className="field-label">Available from</span>
<input type="datetime-local" value={form.unlockAt} onChange={(e) => set("unlockAt", e.target.value)} />
</label>
<label className="field" style={{ marginBottom: 0 }}>
<span className="field-label">Due</span>
<input type="datetime-local" value={form.dueAt} onChange={(e) => set("dueAt", e.target.value)} />
</label>
<label className="field" style={{ marginBottom: 0 }}>
<span className="field-label">Until (locks)</span>
<input type="datetime-local" value={form.lockAt} onChange={(e) => set("lockAt", e.target.value)} />
</label>
<label className="field" style={{ marginBottom: 0 }}>
<span className="field-label">Access code</span>
<input type="text" value={form.accessCode} placeholder="None"
onChange={(e) => set("accessCode", e.target.value)} />
</label>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 8, marginTop: 14 }}>
<Toggle checked={form.shuffleAnswers} onChange={(v) => set("shuffleAnswers", v)} label="Shuffle answer order" />
<Toggle checked={form.showCorrectAnswers} onChange={(v) => set("showCorrectAnswers", v)} label="Show correct answers after submission" />
<Toggle checked={form.oneQuestionAtATime} onChange={(v) => set("oneQuestionAtATime", v)} label="One question at a time" />
{form.oneQuestionAtATime && (
<Toggle checked={form.cantGoBack} onChange={(v) => set("cantGoBack", v)} label="Lock questions after answering" indent />
)}
<Toggle checked={form.includeExplanations} onChange={(v) => set("includeExplanations", v)} label="Include explanations as student feedback" />
</div>
{/* Optional: push straight into a Canvas course (needs credentials in Settings) */}
{!nothingToExport && canvasReady && (
<div style={{ marginTop: 18, paddingTop: 16, borderTop: "1px solid var(--line)" }}>
<div className="field-label">Or send directly to a Canvas course</div>
{sent ? (
<div className="alert alert-info" style={{ marginTop: 8 }}>
Created a quiz with <b>{sent.added}</b> question{sent.added === 1 ? "" : "s"} (unpublished).{" "}
<a href={sent.url} target="_blank" rel="noreferrer">Open in Canvas </a>
</div>
) : courses === null ? (
<div style={{ display: "flex", alignItems: "center", gap: 10, marginTop: 8 }}>
<button className="btn btn-sm" onClick={loadCourses} disabled={loadingCourses}>
{loadingCourses ? <><span className="spinner" /> Loading</> : "Load my courses"}
</button>
{coursesErr && <span className="small redpen" style={{ fontWeight: 600 }}>{coursesErr}</span>}
</div>
) : courses.length === 0 ? (
<p className="small muted" style={{ marginTop: 8 }}>No teachable courses found on this Canvas account.</p>
) : (
<div style={{ display: "flex", gap: 8, alignItems: "center", marginTop: 8, flexWrap: "wrap" }}>
<select value={courseId} onChange={(e) => setCourseId(e.target.value)} style={{ flex: 1, minWidth: 200 }}>
{courses.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
<button className="btn btn-primary btn-sm" onClick={sendToCanvas} disabled={sending || !courseId}>
{sending ? <><span className="spinner" /> Sending</> : "Send to Canvas"}
</button>
</div>
)}
{sendErr && <div className="alert alert-warn" style={{ marginTop: 10 }}>{sendErr}</div>}
</div>
)}
{!nothingToExport && canvasReady === false && (
<p className="small muted" style={{ marginTop: 16, paddingTop: 14, borderTop: "1px solid var(--line)" }}>
Want one-click publishing? Add your Canvas address &amp; access token in <b>Settings Canvas (LMS) integration</b>.
</p>
)}
<div style={{ display: "flex", justifyContent: "flex-end", gap: 10, marginTop: 22 }}>
<button className="btn" onClick={onClose}>Cancel</button>
<button className="btn btn-primary" onClick={doExport} disabled={busy || nothingToExport}>
{busy ? <><span className="spinner" /> Building</> : "Download .zip"}
</button>
</div>
</div>
</div>
);
}
function Toggle({ checked, onChange, label, indent }) {
return (
<label style={{ display: "flex", alignItems: "center", gap: 9, cursor: "pointer", marginLeft: indent ? 22 : 0 }}>
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
<span style={{ fontSize: "0.9rem" }}>{label}</span>
</label>
);
}