"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 (
Downloads a QTI .zip. In Canvas: Settings → Import Course Content → QTI .zip file.
No teachable courses found on this Canvas account.
) : (Want one-click publishing? Add your Canvas address & access token in Settings → Canvas (LMS) integration.
)}