diff --git a/public/admin.html b/public/admin.html index 713b18b..d19830b 100644 --- a/public/admin.html +++ b/public/admin.html @@ -174,6 +174,7 @@ + @@ -318,6 +319,15 @@ +
No questions parsed yet.
'; + return; + } + + let flatQ = 0; // global question counter for unique radio names + wd.sections.forEach((section, si) => { + if (section.title) { + const h = document.createElement('h3'); + h.style.cssText = 'margin:1rem 0 .5rem;font-size:.95rem;border-bottom:1px solid var(--border);padding-bottom:.25rem'; + h.textContent = section.title; + container.appendChild(h); + } + (section.questions || []).forEach((q, qi) => { + const div = document.createElement('div'); + div.className = 'ws-admin-q'; + div.style.cssText = 'margin-bottom:.75rem;padding:.6rem .75rem;background:var(--card-bg,#fff);border:1px solid var(--border);border-radius:8px'; + + if (q.type === 'fitb') { + const label = document.createElement('p'); + label.style.cssText = 'margin:0 0 .4rem;font-size:.9rem;color:var(--muted)'; + label.textContent = `Fill-in-the-blank${q.num !== undefined ? ` #${q.num}` : ''}`; + div.appendChild(label); + + const preview = document.createElement('p'); + preview.style.cssText = 'margin:0 0 .5rem;font-size:1rem'; + preview.textContent = q.template; + div.appendChild(preview); + + q.blanks.forEach((blank, bi) => { + const row = document.createElement('div'); + row.style.cssText = 'display:flex;align-items:center;gap:.5rem;margin-bottom:.35rem'; + const lbl = document.createElement('label'); + lbl.style.cssText = 'font-size:.85rem;color:var(--muted);white-space:nowrap'; + lbl.textContent = `Blank ${bi + 1} answer:`; + const inp = document.createElement('input'); + inp.type = 'text'; + inp.style.cssText = 'flex:1;min-width:0'; + inp.placeholder = 'Correct answerβ¦'; + inp.value = blank; + inp.dataset.si = si; + inp.dataset.qi = qi; + inp.dataset.bi = bi; + inp.className = 'ws-blank-ans'; + inp.addEventListener('input', () => syncWorksheetData()); + row.appendChild(lbl); + row.appendChild(inp); + div.appendChild(row); + }); + + const ptsRow = document.createElement('div'); + ptsRow.style.cssText = 'display:flex;align-items:center;gap:.5rem;margin-top:.25rem'; + ptsRow.innerHTML = ``; + ptsRow.querySelector('.ws-pts-inp').addEventListener('input', () => syncWorksheetData()); + div.appendChild(ptsRow); + + } else if (q.type === 'tf') { + const label = document.createElement('p'); + label.style.cssText = 'margin:0 0 .4rem;font-size:.9rem;color:var(--muted)'; + label.textContent = 'True / False'; + div.appendChild(label); + + const stmt = document.createElement('p'); + stmt.style.cssText = 'margin:0 0 .5rem;font-size:1rem'; + stmt.textContent = q.text; + div.appendChild(stmt); + + const radios = document.createElement('div'); + radios.style.cssText = 'display:flex;gap:1rem;align-items:center'; + const name = `ws-tf-${si}-${qi}-${flatQ}`; + ['T', 'F'].forEach(val => { + const lbl = document.createElement('label'); + lbl.style.cssText = 'display:flex;align-items:center;gap:.35rem;font-size:.95rem;cursor:pointer'; + const radio = document.createElement('input'); + radio.type = 'radio'; + radio.name = name; + radio.value = val; + radio.dataset.si = si; + radio.dataset.qi = qi; + radio.className = 'ws-tf-radio'; + if (q.correct === val) radio.checked = true; + radio.addEventListener('change', () => syncWorksheetData()); + lbl.appendChild(radio); + lbl.appendChild(document.createTextNode(val === 'T' ? 'True' : 'False')); + radios.appendChild(lbl); + }); + div.appendChild(radios); + + } else if (q.type === 'short') { + const label = document.createElement('p'); + label.style.cssText = 'margin:0 0 .25rem;font-size:.9rem;color:var(--muted)'; + label.textContent = `Short answer${q.num !== undefined ? ` #${q.num}` : ''} β parent grades`; + div.appendChild(label); + + const stmt = document.createElement('p'); + stmt.style.cssText = 'margin:0 0 .4rem;font-size:1rem'; + stmt.textContent = q.text; + div.appendChild(stmt); + + const ptsRow = document.createElement('div'); + ptsRow.innerHTML = ``; + ptsRow.querySelector('.ws-pts-inp').addEventListener('input', () => syncWorksheetData()); + div.appendChild(ptsRow); + } + + container.appendChild(div); + flatQ++; + }); + }); +} + +function syncWorksheetData() { + if (!currentWorksheetData) return; + // Sync blank answers from inputs back into currentWorksheetData + document.querySelectorAll('.ws-blank-ans').forEach(inp => { + const si = Number(inp.dataset.si), qi = Number(inp.dataset.qi), bi = Number(inp.dataset.bi); + if (currentWorksheetData.sections[si]?.questions[qi]?.blanks) { + currentWorksheetData.sections[si].questions[qi].blanks[bi] = inp.value.trim(); + } + }); + document.querySelectorAll('.ws-tf-radio:checked').forEach(radio => { + const si = Number(radio.dataset.si), qi = Number(radio.dataset.qi); + if (currentWorksheetData.sections[si]?.questions[qi]) { + currentWorksheetData.sections[si].questions[qi].correct = radio.value; + } + }); + document.querySelectorAll('.ws-pts-inp').forEach(inp => { + const si = Number(inp.dataset.si), qi = Number(inp.dataset.qi); + if (currentWorksheetData.sections[si]?.questions[qi]) { + currentWorksheetData.sections[si].questions[qi].points = Number(inp.value) || 1; + } + }); +} + +function collectWorksheetAnswers() { + if (!currentWorksheetData) return null; + syncWorksheetData(); + return currentWorksheetData; +} + +$('#ie-ws-parse').addEventListener('click', () => { + const text = $('#ie-ws-paste').value; + if (!text.trim()) return; + const wd = parseWorksheet(text); + const total = wd.sections.reduce((s, sec) => s + sec.questions.length, 0); + const statusEl = $('#ie-ws-parse-status'); + if (total === 0) { + statusEl.textContent = 'No questions found β check the format.'; + statusEl.hidden = false; + return; + } + currentWorksheetData = wd; + renderWorksheetEditor(wd); + statusEl.textContent = `Parsed ${total} question${total !== 1 ? 's' : ''}. Fill in any correct answers below, then save.`; + statusEl.hidden = false; +}); + // ============================================================ // Planner // ============================================================ diff --git a/public/app.js b/public/app.js index c07fdf6..8cbeb44 100644 --- a/public/app.js +++ b/public/app.js @@ -28,11 +28,12 @@ const todayStr = () => new Date().toISOString().slice(0, 10); const TYPE_ICON = { lesson: 'π', assignment: 'π', quiz: 'β', matching: 'π€', crossword: 'β¬', - spelling_practice: 'βοΈ', spelling_test: 'β', flashcards: 'ποΈ', + spelling_practice: 'βοΈ', spelling_test: 'β', flashcards: 'ποΈ', worksheet: 'π', }; const TYPE_LABEL = { lesson: 'Lesson', assignment: 'Assignment', quiz: 'Quiz', matching: 'Matching', crossword: 'Crossword', spelling_practice: 'Spelling Practice', spelling_test: 'Spelling Test', flashcards: 'Flashcards', + worksheet: 'Worksheet', }; document.querySelectorAll('[data-nav]').forEach((btn) => @@ -723,6 +724,7 @@ async function openItem(itemId, type) { if (type === 'quiz') return openQuiz(itemId, false); if (type === 'matching') return openMatching(itemId, false); if (type === 'crossword') return openCrossword(itemId); + if (type === 'worksheet') return openWorksheet(itemId); const item = await api(`/api/items/${itemId}?studentId=${currentStudent.id}`); if (type === 'spelling_practice') return startPractice({ listId: item.list.id, itemId }); if (type === 'spelling_test') return startTest({ listId: item.list.id, itemId, allowRetakes: item.allow_retakes }); @@ -817,6 +819,277 @@ async function openAssignment(itemId) { show('assignment'); } +// ---------- worksheet ---------- + +const wsNormalize = (s) => String(s).trim().toLowerCase(); + +async function openWorksheet(itemId) { + const item = await api(`/api/items/${itemId}?studentId=${currentStudent.id}`); + if (!item.worksheetData) return; + + const wd = item.worksheetData; + const sub = item.submission; + const graded = sub && sub.status === 'graded'; + const submitted = sub && (sub.status === 'done' || sub.status === 'graded'); + + backTarget = () => openKid(currentStudent.id); + + $('#ws-kicker').textContent = `${item.course_name} Β· ${item.unit_name}`; + $('#ws-title').textContent = item.title; + + const headerEl = $('#ws-header'); + if (wd.hasHeader) { + headerEl.hidden = false; + $('#ws-date').value = new Date().toLocaleDateString(); + $('#ws-name').value = ''; + } else { + headerEl.hidden = true; + } + + // Build question form + const form = $('#ws-form'); + form.innerHTML = ''; + + wd.sections.forEach((section, si) => { + if (section.title) { + const h = document.createElement('h3'); + h.className = 'ws-section-title'; + h.textContent = section.title; + form.appendChild(h); + } + (section.questions || []).forEach((q, qi) => { + const div = document.createElement('div'); + div.className = 'ws-question'; + + if (q.type === 'fitb') { + const parts = q.template.split('___'); + const numBlanks = q.blankCount || (parts.length - 1); + const p = document.createElement('p'); + p.className = 'ws-fitb'; + if (q.num !== undefined) { + const numSpan = document.createElement('span'); + numSpan.className = 'ws-qnum'; + numSpan.textContent = `${q.num}. `; + p.appendChild(numSpan); + } + parts.forEach((part, pi) => { + if (part) p.appendChild(document.createTextNode(part)); + if (pi < numBlanks) { + const inp = document.createElement('input'); + inp.type = 'text'; + inp.className = 'ws-blank-input'; + inp.name = `${si}:${qi}:${pi}`; + inp.autocomplete = 'off'; + inp.setAttribute('autocapitalize', 'off'); + inp.setAttribute('autocorrect', 'off'); + inp.spellcheck = false; + if (submitted) inp.disabled = true; + if (graded && q.givenBlanks) { + inp.value = q.givenBlanks[pi] ?? ''; + const correct = wsNormalize(q.givenBlanks[pi] ?? '') === wsNormalize((q.blanks || [])[pi] ?? ''); + inp.classList.add(correct ? 'ws-correct' : 'ws-wrong'); + } + p.appendChild(inp); + if (graded && q.blanks && q.blanks[pi]) { + const ans = document.createElement('span'); + ans.className = 'ws-correct-answer'; + ans.textContent = ` (${q.blanks[pi]})`; + p.appendChild(ans); + } + } + }); + div.appendChild(p); + + } else if (q.type === 'tf') { + const p = document.createElement('p'); + p.className = 'ws-tf'; + const key = `${si}:${qi}`; + ['T', 'F'].forEach(val => { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'ws-tf-btn'; + btn.dataset.val = val; + btn.dataset.key = key; + btn.textContent = val; + if (submitted) btn.disabled = true; + if (q.given === val) { + btn.classList.add('selected'); + if (graded) btn.classList.add(q.given === q.correct ? 'ws-correct' : 'ws-wrong'); + } + p.appendChild(btn); + }); + const stmt = document.createElement('span'); + stmt.className = 'ws-tf-stmt'; + stmt.textContent = q.text; + p.appendChild(stmt); + div.appendChild(p); + + } else if (q.type === 'short') { + const label = document.createElement('label'); + label.className = 'ws-short'; + if (q.num !== undefined) { + const numSpan = document.createElement('span'); + numSpan.className = 'ws-qnum'; + numSpan.textContent = `${q.num}. `; + label.appendChild(numSpan); + } + label.appendChild(document.createTextNode(q.text)); + const ta = document.createElement('textarea'); + ta.name = `${si}:${qi}`; + ta.className = 'ws-short-answer'; + ta.rows = 3; + ta.placeholder = 'Write your answer hereβ¦'; + if (submitted) ta.disabled = true; + if (q.given) ta.value = q.given; + label.appendChild(ta); + div.appendChild(label); + } + + form.appendChild(div); + }); + }); + + // T/F toggle (only active when not submitted) + form.addEventListener('click', (e) => { + const btn = e.target.closest('.ws-tf-btn'); + if (!btn || btn.disabled) return; + const key = btn.dataset.key; + form.querySelectorAll(`.ws-tf-btn[data-key="${key}"]`).forEach(b => b.classList.remove('selected')); + btn.classList.add('selected'); + }); + + // Actions + const actions = $('#ws-actions'); + const result = $('#ws-result'); + const submitBtn = $('#ws-submit-btn'); + const printBtn = $('#ws-print-btn'); + const paperBtn = $('#ws-paper-btn'); + + actions.hidden = false; + result.hidden = true; + paperBtn.hidden = submitted; // hide once any submission exists + + if (submitted && !graded) { + result.hidden = false; + result.className = 'status-banner'; + result.textContent = 'β³ Turned in β waiting for a parent to grade it.'; + submitBtn.hidden = true; + } else if (graded) { + result.hidden = false; + result.className = 'status-banner good'; + const hasShort = wd.sections.some(s => (s.questions || []).some(q => q.type === 'short')); + result.textContent = hasShort + ? `π Score: ${sub.score} / ${sub.points_possible} (short answers parent-graded)` + : `π Score: ${sub.score} / ${sub.points_possible}`; + if (item.allow_retakes) { + submitBtn.hidden = false; + submitBtn.textContent = 'π Retake'; + } else { + submitBtn.hidden = true; + } + } else { + submitBtn.hidden = false; + submitBtn.textContent = 'Submit β'; + } + + submitBtn.onclick = async () => { + const answers = {}; + form.querySelectorAll('.ws-blank-input').forEach(inp => { answers[inp.name] = inp.value.trim(); }); + form.querySelectorAll('.ws-tf-btn.selected').forEach(btn => { answers[btn.dataset.key] = btn.dataset.val; }); + form.querySelectorAll('.ws-short-answer').forEach(ta => { answers[ta.name] = ta.value.trim(); }); + try { + await api(`/api/items/${itemId}/worksheet-submit`, { + method: 'POST', + body: { studentId: currentStudent.id, answers, date: todayStr() }, + }); + openWorksheet(itemId); + } catch (err) { + result.hidden = false; + result.className = 'status-banner bad'; + result.textContent = err.message; + } + }; + + printBtn.onclick = () => printWorksheetBlank(item); + + paperBtn.onclick = async () => { + try { + await api(`/api/items/${itemId}/complete`, { + method: 'POST', + body: { studentId: currentStudent.id, date: todayStr() }, + }); + openWorksheet(itemId); + } catch (err) { + result.hidden = false; + result.className = 'status-banner bad'; + result.textContent = err.message; + } + }; + + show('worksheet'); +} + +function printWorksheetBlank(item) { + const wd = item.worksheetData; + if (!wd) return; + + const escHtml = (s) => String(s).replace(/[&<>"']/g, c => + ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); + + let html = ` +${line}
`; + } else if (q.type === 'tf') { + html += `TF ${escHtml(q.text)}
`; + } else if (q.type === 'short') { + html += `${q.num !== undefined ? `${q.num}. ` : ''}${escHtml(q.text)}
`; + html += ``; + } + html += `