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; }
|
||||
}
|
||||
Reference in new issue
Block a user