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>
316 lines
14 KiB
React
316 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 { 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
|
|
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
|
|
onClick={(e) => e.stopPropagation()}
|
|
className="card"
|
|
style={{ width: 560, maxWidth: "100%", padding: 24, animation: "fade-in-up 0.18s ease" }}
|
|
>
|
|
<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="btn btn-sm" onClick={onClose} aria-label="Close">✕</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’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 & 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>
|
|
);
|
|
}
|