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 @@ +
+

Enter word-clue pairs. Words are shown as A, B, Cโ€ฆ (alphabetical order) in the word bank.

+
+ +
+
diff --git a/public/admin.js b/public/admin.js index 25d1b68..12d09ea 100644 --- a/public/admin.js +++ b/public/admin.js @@ -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 = ` + + + `; + 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 { diff --git a/public/app.js b/public/app.js index 9f31b24..9837719 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: 'โ“', + 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) => + `` + ).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) => ` +
+ Word Bank +
+ ${wb.map((w, i) => `${String.fromCharCode(65 + i)}. ${esc(w)}`).join('')} +
+
`; + + 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 = ``; + + $('#quiz-form').innerHTML = wordBankHtml(item.wordBank) + retakeBtn + + item.questions.map((q) => { + const correct = q.given && q.given.toUpperCase() === q.correctLetter; + return `
+ ${esc(q.given || '?')} + ${correct ? 'โœ…' : 'โŒ'} ${esc(q.prompt)} + ${correct ? '' : `โ†’ ${esc(q.correctLetter)} (${esc(q.correctWord)})`} +
`; + }).join(''); + + if (item.allow_retakes) { + $('#quiz-retake-btn').addEventListener('click', () => openMatching(itemId, true)); + } + } else { + result.hidden = true; + $('#quiz-form').innerHTML = wordBankHtml(item.wordBank) + + `

Write the letter from the word bank that matches each clue.

` + + item.questions.map((q, i) => + `
+ + ${i + 1}. ${esc(q.prompt)} +
` + ).join('') + + ``; + + 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 }; diff --git a/public/index.html b/public/index.html index dbb4008..dd208f4 100644 --- a/public/index.html +++ b/public/index.html @@ -21,7 +21,10 @@