Spaces:
Sleeping
Sleeping
| /* ============================================================ | |
| 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 => ` | |
| <button class="quiz-option" type="button" | |
| data-correct="${esc(q.correct)}" | |
| data-val="${esc(opt)}">${esc(opt)}</button> | |
| `).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, | |
| '<span class="fb-blank" id="fbBlankDisplay">___</span>'); | |
| $('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 = ` | |
| <div class="col-4"><div class="my-stat-card"> | |
| <div class="my-stat-val">${me.total_score || 0}</div> | |
| <div class="my-stat-label">Total Score</div> | |
| </div></div> | |
| <div class="col-4"><div class="my-stat-card"> | |
| <div class="my-stat-val">${me.activity_count || 0}</div> | |
| <div class="my-stat-label">Activities</div> | |
| </div></div> | |
| <div class="col-4"><div class="my-stat-card"> | |
| <div class="my-stat-val">#${data.indexOf(me)+1}</div> | |
| <div class="my-stat-label">Rank</div> | |
| </div></div>`; | |
| } | |
| } | |
| } | |
| function renderList(containerId, items, metric) { | |
| const el = $(containerId); | |
| if (!el) return; | |
| if (!items.length) { el.innerHTML = '<p class="text-muted text-center py-3">No data yet</p>'; 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 ` | |
| <div class="lb-item"> | |
| <div class="lb-rank ${rc}">${ri}</div> | |
| <div class="lb-avatar">${esc((item.username||'?').charAt(0).toUpperCase())}</div> | |
| <div class="lb-name">${esc(item.username||'Unknown')}</div> | |
| <div class="lb-score">${val}</div> | |
| </div>`; | |
| }).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 = ` | |
| <div class="text-center py-5"> | |
| <div style="font-family:var(--hanzi-font);font-size:4rem;opacity:.25">η©Ί</div> | |
| <h5 class="mt-3">No data available</h5> | |
| <p class="text-muted">No ${name} data for HSK ${level} yet.</p> | |
| </div>`; | |
| } | |
| 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); | |
| } | |
| })(); |