chinese_learning_v2 / app /static /js /elearning.js
GphaHoa156
Add application file
8b3b66c
/* ============================================================
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);
}
})();