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 @@ + +
diff --git a/public/admin.js b/public/admin.js index 2532ae9..209cd1c 100644 --- a/public/admin.js +++ b/public/admin.js @@ -37,11 +37,12 @@ function msg(text, type = 'success') { 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', }; // ---------- PIN gate ---------- @@ -546,6 +547,10 @@ async function openItemEditor(unitId, itemId) { renderCrosswordPreview(item.crosswordData); $('#ie-crossword-status').textContent = `${item.crosswordData.placed || ''} words placed`; } + if (item.type === 'worksheet' && item.worksheetData) { + currentWorksheetData = item.worksheetData; + renderWorksheetEditor(item.worksheetData); + } $('#ie-delete').hidden = false; $('#ie-delete').onclick = async () => { if (!confirm('Delete this item and any grades for it?')) return; @@ -562,6 +567,10 @@ async function openItemEditor(unitId, itemId) { $('#ie-quiz-instructions').value = ''; $('#ie-matching-instructions').value = ''; currentCrosswordData = null; + currentWorksheetData = null; + $('#ie-ws-paste').value = ''; + $('#ie-ws-editor').innerHTML = ''; + $('#ie-ws-parse-status').hidden = true; $('#ie-points').value = 10; $('#ie-due-date').value = ''; $('#ie-allow-retakes').checked = false; @@ -585,8 +594,10 @@ function updateItemFieldVisibility() { $('#ie-field-crossword').hidden = type !== 'crossword'; $('#ie-field-spelling').hidden = type !== 'spelling_practice' && type !== 'spelling_test'; $('#ie-field-flashcards').hidden = type !== 'flashcards'; + $('#ie-field-worksheet').hidden = type !== 'worksheet'; $('#ie-scan-section').hidden = type !== 'lesson' && type !== 'quiz'; - $('#ie-import-section').hidden = type === 'spelling_practice' || type === 'spelling_test' || type === 'flashcards'; + $('#ie-import-section').hidden = type === 'spelling_practice' || type === 'spelling_test' + || type === 'flashcards' || type === 'worksheet'; } $('#ie-type').addEventListener('change', updateItemFieldVisibility); @@ -962,6 +973,15 @@ $('#item-form').addEventListener('submit', async (e) => { } body.crosswordData = currentCrosswordData; } + if (type === 'worksheet') { + const wd = collectWorksheetAnswers(); + if (!wd) { + $('#ie-error').textContent = 'Parse the worksheet first.'; + $('#ie-error').hidden = false; + return; + } + body.worksheetData = wd; + } const id = $('#ie-id').value; try { @@ -975,6 +995,236 @@ $('#item-form').addEventListener('submit', async (e) => { } }); +// ============================================================ +// Worksheet editor +// ============================================================ + +let currentWorksheetData = null; + +function parseWorksheet(text) { + const lines = text.split('\n'); + const sections = []; + let currentSection = null; + let hasHeader = false; + + for (const rawLine of lines) { + const line = rawLine.trimEnd(); + const trimmed = line.trim(); + if (!trimmed) continue; + + // Name/Date header line + if (!hasHeader && /name\s*:/i.test(trimmed)) { + hasHeader = true; + continue; + } + + // Section header: "Part 1 β€”", "Part 1:", "Section 1 β€”", etc. + if (/^(part|section)\s+\d+/i.test(trimmed)) { + currentSection = { title: trimmed, questions: [] }; + sections.push(currentSection); + continue; + } + + if (!currentSection) { + currentSection = { title: '', questions: [] }; + sections.push(currentSection); + } + + // True/False line: starts with T then whitespace then F + if (/^T\s+F\b/i.test(trimmed)) { + const text = trimmed.replace(/^T\s+F\s*/i, '').trim(); + if (text) currentSection.questions.push({ type: 'tf', text, correct: '', points: 1 }); + continue; + } + + // Strip optional leading number: "1. " or "1) " + const numMatch = trimmed.match(/^(\d+)[.)]\s+(.+)/); + const num = numMatch ? Number(numMatch[1]) : undefined; + const content = numMatch ? numMatch[2] : trimmed; + + // Fill-in-the-blank: contains 3+ underscores anywhere + if (/_{3,}/.test(content)) { + const template = content.replace(/_{3,}/g, '___'); + const blankCount = (template.match(/___/g) || []).length; + currentSection.questions.push({ + type: 'fitb', + num, + template, + blanks: Array(blankCount).fill(''), + points: blankCount, + }); + continue; + } + + // Short answer (numbered or plain) + currentSection.questions.push({ type: 'short', num, text: content, points: 1 }); + } + + // Drop empty sections + return { hasHeader, sections: sections.filter(s => s.questions.length > 0 || s.title) }; +} + +function renderWorksheetEditor(wd) { + const container = $('#ie-ws-editor'); + container.innerHTML = ''; + + if (!wd || !wd.sections || wd.sections.length === 0) { + container.innerHTML = '

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 = ` +${escHtml(item.title)} +`; + + html += `

${escHtml(item.title)}

`; + + if (wd.hasHeader) { + html += `
+
Name:  
+
Date:  
+
`; + } + + wd.sections.forEach(section => { + if (section.title) html += `

${escHtml(section.title)}

`; + (section.questions || []).forEach(q => { + html += `
`; + if (q.type === 'fitb') { + const parts = q.template.split('___'); + let line = q.num !== undefined ? `${q.num}. ` : ''; + parts.forEach((part, pi) => { + line += escHtml(part); + if (pi < parts.length - 1) line += ` `; + }); + 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 += `
`; + }); + }); + + html += ``; + + const win = window.open('', '_blank'); + if (win) { win.document.write(html); win.document.close(); win.print(); } +} + // ---------- quiz ---------- async function openQuiz(itemId, forceRetake = false) { diff --git a/public/index.html b/public/index.html index da2c50d..113f426 100644 --- a/public/index.html +++ b/public/index.html @@ -232,6 +232,26 @@
+ + + diff --git a/public/styles.css b/public/styles.css index cb9a358..87afbfa 100644 --- a/public/styles.css +++ b/public/styles.css @@ -36,7 +36,8 @@ body { #view-quiz, #view-flashcards, #view-practice, -#view-test { +#view-test, +#view-worksheet { max-width: 960px; text-align: left; } @@ -988,3 +989,49 @@ body.theme-indigo { --accent: #5b6abf; --accent-dark: #4a57a0; --bg: #f4f5ff; } .cw-cell.correct-word { background: #1a3a1a; } .cw-cell.wrong-word { background: #3a1a1a; } } + +/* ---------- worksheet ---------- */ +.ws-header { + display: flex; gap: 1.5rem; flex-wrap: wrap; align-items: center; + margin-bottom: 1.25rem; font-size: 1rem; +} +.ws-name-input { width: 12rem; } +.ws-date-input { width: 7rem; } +.ws-section-title { + margin: 1.25rem 0 .5rem; font-size: 1rem; font-weight: 700; + border-bottom: 1px solid var(--border); padding-bottom: .3rem; +} +.ws-question { margin-bottom: 1rem; } +.ws-fitb { font-size: 1rem; line-height: 1.8; margin: 0; } +.ws-qnum { font-weight: 700; } +.ws-blank-input { + display: inline-block; width: 7rem; border: none; + border-bottom: 2px solid var(--accent); background: transparent; + font-size: 1rem; font-family: inherit; padding: 0 .25rem; + outline: none; margin: 0 .2rem; vertical-align: baseline; +} +.ws-blank-input:focus { border-bottom-color: var(--accent-dark); } +.ws-blank-input.ws-correct { border-bottom-color: var(--good); color: var(--good); } +.ws-blank-input.ws-wrong { border-bottom-color: var(--bad); color: var(--bad); } +.ws-correct-answer { font-size: .85rem; color: var(--good); font-style: italic; } +.ws-tf { display: flex; align-items: center; gap: .5rem; font-size: 1rem; margin: 0; } +.ws-tf-btn { + min-width: 2.2rem; padding: .35rem .6rem; border: 2px solid var(--border); + border-radius: 6px; background: transparent; font-size: 1rem; font-weight: 700; + cursor: pointer; transition: all .15s; color: inherit; +} +.ws-tf-btn.selected { background: var(--accent); color: #fff; border-color: var(--accent); } +.ws-tf-btn.selected.ws-correct { background: var(--good); border-color: var(--good); } +.ws-tf-btn.selected.ws-wrong { background: var(--bad); border-color: var(--bad); } +.ws-tf-stmt { flex: 1; } +.ws-short { display: block; font-size: 1rem; margin-bottom: 1rem; } +.ws-short-answer { + display: block; width: 100%; margin-top: .4rem; resize: vertical; + border: 1px solid var(--border); border-radius: 8px; padding: .5rem .75rem; + font-family: inherit; font-size: 1rem; background: var(--input-bg, transparent); +} +.ws-actions { display: flex; gap: .75rem; margin-top: 1.25rem; flex-wrap: wrap; } + +@media (prefers-color-scheme: dark) { + .ws-blank-input { color: #eee; } +} diff --git a/server.js b/server.js index 6980c6a..2bd0c40 100644 --- a/server.js +++ b/server.js @@ -317,8 +317,8 @@ function generateCrossword(acrossEntries, downEntries) { return { rows, cols, grid, across: mkList('across'), down: mkList('down'), placed: placed.length, total: allEntries.length }; } -const ITEM_TYPES = ['lesson', 'assignment', 'quiz', 'matching', 'crossword', 'spelling_practice', 'spelling_test', 'flashcards']; -const GRADABLE_TYPES = ['assignment', 'quiz', 'matching', 'crossword', 'spelling_test']; +const ITEM_TYPES = ['lesson', 'assignment', 'quiz', 'matching', 'crossword', 'spelling_practice', 'spelling_test', 'flashcards', 'worksheet']; +const GRADABLE_TYPES = ['assignment', 'quiz', 'matching', 'crossword', 'spelling_test', 'worksheet']; // ---------- kid picker ---------- @@ -1017,6 +1017,9 @@ app.get('/api/admin/items/:id', requirePin, (req, res) => { if (item.type === 'crossword' && item.body) { try { item.crosswordData = JSON.parse(item.body); } catch { item.crosswordData = null; } } + if (item.type === 'worksheet' && item.body) { + try { item.worksheetData = JSON.parse(item.body); } catch { item.worksheetData = null; } + } res.json(item); }); @@ -1075,6 +1078,12 @@ function validateItemBody(body) { return 'Quiz needs at least one question'; } if (type === 'crossword' && !body.crosswordData) return 'Generate the crossword first'; + if (type === 'worksheet') { + const wd = body.worksheetData; + if (!wd || !Array.isArray(wd.sections) || !wd.sections.some(s => Array.isArray(s.questions) && s.questions.length > 0)) { + return 'Parse the worksheet and add at least one question'; + } + } return null; } @@ -1120,6 +1129,12 @@ app.post('/api/items', requirePin, (req, res) => { const bodyJson = JSON.stringify(cw); db.prepare(`UPDATE items SET body = ?, points = ? WHERE id = ?`).run(bodyJson, totalPoints, id); } + if (type === 'worksheet' && req.body.worksheetData) { + const wd = req.body.worksheetData; + const totalPoints = wd.sections.reduce((sum, s) => + sum + (s.questions || []).reduce((qs, q) => qs + (Number(q.points) || 1), 0), 0); + db.prepare(`UPDATE items SET body = ?, points = ? WHERE id = ?`).run(JSON.stringify(wd), totalPoints, id); + } res.json({ id }); }); @@ -1145,6 +1160,12 @@ app.put('/api/items/:id', requirePin, (req, res) => { const bodyJson = JSON.stringify(cw); db.prepare(`UPDATE items SET body = ?, points = ? WHERE id = ?`).run(bodyJson, totalPoints, req.params.id); } + if (type === 'worksheet' && req.body.worksheetData) { + const wd = req.body.worksheetData; + const totalPoints = wd.sections.reduce((sum, s) => + sum + (s.questions || []).reduce((qs, q) => qs + (Number(q.points) || 1), 0), 0); + db.prepare(`UPDATE items SET body = ?, points = ? WHERE id = ?`).run(JSON.stringify(wd), totalPoints, req.params.id); + } res.json({ ok: true }); }); @@ -1273,6 +1294,43 @@ app.get('/api/items/:id', (req, res) => { savedAnswers, }; } catch { item.crosswordData = null; } + } else if (item.type === 'worksheet' && item.body) { + try { + const wd = JSON.parse(item.body); + const graded = item.submission && item.submission.status === 'graded'; + const savedAnswers = item.submission && item.submission.answers + ? JSON.parse(item.submission.answers) : null; + item.worksheetData = { + hasHeader: wd.hasHeader, + sections: wd.sections.map((section, si) => ({ + title: section.title, + questions: section.questions.map((q, qi) => { + const out = { type: q.type, points: q.points }; + if (q.num !== undefined) out.num = q.num; + if (q.type === 'fitb') { + out.template = q.template; + out.blankCount = Array.isArray(q.blanks) ? q.blanks.length : 0; + if (graded) { + out.blanks = q.blanks; + out.givenBlanks = Array.from({ length: out.blankCount }, (_, bi) => + savedAnswers ? (savedAnswers[`${si}:${qi}:${bi}`] ?? '') : ''); + } + } else if (q.type === 'tf') { + out.text = q.text; + if (graded) { + out.correct = q.correct; + out.given = savedAnswers ? (savedAnswers[`${si}:${qi}`] ?? '') : ''; + } + if (savedAnswers && !graded) out.given = savedAnswers[`${si}:${qi}`] ?? ''; + } else if (q.type === 'short') { + out.text = q.text; + out.given = savedAnswers ? (savedAnswers[`${si}:${qi}`] ?? '') : ''; + } + return out; + }), + })), + }; + } catch { item.worksheetData = null; } } else if (item.type === 'flashcards') { item.deck = db.prepare(`SELECT id, name FROM decks WHERE id = ?`).get(item.ref_id); } else if (item.type === 'spelling_practice' || item.type === 'spelling_test') { @@ -1399,13 +1457,67 @@ app.post('/api/items/:id/crossword-submit', (req, res) => { res.json({ score: earned, total: possible }); }); +// Auto-grade T/F and FITB; queue short answers for parent grading +app.post('/api/items/:id/worksheet-submit', (req, res) => { + const { studentId, answers, date } = req.body; + const item = db.prepare(`SELECT body FROM items WHERE id = ?`).get(req.params.id); + if (!item || !item.body) return res.status(404).json({ error: 'No such worksheet' }); + let wd; + try { wd = JSON.parse(item.body); } catch { return res.status(400).json({ error: 'Invalid worksheet data' }); } + + let earned = 0, possible = 0, hasShort = false; + wd.sections.forEach((section, si) => { + (section.questions || []).forEach((q, qi) => { + const pts = Number(q.points) || 1; + possible += pts; + if (q.type === 'tf') { + const given = answers ? answers[`${si}:${qi}`] : undefined; + if (given !== undefined && normalize(given) === normalize(q.correct || '')) earned += pts; + } else if (q.type === 'fitb') { + const blanks = Array.isArray(q.blanks) ? q.blanks : []; + const perBlank = blanks.length > 0 ? pts / blanks.length : 0; + blanks.forEach((correct, bi) => { + const given = answers ? answers[`${si}:${qi}:${bi}`] : undefined; + if (given !== undefined && normalize(given) === normalize(correct || '')) earned += perBlank; + }); + } else if (q.type === 'short') { + hasShort = true; + } + }); + }); + + if (hasShort) { + db.prepare(` + INSERT INTO submissions (student_id, item_id, status, score, points_possible, answers, completed_at) + VALUES (?, ?, 'done', ?, ?, ?, datetime('now')) + ON CONFLICT (student_id, item_id) DO UPDATE SET + status = 'done', score = excluded.score, points_possible = excluded.points_possible, + answers = excluded.answers, completed_at = datetime('now'), graded_at = NULL + `).run(studentId, req.params.id, earned, possible, JSON.stringify(answers || {})); + } else { + db.prepare(` + INSERT INTO submissions (student_id, item_id, status, score, points_possible, answers, completed_at, graded_at) + VALUES (?, ?, 'graded', ?, ?, ?, datetime('now'), datetime('now')) + ON CONFLICT (student_id, item_id) DO UPDATE SET + status = 'graded', score = excluded.score, points_possible = excluded.points_possible, + answers = excluded.answers, completed_at = datetime('now'), graded_at = datetime('now') + `).run(studentId, req.params.id, earned, possible, JSON.stringify(answers || {})); + } + + db.prepare(`INSERT INTO submission_history (student_id, item_id, score, points_possible, answers) VALUES (?, ?, ?, ?, ?)`) + .run(studentId, req.params.id, earned, possible, JSON.stringify(answers || {})); + + markScheduleDone.run(studentId, req.params.id, isDateStr(date) ? date : today()); + res.json({ score: earned, total: possible, needsGrading: hasShort }); +}); + // ---------- grading (parent) ---------- app.get('/api/grading-queue/count', requirePin, (req, res) => { const { n } = db.prepare(` SELECT COUNT(*) AS n FROM submissions sub JOIN items i ON i.id = sub.item_id - WHERE sub.status = 'done' AND i.type IN ('assignment', 'lesson', 'spelling_practice', 'flashcards') + WHERE sub.status = 'done' AND i.type IN ('assignment', 'lesson', 'spelling_practice', 'flashcards', 'worksheet') `).get(); res.json({ count: n }); }); @@ -1422,7 +1534,7 @@ app.get('/api/grading-queue', requirePin, (req, res) => { JOIN items i ON i.id = sub.item_id JOIN units u ON u.id = i.unit_id JOIN courses c ON c.id = u.course_id - WHERE sub.status = 'done' AND i.type IN ('assignment', 'lesson', 'spelling_practice', 'flashcards') + WHERE sub.status = 'done' AND i.type IN ('assignment', 'lesson', 'spelling_practice', 'flashcards', 'worksheet') ORDER BY sub.completed_at `).all()); });