diff --git a/public/admin.html b/public/admin.html
index 639ce23..d0408e1 100644
--- a/public/admin.html
+++ b/public/admin.html
@@ -130,6 +130,7 @@
+
@@ -247,6 +248,26 @@
+
+
+
+
Across clues
+
+
+
+
+
Down clues
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/admin.js b/public/admin.js
index d0b3daa..55b66bf 100644
--- a/public/admin.js
+++ b/public/admin.js
@@ -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 = `
+
+
+ `;
+ 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 = `';
+ $('#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 {
diff --git a/public/app.js b/public/app.js
index b121a05..a09e1c5 100644
--- a/public/app.js
+++ b/public/app.js
@@ -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 = `';
+ const acrossHtml = (cw.across||[]).map((w) => `${w.num}. ${esc(w.clue)}`).join('');
+ const downHtml = (cw.down||[]).map((w) => `${w.num}. ${esc(w.clue)}`).join('');
+ const win = window.open('', '_blank');
+ win.document.write(`${esc(title)}
+
+
+${esc(title)}
${svg}
+
+
diff --git a/public/styles.css b/public/styles.css
index 8df9577..98cc3c6 100644
--- a/public/styles.css
+++ b/public/styles.css
@@ -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; }
+}
diff --git a/server.js b/server.js
index 7db9a06..b61fa1e 100644
--- a/server.js
+++ b/server.js
@@ -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) => {