mathly / mathly2 /static /lesson_editor.js
wzender's picture
Claude
Subjects and worksheets: more subjects, and a worksheet becomes a lesson (#6)
b6f5bc4 unverified
Raw History Blame Contribute Delete
16.7 kB
// The lesson editor (add-subjects-and-worksheets): a worksheet's pages beside the lesson drafted from it, every part editable.
// The lesson's name; its skills (name, short name, one-line description); each skill's multiple-choice questions with their
// choices, which one is right, and for each wrong one the mistake it shows; the lesson's mistakes (name and tip). Every change is
// saved (the whole document, a moment after the last keystroke), so leaving and coming back finds the draft as it was. A question
// a person edits or adds is marked "written by a person"; the AI's own are marked so a reviewer checks them first. Nothing here
// reaches a student: publishing (with its checks) is separate.
import { api } from "./api.js";
import { esc, fmt } from "./math.js";
import { token } from "./session.js";
const SOURCE = { sheet: "ืžื”ื“ืฃ", ai: "ื ื›ืชื‘ ืขืœ ื™ื“ื™ AI", person: "ื ื›ืชื‘ ืขืœ ื™ื“ื™ ืื“ื" };
const SAVE_AFTER = 700;
let state = null; // {id, view, doc, pages: [object URLs], text, saving, timer, onBack, poll}
/** A page picture needs the sign-in header, so it is fetched and shown from memory. */
async function pageUrl(lessonId, index) {
const response = await fetch(`/api/worksheets/${lessonId}/pages/${index}`, { headers: { Authorization: "Bearer " + await token() } });
return response.ok ? URL.createObjectURL(await response.blob()) : "";
}
const at = (doc, path) => path.split(".").reduce((node, key) => node?.[/^\d+$/.test(key) ? Number(key) : key], doc);
function put(doc, path, value) {
const keys = path.split("."), last = keys.pop();
const parent = keys.length ? at(doc, keys.join(".")) : doc;
if (parent) parent[/^\d+$/.test(last) ? Number(last) : last] = value;
}
function mistakeOptions(chosen) {
const entries = Object.entries(state.doc.mistakes || {});
return `<option value="">โ€” ื‘ื—ืจื• ื˜ืขื•ืช โ€”</option>${entries.map(([id, m]) =>
`<option value="${esc(id)}"${id === chosen ? " selected" : ""}>${esc(m.name || "ื˜ืขื•ืช ื‘ืœื™ ืฉื")}</option>`).join("")}`;
}
function choiceRow(path, choice, ci, qi) {
const name = `right-${path.replace(/\./g, "-")}`;
return `<li class="le-choice" data-path="${path}.choices.${ci}">
<label class="le-right"><input type="radio" name="${name}" data-act="right" ${choice.correct ? "checked" : ""}> ื ื›ื•ื ื”</label>
<input class="grow" data-f="${path}.choices.${ci}.text" value="${esc(choice.text || "")}" aria-label="ืชืฉื•ื‘ื” ${ci + 1}" dir="auto">
${choice.correct ? "" : `<select data-f="${path}.choices.${ci}.mistake" aria-label="ื”ื˜ืขื•ืช ืฉืœ ืชืฉื•ื‘ื” ${ci + 1}">${mistakeOptions(choice.mistake)}</select>`}
<button type="button" data-act="del-choice" aria-label="ืžื—ื™ืงืช ืชืฉื•ื‘ื” ${ci + 1}">โœ•</button></li>`;
}
function questionCard(si, qi, q) {
const path = `skills.${si}.questions.${qi}`;
return `<article class="le-question" data-path="${path}">
<div class="hrow"><b class="grow">ืฉืืœื” ${qi + 1}</b><span class="le-source le-${esc(q.source || "person")}">${SOURCE[q.source] || SOURCE.person}</span>
<button type="button" data-act="up-q" aria-label="ืฉืืœื” ${qi + 1} ืœืžืขืœื”">โ–ฒ</button><button type="button" data-act="down-q" aria-label="ืฉืืœื” ${qi + 1} ืœืžื˜ื”">โ–ผ</button>
<button type="button" class="danger" data-act="del-q">ืžื—ื™ืงื”</button></div>
<textarea data-f="${path}.question" rows="2" aria-label="ื ื•ืกื— ืฉืืœื” ${qi + 1}" dir="auto">${esc(q.question || "")}</textarea>
<ol class="plain le-choices">${(q.choices || []).map((c, ci) => choiceRow(path, c, ci, qi)).join("")}</ol>
<p class="hrow"><button type="button" data-act="add-choice">+ ืชืฉื•ื‘ื”</button></p>
<div class="le-preview" aria-label="ื›ืš ื”ืชืœืžื™ื“ื™ื ื™ืจืื• ืืช ื”ืฉืืœื”"><p dir="auto">${fmt(q.question || "")}</p>
<ul>${(q.choices || []).map(c => `<li dir="auto">${fmt(c.text || "")}</li>`).join("")}</ul></div></article>`;
}
function skillCard(skill, si) {
const path = `skills.${si}`;
return `<section class="card le-skill" data-path="${path}">
<div class="hrow"><h4 class="grow">ืžื™ื•ืžื ื•ืช ${si + 1}</h4>
<button type="button" data-act="up-s" aria-label="ืžื™ื•ืžื ื•ืช ${si + 1} ืœืžืขืœื”">โ–ฒ</button><button type="button" data-act="down-s" aria-label="ืžื™ื•ืžื ื•ืช ${si + 1} ืœืžื˜ื”">โ–ผ</button>
<button type="button" class="danger" data-act="del-s">ืžื—ื™ืงื”</button></div>
<label>ืฉื ื”ืžื™ื•ืžื ื•ืช<input data-f="${path}.name" maxlength="60" value="${esc(skill.name || "")}"></label>
<label>ืฉื ืงืฆืจ<input data-f="${path}.short" maxlength="25" value="${esc(skill.short || "")}"></label>
<label>ืชื™ืื•ืจ ื‘ืžืฉืคื˜<input data-f="${path}.description" maxlength="300" value="${esc(skill.description || "")}"></label>
${(skill.questions || []).map((q, qi) => questionCard(si, qi, q)).join("")}
<p><button type="button" data-act="add-q">+ ืฉืืœื”</button></p></section>`;
}
function mistakesCard() {
return `<section class="card le-mistakes"><h4>ื˜ืขื•ื™ื•ืช</h4><p class="muted">ื›ืœ ืชืฉื•ื‘ื” ืฉื’ื•ื™ื” ืžืจืื” ืœืชืœืžื™ื“ ื˜ืขื•ืช: ืฉื ืงืฆืจ ื•ื˜ื™ืค ืื™ืš ืœื ืœื˜ืขื•ืช ืฉื•ื‘.</p>
<ul class="plain">${Object.entries(state.doc.mistakes || {}).map(([id, m]) => `<li class="le-mistake" data-mistake="${esc(id)}">
<input data-f="mistakes.${esc(id)}.name" maxlength="80" placeholder="ืฉื ื”ื˜ืขื•ืช" value="${esc(m.name || "")}" aria-label="ืฉื ื”ื˜ืขื•ืช">
<input class="grow" data-f="mistakes.${esc(id)}.tip" maxlength="600" placeholder="ื˜ื™ืค" value="${esc(m.tip || "")}" aria-label="ื˜ื™ืค"></li>`).join("")}</ul>
<p><button type="button" data-act="add-mistake">+ ื˜ืขื•ืช</button></p></section>`;
}
function statusPanel() {
const v = state.view;
if (v.status === "drafting") return `<p class="le-status" id="le-status" role="status">ืžื›ื™ื ื™ื ื˜ื™ื•ื˜ื” ืžื“ืฃ ื”ืขื‘ื•ื“ื”. ื–ื” ื™ื›ื•ืœ ืœืงื—ืช ื“ืงื” ืื• ืฉืชื™ื™ืโ€ฆ</p>`;
if (v.status === "failed" && v.failure) return `<div class="error le-status" id="le-status" role="alert"><b>${esc(v.failure.what)}</b>
${v.failure.note ? `<p>${esc(v.failure.note)}</p>` : ""}<p>${esc(v.failure.todo)}</p>
<button type="button" class="primary" id="le-retry">ื ืกื• ืฉื•ื‘</button></div>`;
return "";
}
function pagesColumn() {
const pages = state.pages.map((url, i) => url ? `<img src="${url}" alt="ืขืžื•ื“ ${i + 1} ืฉืœ ื“ืฃ ื”ืขื‘ื•ื“ื”" loading="lazy">` : "").join("");
return `<aside class="le-sheet" aria-label="ื“ืฃ ื”ืขื‘ื•ื“ื”">${pages}${state.text ? `<pre class="le-text" dir="auto">${esc(state.text)}</pre>` : ""}
${!pages && !state.text ? `<p class="muted">ืื™ืŸ ื“ืฃ ืขื‘ื•ื“ื” ืœืฉื™ืขื•ืจ ื”ื–ื”.</p>` : ""}</aside>`;
}
function publishPanel() {
if (!state.doc) return "";
const problems = state.problems || [];
return `<section class="card le-publish" id="le-publish"><div class="hrow"><b class="grow" id="le-state">${state.published ? "ื”ืฉื™ืขื•ืจ ืคื•ืจืกื ืœืชืœืžื™ื“ื™ื" : "ื˜ื™ื•ื˜ื”: ื”ืชืœืžื™ื“ื™ื ืขื•ื“ ืœื ืจื•ืื™ื ืืช ื”ืฉื™ืขื•ืจ"}</b>
<button type="button" class="primary" id="le-do-publish">${state.published ? "ืคืจืกื•ื ื”ืฉื™ื ื•ื™ื™ื" : "ืคืจืกื•ื"}</button>
${state.published ? `<button type="button" id="le-unpublish">ื‘ื™ื˜ื•ืœ ื”ืคืจืกื•ื</button>` : ""}</div>
${state.publishMessage ? `<p role="status" id="le-publish-msg">${esc(state.publishMessage)}</p>` : ""}
${problems.length ? `<div class="error" role="alert" id="le-problems"><b>${esc(state.problemsTitle || "ืขื•ื“ ืœื ืืคืฉืจ ืœืคืจืกื:")}</b><ul>${problems.map(p => `<li>${esc(p)}</li>`).join("")}</ul></div>` : ""}</section>`;
}
function html() {
const doc = state.doc;
return `<section id="lesson-editor" data-lesson="${state.id}">
<p><button type="button" id="le-back">โ€น ื—ื–ืจื” ืœืžืงืฆื•ืขื•ืช</button> <span class="muted" id="le-saved" role="status" aria-live="polite"></span></p>
${statusPanel()}${publishPanel()}
<div class="le-layout">${pagesColumn()}
<div class="le-draft">${doc ? `<section class="card"><label for="le-name">ืฉื ื”ืฉื™ืขื•ืจ</label><input id="le-name" data-f="name" maxlength="80" value="${esc(doc.name || "")}">
${doc.note ? `<p class="muted">ื”ืขืจืช ื”ึพAI: ${esc(doc.note)}</p>` : ""}</section>
${(doc.skills || []).map(skillCard).join("")}
<p><button type="button" data-act="add-s">+ ืžื™ื•ืžื ื•ืช</button></p>
${mistakesCard()}` : ""}</div></div></section>`;
}
function draw() {
const root = document.getElementById("lesson-editor-root");
if (!root) return;
const focused = document.activeElement?.dataset?.f, caret = document.activeElement?.selectionStart;
root.innerHTML = html();
if (focused) {
const again = root.querySelector(`[data-f="${CSS.escape(focused)}"]`);
again?.focus();
if (again && caret != null && again.setSelectionRange) try { again.setSelectionRange(caret, caret); } catch { /* not a text field */ }
}
}
function said(text) { const el = document.getElementById("le-saved"); if (el) el.textContent = text; }
async function saveNow() {
clearTimeout(state.timer);
state.timer = null;
said("ืฉื•ืžืจื™ืโ€ฆ");
try {
const saved = await api(`/api/subjects/lessons/${state.id}/draft`, { method: "PUT", body: { lesson: state.doc } });
const fresh = saved.draft;
const renamed = JSON.stringify(Object.keys(fresh.mistakes || {})) !== JSON.stringify(Object.keys(state.doc.mistakes || {}))
|| (fresh.skills || []).some((s, i) => s.id !== state.doc.skills[i]?.id);
state.doc = fresh;
if (renamed) draw(); // new skills or mistakes got their ids: the selects must name them
said("ื ืฉืžืจ");
} catch (e) { said(""); const el = document.getElementById("le-saved"); if (el) { el.textContent = `ืœื ื ืฉืžืจ: ${e.message}`; el.classList.add("error"); } }
}
function changed({ redraw = false } = {}) {
if (redraw) draw();
said("");
clearTimeout(state.timer);
state.timer = setTimeout(saveNow, SAVE_AFTER);
}
const asPerson = question => { if (question) question.source = "person"; };
const move = (list, from, to) => { if (to < 0 || to >= list.length) return; const [item] = list.splice(from, 1); list.splice(to, 0, item); };
const newMistake = () => { const id = `new-${Date.now()}`; state.doc.mistakes = { ...(state.doc.mistakes || {}), [id]: { name: "", tip: "" } }; return id; };
function onInput(event) {
const field = event.target.closest("[data-f]");
if (!field || !state?.doc) return;
const path = field.dataset.f;
put(state.doc, path, field.value);
const question = path.match(/^skills\.\d+\.questions\.\d+/);
if (question) asPerson(at(state.doc, question[0]));
const preview = field.closest(".le-question");
if (preview) { // the question's preview follows the typing, without redrawing the field
const q = at(state.doc, preview.dataset.path);
preview.querySelector(".le-preview").innerHTML = `<p dir="auto">${fmt(q.question || "")}</p><ul>${(q.choices || []).map(c => `<li dir="auto">${fmt(c.text || "")}</li>`).join("")}</ul>`;
const badge = preview.querySelector(".le-source");
badge.className = "le-source le-person"; badge.textContent = SOURCE.person;
}
changed({ redraw: event.type === "change" && field.tagName === "SELECT" });
}
async function publish() {
if (state.timer) await saveNow();
try {
const done = await api(`/api/subjects/lessons/${state.id}/publish`, { method: "POST" });
Object.assign(state, { published: true, problems: [], publishMessage: "ื”ืฉื™ืขื•ืจ ืคื•ืจืกื. ื”ืชืœืžื™ื“ื™ื ืฉืœ ื”ื›ื™ืชื•ืช ืฉืœื•ืžื“ื•ืช ืืช ื”ืžืงืฆื•ืข ืจื•ืื™ื ืื•ืชื• ืขื›ืฉื™ื•.", doc: done.draft });
} catch (e) {
Object.assign(state, { problems: e.problems || [e.message], problemsTitle: "ืขื•ื“ ืœื ืืคืฉืจ ืœืคืจืกื:", publishMessage: "" });
}
draw();
document.getElementById("le-problems")?.scrollIntoView({ block: "nearest" });
}
async function unpublish() {
if (!confirm("ืœื‘ื˜ืœ ืืช ื”ืคืจืกื•ื? ื”ืฉื™ืขื•ืจ ื™ื™ืขืœื ืžื”ืžืกืœื•ืœ ืฉืœ ื”ืชืœืžื™ื“ื™ื, ื•ื”ื”ืชืงื“ืžื•ืช ืฉืœื”ื ืชื™ืฉืžืจ.")) return;
try {
await api(`/api/subjects/lessons/${state.id}/unpublish`, { method: "POST" });
Object.assign(state, { published: false, problems: [], publishMessage: "ื”ืคืจืกื•ื ื‘ื•ื˜ืœ. ื”ืฉื™ืขื•ืจ ื—ื–ืจ ืœื”ื™ื•ืช ื˜ื™ื•ื˜ื”." });
} catch (e) { Object.assign(state, { problems: [e.message], problemsTitle: "ืื™ ืืคืฉืจ ืœื‘ื˜ืœ ืืช ื”ืคืจืกื•ื:", publishMessage: "" }); }
draw();
}
function onClick(event) {
const button = event.target.closest("[data-act]");
if (event.target.closest("#le-back")) { leave(); return; }
if (event.target.closest("#le-do-publish")) { publish(); return; }
if (event.target.closest("#le-unpublish")) { unpublish(); return; }
if (event.target.closest("#le-retry")) { retry(); return; }
if (!button || !state?.doc) return;
const doc = state.doc, act = button.dataset.act;
const skillPath = button.closest(".le-skill")?.dataset.path, qPath = button.closest(".le-question")?.dataset.path;
const skills = doc.skills = doc.skills || [];
const si = skillPath ? Number(skillPath.split(".")[1]) : -1, qi = qPath ? Number(qPath.split(".")[3]) : -1;
const questions = si >= 0 ? (skills[si].questions = skills[si].questions || []) : [];
if (act === "right") {
const choices = at(doc, qPath).choices, ci = Number(button.closest(".le-choice").dataset.path.split(".").pop());
choices.forEach((c, i) => { if (i === ci) { c.correct = true; delete c.mistake; } else if (c.correct) { delete c.correct; c.mistake = ""; } });
asPerson(at(doc, qPath));
} else if (act === "del-choice") {
at(doc, qPath).choices.splice(Number(button.closest(".le-choice").dataset.path.split(".").pop()), 1); asPerson(at(doc, qPath));
} else if (act === "add-choice") { at(doc, qPath).choices = [...(at(doc, qPath).choices || []), { text: "", mistake: "" }]; asPerson(at(doc, qPath)); }
else if (act === "add-q") questions.push({ question: "", source: "person", choices: [{ text: "", correct: true }, { text: "", mistake: "" }] });
else if (act === "del-q") { if (!confirm("ืœืžื—ื•ืง ืืช ื”ืฉืืœื”?")) return; questions.splice(qi, 1); }
else if (act === "up-q" || act === "down-q") move(questions, qi, qi + (act === "up-q" ? -1 : 1));
else if (act === "add-s") skills.push({ name: "", short: "", description: "", questions: [] });
else if (act === "del-s") { if (!confirm("ืœืžื—ื•ืง ืืช ื”ืžื™ื•ืžื ื•ืช ื•ืืช ื”ืฉืืœื•ืช ืฉืœื”?")) return; skills.splice(si, 1); }
else if (act === "up-s" || act === "down-s") move(skills, si, si + (act === "up-s" ? -1 : 1));
else if (act === "add-mistake") newMistake();
else return;
changed({ redraw: true });
}
async function refresh() {
const v = await api(`/api/worksheets/${state.id}`).catch(() => null);
if (!v || !state) return;
const was = state.view.status;
state.view = v;
if (v.status !== "drafting") {
clearInterval(state.poll); state.poll = null;
if (was === "drafting") { state.doc = (await api(`/api/subjects/lessons/${state.id}`)).draft; draw(); }
}
}
async function retry() {
try { state.view = await api(`/api/worksheets/${state.id}/draft`, { method: "POST" }); draw(); watch(); }
catch (e) { said(e.message); }
}
function watch() {
clearInterval(state.poll);
if (state.view.status === "drafting") state.poll = setInterval(refresh, 2000);
}
function leave() {
const back = state.onBack;
if (state.timer) saveNow();
clearInterval(state.poll);
state.pages.forEach(url => url && URL.revokeObjectURL(url));
state = null;
back();
}
/** Open the editor for a lesson inside `root` (an element the caller drew). `onBack` returns to where the person came from. */
export async function open(root, lessonId, onBack) {
root.id = "lesson-editor-root";
const lesson = await api(`/api/subjects/lessons/${lessonId}`);
const view = lesson.source === "worksheet" ? await api(`/api/worksheets/${lessonId}`) : { status: "ready", pages: [], has_text: false };
state = { id: lessonId, view, doc: view.status === "ready" ? lesson.draft : null, pages: [], text: "", timer: null, poll: null, onBack,
published: lesson.published, problems: [], publishMessage: "" };
if (state.doc && !Object.keys(state.doc).length) state.doc = { name: "", skills: [], mistakes: {} };
state.pages = await Promise.all(view.pages.map(i => pageUrl(lessonId, i)));
if (view.has_text) state.text = (await api(`/api/worksheets/${lessonId}/text`)).text;
draw();
root.addEventListener("input", onInput);
root.addEventListener("change", onInput);
root.addEventListener("click", onClick);
watch();
}