Add student delight features: celebrations, sounds, animations, and background patterns
- Confetti + pop sound on lesson/assignment completion - Done stamp animation (awaitable, shows below toast) - Welcome toast on first login only (not back-navigation) - Web Audio ding/buzz during spelling practice and flashcards - Fanfare on all-day completion - Score counter animate-up on practice/test results - 3D flashcard flip animation (rotateY via CSS keyframes) - Tab slide transition (Today/Courses/Spelling, directional) - Staggered card entrance + time-of-day greeting on home screen - Emoji wobble on kid card hover - Word card green/red flash on correct/wrong spelling answer - Combo streak celebration at milestones (3, 5, 7, 10 in a row) - Random cheer messages for generic celebrations - Button bounce on :active (scale + translateY) - Progress bar smooth transition (0.5s ease-out) - Background pattern picker: dots, stripes, grid, stars, bubbles - bg_pattern persisted per-student via PATCH /api/students/:id/bg-pattern - Theme switching preserves bg pattern class Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
6f73acd03e
commit
61f9288aa5
5 files changed
+523
-13
No files matched your search
@@ -228,6 +228,7 @@ tryAlter(`ALTER TABLE students ADD COLUMN theme TEXT NOT NULL DEFAULT 'blue'`);
|
||||
tryAlter(`ALTER TABLE students ADD COLUMN streak_date TEXT`);
|
||||
tryAlter(`ALTER TABLE students ADD COLUMN streak_count INTEGER NOT NULL DEFAULT 0`);
|
||||
tryAlter(`ALTER TABLE students ADD COLUMN best_streak INTEGER NOT NULL DEFAULT 0`);
|
||||
tryAlter(`ALTER TABLE students ADD COLUMN bg_pattern TEXT NOT NULL DEFAULT 'none'`);
|
||||
tryAlter(`ALTER TABLE lists ADD COLUMN group_name TEXT NOT NULL DEFAULT ''`);
|
||||
tryAlter(`ALTER TABLE decks ADD COLUMN group_name TEXT NOT NULL DEFAULT ''`);
|
||||
|
||||
|
||||
+311
-7
@@ -211,7 +211,19 @@ function applyTheme(theme) {
|
||||
const vars = THEMES[theme] || {};
|
||||
for (const [k, v] of Object.entries(DEFAULT_VARS))
|
||||
document.documentElement.style.setProperty(k, vars[k] || v);
|
||||
const bgClass = [...document.body.classList].find(c => c.startsWith('bg-'));
|
||||
document.body.className = theme && theme !== 'blue' ? `theme-${theme}` : '';
|
||||
if (bgClass) document.body.classList.add(bgClass);
|
||||
}
|
||||
|
||||
const BG_PATTERNS = ['none','dots','stripes','grid','stars','bubbles'];
|
||||
|
||||
function applyBgPattern(pattern) {
|
||||
BG_PATTERNS.forEach(p => document.body.classList.remove(`bg-${p}`));
|
||||
if (pattern && pattern !== 'none') document.body.classList.add(`bg-${pattern}`);
|
||||
document.querySelectorAll('.bg-pattern-btn').forEach(btn =>
|
||||
btn.classList.toggle('active', btn.dataset.pattern === (pattern || 'none'))
|
||||
);
|
||||
}
|
||||
|
||||
function resetTheme() {
|
||||
@@ -225,16 +237,22 @@ let currentStudent = null;
|
||||
|
||||
async function loadHome() {
|
||||
resetTheme();
|
||||
const hour = new Date().getHours();
|
||||
const greeting = hour < 12 ? 'Good morning! ☀️'
|
||||
: hour < 17 ? 'Good afternoon! 🌤️'
|
||||
: 'Good evening! 🌙';
|
||||
document.querySelector('.subtitle').textContent = greeting;
|
||||
|
||||
const students = await api('/api/students');
|
||||
$('#no-kids').hidden = students.length > 0;
|
||||
$('#kid-cards').innerHTML = students
|
||||
.map(
|
||||
(s) => `<button class="kid-card" data-id="${s.id}" data-theme="${esc(s.theme || 'blue')}">
|
||||
(s, i) => `<button class="kid-card" data-id="${s.id}" data-theme="${esc(s.theme || 'blue')}" style="animation-delay:${i * 100}ms">
|
||||
<span class="avatar">${esc(s.emoji)}</span>${esc(s.name)}</button>`
|
||||
)
|
||||
.join('');
|
||||
document.querySelectorAll('.kid-card').forEach((card) =>
|
||||
card.addEventListener('click', () => openKid(Number(card.dataset.id)))
|
||||
card.addEventListener('click', () => openKid(Number(card.dataset.id), 'today', true))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -244,12 +262,26 @@ document.querySelectorAll('.nav-pill').forEach((pill) =>
|
||||
pill.addEventListener('click', () => switchTab(pill.dataset.tab))
|
||||
);
|
||||
|
||||
const TAB_ORDER = ['today', 'courses', 'spelling'];
|
||||
let currentTab = 'today';
|
||||
|
||||
function switchTab(name) {
|
||||
const animate = name !== currentTab;
|
||||
const dir = TAB_ORDER.indexOf(name) > TAB_ORDER.indexOf(currentTab) ? 'right' : 'left';
|
||||
currentTab = name;
|
||||
document.querySelectorAll('.nav-pill').forEach((p) => p.classList.toggle('active', p.dataset.tab === name));
|
||||
document.querySelectorAll('.tab-panel').forEach((panel) => (panel.hidden = panel.id !== `tab-${name}`));
|
||||
document.querySelectorAll('.tab-panel').forEach((panel) => {
|
||||
const isTarget = panel.id === `tab-${name}`;
|
||||
panel.hidden = !isTarget;
|
||||
if (isTarget && animate) {
|
||||
panel.classList.remove('sliding-right', 'sliding-left');
|
||||
void panel.offsetWidth;
|
||||
panel.classList.add(dir === 'right' ? 'sliding-right' : 'sliding-left');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function openKid(id, tab = 'today') {
|
||||
async function openKid(id, tab = 'today', welcome = false) {
|
||||
const [state, agenda, courses, { tests: recentTests }] = await Promise.all([
|
||||
api(`/api/state/${id}`),
|
||||
api(`/api/schedule/${id}?date=${todayStr()}`),
|
||||
@@ -258,6 +290,7 @@ async function openKid(id, tab = 'today') {
|
||||
]);
|
||||
currentStudent = state.student;
|
||||
applyTheme(currentStudent.theme || 'blue');
|
||||
applyBgPattern(currentStudent.bg_pattern || 'none');
|
||||
updateThemeDots(currentStudent.theme || 'blue');
|
||||
|
||||
$('#kid-greeting').textContent = `${state.student.emoji} Hi, ${state.student.name}!`;
|
||||
@@ -277,7 +310,9 @@ async function openKid(id, tab = 'today') {
|
||||
renderSpellingTab(state, recentTests);
|
||||
|
||||
show('kid');
|
||||
currentTab = tab;
|
||||
switchTab(tab);
|
||||
if (welcome) showWelcomeToast(currentStudent.emoji, currentStudent.name);
|
||||
|
||||
// Celebrate if all today's tasks are done
|
||||
if (agenda.tasks.length > 0) {
|
||||
@@ -298,6 +333,7 @@ function updateThemeDots(active) {
|
||||
}
|
||||
|
||||
function showCelebration({ streak, best }) {
|
||||
playFanfare();
|
||||
const isRecord = streak > 1 && streak === best;
|
||||
let streakHtml = '';
|
||||
if (streak >= 2) {
|
||||
@@ -314,12 +350,226 @@ $('#celebration-close').addEventListener('click', () => {
|
||||
$('#celebration-overlay').hidden = true;
|
||||
});
|
||||
|
||||
// ---------- mini celebration (confetti + toast) ----------
|
||||
|
||||
let pendingMiniCelebration = false;
|
||||
|
||||
function popConfetti() {
|
||||
const colors = ['#4f86f7','#e84040','#2e9e5b','#e8802a','#7c5cbf','#d45d8a','#c8960c','#20a8a0','#f7c948','#ff6b6b','#48dbfb'];
|
||||
const container = document.createElement('div');
|
||||
container.style.cssText = 'position:fixed;inset:0;pointer-events:none;z-index:999;overflow:hidden';
|
||||
document.body.appendChild(container);
|
||||
for (let i = 0; i < 160; i++) {
|
||||
const piece = document.createElement('div');
|
||||
const color = colors[Math.floor(Math.random() * colors.length)];
|
||||
const x = Math.random() * 110 - 5;
|
||||
const delay = Math.random() * 1.2;
|
||||
const dur = 1.8 + Math.random() * 1.4;
|
||||
const size = 10 + Math.random() * 18;
|
||||
const shape = Math.random();
|
||||
let borderRadius = '2px';
|
||||
if (shape > 0.66) borderRadius = '50%';
|
||||
else if (shape > 0.33) borderRadius = '0';
|
||||
piece.style.cssText = `position:absolute;left:${x}%;top:-30px;width:${size}px;height:${size * (0.4 + Math.random() * 0.8)}px;`
|
||||
+ `background:${color};border-radius:${borderRadius};`
|
||||
+ `animation:confetti-fall ${dur}s ${delay}s ease-in forwards;transform:rotate(${Math.random()*360}deg)`;
|
||||
container.appendChild(piece);
|
||||
}
|
||||
setTimeout(() => container.remove(), 4000);
|
||||
}
|
||||
|
||||
function playFlip() {
|
||||
try {
|
||||
const ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
const t = ctx.currentTime;
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.connect(gain); gain.connect(ctx.destination);
|
||||
osc.type = 'sine';
|
||||
osc.frequency.setValueAtTime(320, t);
|
||||
osc.frequency.exponentialRampToValueAtTime(680, t + 0.05);
|
||||
osc.frequency.exponentialRampToValueAtTime(220, t + 0.14);
|
||||
gain.gain.setValueAtTime(0.16, t);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.16);
|
||||
osc.start(t); osc.stop(t + 0.16);
|
||||
setTimeout(() => ctx.close(), 400);
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
function playDing() {
|
||||
try {
|
||||
const ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
const t = ctx.currentTime;
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.connect(gain); gain.connect(ctx.destination);
|
||||
osc.type = 'sine'; osc.frequency.setValueAtTime(1400, t);
|
||||
osc.frequency.exponentialRampToValueAtTime(900, t + 0.12);
|
||||
gain.gain.setValueAtTime(0.28, t);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.35);
|
||||
osc.start(t); osc.stop(t + 0.35);
|
||||
setTimeout(() => ctx.close(), 600);
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
function playBuzz() {
|
||||
try {
|
||||
const ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
const t = ctx.currentTime;
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.connect(gain); gain.connect(ctx.destination);
|
||||
osc.type = 'sawtooth'; osc.frequency.setValueAtTime(220, t);
|
||||
osc.frequency.exponentialRampToValueAtTime(80, t + 0.18);
|
||||
gain.gain.setValueAtTime(0.22, t);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.22);
|
||||
osc.start(t); osc.stop(t + 0.22);
|
||||
setTimeout(() => ctx.close(), 400);
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
function playFanfare() {
|
||||
try {
|
||||
const ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
const t = ctx.currentTime;
|
||||
// Rising arpeggio
|
||||
[[523, 0], [659, 0.13], [784, 0.26], [1047, 0.39]].forEach(([freq, delay]) => {
|
||||
const osc = ctx.createOscillator();
|
||||
const g = ctx.createGain();
|
||||
osc.connect(g); g.connect(ctx.destination);
|
||||
osc.frequency.value = freq;
|
||||
const s = t + delay;
|
||||
g.gain.setValueAtTime(0.38, s);
|
||||
g.gain.exponentialRampToValueAtTime(0.001, s + 0.28);
|
||||
osc.start(s); osc.stop(s + 0.28);
|
||||
});
|
||||
// Final sustained chord
|
||||
[523, 659, 784, 1047].forEach((freq) => {
|
||||
const osc = ctx.createOscillator();
|
||||
const g = ctx.createGain();
|
||||
osc.connect(g); g.connect(ctx.destination);
|
||||
osc.frequency.value = freq;
|
||||
const s = t + 0.55;
|
||||
g.gain.setValueAtTime(0.22, s);
|
||||
g.gain.exponentialRampToValueAtTime(0.001, s + 1.1);
|
||||
osc.start(s); osc.stop(s + 1.15);
|
||||
});
|
||||
setTimeout(() => ctx.close(), 2500);
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
function animateScore(el, score, total) {
|
||||
if (!el) return;
|
||||
const duration = 650;
|
||||
const start = performance.now();
|
||||
const tick = (now) => {
|
||||
const p = Math.min((now - start) / duration, 1);
|
||||
const eased = 1 - Math.pow(1 - p, 3);
|
||||
el.textContent = `${Math.round(eased * score)} / ${total}`;
|
||||
if (p < 1) requestAnimationFrame(tick);
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
function playPopSound() {
|
||||
try {
|
||||
const ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
const t = ctx.currentTime;
|
||||
|
||||
// Thump: short pitch-sweep down like a party popper
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.connect(gain); gain.connect(ctx.destination);
|
||||
osc.frequency.setValueAtTime(900, t);
|
||||
osc.frequency.exponentialRampToValueAtTime(80, t + 0.13);
|
||||
gain.gain.setValueAtTime(0.55, t);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.13);
|
||||
osc.start(t); osc.stop(t + 0.13);
|
||||
|
||||
// Sparkle: quick ascending arpeggio C-E-G-C
|
||||
[523, 659, 784, 1047].forEach((freq, i) => {
|
||||
const o = ctx.createOscillator();
|
||||
const g = ctx.createGain();
|
||||
o.connect(g); g.connect(ctx.destination);
|
||||
o.type = 'sine'; o.frequency.value = freq;
|
||||
const s = t + 0.06 + i * 0.075;
|
||||
g.gain.setValueAtTime(0, s);
|
||||
g.gain.linearRampToValueAtTime(0.28, s + 0.02);
|
||||
g.gain.exponentialRampToValueAtTime(0.001, s + 0.13);
|
||||
o.start(s); o.stop(s + 0.15);
|
||||
});
|
||||
|
||||
setTimeout(() => ctx.close(), 1200);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
const CHEERS = [
|
||||
'🎉 Nailed it!', '⭐ Star student!', '💪 You\'re crushing it!',
|
||||
'🔥 On fire!', '🎯 Spot on!', '✨ Brilliant!',
|
||||
'🚀 Killing it!', '🏅 Awesome work!', '💫 You rock!', '🦾 Unstoppable!',
|
||||
];
|
||||
function randomCheer() { return CHEERS[Math.floor(Math.random() * CHEERS.length)]; }
|
||||
|
||||
function showMiniCelebration(msg = '🎉 Great job!') {
|
||||
playPopSound();
|
||||
popConfetti();
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'mini-celebration-toast';
|
||||
toast.textContent = msg;
|
||||
document.body.appendChild(toast);
|
||||
setTimeout(() => { toast.style.animation = 'mini-toast-out 0.5s ease forwards'; }, 1800);
|
||||
setTimeout(() => toast.remove(), 2400);
|
||||
}
|
||||
|
||||
function showWelcomeToast(emoji, name) {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'welcome-toast';
|
||||
toast.innerHTML = `<span class="welcome-emoji">${emoji}</span><div class="welcome-name">Hi, ${esc(name)}! 👋</div>`;
|
||||
document.body.appendChild(toast);
|
||||
setTimeout(() => toast.classList.add('welcome-toast-out'), 1700);
|
||||
setTimeout(() => toast.remove(), 2150);
|
||||
}
|
||||
|
||||
function showDoneStamp() {
|
||||
return new Promise((resolve) => {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'done-stamp-wrap';
|
||||
const stamp = document.createElement('div');
|
||||
stamp.className = 'done-stamp';
|
||||
stamp.textContent = '✅';
|
||||
wrap.appendChild(stamp);
|
||||
document.body.appendChild(wrap);
|
||||
setTimeout(() => {
|
||||
stamp.classList.add('done-stamp-out');
|
||||
setTimeout(() => { wrap.remove(); resolve(); }, 380);
|
||||
}, 750);
|
||||
});
|
||||
}
|
||||
|
||||
// Theme picker toggle
|
||||
$('#theme-picker-btn').addEventListener('click', () => {
|
||||
$('#theme-dots').hidden = !$('#theme-dots').hidden;
|
||||
$('#icon-grid').hidden = true;
|
||||
$('#bg-pattern-panel').hidden = true;
|
||||
});
|
||||
|
||||
// BG pattern picker toggle
|
||||
$('#bg-picker-btn').addEventListener('click', () => {
|
||||
$('#bg-pattern-panel').hidden = !$('#bg-pattern-panel').hidden;
|
||||
$('#theme-dots').hidden = true;
|
||||
$('#icon-grid').hidden = true;
|
||||
});
|
||||
|
||||
document.querySelectorAll('.bg-pattern-btn').forEach(btn =>
|
||||
btn.addEventListener('click', async () => {
|
||||
const pattern = btn.dataset.pattern;
|
||||
applyBgPattern(pattern);
|
||||
currentStudent.bg_pattern = pattern;
|
||||
await api(`/api/students/${currentStudent.id}/bg-pattern`, { method: 'PATCH', body: { pattern } });
|
||||
$('#bg-pattern-panel').hidden = true;
|
||||
})
|
||||
);
|
||||
|
||||
document.querySelectorAll('.theme-dot').forEach((btn) =>
|
||||
btn.addEventListener('click', async () => {
|
||||
const theme = btn.dataset.theme;
|
||||
@@ -352,6 +602,7 @@ $('#icon-picker-btn').addEventListener('click', () => {
|
||||
}
|
||||
grid.hidden = !grid.hidden;
|
||||
$('#theme-dots').hidden = true;
|
||||
$('#bg-pattern-panel').hidden = true;
|
||||
});
|
||||
|
||||
const OFFLINE_STATUS_ICON = { not_started: '⬜', in_progress: '🔄', done: '✅' };
|
||||
@@ -744,6 +995,8 @@ async function openLesson(itemId) {
|
||||
btn.textContent = item.submission ? '✅ Read' : 'Done reading ✓';
|
||||
btn.onclick = async () => {
|
||||
await api(`/api/items/${itemId}/complete`, { method: 'POST', body: { studentId: currentStudent.id, date: todayStr() } });
|
||||
showMiniCelebration('📖 Lesson complete!');
|
||||
await showDoneStamp();
|
||||
backTarget();
|
||||
};
|
||||
}
|
||||
@@ -813,6 +1066,8 @@ async function openAssignment(itemId) {
|
||||
const body = { studentId: currentStudent.id, date: todayStr() };
|
||||
if (needsEvidence) { body.evidenceNotes = $('#assignment-evidence-notes').value || null; body.evidencePhoto = evidencePhotoBase64; body.studentNote = $('#assignment-student-note').value || null; }
|
||||
await api(`/api/items/${itemId}/complete`, { method: 'POST', body });
|
||||
showMiniCelebration('📝 Turned in!');
|
||||
await showDoneStamp();
|
||||
openAssignment(itemId);
|
||||
};
|
||||
}
|
||||
@@ -974,6 +1229,7 @@ async function openWorksheet(itemId) {
|
||||
result.className = 'status-banner';
|
||||
result.textContent = '⏳ Turned in — waiting for a parent to grade it.';
|
||||
submitBtn.hidden = true;
|
||||
if (pendingMiniCelebration) { pendingMiniCelebration = false; showMiniCelebration('📋 Turned in!'); }
|
||||
} else if (graded) {
|
||||
result.hidden = false;
|
||||
result.className = 'status-banner good';
|
||||
@@ -981,6 +1237,11 @@ async function openWorksheet(itemId) {
|
||||
result.textContent = hasShort
|
||||
? `🌟 Score: ${sub.score} / ${sub.points_possible} (short answers parent-graded)`
|
||||
: `🌟 Score: ${sub.score} / ${sub.points_possible}`;
|
||||
if (pendingMiniCelebration) {
|
||||
pendingMiniCelebration = false;
|
||||
const wsPct = sub.points_possible ? Math.round((sub.score / sub.points_possible) * 100) : 100;
|
||||
showMiniCelebration(wsPct >= 70 ? `🌟 ${wsPct}% — Great!` : randomCheer());
|
||||
}
|
||||
if (item.allow_retakes) {
|
||||
submitBtn.hidden = false;
|
||||
submitBtn.textContent = '🔁 Retake';
|
||||
@@ -1002,6 +1263,7 @@ async function openWorksheet(itemId) {
|
||||
method: 'POST',
|
||||
body: { studentId: currentStudent.id, answers, date: todayStr() },
|
||||
});
|
||||
pendingMiniCelebration = true;
|
||||
openWorksheet(itemId);
|
||||
} catch (err) {
|
||||
result.hidden = false;
|
||||
@@ -1108,6 +1370,11 @@ async function openQuiz(itemId, forceRetake = false) {
|
||||
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}%)`;
|
||||
|
||||
if (pendingMiniCelebration) {
|
||||
pendingMiniCelebration = false;
|
||||
showMiniCelebration(pct >= 70 ? `🌟 ${pct}% — Nice!` : randomCheer());
|
||||
}
|
||||
|
||||
// Attempt history
|
||||
const { history } = await api(`/api/items/${itemId}/history?studentId=${currentStudent.id}`);
|
||||
let historyHtml = '';
|
||||
@@ -1179,6 +1446,7 @@ async function openQuiz(itemId, forceRetake = false) {
|
||||
method: 'POST',
|
||||
body: { studentId: currentStudent.id, answers, date: todayStr() },
|
||||
});
|
||||
pendingMiniCelebration = true;
|
||||
openQuiz(itemId, false);
|
||||
};
|
||||
}
|
||||
@@ -1214,6 +1482,11 @@ async function openMatching(itemId, forceRetake = false) {
|
||||
? Math.round((item.submission.score / item.submission.points_possible) * 100) : 0;
|
||||
result.innerHTML = `🌟 Score: ${item.submission.score} / ${item.submission.points_possible} (${pct}%)`;
|
||||
|
||||
if (pendingMiniCelebration) {
|
||||
pendingMiniCelebration = false;
|
||||
showMiniCelebration(pct >= 70 ? `🌟 ${pct}% — Nice!` : randomCheer());
|
||||
}
|
||||
|
||||
let retakeBtn = '';
|
||||
if (item.allow_retakes) retakeBtn = `<button id="quiz-retake-btn" class="check-btn secondary">🔁 Retake</button>`;
|
||||
|
||||
@@ -1269,6 +1542,7 @@ async function openMatching(itemId, forceRetake = false) {
|
||||
method: 'POST',
|
||||
body: { studentId: currentStudent.id, answers, date: todayStr() },
|
||||
});
|
||||
pendingMiniCelebration = true;
|
||||
openMatching(itemId, false);
|
||||
};
|
||||
}
|
||||
@@ -1601,9 +1875,18 @@ function presentFlashcard() {
|
||||
}
|
||||
|
||||
$('#flash-reveal').addEventListener('click', () => {
|
||||
$('#flash-back').hidden = false;
|
||||
$('#flash-reveal').hidden = true;
|
||||
$('#flash-grade-buttons').hidden = false;
|
||||
playFlip();
|
||||
const card = $('#flash-card');
|
||||
card.classList.remove('flip-in');
|
||||
card.classList.add('flip-out');
|
||||
setTimeout(() => {
|
||||
card.classList.remove('flip-out');
|
||||
$('#flash-back').hidden = false;
|
||||
$('#flash-reveal').hidden = true;
|
||||
$('#flash-grade-buttons').hidden = false;
|
||||
card.classList.add('flip-in');
|
||||
setTimeout(() => card.classList.remove('flip-in'), 200);
|
||||
}, 180);
|
||||
});
|
||||
|
||||
let flashGrading = false;
|
||||
@@ -1611,6 +1894,7 @@ let flashGrading = false;
|
||||
async function gradeFlashcard(gotIt) {
|
||||
if (flashGrading) return; // guard against a rapid double-tap misgrading two cards
|
||||
flashGrading = true;
|
||||
if (gotIt) playDing(); else playBuzz();
|
||||
const c = flash.cards[flash.i];
|
||||
await api('/api/flashcards/grade', { method: 'POST', body: { studentId: currentStudent.id, cardId: c.id, gotIt } });
|
||||
flash.reviewed++;
|
||||
@@ -1686,9 +1970,15 @@ $('#practice-form').addEventListener('submit', async (e) => {
|
||||
});
|
||||
|
||||
if (result.correct) {
|
||||
playDing();
|
||||
const card = $('#practice-card');
|
||||
card.classList.remove('flash-correct', 'flash-wrong');
|
||||
void card.offsetWidth;
|
||||
card.classList.add('flash-correct');
|
||||
if (!practice.awaitingRetype) {
|
||||
practice.firstTryCorrect++;
|
||||
practice.streak++;
|
||||
if ([3, 5, 7, 10].includes(practice.streak)) showMiniCelebration(`🔥 ${practice.streak} in a row!`);
|
||||
}
|
||||
const streakMsg = (!practice.awaitingRetype && practice.streak >= 2)
|
||||
? ` <span class="streak-badge">🔥 ${practice.streak} in a row!</span>` : '';
|
||||
@@ -1697,6 +1987,11 @@ $('#practice-form').addEventListener('submit', async (e) => {
|
||||
setTimeout(nextPracticeWord, practice.streak >= 2 ? 1300 : 900);
|
||||
} else {
|
||||
if (!practice.awaitingRetype) {
|
||||
playBuzz();
|
||||
const card = $('#practice-card');
|
||||
card.classList.remove('flash-correct', 'flash-wrong');
|
||||
void card.offsetWidth;
|
||||
card.classList.add('flash-wrong');
|
||||
practice.missed.push(w.word);
|
||||
practice.missedWords.push(w);
|
||||
practice.streak = 0;
|
||||
@@ -1761,6 +2056,9 @@ async function nextPracticeWord() {
|
||||
${drillBtn}
|
||||
<button id="practice-home">Back</button>`;
|
||||
$('#practice-done').hidden = false;
|
||||
animateScore($('#practice-done .big-score'), practice.firstTryCorrect, practice.words.length);
|
||||
if (practice.missed.length === 0) showMiniCelebration('🏆 Perfect round!');
|
||||
else showMiniCelebration(randomCheer());
|
||||
if (missedWords.length) {
|
||||
$('#practice-drill-missed').addEventListener('click', () => {
|
||||
Object.assign(practice, { words: shuffle(missedWords), i: 0, missed: [], missedWords: [], firstTryCorrect: 0, awaitingRetype: false, streak: 0 });
|
||||
@@ -1838,6 +2136,7 @@ async function finishTest() {
|
||||
let retakeBtn = '';
|
||||
if (test.itemId && test.allowRetakes) retakeBtn = `<button id="test-retake-btn" class="check-btn secondary">🔁 Retake Test</button>`;
|
||||
|
||||
const testPct = result.total ? Math.round((result.score / result.total) * 100) : 100;
|
||||
$('#test-done').innerHTML = `
|
||||
<div>⭐ Test complete!</div>
|
||||
<div class="big-score">${result.score} / ${result.total}</div>
|
||||
@@ -1845,6 +2144,11 @@ async function finishTest() {
|
||||
${retakeBtn}
|
||||
<button id="test-home">Back</button>`;
|
||||
$('#test-done').hidden = false;
|
||||
animateScore($('#test-done .big-score'), result.score, result.total);
|
||||
if (testPct === 100) showMiniCelebration('🏆 Perfect score!');
|
||||
else if (testPct >= 80) showMiniCelebration(`⭐ ${result.score}/${result.total} — Amazing!`);
|
||||
else if (testPct >= 60) showMiniCelebration(`✅ ${result.score}/${result.total} — Nice work!`);
|
||||
else showMiniCelebration(randomCheer());
|
||||
$('#test-home').addEventListener('click', () => backTarget());
|
||||
if (test.itemId && test.allowRetakes) {
|
||||
$('#test-retake-btn').addEventListener('click', () => {
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
<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>
|
||||
<button id="bg-picker-btn" class="theme-picker-btn" aria-label="Pick my background">🖼️</button>
|
||||
</div>
|
||||
</div>
|
||||
<h1 id="kid-greeting"></h1>
|
||||
@@ -39,6 +40,14 @@
|
||||
<button class="theme-dot theme-indigo" data-theme="indigo" title="Indigo"></button>
|
||||
</div>
|
||||
<div id="icon-grid" class="icon-grid" hidden></div>
|
||||
<div id="bg-pattern-panel" class="bg-pattern-panel" hidden>
|
||||
<button class="bg-pattern-btn active" data-pattern="none" title="Plain"></button>
|
||||
<button class="bg-pattern-btn" data-pattern="dots" title="Dots"></button>
|
||||
<button class="bg-pattern-btn" data-pattern="stripes" title="Stripes"></button>
|
||||
<button class="bg-pattern-btn" data-pattern="grid" title="Grid"></button>
|
||||
<button class="bg-pattern-btn" data-pattern="stars" title="Stars"></button>
|
||||
<button class="bg-pattern-btn" data-pattern="bubbles" title="Bubbles"></button>
|
||||
</div>
|
||||
|
||||
<div class="kid-nav">
|
||||
<button class="nav-pill active" data-tab="today">📋 Today</button>
|
||||
|
||||
+193
-4
@@ -55,6 +55,18 @@ body {
|
||||
.error { color: var(--bad); margin: 0.5rem 0; }
|
||||
|
||||
/* ---------- kid picker ---------- */
|
||||
@keyframes card-enter {
|
||||
from { opacity: 0; transform: translateY(26px) scale(0.93); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
@keyframes emoji-wobble {
|
||||
0% { transform: rotate(0deg) scale(1); }
|
||||
20% { transform: rotate(-15deg) scale(1.18); }
|
||||
50% { transform: rotate(13deg) scale(1.14); }
|
||||
75% { transform: rotate(-7deg) scale(1.07); }
|
||||
100% { transform: rotate(0deg) scale(1); }
|
||||
}
|
||||
|
||||
.kid-cards {
|
||||
display: flex; flex-wrap: wrap; gap: 1rem; justify-content: center;
|
||||
margin-bottom: 2.5rem;
|
||||
@@ -68,10 +80,16 @@ body {
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 12px rgba(45, 49, 66, 0.08);
|
||||
transition: transform 0.1s, border-color 0.1s;
|
||||
transition: transform 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease;
|
||||
min-width: 140px;
|
||||
animation: card-enter 0.42s ease backwards;
|
||||
}
|
||||
.kid-card:hover { transform: translateY(-3px) scale(1.03); border-color: var(--accent); }
|
||||
.kid-card:hover {
|
||||
transform: translateY(-7px) scale(1.07);
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 12px 30px rgba(45, 49, 66, 0.18);
|
||||
}
|
||||
.kid-card:hover .avatar { animation: emoji-wobble 0.48s ease; }
|
||||
.kid-card .avatar { display: block; font-size: 3rem; margin-bottom: 0.5rem; }
|
||||
|
||||
/* ---------- kid home ---------- */
|
||||
@@ -111,7 +129,7 @@ body {
|
||||
.progress-track {
|
||||
height: 12px; background: #e8e2d0; border-radius: 6px; overflow: hidden; margin-bottom: 1.5rem;
|
||||
}
|
||||
.progress-fill { height: 100%; width: 0; background: var(--accent); transition: width 0.3s; }
|
||||
.progress-fill { height: 100%; width: 0; background: var(--accent); transition: width 0.5s ease-out; }
|
||||
.progress-fill.test-fill { background: var(--gold); }
|
||||
|
||||
.word-card {
|
||||
@@ -140,8 +158,9 @@ body {
|
||||
background: var(--accent); color: #fff; border: none;
|
||||
border-radius: 12px; padding: 0.75rem 2.5rem; cursor: pointer;
|
||||
box-shadow: 0 4px 0 var(--accent-dark);
|
||||
transition: transform 0.12s;
|
||||
}
|
||||
.check-btn:active { transform: translateY(3px); box-shadow: 0 1px 0 var(--accent-dark); }
|
||||
.check-btn:active { transform: scale(0.94) translateY(2px); box-shadow: 0 1px 0 var(--accent-dark); }
|
||||
|
||||
/* letter-diff feedback */
|
||||
.feedback { min-height: 1rem; margin-bottom: 1rem; font-size: 1.2rem; }
|
||||
@@ -276,6 +295,16 @@ table.results tr:nth-child(even) td { background: #faf7ee; }
|
||||
}
|
||||
.nav-pill.active { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||
.tab-panel { text-align: left; }
|
||||
@keyframes tab-slide-right {
|
||||
from { transform: translateX(56px); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
@keyframes tab-slide-left {
|
||||
from { transform: translateX(-56px); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
.tab-panel.sliding-right { animation: tab-slide-right 0.26s ease; }
|
||||
.tab-panel.sliding-left { animation: tab-slide-left 0.26s ease; }
|
||||
|
||||
/* ---------- today list / item rows (shared by agenda + course pages) ---------- */
|
||||
.today-row {
|
||||
@@ -441,6 +470,28 @@ table.results tr:nth-child(even) td { background: #faf7ee; }
|
||||
}
|
||||
.grade-btn.again { background: var(--bad); }
|
||||
.grade-btn.got-it { background: var(--good); }
|
||||
@keyframes card-flash-correct {
|
||||
0% { background: var(--card); }
|
||||
30% { background: color-mix(in srgb, var(--good) 25%, var(--card)); }
|
||||
100% { background: var(--card); }
|
||||
}
|
||||
@keyframes card-flash-wrong {
|
||||
0% { background: var(--card); }
|
||||
30% { background: color-mix(in srgb, var(--bad) 20%, var(--card)); }
|
||||
100% { background: var(--card); }
|
||||
}
|
||||
.word-card.flash-correct { animation: card-flash-correct 0.55s ease; }
|
||||
.word-card.flash-wrong { animation: card-flash-wrong 0.45s ease; }
|
||||
@keyframes flash-flip-out {
|
||||
from { transform: perspective(800px) rotateY(0deg); }
|
||||
to { transform: perspective(800px) rotateY(90deg); }
|
||||
}
|
||||
@keyframes flash-flip-in {
|
||||
from { transform: perspective(800px) rotateY(-90deg); }
|
||||
to { transform: perspective(800px) rotateY(0deg); }
|
||||
}
|
||||
#flash-card.flip-out { animation: flash-flip-out 0.18s ease-in forwards; }
|
||||
#flash-card.flip-in { animation: flash-flip-in 0.18s ease-out forwards; }
|
||||
|
||||
/* ---------- admin console ---------- */
|
||||
.admin-header {
|
||||
@@ -800,6 +851,144 @@ body.theme-indigo { --accent: #5b6abf; --accent-dark: #4a57a0; --bg: #f4f5ff; }
|
||||
.icon-choice:hover { transform: scale(1.2); }
|
||||
.icon-choice.active { border-color: var(--accent); background: var(--bg); }
|
||||
|
||||
/* ---------- background patterns ---------- */
|
||||
body.bg-dots {
|
||||
background-image: radial-gradient(circle, color-mix(in srgb, var(--accent) 38%, transparent) 2.5px, transparent 2.5px);
|
||||
background-size: 26px 26px;
|
||||
}
|
||||
body.bg-stripes {
|
||||
background-image: repeating-linear-gradient(
|
||||
45deg, transparent, transparent 14px,
|
||||
color-mix(in srgb, var(--accent) 18%, transparent) 14px,
|
||||
color-mix(in srgb, var(--accent) 18%, transparent) 28px
|
||||
);
|
||||
}
|
||||
body.bg-grid {
|
||||
background-image:
|
||||
linear-gradient(color-mix(in srgb, var(--accent) 16%, transparent) 1px, transparent 1px),
|
||||
linear-gradient(90deg, color-mix(in srgb, var(--accent) 16%, transparent) 1px, transparent 1px);
|
||||
background-size: 32px 32px;
|
||||
}
|
||||
body.bg-stars {
|
||||
background-image:
|
||||
radial-gradient(circle, color-mix(in srgb, var(--accent) 55%, transparent) 1.5px, transparent 1.5px),
|
||||
radial-gradient(circle, color-mix(in srgb, var(--accent) 30%, transparent) 1px, transparent 1px),
|
||||
radial-gradient(circle, color-mix(in srgb, var(--accent) 45%, transparent) 2px, transparent 2px);
|
||||
background-size: 48px 48px, 28px 28px, 68px 68px;
|
||||
background-position: 5px 8px, 18px 32px, 46px 18px;
|
||||
}
|
||||
body.bg-bubbles {
|
||||
background-image: radial-gradient(circle, color-mix(in srgb, var(--accent) 18%, transparent) 18px, transparent 18px);
|
||||
background-size: 64px 64px;
|
||||
}
|
||||
|
||||
/* ---------- bg pattern picker ---------- */
|
||||
.bg-pattern-panel {
|
||||
display: flex; gap: 0.5rem; flex-wrap: wrap;
|
||||
padding: 0.6rem 0.75rem; background: var(--card);
|
||||
border-radius: var(--radius); box-shadow: 0 4px 16px rgba(0,0,0,0.12);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.bg-pattern-btn {
|
||||
width: 46px; height: 46px; border-radius: 8px;
|
||||
border: 2.5px solid transparent; cursor: pointer;
|
||||
background-color: var(--bg); flex-shrink: 0;
|
||||
transition: border-color 0.15s, transform 0.15s;
|
||||
}
|
||||
.bg-pattern-btn:hover { transform: scale(1.12); }
|
||||
.bg-pattern-btn.active { border-color: var(--accent); }
|
||||
.bg-pattern-btn[data-pattern="dots"] {
|
||||
background-image: radial-gradient(circle, color-mix(in srgb, var(--accent) 50%, transparent) 2.5px, transparent 2.5px);
|
||||
background-size: 9px 9px;
|
||||
}
|
||||
.bg-pattern-btn[data-pattern="stripes"] {
|
||||
background-image: repeating-linear-gradient(
|
||||
45deg, transparent, transparent 5px,
|
||||
color-mix(in srgb, var(--accent) 30%, transparent) 5px,
|
||||
color-mix(in srgb, var(--accent) 30%, transparent) 10px
|
||||
);
|
||||
}
|
||||
.bg-pattern-btn[data-pattern="grid"] {
|
||||
background-image:
|
||||
linear-gradient(color-mix(in srgb, var(--accent) 25%, transparent) 1px, transparent 1px),
|
||||
linear-gradient(90deg, color-mix(in srgb, var(--accent) 25%, transparent) 1px, transparent 1px);
|
||||
background-size: 10px 10px;
|
||||
}
|
||||
.bg-pattern-btn[data-pattern="stars"] {
|
||||
background-image:
|
||||
radial-gradient(circle, color-mix(in srgb, var(--accent) 60%, transparent) 1.5px, transparent 1.5px),
|
||||
radial-gradient(circle, color-mix(in srgb, var(--accent) 35%, transparent) 1px, transparent 1px);
|
||||
background-size: 14px 14px, 8px 8px;
|
||||
background-position: 2px 3px, 6px 10px;
|
||||
}
|
||||
.bg-pattern-btn[data-pattern="bubbles"] {
|
||||
background-image: radial-gradient(circle, color-mix(in srgb, var(--accent) 25%, transparent) 8px, transparent 8px);
|
||||
background-size: 22px 22px;
|
||||
}
|
||||
|
||||
/* ---------- welcome toast ---------- */
|
||||
.welcome-toast {
|
||||
position: fixed; top: 50%; left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: var(--card); border-radius: var(--radius);
|
||||
padding: 2.2rem 3.5rem; text-align: center;
|
||||
box-shadow: 0 16px 56px rgba(0,0,0,0.22); z-index: 1000;
|
||||
pointer-events: none;
|
||||
animation: welcome-in 0.38s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
|
||||
}
|
||||
.welcome-emoji { display: block; font-size: 4.5rem; margin-bottom: 0.4rem; }
|
||||
.welcome-name { font-size: 2.2rem; font-weight: 800; }
|
||||
@keyframes welcome-in {
|
||||
from { opacity: 0; transform: translate(-50%, -50%) scale(0.7); }
|
||||
to { opacity: 1; transform: translate(-50%, -50%) scale(1); }
|
||||
}
|
||||
@keyframes welcome-out {
|
||||
to { opacity: 0; transform: translate(-50%, -54%) scale(0.88); }
|
||||
}
|
||||
.welcome-toast-out { animation: welcome-out 0.4s ease forwards; }
|
||||
|
||||
/* ---------- done stamp ---------- */
|
||||
.done-stamp-wrap {
|
||||
position: fixed; inset: 0;
|
||||
display: flex; align-items: flex-end; justify-content: center;
|
||||
padding-bottom: 10vh;
|
||||
z-index: 1000; pointer-events: none;
|
||||
background: rgba(0,0,0,0.06);
|
||||
}
|
||||
.done-stamp {
|
||||
background: var(--card); border-radius: 50%;
|
||||
width: 170px; height: 170px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 5.5rem;
|
||||
box-shadow: 0 10px 48px rgba(0,0,0,0.22);
|
||||
animation: stamp-in 0.38s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
|
||||
}
|
||||
@keyframes stamp-in {
|
||||
from { transform: scale(0) rotate(-18deg); opacity: 0; }
|
||||
to { transform: scale(1) rotate(0deg); opacity: 1; }
|
||||
}
|
||||
@keyframes stamp-out {
|
||||
to { transform: scale(0.75) rotate(6deg); opacity: 0; }
|
||||
}
|
||||
.done-stamp-out { animation: stamp-out 0.35s ease forwards; }
|
||||
|
||||
/* ---------- mini celebration (confetti + toast) ---------- */
|
||||
@keyframes confetti-fall {
|
||||
to { transform: translateY(105vh) rotate(540deg); opacity: 0; }
|
||||
}
|
||||
.mini-celebration-toast {
|
||||
position: fixed; top: 45%; left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: var(--card); border-radius: var(--radius);
|
||||
padding: 1.6rem 3.2rem; font-size: 2.4rem; font-weight: 800;
|
||||
text-align: center; white-space: nowrap;
|
||||
box-shadow: 0 12px 48px rgba(0,0,0,0.28); z-index: 1000;
|
||||
animation: pop-in 0.25s ease; pointer-events: none;
|
||||
}
|
||||
@keyframes mini-toast-out {
|
||||
to { opacity: 0; transform: translate(-50%, -65%) scale(0.88); }
|
||||
}
|
||||
|
||||
/* ---------- celebration overlay ---------- */
|
||||
#celebration-overlay {
|
||||
position: fixed; inset: 0; z-index: 200;
|
||||
|
||||
@@ -323,10 +323,11 @@ const GRADABLE_TYPES = ['assignment', 'quiz', 'matching', 'crossword', 'spelling
|
||||
// ---------- kid picker ----------
|
||||
|
||||
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());
|
||||
res.json(db.prepare(`SELECT id, name, emoji, theme, bg_pattern, streak_count, streak_date FROM students ORDER BY name`).all());
|
||||
});
|
||||
|
||||
const VALID_THEMES = ['blue','green','purple','orange','pink','red','teal','yellow','indigo'];
|
||||
const VALID_BG_PATTERNS = ['none','dots','stripes','grid','stars','bubbles'];
|
||||
|
||||
app.patch('/api/students/:id/theme', (req, res) => {
|
||||
const theme = VALID_THEMES.includes(req.body.theme) ? req.body.theme : 'blue';
|
||||
@@ -334,6 +335,12 @@ app.patch('/api/students/:id/theme', (req, res) => {
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.patch('/api/students/:id/bg-pattern', (req, res) => {
|
||||
const pattern = VALID_BG_PATTERNS.includes(req.body.pattern) ? req.body.pattern : 'none';
|
||||
db.prepare(`UPDATE students SET bg_pattern = ? WHERE id = ?`).run(pattern, 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' });
|
||||
@@ -361,7 +368,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, theme FROM students WHERE id = ?`).get(id);
|
||||
const student = db.prepare(`SELECT id, name, emoji, theme, bg_pattern FROM students WHERE id = ?`).get(id);
|
||||
if (!student) return res.status(404).json({ error: 'No such student' });
|
||||
|
||||
const assignment = db.prepare(`
|
||||
|
||||
Reference in new issue
Block a user