Add matching quiz type, more theme colors, animal icon picker, and fix color persistence
- Fix theme not saving across sessions: /api/state was missing `theme` in SELECT - Add 4 new color themes: red, teal, yellow, indigo (student + admin) - Add animal icon picker (32 animals) with PATCH /api/students/:id/emoji endpoint - Add Matching item type: word bank (A, B, C… alphabetical) + clue blanks, auto-graded - Matching submit button stays disabled until all blanks are filled Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
183bcf0ef2
commit
05cfdf7b09
6 files changed
+281
-14
No files matched your search
@@ -129,6 +129,7 @@
|
||||
<option value="quiz">❓ Quiz</option>
|
||||
<option value="spelling_practice">✏️ Spelling Practice</option>
|
||||
<option value="spelling_test">⭐ Spelling Test</option>
|
||||
<option value="matching">🔤 Matching</option>
|
||||
<option value="flashcards">🗂️ Flashcards</option>
|
||||
</select>
|
||||
</label>
|
||||
@@ -234,6 +235,12 @@
|
||||
<button type="button" id="ie-add-question" class="secondary">+ Add question</button>
|
||||
</div>
|
||||
|
||||
<div id="ie-field-matching" class="ie-field">
|
||||
<p class="hint" style="margin-bottom:.5rem">Enter word-clue pairs. Words are shown as A, B, C… (alphabetical order) in the word bank.</p>
|
||||
<div id="ie-pairs"></div>
|
||||
<button type="button" id="ie-add-pair" class="secondary">+ Add pair</button>
|
||||
</div>
|
||||
|
||||
<div id="ie-field-spelling" class="ie-field">
|
||||
<label>Word list <select id="ie-list"></select></label>
|
||||
</div>
|
||||
|
||||
+32
-1
@@ -104,7 +104,7 @@ function showPanel(name) {
|
||||
// Kids
|
||||
// ============================================================
|
||||
|
||||
const THEME_COLORS = { blue: '#4f86f7', green: '#2e9e5b', purple: '#7c5cbf', orange: '#e8802a', pink: '#d45d8a' };
|
||||
const THEME_COLORS = { blue: '#4f86f7', green: '#2e9e5b', purple: '#7c5cbf', orange: '#e8802a', pink: '#d45d8a', red: '#e84040', teal: '#20a8a0', yellow: '#c8960c', indigo: '#5b6abf' };
|
||||
|
||||
async function loadKids() {
|
||||
const students = await api('/api/students');
|
||||
@@ -388,6 +388,8 @@ async function openItemEditor(unitId, itemId) {
|
||||
$('#ie-error').hidden = true;
|
||||
$('#ie-questions').innerHTML = '';
|
||||
questionCount = 0;
|
||||
$('#ie-pairs').innerHTML = '';
|
||||
pairCount = 0;
|
||||
|
||||
if (itemId) {
|
||||
const item = await api(`/api/admin/items/${itemId}`);
|
||||
@@ -405,6 +407,11 @@ async function openItemEditor(unitId, itemId) {
|
||||
if (item.type === 'spelling_practice' || item.type === 'spelling_test') $('#ie-list').value = item.ref_id;
|
||||
if (item.type === 'flashcards') $('#ie-deck').value = item.ref_id;
|
||||
if (item.type === 'quiz') (item.questions || []).forEach(addQuestionRow);
|
||||
if (item.type === 'matching') {
|
||||
$('#ie-pairs').innerHTML = '';
|
||||
pairCount = 0;
|
||||
(item.questions || []).forEach(addMatchingRow);
|
||||
}
|
||||
$('#ie-delete').hidden = false;
|
||||
$('#ie-delete').onclick = async () => {
|
||||
if (!confirm('Delete this item and any grades for it?')) return;
|
||||
@@ -437,6 +444,7 @@ function updateItemFieldVisibility() {
|
||||
$('#ie-field-lesson').hidden = type !== 'lesson';
|
||||
$('#ie-field-assignment').hidden = type !== 'assignment';
|
||||
$('#ie-field-quiz').hidden = type !== 'quiz';
|
||||
$('#ie-field-matching').hidden = type !== 'matching';
|
||||
$('#ie-field-spelling').hidden = type !== 'spelling_practice' && type !== 'spelling_test';
|
||||
$('#ie-field-flashcards').hidden = type !== 'flashcards';
|
||||
$('#ie-scan-section').hidden = type !== 'lesson' && type !== 'quiz';
|
||||
@@ -658,6 +666,20 @@ function addQuestionRow(q = {}) {
|
||||
}
|
||||
$('#ie-add-question').addEventListener('click', () => addQuestionRow());
|
||||
|
||||
let pairCount = 0;
|
||||
function addMatchingRow(pair = {}) {
|
||||
const n = pairCount++;
|
||||
const div = document.createElement('div');
|
||||
div.className = 'matching-pair-row';
|
||||
div.innerHTML = `
|
||||
<input class="pair-word" placeholder="Word / term" value="${esc(pair.correct_answer || pair.word || '')}">
|
||||
<input class="pair-clue" placeholder="Clue / definition" value="${esc(pair.prompt || pair.clue || '')}">
|
||||
<button type="button" class="danger small pair-remove">✕</button>`;
|
||||
div.querySelector('.pair-remove').addEventListener('click', () => div.remove());
|
||||
$('#ie-pairs').appendChild(div);
|
||||
}
|
||||
$('#ie-add-pair').addEventListener('click', () => addMatchingRow());
|
||||
|
||||
$('#ie-load-template').addEventListener('click', async () => {
|
||||
const tid = $('#ie-template-select').value;
|
||||
if (!tid) return msg('Pick a template first.');
|
||||
@@ -710,6 +732,15 @@ $('#item-form').addEventListener('submit', async (e) => {
|
||||
points: Number(row.querySelector('.qn-points').value) || 1,
|
||||
}));
|
||||
}
|
||||
if (type === 'matching') {
|
||||
body.questions = Array.from(document.querySelectorAll('.matching-pair-row')).map((row) => ({
|
||||
type: 'match',
|
||||
prompt: row.querySelector('.pair-clue').value.trim(),
|
||||
correctAnswer: row.querySelector('.pair-word').value.trim(),
|
||||
choices: [],
|
||||
points: 1,
|
||||
})).filter((q) => q.prompt && q.correctAnswer);
|
||||
}
|
||||
|
||||
const id = $('#ie-id').value;
|
||||
try {
|
||||
|
||||
+117
-2
@@ -27,11 +27,11 @@ const esc = (s) =>
|
||||
const todayStr = () => new Date().toISOString().slice(0, 10);
|
||||
|
||||
const TYPE_ICON = {
|
||||
lesson: '📖', assignment: '📝', quiz: '❓',
|
||||
lesson: '📖', assignment: '📝', quiz: '❓', matching: '🔤',
|
||||
spelling_practice: '✏️', spelling_test: '⭐', flashcards: '🗂️',
|
||||
};
|
||||
const TYPE_LABEL = {
|
||||
lesson: 'Lesson', assignment: 'Assignment', quiz: 'Quiz',
|
||||
lesson: 'Lesson', assignment: 'Assignment', quiz: 'Quiz', matching: 'Matching',
|
||||
spelling_practice: 'Spelling Practice', spelling_test: 'Spelling Test', flashcards: 'Flashcards',
|
||||
};
|
||||
|
||||
@@ -191,7 +191,18 @@ const THEMES = {
|
||||
purple: { '--accent': '#7c5cbf', '--accent-dark': '#6245a0', '--bg': '#f8f4ff' },
|
||||
orange: { '--accent': '#e8802a', '--accent-dark': '#c96a1a', '--bg': '#fff8f0' },
|
||||
pink: { '--accent': '#d45d8a', '--accent-dark': '#b4487a', '--bg': '#fff0f5' },
|
||||
red: { '--accent': '#e84040', '--accent-dark': '#c42828', '--bg': '#fff5f5' },
|
||||
teal: { '--accent': '#20a8a0', '--accent-dark': '#178880', '--bg': '#f0faf9' },
|
||||
yellow: { '--accent': '#c8960c', '--accent-dark': '#a87a06', '--bg': '#fffbf0' },
|
||||
indigo: { '--accent': '#5b6abf', '--accent-dark': '#4a57a0', '--bg': '#f4f5ff' },
|
||||
};
|
||||
|
||||
const ANIMAL_EMOJIS = [
|
||||
'🐶','🐱','🐰','🦊','🐻','🐼','🐨','🐯',
|
||||
'🦁','🐮','🐷','🐸','🐵','🐔','🐧','🦆',
|
||||
'🦉','🦋','🐢','🦄','🐬','🦈','🦖','🦕',
|
||||
'🐺','🦝','🦔','🐿️','🦜','🦩','🦚','🐙',
|
||||
];
|
||||
const THEME_NAMES = Object.keys(THEMES);
|
||||
const DEFAULT_VARS = { '--accent': '#4f86f7', '--accent-dark': '#3a6fd8', '--bg': '#fdf6e3' };
|
||||
|
||||
@@ -305,6 +316,7 @@ $('#celebration-close').addEventListener('click', () => {
|
||||
// Theme picker toggle
|
||||
$('#theme-picker-btn').addEventListener('click', () => {
|
||||
$('#theme-dots').hidden = !$('#theme-dots').hidden;
|
||||
$('#icon-grid').hidden = true;
|
||||
});
|
||||
|
||||
document.querySelectorAll('.theme-dot').forEach((btn) =>
|
||||
@@ -318,6 +330,29 @@ document.querySelectorAll('.theme-dot').forEach((btn) =>
|
||||
})
|
||||
);
|
||||
|
||||
// ---------- animal / icon picker ----------
|
||||
|
||||
$('#icon-picker-btn').addEventListener('click', () => {
|
||||
const grid = $('#icon-grid');
|
||||
if (grid.hidden) {
|
||||
grid.innerHTML = ANIMAL_EMOJIS.map((e) =>
|
||||
`<button class="icon-choice${currentStudent && currentStudent.emoji === e ? ' active' : ''}" data-emoji="${e}">${e}</button>`
|
||||
).join('');
|
||||
grid.querySelectorAll('.icon-choice').forEach((btn) =>
|
||||
btn.addEventListener('click', async () => {
|
||||
const emoji = btn.dataset.emoji;
|
||||
currentStudent.emoji = emoji;
|
||||
$('#kid-greeting').textContent = `${emoji} Hi, ${currentStudent.name}!`;
|
||||
grid.querySelectorAll('.icon-choice').forEach((b) => b.classList.toggle('active', b.dataset.emoji === emoji));
|
||||
await api(`/api/students/${currentStudent.id}/emoji`, { method: 'PATCH', body: { emoji } });
|
||||
grid.hidden = true;
|
||||
})
|
||||
);
|
||||
}
|
||||
grid.hidden = !grid.hidden;
|
||||
$('#theme-dots').hidden = true;
|
||||
});
|
||||
|
||||
const OFFLINE_STATUS_ICON = { not_started: '⬜', in_progress: '🔄', done: '✅' };
|
||||
|
||||
function renderToday(tasks) {
|
||||
@@ -600,6 +635,7 @@ async function openItem(itemId, type) {
|
||||
if (type === 'lesson') return openLesson(itemId);
|
||||
if (type === 'assignment') return openAssignment(itemId);
|
||||
if (type === 'quiz') return openQuiz(itemId, false);
|
||||
if (type === 'matching') return openMatching(itemId, false);
|
||||
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 });
|
||||
@@ -788,6 +824,85 @@ async function openQuiz(itemId, forceRetake = false) {
|
||||
|
||||
const normalizeAnswer = (s) => String(s ?? '').trim().toLowerCase();
|
||||
|
||||
// ---------- matching ----------
|
||||
|
||||
async function openMatching(itemId, forceRetake = false) {
|
||||
const item = await api(`/api/items/${itemId}?studentId=${currentStudent.id}`);
|
||||
$('#quiz-kicker').textContent = `${item.course_name} · ${item.unit_name}`;
|
||||
$('#quiz-title').textContent = item.title;
|
||||
const graded = !forceRetake && item.submission && item.submission.status === 'graded';
|
||||
const result = $('#quiz-result');
|
||||
|
||||
const wordBankHtml = (wb) => `
|
||||
<div class="match-word-bank">
|
||||
<strong>Word Bank</strong>
|
||||
<div class="match-bank-list">
|
||||
${wb.map((w, i) => `<span class="match-bank-item"><strong>${String.fromCharCode(65 + i)}.</strong> ${esc(w)}</span>`).join('')}
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
if (graded) {
|
||||
result.hidden = false;
|
||||
result.className = 'status-banner good';
|
||||
const pct = item.submission.points_possible
|
||||
? Math.round((item.submission.score / item.submission.points_possible) * 100) : 0;
|
||||
result.innerHTML = `🌟 Score: ${item.submission.score} / ${item.submission.points_possible} (${pct}%)`;
|
||||
|
||||
let retakeBtn = '';
|
||||
if (item.allow_retakes) retakeBtn = `<button id="quiz-retake-btn" class="check-btn secondary">🔁 Retake</button>`;
|
||||
|
||||
$('#quiz-form').innerHTML = wordBankHtml(item.wordBank) + retakeBtn +
|
||||
item.questions.map((q) => {
|
||||
const correct = q.given && q.given.toUpperCase() === q.correctLetter;
|
||||
return `<div class="quiz-question review match-review-row">
|
||||
<span class="match-blank-badge ${correct ? 'correct' : 'wrong'}">${esc(q.given || '?')}</span>
|
||||
<span class="match-clue">${correct ? '✅' : '❌'} ${esc(q.prompt)}</span>
|
||||
${correct ? '' : `<span class="q-correct-answer">→ ${esc(q.correctLetter)} (${esc(q.correctWord)})</span>`}
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
if (item.allow_retakes) {
|
||||
$('#quiz-retake-btn').addEventListener('click', () => openMatching(itemId, true));
|
||||
}
|
||||
} else {
|
||||
result.hidden = true;
|
||||
$('#quiz-form').innerHTML = wordBankHtml(item.wordBank) +
|
||||
`<p class="match-instructions">Write the letter from the word bank that matches each clue.</p>` +
|
||||
item.questions.map((q, i) =>
|
||||
`<div class="quiz-question match-row">
|
||||
<input type="text" class="match-input" name="q${q.id}" maxlength="2"
|
||||
placeholder="?" autocomplete="off" autocapitalize="characters">
|
||||
<span class="match-clue">${i + 1}. ${esc(q.prompt)}</span>
|
||||
</div>`
|
||||
).join('') +
|
||||
`<button type="submit" class="check-btn" disabled>Submit ✓</button>`;
|
||||
|
||||
const submitBtn = $('#quiz-form button[type=submit]');
|
||||
const inputs = Array.from($('#quiz-form').querySelectorAll('.match-input'));
|
||||
const checkAllFilled = () => {
|
||||
submitBtn.disabled = !inputs.every((inp) => inp.value.trim().length > 0);
|
||||
};
|
||||
inputs.forEach((inp) => inp.addEventListener('input', checkAllFilled));
|
||||
|
||||
$('#quiz-form').onsubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (submitBtn.disabled) return;
|
||||
submitBtn.disabled = true;
|
||||
const answers = {};
|
||||
for (const q of item.questions) {
|
||||
const field = $('#quiz-form').elements[`q${q.id}`];
|
||||
answers[q.id] = field ? field.value.trim().toUpperCase() : '';
|
||||
}
|
||||
await api(`/api/items/${itemId}/quiz-submit`, {
|
||||
method: 'POST',
|
||||
body: { studentId: currentStudent.id, answers, date: todayStr() },
|
||||
});
|
||||
openMatching(itemId, false);
|
||||
};
|
||||
}
|
||||
show('quiz');
|
||||
}
|
||||
|
||||
// ---------- flashcards ----------
|
||||
|
||||
const flash = { cards: [], i: 0, itemId: null, reviewed: 0 };
|
||||
|
||||
+9
-1
@@ -21,7 +21,10 @@
|
||||
<section id="view-kid" class="view" hidden>
|
||||
<div class="kid-topbar">
|
||||
<button class="back-btn" data-nav="home">← Switch</button>
|
||||
<button id="theme-picker-btn" class="theme-picker-btn" aria-label="Pick my color">🎨</button>
|
||||
<div class="kid-topbar-actions">
|
||||
<button id="icon-picker-btn" class="theme-picker-btn" aria-label="Change my animal">🐾</button>
|
||||
<button id="theme-picker-btn" class="theme-picker-btn" aria-label="Pick my color">🎨</button>
|
||||
</div>
|
||||
</div>
|
||||
<h1 id="kid-greeting"></h1>
|
||||
<div id="theme-dots" class="theme-dots" hidden>
|
||||
@@ -30,7 +33,12 @@
|
||||
<button class="theme-dot theme-purple" data-theme="purple" title="Purple"></button>
|
||||
<button class="theme-dot theme-orange" data-theme="orange" title="Orange"></button>
|
||||
<button class="theme-dot theme-pink" data-theme="pink" title="Pink"></button>
|
||||
<button class="theme-dot theme-red" data-theme="red" title="Red"></button>
|
||||
<button class="theme-dot theme-teal" data-theme="teal" title="Teal"></button>
|
||||
<button class="theme-dot theme-yellow" data-theme="yellow" title="Yellow"></button>
|
||||
<button class="theme-dot theme-indigo" data-theme="indigo" title="Indigo"></button>
|
||||
</div>
|
||||
<div id="icon-grid" class="icon-grid" hidden></div>
|
||||
|
||||
<div class="kid-nav">
|
||||
<button class="nav-pill active" data-tab="today">📋 Today</button>
|
||||
|
||||
@@ -740,9 +740,14 @@ body.theme-green { --accent: #2e9e5b; --accent-dark: #247a47; --bg: #f0faf4; }
|
||||
body.theme-purple { --accent: #7c5cbf; --accent-dark: #6245a0; --bg: #f8f4ff; }
|
||||
body.theme-orange { --accent: #e8802a; --accent-dark: #c96a1a; --bg: #fff8f0; }
|
||||
body.theme-pink { --accent: #d45d8a; --accent-dark: #b4487a; --bg: #fff0f5; }
|
||||
body.theme-red { --accent: #e84040; --accent-dark: #c42828; --bg: #fff5f5; }
|
||||
body.theme-teal { --accent: #20a8a0; --accent-dark: #178880; --bg: #f0faf9; }
|
||||
body.theme-yellow { --accent: #c8960c; --accent-dark: #a87a06; --bg: #fffbf0; }
|
||||
body.theme-indigo { --accent: #5b6abf; --accent-dark: #4a57a0; --bg: #f4f5ff; }
|
||||
|
||||
/* ---------- kid top bar + theme picker ---------- */
|
||||
.kid-topbar { display: flex; justify-content: space-between; align-items: center; }
|
||||
.kid-topbar-actions { display: flex; gap: 0.25rem; align-items: center; }
|
||||
.theme-picker-btn {
|
||||
background: none; border: none; font-size: 1.3rem; cursor: pointer;
|
||||
padding: 0.25rem 0.5rem; color: var(--muted); line-height: 1;
|
||||
@@ -763,6 +768,25 @@ body.theme-pink { --accent: #d45d8a; --accent-dark: #b4487a; --bg: #fff0f5; }
|
||||
.theme-dot.theme-purple { background: #7c5cbf; }
|
||||
.theme-dot.theme-orange { background: #e8802a; }
|
||||
.theme-dot.theme-pink { background: #d45d8a; }
|
||||
.theme-dot.theme-red { background: #e84040; }
|
||||
.theme-dot.theme-teal { background: #20a8a0; }
|
||||
.theme-dot.theme-yellow { background: #c8960c; }
|
||||
.theme-dot.theme-indigo { background: #5b6abf; }
|
||||
|
||||
/* ---------- icon / animal picker ---------- */
|
||||
.icon-grid {
|
||||
display: grid; grid-template-columns: repeat(8, 1fr);
|
||||
gap: 0.35rem; margin-bottom: 0.75rem;
|
||||
background: var(--card); border-radius: var(--radius);
|
||||
padding: 0.5rem; box-shadow: 0 2px 8px rgba(0,0,0,.1);
|
||||
}
|
||||
.icon-choice {
|
||||
font-size: 1.6rem; background: none; border: 2px solid transparent;
|
||||
border-radius: 0.5rem; cursor: pointer; padding: 0.2rem;
|
||||
transition: transform 0.1s, border-color 0.1s;
|
||||
}
|
||||
.icon-choice:hover { transform: scale(1.2); }
|
||||
.icon-choice.active { border-color: var(--accent); background: var(--bg); }
|
||||
|
||||
/* ---------- celebration overlay ---------- */
|
||||
#celebration-overlay {
|
||||
@@ -806,6 +830,46 @@ body.theme-pink { --accent: #d45d8a; --accent-dark: #b4487a; --bg: #fff0f5; }
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
/* ---------- matching quiz ---------- */
|
||||
.match-word-bank {
|
||||
background: var(--bg); border: 2px solid var(--accent);
|
||||
border-radius: var(--radius); padding: 0.75rem 1rem;
|
||||
margin-bottom: 1rem; text-align: left;
|
||||
}
|
||||
.match-bank-list {
|
||||
display: flex; flex-wrap: wrap; gap: 0.4rem 1.2rem;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
.match-bank-item { font-size: 0.95rem; }
|
||||
.match-instructions { font-size: 0.9rem; color: var(--muted); margin-bottom: 0.75rem; }
|
||||
.match-row {
|
||||
display: flex; align-items: center; gap: 0.6rem;
|
||||
text-align: left;
|
||||
}
|
||||
.match-input {
|
||||
width: 2.6rem; text-align: center; font-size: 1.1rem; font-weight: 700;
|
||||
text-transform: uppercase; border: 2px solid var(--accent);
|
||||
border-radius: 8px; padding: 0.3rem; flex-shrink: 0;
|
||||
}
|
||||
.match-clue { flex: 1; font-size: 0.95rem; }
|
||||
.match-review-row {
|
||||
display: flex; align-items: center; gap: 0.6rem;
|
||||
text-align: left; flex-wrap: wrap;
|
||||
}
|
||||
.match-blank-badge {
|
||||
display: inline-block; width: 2.2rem; text-align: center;
|
||||
font-size: 1rem; font-weight: 700; border-radius: 6px;
|
||||
padding: 0.15rem 0; flex-shrink: 0;
|
||||
}
|
||||
.match-blank-badge.correct { background: var(--good-bg); color: var(--good); }
|
||||
.match-blank-badge.wrong { background: var(--bad-bg); color: var(--bad); }
|
||||
|
||||
/* admin matching pair rows */
|
||||
.matching-pair-row {
|
||||
display: grid; grid-template-columns: 1fr 2fr auto;
|
||||
gap: 0.4rem; margin-bottom: 0.4rem; align-items: center;
|
||||
}
|
||||
|
||||
/* ---------- print list button ---------- */
|
||||
.big-btn.print-list {
|
||||
background: #6b7280; color: #fff; font-size: 1.2rem;
|
||||
@@ -828,6 +892,10 @@ body.theme-pink { --accent: #d45d8a; --accent-dark: #b4487a; --bg: #fff0f5; }
|
||||
.theme-swatch.theme-purple { background: #7c5cbf; }
|
||||
.theme-swatch.theme-orange { background: #e8802a; }
|
||||
.theme-swatch.theme-pink { background: #d45d8a; }
|
||||
.theme-swatch.theme-red { background: #e84040; }
|
||||
.theme-swatch.theme-teal { background: #20a8a0; }
|
||||
.theme-swatch.theme-yellow { background: #c8960c; }
|
||||
.theme-swatch.theme-indigo { background: #5b6abf; }
|
||||
|
||||
/* ---------- student portal: test history ---------- */
|
||||
.thr-heading { margin: 1.5rem 0 0.5rem; font-size: 1rem; }
|
||||
|
||||
@@ -79,8 +79,8 @@ const markScheduleDone = db.prepare(`
|
||||
WHERE student_id = ? AND item_id = ? AND date = ?
|
||||
`);
|
||||
|
||||
const ITEM_TYPES = ['lesson', 'assignment', 'quiz', 'spelling_practice', 'spelling_test', 'flashcards'];
|
||||
const GRADABLE_TYPES = ['assignment', 'quiz', 'spelling_test'];
|
||||
const ITEM_TYPES = ['lesson', 'assignment', 'quiz', 'matching', 'spelling_practice', 'spelling_test', 'flashcards'];
|
||||
const GRADABLE_TYPES = ['assignment', 'quiz', 'matching', 'spelling_test'];
|
||||
|
||||
// ---------- kid picker ----------
|
||||
|
||||
@@ -88,12 +88,21 @@ app.get('/api/students', (req, res) => {
|
||||
res.json(db.prepare(`SELECT id, name, emoji, theme, streak_count, streak_date FROM students ORDER BY name`).all());
|
||||
});
|
||||
|
||||
const VALID_THEMES = ['blue','green','purple','orange','pink','red','teal','yellow','indigo'];
|
||||
|
||||
app.patch('/api/students/:id/theme', (req, res) => {
|
||||
const theme = ['blue','green','purple','orange','pink'].includes(req.body.theme) ? req.body.theme : 'blue';
|
||||
const theme = VALID_THEMES.includes(req.body.theme) ? req.body.theme : 'blue';
|
||||
db.prepare(`UPDATE students SET theme = ? WHERE id = ?`).run(theme, req.params.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.patch('/api/students/:id/emoji', (req, res) => {
|
||||
const emoji = req.body.emoji ? String(req.body.emoji).trim() : null;
|
||||
if (!emoji) return res.status(400).json({ error: 'emoji required' });
|
||||
db.prepare(`UPDATE students SET emoji = ? WHERE id = ?`).run(emoji, req.params.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post('/api/students/:id/complete-day', (req, res) => {
|
||||
const student = db.prepare(`SELECT streak_date, streak_count, best_streak FROM students WHERE id = ?`).get(req.params.id);
|
||||
if (!student) return res.status(404).json({ error: 'Not found' });
|
||||
@@ -114,7 +123,7 @@ app.post('/api/students/:id/complete-day', (req, res) => {
|
||||
// Everything the standalone spelling home screen needs
|
||||
app.get('/api/state/:studentId', (req, res) => {
|
||||
const id = req.params.studentId;
|
||||
const student = db.prepare(`SELECT id, name, emoji FROM students WHERE id = ?`).get(id);
|
||||
const student = db.prepare(`SELECT id, name, emoji, theme FROM students WHERE id = ?`).get(id);
|
||||
if (!student) return res.status(404).json({ error: 'No such student' });
|
||||
|
||||
const assignment = db.prepare(`
|
||||
@@ -409,7 +418,7 @@ app.post('/api/students', requirePin, (req, res) => {
|
||||
app.put('/api/students/:id', requirePin, (req, res) => {
|
||||
const { emoji, theme } = req.body;
|
||||
if (emoji) db.prepare(`UPDATE students SET emoji = ? WHERE id = ?`).run(String(emoji), req.params.id);
|
||||
if (theme && ['blue','green','purple','orange','pink'].includes(theme))
|
||||
if (theme && VALID_THEMES.includes(theme))
|
||||
db.prepare(`UPDATE students SET theme = ? WHERE id = ?`).run(theme, req.params.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
@@ -558,7 +567,7 @@ app.get('/api/admin/courses/:id', requirePin, (req, res) => {
|
||||
app.get('/api/admin/items/:id', requirePin, (req, res) => {
|
||||
const item = db.prepare(`SELECT id, unit_id, type, title, body, points, ref_id, due_date, allow_retakes, prereq_item_id, evidence_mode, retake_policy FROM items WHERE id = ?`).get(req.params.id);
|
||||
if (!item) return res.status(404).json({ error: 'No such item' });
|
||||
if (item.type === 'quiz') {
|
||||
if (item.type === 'quiz' || item.type === 'matching') {
|
||||
item.questions = db.prepare(`
|
||||
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) }));
|
||||
@@ -646,7 +655,7 @@ app.post('/api/items', requirePin, (req, res) => {
|
||||
(type === 'flashcards' || type === 'spelling_practice' || type === 'spelling_test') ? refId : null,
|
||||
sort, dueDate || null, allowRetakes ? 1 : 0, prereqItemId || null,
|
||||
evidenceMode || 'none', retakePolicy || 'latest').lastInsertRowid;
|
||||
if (type === 'quiz') {
|
||||
if (type === 'quiz' || type === 'matching') {
|
||||
saveQuizQuestions(id, req.body.questions);
|
||||
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);
|
||||
@@ -661,7 +670,7 @@ app.put('/api/items/:id', requirePin, (req, res) => {
|
||||
return res.status(404).json({ error: 'No such item' });
|
||||
}
|
||||
const { type, title, body, points, refId, dueDate, allowRetakes, prereqItemId, evidenceMode, retakePolicy } = req.body;
|
||||
const finalPoints = type === 'quiz'
|
||||
const finalPoints = (type === 'quiz' || type === 'matching')
|
||||
? req.body.questions.reduce((sum, q) => sum + (Number(q.points) || 1), 0)
|
||||
: (Number(points) || 0);
|
||||
db.prepare(`UPDATE items SET type = ?, title = ?, body = ?, points = ?, ref_id = ?, due_date = ?, allow_retakes = ?, prereq_item_id = ?, evidence_mode = ?, retake_policy = ? WHERE id = ?`)
|
||||
@@ -669,7 +678,7 @@ app.put('/api/items/:id', requirePin, (req, res) => {
|
||||
(type === 'flashcards' || type === 'spelling_practice' || type === 'spelling_test') ? refId : null,
|
||||
dueDate || null, allowRetakes ? 1 : 0, prereqItemId || null,
|
||||
evidenceMode || 'none', retakePolicy || 'latest', req.params.id);
|
||||
if (type === 'quiz') saveQuizQuestions(req.params.id, req.body.questions);
|
||||
if (type === 'quiz' || type === 'matching') saveQuizQuestions(req.params.id, req.body.questions);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
@@ -766,6 +775,21 @@ app.get('/api/items/:id', (req, res) => {
|
||||
choices: JSON.parse(q.choices),
|
||||
given: savedAnswers ? savedAnswers[q.id] : undefined,
|
||||
}));
|
||||
} else if (item.type === 'matching') {
|
||||
const graded = item.submission && item.submission.status === 'graded';
|
||||
const savedAnswers = graded && item.submission.answers ? JSON.parse(item.submission.answers) : null;
|
||||
const qs = db.prepare(`SELECT id, prompt, correct_answer, points FROM quiz_questions WHERE item_id = ? ORDER BY sort, id`).all(req.params.id);
|
||||
const words = [...new Set(qs.map(q => q.correct_answer))].sort((a, b) => a.localeCompare(b));
|
||||
const wordToLetter = Object.fromEntries(words.map((w, i) => [w, String.fromCharCode(65 + i)]));
|
||||
item.wordBank = words;
|
||||
item.questions = qs.map(q => ({
|
||||
id: q.id,
|
||||
prompt: q.prompt,
|
||||
points: q.points,
|
||||
given: savedAnswers ? savedAnswers[q.id] : undefined,
|
||||
correctLetter: graded ? wordToLetter[q.correct_answer] : undefined,
|
||||
correctWord: graded ? q.correct_answer : undefined,
|
||||
}));
|
||||
} 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') {
|
||||
@@ -811,15 +835,29 @@ app.post('/api/items/:id/complete', (req, res) => {
|
||||
// Auto-graded quiz submission
|
||||
app.post('/api/items/:id/quiz-submit', (req, res) => {
|
||||
const { studentId, answers, date } = req.body; // answers: { questionId: value }
|
||||
const item = db.prepare(`SELECT type FROM items WHERE id = ?`).get(req.params.id);
|
||||
const questions = db.prepare(`SELECT * FROM quiz_questions WHERE item_id = ?`).all(req.params.id);
|
||||
if (questions.length === 0) return res.status(404).json({ error: 'No such quiz' });
|
||||
|
||||
// For matching: build letter→word map from alphabetically sorted correct_answers
|
||||
let letterToWord = null;
|
||||
if (item && item.type === 'matching') {
|
||||
const words = [...new Set(questions.map(q => q.correct_answer))].sort((a, b) => a.localeCompare(b));
|
||||
letterToWord = Object.fromEntries(words.map((w, i) => [String.fromCharCode(65 + i), w]));
|
||||
}
|
||||
|
||||
let earned = 0, possible = 0;
|
||||
const record = {};
|
||||
for (const q of questions) {
|
||||
possible += q.points;
|
||||
const given = answers ? answers[q.id] : undefined;
|
||||
const correct = given !== undefined && normalize(given) === normalize(q.correct_answer);
|
||||
let correct;
|
||||
if (letterToWord) {
|
||||
const mappedWord = given ? (letterToWord[String(given).trim().toUpperCase()] || '') : '';
|
||||
correct = given !== undefined && normalize(mappedWord) === normalize(q.correct_answer);
|
||||
} else {
|
||||
correct = given !== undefined && normalize(given) === normalize(q.correct_answer);
|
||||
}
|
||||
if (correct) earned += q.points;
|
||||
record[q.id] = given ?? '';
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user