Add paper/print alternatives for off-screen learning
Build and Push Docker Image / build-and-push (push) Successful in 3m35s

- Spelling: "Blank test" print button per word list (numbered lines, no words shown) for dictated paper tests
- Spelling: "Record Paper Test" form in admin to log a paper test score to the gradebook
- Gradebook: "Record Paper Score" form to log a paper quiz/assignment score for any course item
- Flashcards: "Print" button per deck generates a cut-and-fold card sheet (fronts page + backs page)
- Crossword: print now includes a second page with a filled answer key
- Lessons + Assignments: 🖨 Print button in student view generates a clean printable page
- Word lists: new "CSV file" import tab — upload a .csv with word, sentence, definition columns

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 4.6 committed 2026-10-19 12:43:38 -04:00
1 parent 6baea15986
commit a92db291c7
5 files changed
+392 -3

No files matched your search

+239 -1
View File
@@ -2070,10 +2070,11 @@ function renderAdminMsgs(msgs) {
let gradebookCourseId = null;
async function loadGradebookPanel() {
const courses = await api('/api/admin/courses');
const [courses, students] = await Promise.all([api('/api/admin/courses'), api('/api/students')]);
$('#gradebook-course').innerHTML = courses.map((c) => `<option value="${c.id}">${esc(c.name)}</option>`).join('');
if (courses.length) renderGradebook(courses[0].id);
loadSpellingTests();
initPaperScoreForm(students, courses);
}
async function loadSpellingTests() {
@@ -2275,6 +2276,7 @@ async function loadSpelling() {
renderLists(lists);
loadPracticeSessions();
loadPracticeActivity();
initPaperTestForm();
}
async function loadPracticeSessions() {
@@ -2477,6 +2479,7 @@ function renderLists(lists, filterText = '') {
<span class="muted-label">${l.wordCount} words${l.builtin ? ' · built-in' : ''}</span>
<button class="secondary small" data-assign-list="${l.id}">Assign →</button>
${l.builtin ? '' : `<button class="secondary small" data-print-list="${l.id}">🖨 Worksheet</button>`}
${l.builtin ? '' : `<button class="secondary small" data-blank-test="${l.id}">🖨 Blank test</button>`}
<button class="small" data-edit-list="${l.id}">${l.builtin ? 'Copy & edit' : 'Edit'}</button>
${l.builtin
? `<button class="secondary small" data-hide-list="${l.id}">${isHidden ? 'Unhide' : 'Hide'}</button>`
@@ -2495,6 +2498,12 @@ function renderLists(lists, filterText = '') {
printSpellingWorksheet(list.name, list.words);
})
);
document.querySelectorAll('[data-blank-test]').forEach((btn) =>
btn.addEventListener('click', async () => {
const list = await api(`/api/lists/${btn.dataset.blankTest}`);
printBlankSpellingTest(list.name, list.words);
})
);
document.querySelectorAll('[data-edit-list]').forEach((btn) =>
btn.addEventListener('click', async () => {
const list = await api(`/api/lists/${btn.dataset.editList}`);
@@ -2595,14 +2604,22 @@ function clearListForm() {
function showSection(type, which) {
$(`#${type}-editor-section`).hidden = which !== 'editor';
$(`#${type}-bulk-section`).hidden = which !== 'bulk';
const csvSec = $(`#${type}-csv-section`);
if (csvSec) csvSec.hidden = which !== 'csv';
$(`#${type}-new-btn`).classList.toggle('active', which === 'editor');
$(`#${type}-bulk-btn`).classList.toggle('active', which === 'bulk');
const csvBtn = $(`#${type}-csv-btn`);
if (csvBtn) csvBtn.classList.toggle('active', which === 'csv');
}
function hideSections(type) {
$(`#${type}-editor-section`).hidden = true;
$(`#${type}-bulk-section`).hidden = true;
const csvSec = $(`#${type}-csv-section`);
if (csvSec) csvSec.hidden = true;
$(`#${type}-new-btn`).classList.remove('active');
$(`#${type}-bulk-btn`).classList.remove('active');
const csvBtn = $(`#${type}-csv-btn`);
if (csvBtn) csvBtn.classList.remove('active');
}
$('#list-new-btn').addEventListener('click', () => {
@@ -2613,6 +2630,55 @@ $('#list-bulk-btn').addEventListener('click', () => {
if ($('#list-bulk-section').hidden) showSection('list', 'bulk');
else hideSections('list');
});
$('#list-csv-btn').addEventListener('click', () => {
if ($('#list-csv-section').hidden) showSection('list', 'csv');
else hideSections('list');
});
$('#list-csv-browse').addEventListener('click', () => $('#list-csv-file').click());
$('#list-csv-file').addEventListener('change', () => {
const f = $('#list-csv-file').files[0];
$('#list-csv-filename').textContent = f ? f.name : '';
$('#list-csv-go').disabled = !f;
if (f && !$('#list-csv-name').value.trim()) {
$('#list-csv-name').value = f.name.replace(/\.[^.]+$/, '').replace(/[_-]/g, ' ');
}
$('#list-csv-status').textContent = '';
});
$('#list-csv-go').addEventListener('click', async () => {
const f = $('#list-csv-file').files[0];
const name = $('#list-csv-name').value.trim();
const groupName = $('#list-csv-group').value.trim();
if (!f || !name) { $('#list-csv-status').textContent = 'Choose a file and enter a list name.'; return; }
$('#list-csv-status').textContent = 'Parsing…';
const text = await f.text();
const lines = text.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
const words = [];
for (const line of lines) {
const parts = line.split(',').map((p) => p.trim().replace(/^["']|["']$/g, ''));
const word = parts[0];
if (!word || word.toLowerCase() === 'word') continue; // skip header
const entry = { word };
if (parts[1]) entry.sentence = parts[1];
if (parts[2]) entry.definition = parts[2];
words.push(entry);
}
if (words.length === 0) { $('#list-csv-status').textContent = 'No words found in file.'; return; }
try {
await api('/api/lists', { method: 'POST', body: { name, words, groupName } });
msg(`Imported "${name}" (${words.length} words).`);
$('#list-csv-file').value = '';
$('#list-csv-filename').textContent = '';
$('#list-csv-go').disabled = true;
$('#list-csv-name').value = '';
$('#list-csv-group').value = '';
$('#list-csv-status').textContent = '';
hideSections('list');
loadSpelling();
} catch (e) {
$('#list-csv-status').textContent = `Error: ${e.message}`;
}
});
function parseBulkSections(text) {
const sections = [];
@@ -2715,6 +2781,82 @@ async function printSpellingReport(testId) {
win.document.close();
}
function printBlankSpellingTest(name, words) {
const today = new Date().toLocaleDateString();
const rows = words
.map((w, i) => {
const hint = w.sentence ? `<div style="color:#888;font-size:.82em;font-style:italic;margin:.1rem 0 .3rem">${esc(w.sentence)}</div>` : '';
return `<tr>
<td style="width:2rem;color:#999;font-size:.9em;padding:.55rem .5rem .55rem 0">${i + 1}.</td>
<td>${hint}<div style="border-bottom:1.5px solid #aaa;min-width:16rem;height:1.5rem"></div></td>
</tr>`;
})
.join('');
const win = window.open('', '_blank');
win.document.write(`<!DOCTYPE html><html><head><title>Spelling Test: ${esc(name)}</title>
<style>
body { font-family: Georgia, serif; max-width: 560px; margin: 2rem auto; color: #222; }
h1 { font-size: 1.3rem; border-bottom: 2px solid #222; padding-bottom: .4rem; }
.header { display:flex; gap: 2rem; margin: .75rem 0 1.25rem; }
.header label { flex:1; border-bottom: 1.5px solid #aaa; padding-bottom:.2rem; color:#555; font-size:.9rem; }
table { width: 100%; border-collapse: collapse; }
td { padding: .3rem .25rem; vertical-align: bottom; }
</style></head><body>
<h1>⭐ Spelling Test: ${esc(name)}</h1>
<div class="header">
<label>Name: ____________________________</label>
<label>Date: ${esc(today)}</label>
</div>
<table>${rows}</table>
<script>window.print()<\/script></body></html>`);
win.document.close();
}
// Paper test recording form (populated when spelling panel loads)
function initPaperTestForm() {
const studentSel = $('#paper-test-student');
const listSel = $('#paper-test-list');
const scoreIn = $('#paper-test-score');
const totalIn = $('#paper-test-total');
const dateIn = $('#paper-test-date');
const status = $('#paper-test-status');
dateIn.value = new Date().toISOString().slice(0, 10);
studentSel.innerHTML = cachedStudents.map((s) =>
`<option value="${s.id}">${esc(s.emoji)} ${esc(s.name)}</option>`).join('');
listSel.innerHTML = cachedLists.map((l) =>
`<option value="${l.id}">${esc(l.name)}</option>`).join('');
// Auto-fill total when list changes
listSel.addEventListener('change', async () => {
const list = await api(`/api/lists/${listSel.value}`);
totalIn.value = list.words.length;
scoreIn.max = list.words.length;
});
if (listSel.value) listSel.dispatchEvent(new Event('change'));
$('#paper-test-save').onclick = async () => {
const score = Number(scoreIn.value), total = Number(totalIn.value);
if (!studentSel.value || !listSel.value || isNaN(score) || isNaN(total) || total <= 0) {
status.textContent = 'Fill in all fields.'; return;
}
status.textContent = 'Saving…';
try {
await api('/api/paper-spelling-test', { method: 'POST', body: {
studentId: Number(studentSel.value), listId: Number(listSel.value),
score, total, date: dateIn.value,
}});
status.textContent = `Saved: ${score}/${total}.`;
scoreIn.value = '';
$('#paper-test-details').removeAttribute('open');
loadSpellingTests();
} catch (e) {
status.textContent = `Error: ${e.message}`;
}
};
}
// ============================================================
// Flashcard decks
// ============================================================
@@ -2735,6 +2877,7 @@ function renderDecks(decks, filterText = '') {
const renderItem = (d) => `<div class="item-row">
<strong class="grow">${esc(d.name)}</strong>
<span>${d.cardCount} cards${d.builtin ? ' · built-in' : ''}</span>
<button class="secondary small" data-print-deck="${d.id}">🖨 Print</button>
<button data-edit-deck="${d.id}">${d.builtin ? 'Copy & edit' : 'Edit'}</button>
<button class="danger" data-del-deck="${d.id}">Delete</button>
</div>`;
@@ -2753,6 +2896,12 @@ function renderDecks(decks, filterText = '') {
$('#deck-name').scrollIntoView({ behavior: 'smooth' });
})
);
document.querySelectorAll('[data-print-deck]').forEach((btn) =>
btn.addEventListener('click', async () => {
const deck = await api(`/api/decks/${btn.dataset.printDeck}`);
printFlashcardDeck(deck);
})
);
document.querySelectorAll('[data-del-deck]').forEach((btn) =>
btn.addEventListener('click', async () => {
if (!confirm('Delete this deck?')) return;
@@ -2833,6 +2982,95 @@ $('#deck-bulk-clear').addEventListener('click', () => {
$('#deck-bulk-status').textContent = '';
});
function printFlashcardDeck(deck) {
const cardStyle = `
display:inline-block; width:3.8in; min-height:1.5in; border:1.5px solid #aaa; border-radius:6px;
padding:.5rem .75rem; margin:.25rem; vertical-align:top; page-break-inside:avoid;
font-family:Georgia,serif; font-size:.95rem; box-sizing:border-box;`;
const cutLine = `border-top:1px dashed #bbb; margin:1rem 0; page-break-after:always;`;
const frontCards = deck.cards.map((c) =>
`<div style="${cardStyle}"><div style="font-size:.65rem;color:#aaa;text-transform:uppercase;letter-spacing:.05em;margin-bottom:.35rem">Front</div>${esc(c.front)}</div>`
).join('');
const backCards = deck.cards.map((c) =>
`<div style="${cardStyle}"><div style="font-size:.65rem;color:#aaa;text-transform:uppercase;letter-spacing:.05em;margin-bottom:.35rem">Back</div>${esc(c.back)}</div>`
).join('');
const win = window.open('', '_blank');
win.document.write(`<!DOCTYPE html><html><head><title>Flashcards: ${esc(deck.name)}</title>
<style>
body { font-family:Georgia,serif; padding:1rem; color:#222; }
h1 { font-size:1.2rem; margin-bottom:.5rem; }
p.hint { color:#888; font-size:.85rem; margin:.25rem 0 1rem; }
@media print { @page { size: letter; margin: .5in; } }
</style></head><body>
<h1>🗂️ ${esc(deck.name)}</h1>
<p class="hint">Print and cut — fold each card so the front faces out, back faces in.</p>
<h3 style="font-size:.9rem;color:#555;margin:.25rem 0">Fronts</h3>
${frontCards}
<div style="${cutLine}"></div>
<h3 style="font-size:.9rem;color:#555;margin:.25rem 0">Backs (same order)</h3>
${backCards}
<script>window.print()<\/script>
</body></html>`);
win.document.close();
}
// Paper score form (populated when gradebook panel loads)
function initPaperScoreForm(students, courses) {
const studentSel = $('#paper-score-student');
const courseSel = $('#paper-score-course');
const itemSel = $('#paper-score-item');
const scoreIn = $('#paper-score-score');
const totalIn = $('#paper-score-total');
const dateIn = $('#paper-score-date');
const status = $('#paper-score-status');
const GRADABLE = ['assignment', 'quiz', 'matching', 'crossword', 'spelling_test', 'worksheet'];
dateIn.value = new Date().toISOString().slice(0, 10);
studentSel.innerHTML = students.map((s) =>
`<option value="${s.id}">${esc(s.emoji || '')} ${esc(s.name)}</option>`).join('');
courseSel.innerHTML = `<option value="">— select —</option>` +
courses.map((c) => `<option value="${c.id}">${esc(c.name)}</option>`).join('');
courseSel.addEventListener('change', async () => {
if (!courseSel.value) { itemSel.innerHTML = '<option value="">— select a course first —</option>'; return; }
const course = await api(`/api/admin/courses/${courseSel.value}`);
const items = course.units.flatMap((u) => u.items.filter((it) => GRADABLE.includes(it.type)));
if (items.length === 0) {
itemSel.innerHTML = '<option value="">No gradable items in this course</option>';
return;
}
itemSel.innerHTML = items.map((it) => `<option value="${it.id}" data-pts="${it.points || ''}">${esc(it.title)} (${it.type})</option>`).join('');
itemSel.dispatchEvent(new Event('change'));
});
itemSel.addEventListener('change', () => {
const opt = itemSel.selectedOptions[0];
const pts = opt ? Number(opt.dataset.pts) : NaN;
if (!isNaN(pts) && pts > 0) { totalIn.value = pts; scoreIn.max = pts; }
});
$('#paper-score-save').onclick = async () => {
const score = Number(scoreIn.value), total = Number(totalIn.value);
if (!studentSel.value || !itemSel.value || isNaN(score) || isNaN(total) || total <= 0) {
status.textContent = 'Fill in all fields.'; return;
}
status.textContent = 'Saving…';
try {
await api(`/api/paper-score/${itemSel.value}`, { method: 'POST', body: {
studentId: Number(studentSel.value), score, total, date: dateIn.value,
}});
status.textContent = `Saved: ${score}/${total}.`;
scoreIn.value = '';
$('#paper-score-details').removeAttribute('open');
loadGradebookPanel();
} catch (e) {
status.textContent = `Error: ${e.message}`;
}
};
}
// ============================================================
// Settings
// ============================================================