diff --git a/public/admin.html b/public/admin.html
index d0a459d..d955908 100644
--- a/public/admin.html
+++ b/public/admin.html
@@ -129,6 +129,7 @@
+
@@ -234,6 +235,12 @@
+
Write the letter from the word bank that matches each clue.
` +
+ item.questions.map((q, i) =>
+ `
-
+
+
+
+
@@ -30,7 +33,12 @@
+
+
+
+
+
diff --git a/public/styles.css b/public/styles.css
index 8d79eb0..e86b34a 100644
--- a/public/styles.css
+++ b/public/styles.css
@@ -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; }
diff --git a/server.js b/server.js
index 536dd8f..7db9a06 100644
--- a/server.js
+++ b/server.js
@@ -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 ?? '';
}