/* ============================================================ HanziLearn – elearning.js Schema fields: kanji, pinyin, sino, vietnamese, topic_name ============================================================ */ (() => { const { level, mode, color } = window.LEARN_CONFIG; /* ── Apply CSS variable for header colour ─────────────── */ const header = document.getElementById('learnHeader'); const badge = document.getElementById('learnBadge'); if (header) header.style.setProperty('--lc', color); if (badge) badge.style.background = color; /* ── DOM helpers ─────────────────────────────────────── */ const $ = id => document.getElementById(id); const showPanel = name => $(`${name}Panel`)?.classList.remove('d-none'); const hidePanel = name => $(`${name}Panel`)?.classList.add('d-none'); function esc(str = '') { const d = document.createElement('div'); d.textContent = str; return d.innerHTML; } /* ── Username chip ───────────────────────────────────── */ document.addEventListener('DOMContentLoaded', () => { const username = window.api?.getUsername() || 'Guest'; const el = $('learnUsername'); if (el) el.textContent = username; }); /* ── Bootstrap entry point ────────────────────────────── */ document.addEventListener('DOMContentLoaded', () => { $('loadingPanel')?.classList.remove('d-none'); const init = { flashcard: initFlashcard, quiz: initQuiz, fillblank: initFillBlank, leaderboard: initLeaderboard }; (init[mode] || initFlashcard)(); }); function hideLoading() { $('loadingPanel')?.classList.add('d-none'); } /* ══════════════════════════════════════════════════════ FLASHCARD MODE ══════════════════════════════════════════════════════ */ async function initFlashcard() { const json = await fetch(`/api/flashcards/${level}`).then(r => r.json()); hideLoading(); if (!json.success || !json.data.length) { renderNoData('flashcard'); return; } let cards = json.data; let index = 0; let flipped = false; showPanel('flashcard'); /* refs */ const fcCard = $('fcCard'); const fcScene = $('fcScene'); const progBar = $('fcProgressBar'); function renderCard() { /* Reset flip state without animation for instant re-render */ fcCard.style.transition = 'none'; fcCard.classList.remove('flipped'); flipped = false; /* Force reflow so the removal takes effect before re-enabling transition */ void fcCard.offsetWidth; fcCard.style.transition = ''; const c = cards[index]; $('fcHanziFront').textContent = c.kanji; $('fcHanziBack').textContent = c.kanji; $('fcPinyin').textContent = c.pinyin; $('fcEnglish').textContent = c.sino; $('fcVietnamese').textContent = c.vietnamese; /* Topic badge on back */ const topicEl = document.getElementById('fcTopic'); if (topicEl) topicEl.textContent = c.topic_name || ''; $('fcCurrent').textContent = index + 1; $('fcTotal').textContent = cards.length; progBar.style.width = `${((index + 1) / cards.length) * 100}%`; } function flip() { flipped = !flipped; fcCard.classList.toggle('flipped', flipped); if (flipped) window.logActivity?.('flashcard_view', level, 0, { word_id: cards[index].id }); } /* Click on scene or Flip button */ fcScene.addEventListener('click', flip); $('fcFlip').addEventListener('click', e => { e.stopPropagation(); flip(); }); $('fcNext').addEventListener('click', () => { if (index < cards.length - 1) { index++; renderCard(); } else { window.logActivity?.('flashcard_complete', level, cards.length); showToast(`🎉 Finished all ${cards.length} cards! Reshuffling…`, () => { cards = shuffleArr([...json.data]); index = 0; renderCard(); }); } }); $('fcPrev').addEventListener('click', () => { if (index > 0) { index--; renderCard(); } }); $('fcShuffle').addEventListener('click', () => { cards = shuffleArr([...cards]); index = 0; renderCard(); showToast('Cards shuffled!'); }); /* Keyboard shortcuts */ document.addEventListener('keydown', e => { if (mode !== 'flashcard') return; if (e.key === 'ArrowRight') { e.preventDefault(); $('fcNext').click(); } if (e.key === 'ArrowLeft') { e.preventDefault(); $('fcPrev').click(); } if (e.key === ' ') { e.preventDefault(); flip(); } }); renderCard(); } /* ══════════════════════════════════════════════════════ QUIZ MODE ══════════════════════════════════════════════════════ */ async function initQuiz() { const json = await fetch(`/api/quiz/${level}?count=10`).then(r => r.json()); hideLoading(); if (!json.success || !json.data.length) { renderNoData('quiz'); return; } let questions = json.data; let qIndex = 0; let score = 0; let timer = null; let timeLeft = 30; let answered = false; showPanel('quiz'); $('quizResult').classList.add('d-none'); $('quizGame').classList.remove('d-none'); function renderQuestion() { answered = false; timeLeft = 30; const q = questions[qIndex]; $('qNum').textContent = qIndex + 1; $('qTotal').textContent = questions.length; $('qHanzi').textContent = q.kanji; $('qPinyin').textContent = q.pinyin; $('qScore').textContent = `Score: ${score}`; $('qProgress').style.width = `${(qIndex / questions.length) * 100}%`; $('qOptions').innerHTML = q.options.map(opt => ` `).join(''); $('qOptions').querySelectorAll('.quiz-option').forEach(btn => btn.addEventListener('click', () => handleAnswer(btn, q.correct)) ); startTimer(); } function handleAnswer(btn, correct) { if (answered) return; answered = true; clearInterval(timer); $('qOptions').querySelectorAll('.quiz-option').forEach(b => { b.disabled = true; if (b.dataset.val === correct) b.classList.add('correct'); }); if (btn.dataset.val === correct) { btn.classList.add('correct'); score += 10; } else { btn.classList.add('wrong'); } $('qScore').textContent = `Score: ${score}`; setTimeout(() => { qIndex++; qIndex < questions.length ? renderQuestion() : showResult(); }, 1200); } function startTimer() { clearInterval(timer); const timerEl = $('quizTimer'); timerEl.textContent = timeLeft; timerEl.classList.remove('urgent'); timer = setInterval(() => { timeLeft--; timerEl.textContent = timeLeft; if (timeLeft <= 10) timerEl.classList.add('urgent'); if (timeLeft <= 0) { clearInterval(timer); if (!answered) { answered = true; $('qOptions').querySelectorAll('.quiz-option').forEach(b => { b.disabled = true; if (b.dataset.val === questions[qIndex].correct) b.classList.add('correct'); }); setTimeout(() => { qIndex++; qIndex < questions.length ? renderQuestion() : showResult(); }, 1000); } } }, 1000); } function showResult() { clearInterval(timer); $('quizGame').classList.add('d-none'); $('quizResult').classList.remove('d-none'); const pct = Math.round((score / (questions.length * 10)) * 100); $('resultScore').textContent = `${score}/${questions.length * 10}`; $('resultPercent').textContent = `${pct}%`; const [emoji, title, msg] = pct >= 90 ? ['🏆','Outstanding!','Perfect performance!'] : pct >= 70 ? ['🎉','Great job!','Almost perfect!'] : pct >= 50 ? ['👍','Good effort!','Keep practicing!'] : ['📚','Keep going!','Review flashcards first.']; $('resultEmoji').textContent = emoji; $('resultTitle').textContent = title; $('resultMsg').textContent = msg; window.logActivity?.('quiz_complete', level, score, { percent: pct }); } $('quizRetry').addEventListener('click', async () => { $('quizResult').classList.add('d-none'); $('quizGame').classList.remove('d-none'); $('loadingPanel').classList.remove('d-none'); hidePanel('quiz'); const j2 = await fetch(`/api/quiz/${level}?count=10`).then(r => r.json()); questions = j2.data || questions; qIndex = 0; score = 0; hideLoading(); showPanel('quiz'); renderQuestion(); }); renderQuestion(); } /* ══════════════════════════════════════════════════════ FILL BLANK MODE ══════════════════════════════════════════════════════ */ async function initFillBlank() { const json = await fetch(`/api/fillblank/${level}`).then(r => r.json()); hideLoading(); if (!json.success || !json.data.length) { renderNoData('fillblank'); return; } const exercises = json.data; let index = 0, score = 0, submitted = false; showPanel('fillblank'); $('fbTotal').textContent = exercises.length; function renderExercise() { submitted = false; const ex = exercises[index]; $('fbCurrent').textContent = index + 1; $('fbScore').textContent = score; $('fbContext').textContent = ex.context || 'Complete the sentence'; /* Replace ___ with a styled span */ const sentence = esc(ex.sentence).replace(/___/g, '___'); $('fbSentence').innerHTML = sentence; $('fbHint').textContent = ex.hint_pinyin ? `Hint: ${ex.hint_pinyin}` : ''; $('fbFeedback').className = 'fb-feedback d-none'; $('fbTranslation').className = 'fb-translation d-none'; const inp = $('fbInput'); inp.value = ''; inp.className = 'form-control fb-input'; inp.focus(); } function checkAnswer() { if (submitted) return; const inp = $('fbInput'); const answer = inp.value.trim(); const correct = (exercises[index].answer || '').trim(); if (!answer) { inp.classList.add('is-invalid'); return; } inp.classList.remove('is-invalid'); submitted = true; const ok = answer === correct || answer.toLowerCase() === correct.toLowerCase(); const blank = document.getElementById('fbBlankDisplay'); if (blank) { blank.textContent = correct; blank.style.color = ok ? '#4CAF50' : '#F44336'; } const fb = $('fbFeedback'); fb.className = `fb-feedback ${ok ? 'correct' : 'wrong'}`; fb.textContent = ok ? `✓ Correct! "${correct}"` : `✗ Wrong. Answer: "${correct}"`; const tr = $('fbTranslation'); tr.className = 'fb-translation'; tr.textContent = exercises[index].translation || ''; if (ok) score += 5; $('fbScore').textContent = score; window.logActivity?.('fillblank_answer', level, ok ? 5 : 0, { correct: ok }); } $('fbSubmit').addEventListener('click', checkAnswer); $('fbInput').addEventListener('keydown', e => { if (e.key === 'Enter') checkAnswer(); }); $('fbNext').addEventListener('click', () => { if (index < exercises.length - 1) { index++; renderExercise(); } else showToast('🎉 All done! Starting over…', () => { index = 0; renderExercise(); }); }); $('fbPrev').addEventListener('click', () => { if (index > 0) { index--; renderExercise(); } }); renderExercise(); } /* ══════════════════════════════════════════════════════ LEADERBOARD MODE ══════════════════════════════════════════════════════ */ async function initLeaderboard() { const json = await fetch('/api/leaderboard').then(r => r.json()); hideLoading(); showPanel('leaderboard'); const data = json.data || []; renderList('topScoresList', data, 'score'); renderList('mostActiveList', [...data].sort((a,b) => (b.activity_count||0)-(a.activity_count||0)), 'count'); const username = window.api?.getUsername(); if (username) { const me = data.find(d => d.username === username); if (me) { $('myStats').style.display = 'block'; $('myStatsRow').innerHTML = `
${me.total_score || 0}
Total Score
${me.activity_count || 0}
Activities
#${data.indexOf(me)+1}
Rank
`; } } } function renderList(containerId, items, metric) { const el = $(containerId); if (!el) return; if (!items.length) { el.innerHTML = '

No data yet

'; return; } el.innerHTML = items.slice(0, 10).map((item, i) => { const rc = i === 0 ? 'gold' : i === 1 ? 'silver' : i === 2 ? 'bronze' : ''; const ri = i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : i + 1; const val = metric === 'score' ? `${item.total_score || 0} pts` : `${item.activity_count || 0} sessions`; return `
${ri}
${esc((item.username||'?').charAt(0).toUpperCase())}
${esc(item.username||'Unknown')}
${val}
`; }).join(''); } /* ── Shared utilities ────────────────────────────────── */ function shuffleArr(arr) { for (let i = arr.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [arr[i], arr[j]] = [arr[j], arr[i]]; } return arr; } function renderNoData(name) { showPanel(name); const p = $(`${name}Panel`); if (p) p.innerHTML = `
No data available

No ${name} data for HSK ${level} yet.

`; } function showToast(msg, callback) { const old = document.getElementById('hl-toast'); if (old) old.remove(); const t = document.createElement('div'); t.id = 'hl-toast'; t.style.cssText = [ 'position:fixed','bottom:2rem','left:50%','transform:translateX(-50%)', 'background:var(--bg-elevated)','border:1px solid var(--border)', 'border-radius:999px','padding:.6rem 1.5rem','font-size:.9rem', 'color:var(--text-primary)','z-index:9999', 'box-shadow:0 8px 32px rgba(0,0,0,.4)', 'animation:slideUp .3s cubic-bezier(.4,0,.2,1)' ].join(';'); t.textContent = msg; if (!document.getElementById('hl-toast-style')) { const s = document.createElement('style'); s.id = 'hl-toast-style'; s.textContent = `@keyframes slideUp{from{opacity:0;transform:translateX(-50%) translateY(16px)}to{opacity:1;transform:translateX(-50%) translateY(0)}}`; document.head.appendChild(s); } document.body.appendChild(t); setTimeout(() => { t.remove(); callback?.(); }, 2500); } })();