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:
1 parent
60bfe3ca4a
commit
682534a061
6 files changed
+680
-6
No files matched your search
@@ -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
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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>
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
Reference in new issue
Block a user