Add paper/print alternatives for off-screen learning
Build and Push Docker Image / build-and-push (push) Successful in 3m35s
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:
1 parent
6baea15986
commit
a92db291c7
5 files changed
+392
-3
No files matched your search
@@ -396,6 +396,28 @@
|
||||
<button id="gradebook-csv-btn" class="secondary">⬇ Export CSV</button>
|
||||
</div>
|
||||
<div id="gradebook-table"></div>
|
||||
|
||||
<details class="admin-card" id="paper-score-details" style="margin:1.5rem 0">
|
||||
<summary style="cursor:pointer;font-weight:600">📝 Record Paper Score</summary>
|
||||
<p class="hint" style="margin:.5rem 0 .75rem">Use this to log a score from a paper quiz, assignment, or other item done off-screen.</p>
|
||||
<div class="inline-form" style="flex-direction:column;gap:.6rem">
|
||||
<div class="inline-form" style="gap:.5rem;flex-wrap:wrap">
|
||||
<label>Student <select id="paper-score-student"></select></label>
|
||||
<label>Course <select id="paper-score-course"></select></label>
|
||||
</div>
|
||||
<label>Item <select id="paper-score-item"><option value="">— select a course first —</option></select></label>
|
||||
<div class="inline-form" style="gap:.5rem;align-items:center">
|
||||
<label>Score <input type="number" id="paper-score-score" min="0" style="width:4.5rem" placeholder="0"></label>
|
||||
<span style="padding-top:1.4rem">out of</span>
|
||||
<label>Total <input type="number" id="paper-score-total" min="1" style="width:4.5rem"></label>
|
||||
<label>Date <input type="date" id="paper-score-date"></label>
|
||||
</div>
|
||||
<div>
|
||||
<button type="button" id="paper-score-save">Record score</button>
|
||||
<span id="paper-score-status" class="hint" style="margin-left:.5rem"></span>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<h2>Spelling Test History</h2>
|
||||
<div class="inline-form">
|
||||
<label>Period <select id="spelling-tests-since">
|
||||
@@ -432,6 +454,26 @@
|
||||
<section id="panel-spelling" class="panel">
|
||||
<h1>Spelling</h1>
|
||||
|
||||
<details class="admin-card" id="paper-test-details" style="margin-bottom:1.5rem">
|
||||
<summary style="cursor:pointer;font-weight:600">📝 Record Paper Test</summary>
|
||||
<div class="inline-form" style="flex-direction:column;gap:.6rem;margin-top:.75rem">
|
||||
<div class="inline-form" style="gap:.5rem;flex-wrap:wrap">
|
||||
<label>Student <select id="paper-test-student"></select></label>
|
||||
<label>Word list <select id="paper-test-list"></select></label>
|
||||
</div>
|
||||
<div class="inline-form" style="gap:.5rem;align-items:center">
|
||||
<label>Score <input type="number" id="paper-test-score" min="0" style="width:4.5rem" placeholder="0"></label>
|
||||
<span style="padding-top:1.4rem">out of</span>
|
||||
<label>Total <input type="number" id="paper-test-total" min="1" style="width:4.5rem"></label>
|
||||
<label>Date <input type="date" id="paper-test-date"></label>
|
||||
</div>
|
||||
<div>
|
||||
<button type="button" id="paper-test-save">Record test</button>
|
||||
<span id="paper-test-status" class="hint" style="margin-left:.5rem"></span>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<h2>Practice Sessions</h2>
|
||||
<div class="inline-form">
|
||||
<label>Period <select id="sessions-since">
|
||||
@@ -475,6 +517,7 @@
|
||||
<div class="toggle-btns">
|
||||
<button type="button" id="list-new-btn" class="secondary">+ New list</button>
|
||||
<button type="button" id="list-bulk-btn" class="secondary">⬆ Bulk import</button>
|
||||
<button type="button" id="list-csv-btn" class="secondary">📄 CSV file</button>
|
||||
</div>
|
||||
<div id="list-editor-section" hidden>
|
||||
<h3 id="list-editor-title" class="section-form-title">New list</h3>
|
||||
@@ -505,6 +548,23 @@
|
||||
<p id="list-bulk-status" class="hint" style="margin:0.4rem 0 0"></p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="list-csv-section" hidden>
|
||||
<h3 class="section-form-title">Import from CSV file</h3>
|
||||
<div class="admin-card">
|
||||
<p class="hint" style="margin:0 0 0.5rem">Upload a <code>.csv</code> or <code>.txt</code> file. Each row is one word. Columns: <code>word</code> (required), <code>sentence</code> (optional), <code>definition</code> (optional). A header row is ignored automatically.</p>
|
||||
<div class="inline-form" style="flex-direction:column;gap:.5rem">
|
||||
<input id="list-csv-name" placeholder="List name (required)" aria-label="List name for CSV import">
|
||||
<input id="list-csv-group" placeholder="Group (optional)" aria-label="Group for CSV import" list="list-groups-datalist">
|
||||
</div>
|
||||
<input type="file" id="list-csv-file" accept=".csv,.txt" hidden>
|
||||
<div class="inline-form" style="align-items:center;gap:.5rem;margin-top:.5rem">
|
||||
<button type="button" id="list-csv-browse" class="secondary">📂 Choose file…</button>
|
||||
<span id="list-csv-filename" class="hint" style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"></span>
|
||||
<button type="button" id="list-csv-go" class="secondary" disabled>📥 Import</button>
|
||||
</div>
|
||||
<p id="list-csv-status" class="hint" style="margin:0.4rem 0 0"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
+239
-1
@@ -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
|
||||
// ============================================================
|
||||
|
||||
+43
-2
@@ -1222,6 +1222,7 @@ async function openLesson(itemId) {
|
||||
$('#lesson-kicker').textContent = `${item.course_name} · ${item.unit_name}`;
|
||||
$('#lesson-title').textContent = item.title;
|
||||
$('#lesson-body').innerHTML = item.body;
|
||||
$('#lesson-print-btn').onclick = () => printItemPage('📖 ' + item.title, item.course_name + ' · ' + item.unit_name, item.body);
|
||||
show('lesson');
|
||||
|
||||
const btn = $('#lesson-done-btn');
|
||||
@@ -1252,6 +1253,7 @@ async function openAssignment(itemId) {
|
||||
$('#assignment-title').textContent = item.title;
|
||||
$('#assignment-points').textContent = item.points ? `Worth ${item.points} points` : '';
|
||||
$('#assignment-body').innerHTML = item.body || '';
|
||||
$('#assignment-print-btn').onclick = () => printItemPage('📝 ' + item.title, item.course_name + ' · ' + item.unit_name, item.body || '');
|
||||
|
||||
const btn = $('#assignment-done-btn');
|
||||
const status = $('#assignment-status');
|
||||
@@ -2053,7 +2055,27 @@ async function openCrossword(itemId) {
|
||||
show('crossword');
|
||||
}
|
||||
|
||||
function printCrosswordWorksheet(title, cw) {
|
||||
function printItemPage(title, kicker, bodyHtml) {
|
||||
const win = window.open('', '_blank');
|
||||
win.document.write(`<!DOCTYPE html><html><head><title>${esc(title)}</title>
|
||||
<style>
|
||||
body{font-family:Georgia,serif;max-width:680px;margin:2rem auto;color:#222}
|
||||
.kicker{color:#888;font-size:.85rem;margin-bottom:.25rem}
|
||||
h1{font-size:1.4rem;margin:0 0 1rem;border-bottom:2px solid #222;padding-bottom:.4rem}
|
||||
.body{line-height:1.65}
|
||||
.body h2,.body h3{margin:1rem 0 .4rem}
|
||||
.body ul,.body ol{padding-left:1.5rem}
|
||||
@media print{body{margin:.75in;max-width:none}}
|
||||
</style></head><body>
|
||||
<p class="kicker">${esc(kicker)}</p>
|
||||
<h1>${esc(title)}</h1>
|
||||
<div class="body">${bodyHtml}</div>
|
||||
<script>window.print()<\/script>
|
||||
</body></html>`);
|
||||
win.document.close();
|
||||
}
|
||||
|
||||
function buildCrosswordSvg(cw, filled) {
|
||||
const CELL = 28;
|
||||
const numMap = {};
|
||||
for (const w of [...(cw.across||[]), ...(cw.down||[])]) if (w.num) numMap[`${w.row},${w.col}`] = w.num;
|
||||
@@ -2067,21 +2089,40 @@ function printCrosswordWorksheet(title, cw) {
|
||||
svg += `<rect x="${x}" y="${y}" width="${CELL}" height="${CELL}" fill="#fff" stroke="#999" stroke-width="0.5"/>`;
|
||||
const key = `${r},${c}`;
|
||||
if (numMap[key]) svg += `<text x="${x+2}" y="${y+9}" font-size="6" font-family="sans-serif">${numMap[key]}</text>`;
|
||||
if (filled && cw.grid[r][c]) {
|
||||
const cx = x + CELL / 2, cy = y + CELL / 2 + 5;
|
||||
svg += `<text x="${cx}" y="${cy}" font-size="13" font-family="sans-serif" text-anchor="middle" fill="#1a1a1a">${esc(cw.grid[r][c])}</text>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
svg += '</svg>';
|
||||
return svg;
|
||||
}
|
||||
|
||||
function printCrosswordWorksheet(title, cw) {
|
||||
const svg = buildCrosswordSvg(cw, false);
|
||||
const svgKey = buildCrosswordSvg(cw, true);
|
||||
const acrossHtml = (cw.across||[]).map((w) => `<li><strong>${w.num}.</strong> ${esc(w.clue)}</li>`).join('');
|
||||
const downHtml = (cw.down||[]).map((w) => `<li><strong>${w.num}.</strong> ${esc(w.clue)}</li>`).join('');
|
||||
const win = window.open('', '_blank');
|
||||
win.document.write(`<!DOCTYPE html><html><head><title>${esc(title)}</title>
|
||||
<style>body{font-family:sans-serif;padding:1.5rem}h1{margin-bottom:1rem}.cols{display:flex;gap:2rem}ol{list-style:none;padding:0}li{margin:.2rem 0;font-size:.9rem}@media print{body{padding:.5rem}}</style>
|
||||
<style>
|
||||
body{font-family:sans-serif;padding:1.5rem}h1{margin-bottom:1rem}
|
||||
.cols{display:flex;gap:2rem}ol{list-style:none;padding:0}li{margin:.2rem 0;font-size:.9rem}
|
||||
.page-break{page-break-before:always;margin-top:2rem}
|
||||
@media print{body{padding:.5rem}}
|
||||
</style>
|
||||
</head><body>
|
||||
<h1>${esc(title)}</h1>${svg}
|
||||
<div class="cols">
|
||||
<div><h3>Across</h3><ol>${acrossHtml}</ol></div>
|
||||
<div><h3>Down</h3><ol>${downHtml}</ol></div>
|
||||
</div>
|
||||
<div class="page-break">
|
||||
<h2 style="margin-bottom:.75rem">Answer Key — ${esc(title)}</h2>
|
||||
${svgKey}
|
||||
</div>
|
||||
<script>window.print();<\/script>
|
||||
</body></html>`);
|
||||
win.document.close();
|
||||
|
||||
@@ -160,6 +160,7 @@
|
||||
<div class="item-card">
|
||||
<p class="item-kicker" id="lesson-kicker"></p>
|
||||
<h1 id="lesson-title"></h1>
|
||||
<button id="lesson-print-btn" class="secondary small" style="float:right;margin-bottom:.5rem">🖨 Print</button>
|
||||
<div id="lesson-body" class="lesson-body"></div>
|
||||
<button id="lesson-done-btn" class="check-btn">Done reading ✓</button>
|
||||
<div id="lesson-next-actions" class="next-actions" hidden></div>
|
||||
@@ -172,6 +173,7 @@
|
||||
<div class="item-card">
|
||||
<p class="item-kicker" id="assignment-kicker"></p>
|
||||
<h1 id="assignment-title"></h1>
|
||||
<button id="assignment-print-btn" class="secondary small" style="float:right;margin-bottom:.5rem">🖨 Print</button>
|
||||
<p id="assignment-points" class="points-label"></p>
|
||||
<div id="assignment-body" class="lesson-body"></div>
|
||||
<div id="assignment-evidence-section" hidden>
|
||||
|
||||
Reference in new issue
Block a user