// 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 `${entries.map(([id, m]) => ``).join("")}`; } function choiceRow(path, choice, ci, qi) { const name = `right-${path.replace(/\./g, "-")}`; return `
  • ${choice.correct ? "" : ``}
  • `; } function questionCard(si, qi, q) { const path = `skills.${si}.questions.${qi}`; return `
    שאלה ${qi + 1}${SOURCE[q.source] || SOURCE.person}
      ${(q.choices || []).map((c, ci) => choiceRow(path, c, ci, qi)).join("")}

    ${fmt(q.question || "")}

    `; } function skillCard(skill, si) { const path = `skills.${si}`; return `

    מיומנות ${si + 1}

    ${(skill.questions || []).map((q, qi) => questionCard(si, qi, q)).join("")}

    `; } function mistakesCard() { return `

    טעויות

    כל תשובה שגויה מראה לתלמיד טעות: שם קצר וטיפ איך לא לטעות שוב.

    `; } function statusPanel() { const v = state.view; if (v.status === "drafting") return `

    מכינים טיוטה מדף העבודה. זה יכול לקחת דקה או שתיים…

    `; if (v.status === "failed" && v.failure) return ``; return ""; } function pagesColumn() { const pages = state.pages.map((url, i) => url ? `עמוד ${i + 1} של דף העבודה` : "").join(""); return ``; } function publishPanel() { if (!state.doc) return ""; const problems = state.problems || []; return `
    ${state.published ? "השיעור פורסם לתלמידים" : "טיוטה: התלמידים עוד לא רואים את השיעור"} ${state.published ? `` : ""}
    ${state.publishMessage ? `

    ${esc(state.publishMessage)}

    ` : ""} ${problems.length ? `` : ""}
    `; } function html() { const doc = state.doc; return `

    ${statusPanel()}${publishPanel()}
    ${pagesColumn()}
    ${doc ? `
    ${doc.note ? `

    הערת ה־AI: ${esc(doc.note)}

    ` : ""}
    ${(doc.skills || []).map(skillCard).join("")}

    ${mistakesCard()}` : ""}
    `; } 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 = `

    ${fmt(q.question || "")}

    `; 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(); }