Add crossword puzzle item type

Admin enters Across/Down words and clues; server auto-generates the grid
using a backtracking placement algorithm (longest-first, intersection-based).
Students fill in the interactive grid with click-to-select, direction toggle,
arrow-key navigation, and Tab-to-next-word. Auto-graded per word on submit,
with correct/wrong color highlighting on the results view. Includes a print
worksheet option for blank paper practice.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Nate EmmertandClaude Sonnet 4.6 committed 2026-08-01 17:56:46 -04:00
1 parent 60bfe3ca4a
commit 682534a061
6 files changed
+680 -6

No files matched your search

+21
View File
@@ -130,6 +130,7 @@
<option value="spelling_practice">✏️ Spelling Practice</option>
<option value="spelling_test">⭐ Spelling Test</option>
<option value="matching">🔤 Matching</option>
<option value="crossword">⬛ Crossword</option>
<option value="flashcards">🗂️ Flashcards</option>
</select>
</label>
@@ -247,6 +248,26 @@
<button type="button" id="ie-add-pair" class="secondary">+ Add pair</button>
</div>
<div id="ie-field-crossword" class="ie-field" hidden>
<div class="cw-entry-cols">
<div>
<h3 style="margin-bottom:.5rem">Across clues</h3>
<div id="ie-across-clues"></div>
<button type="button" id="ie-add-across" class="secondary small">+ Add across clue</button>
</div>
<div>
<h3 style="margin-bottom:.5rem">Down clues</h3>
<div id="ie-down-clues"></div>
<button type="button" id="ie-add-down" class="secondary small">+ Add down clue</button>
</div>
</div>
<div style="margin-top:1rem;display:flex;gap:.75rem;align-items:center;flex-wrap:wrap">
<button type="button" id="ie-crossword-generate" class="secondary">⚡ Generate crossword</button>
<span id="ie-crossword-status" class="hint"></span>
</div>
<div id="ie-crossword-preview" style="margin-top:.75rem;overflow-x:auto"></div>
</div>
<div id="ie-field-spelling" class="ie-field">
<label>Word list <select id="ie-list"></select></label>
</div>
+95 -2
View File
@@ -36,11 +36,11 @@ function msg(text, type = 'success') {
}
const TYPE_ICON = {
lesson: '📖', assignment: '📝', quiz: '❓', matching: '🔤',
lesson: '📖', assignment: '📝', quiz: '❓', matching: '🔤', crossword: '⬛',
spelling_practice: '✏️', spelling_test: '⭐', flashcards: '🗂️',
};
const TYPE_LABEL = {
lesson: 'Lesson', assignment: 'Assignment', quiz: 'Quiz', matching: 'Matching',
lesson: 'Lesson', assignment: 'Assignment', quiz: 'Quiz', matching: 'Matching', crossword: 'Crossword',
spelling_practice: 'Spelling Practice', spelling_test: 'Spelling Test', flashcards: 'Flashcards',
};
@@ -390,6 +390,11 @@ async function openItemEditor(unitId, itemId) {
questionCount = 0;
$('#ie-pairs').innerHTML = '';
pairCount = 0;
$('#ie-across-clues').innerHTML = '';
$('#ie-down-clues').innerHTML = '';
$('#ie-crossword-preview').innerHTML = '';
$('#ie-crossword-status').textContent = '';
currentCrosswordData = null;
if (itemId) {
const item = await api(`/api/admin/items/${itemId}`);
@@ -416,6 +421,13 @@ async function openItemEditor(unitId, itemId) {
pairCount = 0;
(item.questions || []).forEach(addMatchingRow);
}
if (item.type === 'crossword' && item.crosswordData) {
currentCrosswordData = item.crosswordData;
(item.crosswordData.across || []).forEach(w => addCrosswordClueRow('across', { word: w.answer, clue: w.clue }));
(item.crosswordData.down || []).forEach(w => addCrosswordClueRow('down', { word: w.answer, clue: w.clue }));
renderCrosswordPreview(item.crosswordData);
$('#ie-crossword-status').textContent = `${item.crosswordData.placed || ''} words placed`;
}
$('#ie-delete').hidden = false;
$('#ie-delete').onclick = async () => {
if (!confirm('Delete this item and any grades for it?')) return;
@@ -431,6 +443,7 @@ async function openItemEditor(unitId, itemId) {
$('#ie-body-assignment').innerHTML = '';
$('#ie-quiz-instructions').value = '';
$('#ie-matching-instructions').value = '';
currentCrosswordData = null;
$('#ie-points').value = 10;
$('#ie-due-date').value = '';
$('#ie-allow-retakes').checked = false;
@@ -451,6 +464,7 @@ function updateItemFieldVisibility() {
$('#ie-field-assignment').hidden = type !== 'assignment';
$('#ie-field-quiz').hidden = type !== 'quiz';
$('#ie-field-matching').hidden = type !== 'matching';
$('#ie-field-crossword').hidden = type !== 'crossword';
$('#ie-field-spelling').hidden = type !== 'spelling_practice' && type !== 'spelling_test';
$('#ie-field-flashcards').hidden = type !== 'flashcards';
$('#ie-scan-section').hidden = type !== 'lesson' && type !== 'quiz';
@@ -686,6 +700,77 @@ function addMatchingRow(pair = {}) {
}
$('#ie-add-pair').addEventListener('click', () => addMatchingRow());
// ---------- crossword editor ----------
let currentCrosswordData = null;
function addCrosswordClueRow(dir, entry = {}) {
const container = dir === 'across' ? $('#ie-across-clues') : $('#ie-down-clues');
const div = document.createElement('div');
div.className = 'cw-clue-row';
div.innerHTML = `
<input class="cw-word" placeholder="WORD" value="${esc(entry.word || '')}" style="text-transform:uppercase;width:7rem">
<input class="cw-clue" placeholder="Clue" value="${esc(entry.clue || '')}" style="flex:1">
<button type="button" class="danger small cw-remove">✕</button>`;
div.querySelector('.cw-word').addEventListener('input', (e) => { e.target.value = e.target.value.toUpperCase(); });
div.querySelector('.cw-remove').addEventListener('click', () => div.remove());
container.appendChild(div);
}
$('#ie-add-across').addEventListener('click', () => addCrosswordClueRow('across'));
$('#ie-add-down').addEventListener('click', () => addCrosswordClueRow('down'));
function renderCrosswordPreview(cw) {
const cell = 22;
const w = cw.cols * cell, h = cw.rows * cell;
let svg = `<svg width="${w}" height="${h}" style="border:1px solid #ccc;display:block">`;
for (let r = 0; r < cw.rows; r++) {
for (let c = 0; c < cw.cols; c++) {
const x = c * cell, y = r * cell;
if (cw.grid[r][c] === null) {
svg += `<rect x="${x}" y="${y}" width="${cell}" height="${cell}" fill="#222"/>`;
} else {
svg += `<rect x="${x}" y="${y}" width="${cell}" height="${cell}" fill="#fff" stroke="#999" stroke-width="0.5"/>`;
if (cw.grid[r][c]) {
svg += `<text x="${x + cell/2}" y="${y + cell/2 + 5}" text-anchor="middle" font-size="11" font-family="monospace">${esc(cw.grid[r][c])}</text>`;
}
}
}
}
// Draw numbers
const allWords = [...(cw.across||[]), ...(cw.down||[])];
const nums = {};
for (const w2 of allWords) if (w2.num) nums[`${w2.row},${w2.col}`] = w2.num;
for (const [key, n] of Object.entries(nums)) {
const [r, c] = key.split(',').map(Number);
svg += `<text x="${c * cell + 1}" y="${r * cell + 8}" font-size="6" font-family="sans-serif" fill="#333">${n}</text>`;
}
svg += '</svg>';
$('#ie-crossword-preview').innerHTML = svg;
}
$('#ie-crossword-generate').addEventListener('click', async () => {
const across = Array.from($('#ie-across-clues').querySelectorAll('.cw-clue-row')).map(row => ({
word: row.querySelector('.cw-word').value.trim(),
clue: row.querySelector('.cw-clue').value.trim(),
})).filter(e => e.word && e.clue);
const down = Array.from($('#ie-down-clues').querySelectorAll('.cw-clue-row')).map(row => ({
word: row.querySelector('.cw-word').value.trim(),
clue: row.querySelector('.cw-clue').value.trim(),
})).filter(e => e.word && e.clue);
if (across.length + down.length < 2) return msg('Add at least 2 words with clues before generating.');
$('#ie-crossword-status').textContent = '⚡ Generating…';
try {
const result = await api('/api/crossword/generate', { method: 'POST', body: { across, down } });
currentCrosswordData = result;
renderCrosswordPreview(result);
const skipped = result.total - result.placed;
$('#ie-crossword-status').textContent = `✅ ${result.placed} words placed${skipped ? ` (${skipped} couldn't fit — try sharing more letters)` : ''}`;
} catch (err) {
$('#ie-crossword-status').textContent = `❌ ${err.message}`;
}
});
$('#ie-load-template').addEventListener('click', async () => {
const tid = $('#ie-template-select').value;
if (!tid) return msg('Pick a template first.');
@@ -751,6 +836,14 @@ $('#item-form').addEventListener('submit', async (e) => {
points: 1,
})).filter((q) => q.prompt && q.correctAnswer);
}
if (type === 'crossword') {
if (!currentCrosswordData) {
$('#ie-error').textContent = 'Generate the crossword first.';
$('#ie-error').hidden = false;
return;
}
body.crosswordData = currentCrosswordData;
}
const id = $('#ie-id').value;
try {
+291 -2
View File
@@ -27,11 +27,11 @@ const esc = (s) =>
const todayStr = () => new Date().toISOString().slice(0, 10);
const TYPE_ICON = {
lesson: '📖', assignment: '📝', quiz: '❓', matching: '🔤',
lesson: '📖', assignment: '📝', quiz: '❓', matching: '🔤', crossword: '⬛',
spelling_practice: '✏️', spelling_test: '⭐', flashcards: '🗂️',
};
const TYPE_LABEL = {
lesson: 'Lesson', assignment: 'Assignment', quiz: 'Quiz', matching: 'Matching',
lesson: 'Lesson', assignment: 'Assignment', quiz: 'Quiz', matching: 'Matching', crossword: 'Crossword',
spelling_practice: 'Spelling Practice', spelling_test: 'Spelling Test', flashcards: 'Flashcards',
};
@@ -671,6 +671,7 @@ async function openItem(itemId, type) {
if (type === 'assignment') return openAssignment(itemId);
if (type === 'quiz') return openQuiz(itemId, false);
if (type === 'matching') return openMatching(itemId, false);
if (type === 'crossword') return openCrossword(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 });
@@ -950,6 +951,294 @@ async function openMatching(itemId, forceRetake = false) {
show('quiz');
}
// ---------- crossword ----------
async function openCrossword(itemId) {
const item = await api(`/api/items/${itemId}?studentId=${currentStudent.id}`);
const cw = item.crosswordData;
if (!cw) return;
const view = $('#view-crossword');
view.querySelector('.cw-kicker').textContent = `${item.course_name} · ${item.unit_name}`;
view.querySelector('.cw-title').textContent = item.title;
const graded = item.submission && item.submission.status === 'graded';
const CELL = 34;
// Clue number map: 'r,c' -> num
const numMap = {};
for (const w of [...(cw.across||[]), ...(cw.down||[])]) if (w.num) numMap[`${w.row},${w.col}`] = w.num;
// Word membership map: 'r,c' -> {across: wordObj|null, down: wordObj|null}
const wordMap = {};
for (let r = 0; r < cw.rows; r++)
for (let c = 0; c < cw.cols; c++)
if (cw.grid[r][c] !== null) wordMap[`${r},${c}`] = { across: null, down: null };
for (const w of (cw.across||[])) for (let i = 0; i < w.len; i++) wordMap[`${w.row},${w.col+i}`].across = w;
for (const w of (cw.down||[])) for (let i = 0; i < w.len; i++) wordMap[`${w.row+i},${w.col}`].down = w;
// Build grid DOM
const gridEl = view.querySelector('.cw-grid');
gridEl.style.gridTemplateColumns = `repeat(${cw.cols}, ${CELL}px)`;
gridEl.style.gridTemplateRows = `repeat(${cw.rows}, ${CELL}px)`;
gridEl.innerHTML = '';
const cells = {};
for (let r = 0; r < cw.rows; r++) {
for (let c = 0; c < cw.cols; c++) {
const key = `${r},${c}`;
const div = document.createElement('div');
div.className = cw.grid[r][c] === null ? 'cw-cell black' : 'cw-cell';
div.dataset.r = r; div.dataset.c = c;
if (cw.grid[r][c] !== null) {
if (numMap[key]) {
const span = document.createElement('span');
span.className = 'cw-num';
span.textContent = numMap[key];
div.appendChild(span);
}
const inp = document.createElement('input');
inp.type = 'text'; inp.className = 'cw-input';
inp.maxLength = 1; inp.readOnly = graded;
inp.setAttribute('autocomplete', 'off');
inp.setAttribute('autocapitalize', 'characters');
if (graded && cw.savedAnswers && cw.savedAnswers[key]) inp.value = cw.savedAnswers[key];
div.appendChild(inp);
cells[key] = { el: div, input: inp };
}
gridEl.appendChild(div);
}
}
// Build clue lists
const acrossListEl = $('#cw-across-list');
const downListEl = $('#cw-down-list');
acrossListEl.innerHTML = '';
downListEl.innerHTML = '';
for (const w of (cw.across||[])) {
const li = document.createElement('li');
li.dataset.num = w.num; li.dataset.dir = 'across';
li.textContent = `${w.num}. ${w.clue}`;
acrossListEl.appendChild(li);
}
for (const w of (cw.down||[])) {
const li = document.createElement('li');
li.dataset.num = w.num; li.dataset.dir = 'down';
li.textContent = `${w.num}. ${w.clue}`;
downListEl.appendChild(li);
}
const resultEl = $('#cw-result');
if (graded) {
// Color cells by word correctness
const cellStatus = {};
for (const key of Object.keys(cells)) cellStatus[key] = 'correct';
for (const [words, dir] of [[(cw.across||[]), 'across'], [(cw.down||[]), 'down']]) {
for (const w of words) {
let ok = !!(w.answer);
for (let i = 0; i < w.len; i++) {
const r = w.row + (dir === 'down' ? i : 0);
const c = w.col + (dir === 'across' ? i : 0);
const given = (cw.savedAnswers || {})[`${r},${c}`] || '';
if (!given || given !== (w.answer||'')[i]) ok = false;
}
if (!ok) for (let i = 0; i < w.len; i++) {
const r = w.row + (dir === 'down' ? i : 0);
const c = w.col + (dir === 'across' ? i : 0);
cellStatus[`${r},${c}`] = 'wrong';
}
}
}
for (const [key, status] of Object.entries(cellStatus)) {
if (cells[key]) cells[key].el.classList.add(status === 'correct' ? 'correct-word' : 'wrong-word');
}
const score = item.submission.score, total = item.submission.points_possible;
const pct = total ? Math.round((score / total) * 100) : 0;
resultEl.className = 'status-banner good';
resultEl.textContent = `🌟 Score: ${score} / ${total} (${pct}%)`;
resultEl.hidden = false;
$('#cw-actions').hidden = true;
$('#cw-hint').hidden = true;
backTarget = () => openKid(currentStudent.id);
show('crossword');
return;
}
// Interactive (unanswered) mode
resultEl.hidden = true;
$('#cw-actions').hidden = false;
$('#cw-hint').hidden = false;
let selectedDir = 'across';
function clearHighlights() {
for (const { el } of Object.values(cells)) el.classList.remove('selected', 'word-hi');
acrossListEl.querySelectorAll('li').forEach((li) => li.classList.remove('selected-clue', 'word-hi'));
downListEl.querySelectorAll('li').forEach((li) => li.classList.remove('selected-clue', 'word-hi'));
}
let selectedWord = null;
function selectWord(word, dir) {
clearHighlights();
selectedWord = word;
if (!word) return;
for (let i = 0; i < word.len; i++) {
const r = word.row + (dir === 'down' ? i : 0);
const c = word.col + (dir === 'across' ? i : 0);
if (cells[`${r},${c}`]) cells[`${r},${c}`].el.classList.add('word-hi');
}
const listEl = dir === 'across' ? acrossListEl : downListEl;
const li = listEl.querySelector(`[data-num="${word.num}"]`);
if (li) { li.classList.add('selected-clue'); li.scrollIntoView({ block: 'nearest' }); }
}
function pickWord(key) {
const wm = wordMap[key];
if (!wm) return null;
return wm[selectedDir] || wm.across || wm.down;
}
function selectCell(key) {
const { el } = cells[key] || {};
if (!el) return;
clearHighlights();
const word = pickWord(key);
if (word) {
const dir = wordMap[key][selectedDir] === word ? selectedDir : (wordMap[key].across === word ? 'across' : 'down');
selectedDir = dir;
selectWord(word, dir);
el.classList.remove('word-hi');
}
el.classList.add('selected');
}
function selectAndFocus(r, c) {
const key = `${r},${c}`;
if (!cells[key]) return;
selectCell(key);
cells[key].input.focus();
}
// Wire up cells
for (const [key, { el, input }] of Object.entries(cells)) {
const [r, c] = key.split(',').map(Number);
input.addEventListener('focus', () => selectCell(key));
input.addEventListener('click', () => {
if (el.classList.contains('selected')) {
const wm = wordMap[key];
if (wm && wm.across && wm.down) {
selectedDir = selectedDir === 'across' ? 'down' : 'across';
selectCell(key);
}
}
});
input.addEventListener('keydown', (e) => {
if (e.key === 'Backspace' || e.key === 'Delete') {
e.preventDefault();
if (input.value) { input.value = ''; return; }
if (selectedDir === 'across' && c > 0) selectAndFocus(r, c - 1);
else if (selectedDir === 'down' && r > 0) selectAndFocus(r - 1, c);
return;
}
if (e.key === 'ArrowRight') { e.preventDefault(); selectAndFocus(r, c + 1); return; }
if (e.key === 'ArrowLeft') { e.preventDefault(); selectAndFocus(r, c - 1); return; }
if (e.key === 'ArrowDown') { e.preventDefault(); selectAndFocus(r + 1, c); return; }
if (e.key === 'ArrowUp') { e.preventDefault(); selectAndFocus(r - 1, c); return; }
if (e.key === 'Tab') {
e.preventDefault();
const words = [...(cw.across||[]), ...(cw.down||[])];
const cur = words.indexOf(selectedWord);
const next = words[(cur + 1) % words.length];
if (next) {
selectedDir = (cw.across||[]).includes(next) ? 'across' : 'down';
selectAndFocus(next.row, next.col);
}
}
});
input.addEventListener('input', () => {
const v = input.value.replace(/[^a-zA-Z]/g, '').toUpperCase();
input.value = v.slice(-1);
if (!input.value) return;
if (selectedDir === 'across') {
const nk = `${r},${c + 1}`;
if (cells[nk] && wordMap[key]?.across && wordMap[nk]?.across === wordMap[key]?.across)
selectAndFocus(r, c + 1);
} else {
const nk = `${r + 1},${c}`;
if (cells[nk] && wordMap[key]?.down && wordMap[nk]?.down === wordMap[key]?.down)
selectAndFocus(r + 1, c);
}
});
}
// Clue list clicks
for (const w of (cw.across||[])) {
const li = acrossListEl.querySelector(`[data-num="${w.num}"]`);
if (li) li.addEventListener('click', () => { selectedDir = 'across'; selectAndFocus(w.row, w.col); });
}
for (const w of (cw.down||[])) {
const li = downListEl.querySelector(`[data-num="${w.num}"]`);
if (li) li.addEventListener('click', () => { selectedDir = 'down'; selectAndFocus(w.row, w.col); });
}
// Submit
$('#cw-check-btn').onclick = async () => {
const answers = {};
for (const [key, { input }] of Object.entries(cells)) {
if (input.value) answers[key] = input.value.toUpperCase();
}
await api(`/api/items/${itemId}/crossword-submit`, {
method: 'POST',
body: { studentId: currentStudent.id, answers, date: todayStr() },
});
openCrossword(itemId);
};
$('#cw-print-btn').onclick = () => printCrosswordWorksheet(item.title, cw);
backTarget = () => openKid(currentStudent.id);
show('crossword');
}
function printCrosswordWorksheet(title, cw) {
const CELL = 28;
const numMap = {};
for (const w of [...(cw.across||[]), ...(cw.down||[])]) if (w.num) numMap[`${w.row},${w.col}`] = w.num;
let svg = `<svg width="${cw.cols * CELL}" height="${cw.rows * CELL}" style="display:block;margin-bottom:1.5rem">`;
for (let r = 0; r < cw.rows; r++) {
for (let c = 0; c < cw.cols; c++) {
const x = c * CELL, y = r * CELL;
if (cw.grid[r][c] === null) {
svg += `<rect x="${x}" y="${y}" width="${CELL}" height="${CELL}" fill="#222"/>`;
} else {
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>`;
}
}
}
svg += '</svg>';
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>
</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>
<script>window.print();<\/script>
</body></html>`);
win.document.close();
}
// ---------- flashcards ----------
const flash = { cards: [], i: 0, itemId: null, reviewed: 0 };
+30
View File
@@ -202,6 +202,36 @@
<div id="test-done" class="done-card" hidden></div>
</section>
<!-- Crossword -->
<section id="view-crossword" class="view" hidden>
<button class="back-btn" data-back="crossword">← Back</button>
<div class="item-card">
<p class="item-kicker cw-kicker"></p>
<h1 class="cw-title"></h1>
<p id="cw-hint" class="hint" hidden>Click a square to start — click the same square again to switch Across ↔ Down. Use arrow keys to move and Tab to jump to the next word.</p>
<div class="cw-layout">
<div class="cw-grid-wrap">
<div class="cw-grid"></div>
</div>
<div class="cw-sidebar">
<div class="cw-clues">
<h3>Across</h3>
<ol class="cw-clue-list" id="cw-across-list"></ol>
</div>
<div class="cw-clues">
<h3>Down</h3>
<ol class="cw-clue-list" id="cw-down-list"></ol>
</div>
</div>
</div>
<div class="cw-actions" id="cw-actions" hidden>
<button id="cw-check-btn" class="check-btn">Check answers ✓</button>
<button id="cw-print-btn" class="secondary" style="margin-left:.5rem">🖨 Print worksheet</button>
</div>
<div id="cw-result" class="status-banner" hidden></div>
</div>
</section>
<script src="app.js"></script>
</body>
</html>
+52
View File
@@ -925,3 +925,55 @@ body.theme-indigo { --accent: #5b6abf; --accent-dark: #4a57a0; --bg: #f4f5ff; }
#toast.toast-error { background: var(--bad); }
#toast.toast-out { opacity: 0; transform: translateY(0.5rem); }
.planner-pending { border: 2px dashed var(--accent) !important; background: color-mix(in srgb, var(--accent) 10%, var(--card)) !important; color: var(--accent) !important; opacity: 0.85; cursor: default; }
/* ---------- crossword ---------- */
.cw-layout { display: flex; gap: 1.5rem; flex-wrap: wrap; align-items: flex-start; margin-top: 1rem; }
.cw-grid-wrap { overflow: auto; }
.cw-grid { display: grid; border: 2px solid #555; line-height: 1; width: fit-content; }
.cw-cell {
width: 34px; height: 34px;
background: #fff; border: 1px solid #999;
position: relative; box-sizing: border-box; flex-shrink: 0;
}
.cw-cell.black { background: #222; border-color: #222; }
.cw-cell.selected { background: color-mix(in srgb, var(--accent) 60%, #fff); }
.cw-cell.word-hi { background: color-mix(in srgb, var(--accent) 25%, #fff); }
.cw-cell.correct-word { background: #c8f0c8; }
.cw-cell.wrong-word { background: #f8d0d0; }
.cw-num {
position: absolute; top: 1px; left: 2px;
font-size: 7px; line-height: 1; color: #333;
pointer-events: none; z-index: 1;
}
.cw-input {
position: absolute; inset: 0; width: 100%; height: 100%;
border: none; background: transparent; text-align: center;
font-size: 1rem; font-weight: 700; text-transform: uppercase;
font-family: monospace; cursor: pointer; color: inherit;
padding-top: 8px; caret-color: transparent; outline: none;
}
.cw-sidebar { flex: 1; min-width: 180px; max-width: 320px; }
.cw-clues { margin-bottom: 1rem; }
.cw-clues h3 { font-size: 0.9rem; margin-bottom: 0.35rem; }
.cw-clue-list { list-style: none; padding: 0; margin: 0; }
.cw-clue-list li {
padding: 0.2rem 0.35rem; font-size: 0.85rem;
border-radius: 4px; cursor: pointer; line-height: 1.35;
}
.cw-clue-list li.word-hi { background: color-mix(in srgb, var(--accent) 25%, transparent); }
.cw-clue-list li.selected-clue { background: var(--accent); color: #fff; }
.cw-actions { margin-top: 1rem; display: flex; flex-wrap: wrap; gap: 0.5rem; }
/* admin editor crossword rows */
.cw-entry-cols { display: flex; gap: 1.5rem; flex-wrap: wrap; }
.cw-clue-row { display: flex; gap: 0.5rem; margin-bottom: 0.35rem; align-items: center; }
@media (prefers-color-scheme: dark) {
.cw-cell { background: #2a2a2a; border-color: #555; }
.cw-cell.black { background: #111; border-color: #111; }
.cw-num { color: #ccc; }
.cw-input { color: #eee; }
.cw-cell.selected { background: color-mix(in srgb, var(--accent) 50%, #2a2a2a); }
.cw-cell.word-hi { background: color-mix(in srgb, var(--accent) 25%, #2a2a2a); }
.cw-cell.correct-word { background: #1a3a1a; }
.cw-cell.wrong-word { background: #3a1a1a; }
}
+191 -2
View File
@@ -79,8 +79,116 @@ const markScheduleDone = db.prepare(`
WHERE student_id = ? AND item_id = ? AND date = ?
`);
const ITEM_TYPES = ['lesson', 'assignment', 'quiz', 'matching', 'spelling_practice', 'spelling_test', 'flashcards'];
const GRADABLE_TYPES = ['assignment', 'quiz', 'matching', 'spelling_test'];
// ---------- crossword generator ----------
function generateCrossword(acrossEntries, downEntries) {
const allEntries = [
...acrossEntries.map(e => ({ word: String(e.word || '').toUpperCase().replace(/[^A-Z]/g, ''), clue: e.clue, preferDir: 'across' })),
...downEntries.map(e => ({ word: String(e.word || '').toUpperCase().replace(/[^A-Z]/g, ''), clue: e.clue, preferDir: 'down' })),
].filter(e => e.word.length > 1);
if (allEntries.length === 0) return null;
allEntries.sort((a, b) => b.word.length - a.word.length);
const cells = new Map(); // 'r,c' -> letter
const placed = [];
const dr = (dir) => dir === 'down' ? 1 : 0;
const dc = (dir) => dir === 'across' ? 1 : 0;
function placeWord(word, r, c, dir) {
for (let i = 0; i < word.length; i++) cells.set(`${r + i * dr(dir)},${c + i * dc(dir)}`, word[i]);
}
function canPlace(word, r, c, dir) {
if (cells.has(`${r - dr(dir)},${c - dc(dir)}`)) return false;
if (cells.has(`${r + word.length * dr(dir)},${c + word.length * dc(dir)}`)) return false;
let intersects = false;
for (let i = 0; i < word.length; i++) {
const cr = r + i * dr(dir), cc = c + i * dc(dir);
const existing = cells.get(`${cr},${cc}`);
if (existing !== undefined) {
if (existing !== word[i]) return false;
intersects = true;
} else {
if (cells.has(`${cr - dc(dir)},${cc - dr(dir)}`)) return false;
if (cells.has(`${cr + dc(dir)},${cc + dr(dir)}`)) return false;
}
}
return intersects;
}
function countIntersections(word, r, c, dir) {
let n = 0;
for (let i = 0; i < word.length; i++) if (cells.has(`${r + i * dr(dir)},${c + i * dc(dir)}`)) n++;
return n;
}
const first = allEntries[0];
placeWord(first.word, 0, 0, first.preferDir);
placed.push({ ...first, dir: first.preferDir, row: 0, col: 0 });
for (let i = 1; i < allEntries.length; i++) {
const entry = allEntries[i];
const { word, preferDir } = entry;
const dirs = [preferDir, preferDir === 'across' ? 'down' : 'across'];
let best = null, bestScore = -1;
for (const dir of dirs) {
for (let wi = 0; wi < word.length; wi++) {
for (const [key, letter] of cells) {
if (letter !== word[wi]) continue;
const [er, ec] = key.split(',').map(Number);
const r = er - wi * dr(dir), c = ec - wi * dc(dir);
if (canPlace(word, r, c, dir)) {
const score = countIntersections(word, r, c, dir);
if (score > bestScore) { bestScore = score; best = { r, c, dir }; }
}
}
}
if (best) break;
}
if (best) {
placeWord(word, best.r, best.c, best.dir);
placed.push({ ...entry, dir: best.dir, row: best.r, col: best.c });
}
}
if (placed.length === 0) return null;
let minR = Infinity, minC = Infinity, maxR = -Infinity, maxC = -Infinity;
for (const k of cells.keys()) {
const [r, c] = k.split(',').map(Number);
if (r < minR) minR = r; if (r > maxR) maxR = r;
if (c < minC) minC = c; if (c > maxC) maxC = c;
}
for (const p of placed) { p.row -= minR; p.col -= minC; }
const rows = maxR - minR + 1, cols = maxC - minC + 1;
const grid = Array.from({ length: rows }, () => Array(cols).fill(null));
for (const [k, letter] of cells) {
const [r, c] = k.split(',').map(Number);
grid[r - minR][c - minC] = letter;
}
let num = 1;
const cellNum = {};
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
if (!grid[r][c]) continue;
const sa = (c === 0 || !grid[r][c - 1]) && c + 1 < cols && grid[r][c + 1];
const sd = (r === 0 || !grid[r - 1][c]) && r + 1 < rows && grid[r + 1][c];
if (sa || sd) cellNum[`${r},${c}`] = num++;
}
}
for (const p of placed) p.num = cellNum[`${p.row},${p.col}`] || null;
const mkList = (dir) => placed.filter(p => p.dir === dir && p.num)
.map(p => ({ num: p.num, clue: p.clue, row: p.row, col: p.col, len: p.word.length, answer: p.word }))
.sort((a, b) => a.num - b.num);
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'];
// ---------- kid picker ----------
@@ -572,6 +680,9 @@ app.get('/api/admin/items/:id', requirePin, (req, res) => {
SELECT id, type, prompt, choices, correct_answer, points FROM quiz_questions WHERE item_id = ? ORDER BY sort, id
`).all(req.params.id).map((q) => ({ ...q, choices: JSON.parse(q.choices) }));
}
if (item.type === 'crossword' && item.body) {
try { item.crosswordData = JSON.parse(item.body); } catch { item.crosswordData = null; }
}
res.json(item);
});
@@ -608,6 +719,14 @@ app.delete('/api/units/:id', requirePin, (req, res) => {
// ---------- items (admin) ----------
app.post('/api/crossword/generate', requirePin, (req, res) => {
const { across, down } = req.body;
if (!Array.isArray(across) && !Array.isArray(down)) return res.status(400).json({ error: 'across and/or down arrays required' });
const result = generateCrossword(across || [], down || []);
if (!result) return res.status(400).json({ error: 'Could not place any words — check that words share common letters' });
res.json(result);
});
function validateItemBody(body) {
const { type, title, refId } = body;
if (!ITEM_TYPES.includes(type)) return 'Invalid item type';
@@ -621,6 +740,7 @@ function validateItemBody(body) {
if (type === 'quiz' && (!Array.isArray(body.questions) || body.questions.length === 0)) {
return 'Quiz needs at least one question';
}
if (type === 'crossword' && !body.crosswordData) return 'Generate the crossword first';
return null;
}
@@ -660,6 +780,12 @@ app.post('/api/items', requirePin, (req, res) => {
const totalPoints = req.body.questions.reduce((sum, q) => sum + (Number(q.points) || 1), 0);
db.prepare(`UPDATE items SET points = ? WHERE id = ?`).run(totalPoints, id);
}
if (type === 'crossword' && req.body.crosswordData) {
const cw = req.body.crosswordData;
const totalPoints = (cw.across || []).length + (cw.down || []).length;
const bodyJson = JSON.stringify(cw);
db.prepare(`UPDATE items SET body = ?, points = ? WHERE id = ?`).run(bodyJson, totalPoints, id);
}
res.json({ id });
});
@@ -679,6 +805,12 @@ app.put('/api/items/:id', requirePin, (req, res) => {
dueDate || null, allowRetakes ? 1 : 0, prereqItemId || null,
evidenceMode || 'none', retakePolicy || 'latest', req.params.id);
if (type === 'quiz' || type === 'matching') saveQuizQuestions(req.params.id, req.body.questions);
if (type === 'crossword' && req.body.crosswordData) {
const cw = req.body.crosswordData;
const totalPoints = (cw.across || []).length + (cw.down || []).length;
const bodyJson = JSON.stringify(cw);
db.prepare(`UPDATE items SET body = ?, points = ? WHERE id = ?`).run(bodyJson, totalPoints, req.params.id);
}
res.json({ ok: true });
});
@@ -790,6 +922,23 @@ app.get('/api/items/:id', (req, res) => {
correctLetter: graded ? wordToLetter[q.correct_answer] : undefined,
correctWord: graded ? q.correct_answer : undefined,
}));
} else if (item.type === 'crossword' && item.body) {
try {
const cw = JSON.parse(item.body);
const graded = item.submission && item.submission.status === 'graded';
const savedAnswers = graded && item.submission.answers ? JSON.parse(item.submission.answers) : null;
// Strip answers from grid for students unless graded
const safeGrid = cw.grid.map(row => row.map(cell => cell !== null ? '' : null));
const safeAcross = cw.across.map(({ num, clue, row, col, len }) => ({ num, clue, row, col, len }));
const safeDown = cw.down.map(({ num, clue, row, col, len }) => ({ num, clue, row, col, len }));
item.crosswordData = {
rows: cw.rows, cols: cw.cols,
grid: safeGrid,
across: graded ? cw.across : safeAcross,
down: graded ? cw.down : safeDown,
savedAnswers,
};
} catch { item.crosswordData = 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') {
@@ -877,6 +1026,46 @@ app.post('/api/items/:id/quiz-submit', (req, res) => {
res.json({ score: earned, total: possible });
});
// Auto-graded crossword submission
app.post('/api/items/:id/crossword-submit', (req, res) => {
const { studentId, answers, date } = req.body; // answers: {'r,c': 'LETTER'}
const item = db.prepare(`SELECT body, points FROM items WHERE id = ?`).get(req.params.id);
if (!item || !item.body) return res.status(404).json({ error: 'No such crossword' });
let cw;
try { cw = JSON.parse(item.body); } catch { return res.status(400).json({ error: 'Invalid crossword data' }); }
const allWords = [...(cw.across || []), ...(cw.down || [])];
const isDown = (w) => cw.down.some(d => d.num === w.num && d.row === w.row && d.col === w.col);
let earned = 0;
const possible = allWords.length;
for (const w of allWords) {
const dir = cw.across.includes(w) ? 'across' : 'down';
let wordCorrect = true;
for (let i = 0; i < w.len; i++) {
const r = w.row + (dir === 'down' ? i : 0);
const c = w.col + (dir === 'across' ? i : 0);
const given = (answers && answers[`${r},${c}`]) ? String(answers[`${r},${c}`]).toUpperCase() : '';
if (given !== w.answer[i]) { wordCorrect = false; break; }
}
if (wordCorrect) earned++;
}
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 });
});
// ---------- grading (parent) ----------
app.get('/api/grading-queue/count', requirePin, (req, res) => {