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 @@ + +
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 = ``; + 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 += ``; + } else { + svg += ``; + if (cw.grid[r][c]) { + svg += `${esc(cw.grid[r][c])}`; + } + } + } + } + // 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 += `${n}`; + } + 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 = ``; + 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 += ``; + } else { + svg += ``; + const key = `${r},${c}`; + if (numMap[key]) svg += `${numMap[key]}`; + } + } + } + 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} +
    +

    Across

      ${acrossHtml}
    +

    Down

      ${downHtml}
    +
    + 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) => {