"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 (
e.stopPropagation()} className="modal" style={{ width: 560, maxWidth: "100%", padding: 24 }} >

Export to Canvas

Downloads a QTI .zip. In Canvas: Settings → Import Course Content → QTI .zip file.

{/* Summary of what will be exported */}
{nothingToExport ? ( Nothing to export — this assignment has no Canvas-compatible questions. ) : ( <> {ir.gradedCount} question{ir.gradedCount === 1 ? "" : "s"} → one Canvas quiz {" "}({ir.quiz.pointsPossible} pts).
{Object.entries(breakdown).map(([t, n]) => ( {TYPE_LABEL[t] || t}: {n} ))}
)}
{skippedDiscussion > 0 && (
{skippedDiscussion} discussion question{skippedDiscussion === 1 ? "" : "s"} left out — Canvas quizzes have no discussion type.
)} {skippedOther > 0 && (
{skippedOther} question{skippedOther === 1 ? "" : "s"} couldn’t be converted and were left out.
)} {/* Optional Canvas settings — auto-filled with sensible defaults */}
Quiz settings (optional — change in Canvas later too)
set("shuffleAnswers", v)} label="Shuffle answer order" /> set("showCorrectAnswers", v)} label="Show correct answers after submission" /> set("oneQuestionAtATime", v)} label="One question at a time" /> {form.oneQuestionAtATime && ( set("cantGoBack", v)} label="Lock questions after answering" indent /> )} set("includeExplanations", v)} label="Include explanations as student feedback" />
{/* Optional: push straight into a Canvas course (needs credentials in Settings) */} {!nothingToExport && canvasReady && (
Or send directly to a Canvas course
{sent ? (
✓ Created a quiz with {sent.added} question{sent.added === 1 ? "" : "s"} (unpublished).{" "} Open in Canvas ↗
) : courses === null ? (
{coursesErr && {coursesErr}}
) : courses.length === 0 ? (

No teachable courses found on this Canvas account.

) : (
)} {sendErr &&
{sendErr}
}
)} {!nothingToExport && canvasReady === false && (

Want one-click publishing? Add your Canvas address & access token in Settings → Canvas (LMS) integration.

)}
); } function Toggle({ checked, onChange, label, indent }) { return ( ); }