// ---- User accounts & per-user storage ---- // Simple client-side accounts; all progress data (My Words, story progress, // quiz stats, listened scenes) is namespaced per logged-in user. const USERS = { judy: 'happy', halldor: 'test' }; let currentUser = window.localStorage.getItem('authUser') || null; const store = { getItem(k) { return window.localStorage.getItem(`u:${currentUser}:${k}`); }, setItem(k, v) { window.localStorage.setItem(`u:${currentUser}:${k}`, v); }, }; function showLogin(show) { document.getElementById('loginScreen').classList.toggle('hidden', !show); document.querySelector('header').style.display = show ? 'none' : ''; document.querySelector('main').style.display = show ? 'none' : ''; document.getElementById('tracker').style.display = show ? 'none' : ''; } function tryLogin() { const u = document.getElementById('loginUser').value.trim().toLowerCase(); const p = document.getElementById('loginPass').value; if (USERS[u] !== undefined && USERS[u] === p) { currentUser = u; window.localStorage.setItem('authUser', u); document.getElementById('loginError').classList.add('hidden'); document.getElementById('userName').textContent = u; showLogin(false); // Re-render everything for this user's data renderStoryList(); updateMyDictCount(); updateTracker(); } else { document.getElementById('loginError').classList.remove('hidden'); } } function logout() { window.localStorage.removeItem('authUser'); location.reload(); } // Language: 'en' or 'zh' let currentLang = 'en'; let currentStory = null; let currentScene = 0; let activeWord = null; // Normalize a word for dictionary lookup function normalize(word) { return word .toLowerCase() .replace(/[.,!?;:"""''«»…—–\-]/g, '') .trim(); } // Try exact match, then strip common suffixes for Icelandic inflections function lookupWord(raw) { const word = normalize(raw); if (!word) return null; // Direct match if (DICTIONARY[word]) return { entry: DICTIONARY[word], matched: word }; // Try stripping definite article suffixes: -inn, -in, -ið, -num, -ins, -nni, -nnar, -na, -nar, -inu const articleSuffixes = ['inn', 'inum', 'ins', 'in', 'inni', 'innar', 'ið', 'iðu', 'num', 'na', 'nar', 'nni', 'nnar']; for (const suf of articleSuffixes) { if (word.endsWith(suf) && word.length > suf.length + 2) { const stem = word.slice(0, word.length - suf.length); if (DICTIONARY[stem]) return { entry: DICTIONARY[stem], matched: stem }; // try with -ur restored if (DICTIONARY[stem + 'ur']) return { entry: DICTIONARY[stem + 'ur'], matched: stem + 'ur' }; if (DICTIONARY[stem + 'r']) return { entry: DICTIONARY[stem + 'r'], matched: stem + 'r' }; } } // Strip common verb/noun endings const genericSuffixes = ['um', 'ur', 'ar', 'ir', 'a', 'i', 'u', 'r', 's', 't']; for (const suf of genericSuffixes) { if (word.endsWith(suf) && word.length > suf.length + 1) { const stem = word.slice(0, word.length - suf.length); if (DICTIONARY[stem]) return { entry: DICTIONARY[stem], matched: stem }; if (DICTIONARY[stem + 'a']) return { entry: DICTIONARY[stem + 'a'], matched: stem + 'a' }; if (DICTIONARY[stem + 'ur']) return { entry: DICTIONARY[stem + 'ur'], matched: stem + 'ur' }; } } // Forward-match: the word is a bare stem (e.g. adjective "ung" -> "ungur") const addEndings = ['ur', 'r', 'i', 'a', 'll', 'inn', 'n']; for (const end of addEndings) { if (DICTIONARY[word + end]) return { entry: DICTIONARY[word + end], matched: word + end }; } return null; } // Wrap each word in a ; each paragraph gets its own play/pause btn function tokenizeText(text) { let pIdx = -1; return text.split('\n').map(para => { if (!para.trim()) return ''; pIdx++; const tokens = para.split(/(\s+)/); const wrapped = tokens.map(tok => { if (/^\s+$/.test(tok)) return tok; if (!tok) return ''; // Extract punctuation wrapper const match = tok.match(/^([^\wÁÐÉÍÓÚÝÞÆÖáðéíóúýþæö]*)([wÁÐÉÍÓÚÝÞÆÖáðéíóúýþæöa-zA-ZÀ-ž]+['']?[a-zA-ZÀ-ž]*)([^\wÁÐÉÍÓÚÝÞÆÖáðéíóúýþæö]*)$/); if (match) { const pre = match[1]; const word = match[2]; const post = match[3]; const lookup = lookupWord(word); const cls = lookup ? 'w known' : 'w unknown'; return `${pre}${word}${post}`; } return tok; }); return `

${wrapped.join('')}

`; }).join(''); } // UI strings per language const UI = { en: { back: '← Stories', prev: '← Previous', next: 'Next →', scene: (a, b) => `Scene ${a} of ${b}`, tagline: 'Graded reader · Level A2' }, zh: { back: '← 故事列表', prev: '← 上一段', next: '下一段 →', scene: (a, b) => `第 ${a} 段 / 共 ${b} 段`, tagline: '分級讀物 · A2 程度' } }; function applyUILang() { const t = UI[currentLang]; document.getElementById('backBtn').textContent = t.back; document.getElementById('prevScene').textContent = t.prev; document.getElementById('nextScene').textContent = t.next; const tl = document.getElementById('tagline'); if (tl) tl.textContent = t.tagline; const sl = document.getElementById('speedLabel'); if (sl) sl.textContent = currentLang === 'zh' ? '速度' : 'Speed'; const wh = document.getElementById('wordHintText'); if (wh) wh.textContent = currentLang === 'zh' ? '點按任何單字即可查看意思(可隨時切換 中文/EN)' : 'Tap any underlined word for its meaning (switch EN / 中文 anytime)'; const whx = document.getElementById('wordHintClose'); if (whx) whx.textContent = currentLang === 'zh' ? '知道了' : 'Got it'; const mdl = document.getElementById('myDictLabel'); if (mdl) mdl.textContent = currentLang === 'zh' ? '生詞本' : 'My Words'; updateTracker(); const vh = document.getElementById('vocabHeading'); if (vh) vh.textContent = currentLang === 'zh' ? '詞彙表' : 'Vocabulary lists'; const vi = document.getElementById('vocabIntro'); if (vi) vi.textContent = currentLang === 'zh' ? '出自《Íslenska sem annað mál I》(MúltíKúltí)' : 'From Íslenska sem annað mál I (MúltíKúltí)'; const names = currentLang === 'zh' ? { 70: '慢', 85: '中', 100: '正常' } : { 70: 'Slow', 85: 'Medium', 100: 'Normal' }; document.querySelectorAll('.speed-opt').forEach(b => { const bb = b.querySelector('b'); if (bb) bb.textContent = names[b.dataset.pct]; }); } // Linear progression: a story unlocks when the previous one is completed. function loadProgress() { try { return JSON.parse(store.getItem('storyProgress') || '{}'); } catch (e) { return {}; } } // Per-story quiz stats {storyId:{right,wrong}} and listened scenes {sceneKey:true} function loadQuizStats() { try { return JSON.parse(store.getItem('quizStats') || '{}'); } catch (e) { return {}; } } function recordQuizAnswer(correct) { const s = loadQuizStats(); const id = currentStory ? currentStory.id : '_'; s[id] = s[id] || { right: 0, wrong: 0 }; if (correct) s[id].right++; else s[id].wrong++; store.setItem('quizStats', JSON.stringify(s)); updateTracker(); } function quizTotals() { const s = loadQuizStats(); let right = 0, wrong = 0; for (const k in s) { right += s[k].right; wrong += s[k].wrong; } return { right, wrong }; } function loadListened() { try { return JSON.parse(store.getItem('listened') || '{}'); } catch (e) { return {}; } } function markListened(key) { const l = loadListened(); l[key] = true; store.setItem('listened', JSON.stringify(l)); } // Words opened while reading a given story (from My Dictionary logs) function wordsForStory(id) { const db = loadMyDict(); const set = new Set(); for (const k in db) { if ((db[k].times || []).some(t => (t.loc || '').startsWith(id + '-'))) set.add(k); } return set.size; } function markStoryDone(id) { const p = loadProgress(); if (!p[id] || p[id] === true) { const s = loadQuizStats()[id] || { right: 0, wrong: 0 }; p[id] = { done: true, words: wordsForStory(id), right: s.right, wrong: s.wrong, t: Date.now() }; } store.setItem('storyProgress', JSON.stringify(p)); } function updateTracker() { const el = document.getElementById('tracker'); if (!el) return; const zh = currentLang === 'zh'; const words = Object.keys(loadMyDict()).length; const { right, wrong } = quizTotals(); const pct = (right + wrong) ? Math.round(100 * right / (right + wrong)) : null; el.innerHTML = `📖 ${words} ${zh ? '已學單字' : 'words'}` + `🎯 ${pct === null ? '—' : pct + '%'} ${zh ? '答對率' : 'correct'}`; } function isUnlocked(idx) { if (idx === 0) return true; const p = loadProgress(); return !!p[STORIES[idx - 1].id]; } // Render story list (vertical, numbered, locked future stories greyed out) function renderStoryList() { const zh = currentLang === 'zh'; const p = loadProgress(); const list = document.getElementById('storyList'); list.innerHTML = STORIES.map((story, idx) => { const sub = zh ? story.subtitle_zh : story.subtitle; const arc = zh ? story.arc_zh : story.arc; const tags = zh ? story.tags_zh : story.tags; const unlocked = isUnlocked(idx); const rec = p[story.id]; const done = !!rec; let status = ''; if (done) { const st = (rec === true) ? null : rec; const pct = st && (st.right + st.wrong) ? Math.round(100 * st.right / (st.right + st.wrong)) : null; const stamp = st ? ` 📖 ${st.words} ${zh ? '字' : 'words'} · 🎯 ${pct === null ? '—' : pct + '%'}` : ''; status = `✓ ${zh ? '已完成' : 'Completed'}${stamp}`; } else if (!unlocked) { status = `🔒 ${zh ? '完成上一個故事解鎖' : 'Finish the previous story to unlock'}`; } return `
${idx + 1}
${story.emoji}

${story.title}

${sub}
${tags.map(t => `${t}`).join('')}
${arc}
${status}
`; }).join(''); list.querySelectorAll('.story-card').forEach((card, idx) => { card.addEventListener('click', () => { if (isUnlocked(idx)) openStory(card.dataset.id); }); }); } function openStory(id) { currentStory = STORIES.find(s => s.id === id); currentScene = 0; document.getElementById('storyList').classList.add('hidden'); document.getElementById('vocabSection').classList.add('hidden'); document.getElementById('readerView').classList.remove('hidden'); document.getElementById('storyTitle').textContent = `${currentStory.emoji} ${currentStory.title}`; const tagsEl = document.getElementById('storyTags'); const tags = currentLang === 'zh' ? currentStory.tags_zh : currentStory.tags; tagsEl.innerHTML = tags.map(t => `${t}`).join(''); const hint = document.getElementById('wordHint'); if (hint) hint.classList.toggle('hidden', !!store.getItem('wordHintDismissed')); renderScene(); } function renderScene() { const scene = currentStory.scenes[currentScene]; const total = currentStory.scenes.length; document.getElementById('storyText').innerHTML = ` ${scene.beat}
${scene.title}
${tokenizeText(scene.body)}
`; document.getElementById('sceneIndicator').textContent = UI[currentLang].scene(currentScene + 1, total); document.getElementById('prevScene').disabled = currentScene === 0; // Next unlocks only after the scene narration has been heard to the end // (or if this story was already completed before). updateNextGate(); // Back to reading mode (hide any quiz interstitial) quizActive = false; setReadingVisible(true); const box = document.getElementById('quizBox'); if (box) box.innerHTML = ''; // Index each clickable word in reading order + attach listeners const spans = document.querySelectorAll('#storyText .w'); spans.forEach((el, idx) => { el.dataset.wi = idx; el.addEventListener('click', e => { e.stopPropagation(); showPopup(el.dataset.word, el); }); }); loadAudioForScene(); } function updateNextGate() { const btn = document.getElementById('nextScene'); if (!btn || !currentStory) return; const storyDone = !!loadProgress()[currentStory.id]; const heard = !!loadListened()[sceneKey()]; btn.disabled = !(storyDone || heard); const zh = currentLang === 'zh'; btn.title = btn.disabled ? (zh ? '請先聽完整段朗讀' : 'Listen to the narration to the end first') : ''; } // Show/hide the reading UI so the quiz can take over the page between scenes function setReadingVisible(v) { const show = v ? '' : 'none'; const st = document.getElementById('storyText'); if (st) st.style.display = show; const pb = document.querySelector('.play-bar'); if (pb) pb.style.display = show; const sr = document.querySelector('.speed-row'); if (sr) sr.style.display = show; const nv = document.querySelector('.story-nav'); if (nv) nv.style.display = show; } function advanceScene() { const total = currentStory.scenes.length; if (currentScene < total - 1) { currentScene++; renderScene(); } else { // Story finished -> unlock the next one markStoryDone(currentStory.id); resetAudio(); document.getElementById('readerView').classList.add('hidden'); document.getElementById('storyList').classList.remove('hidden'); document.getElementById('vocabSection').classList.remove('hidden'); renderStoryList(); } window.scrollTo(0, 0); } function enterQuiz(qs, isFinal) { resetAudio(); quizActive = true; setReadingVisible(false); renderQuizInterstitial(qs, isFinal); window.scrollTo(0, 0); } // ---- Comprehension quiz: shown as a small interstitial page on "Next" ---- let quizActive = false; function renderQuizInterstitial(qs, isFinal) { const box = document.getElementById('quizBox'); if (!box) return; const zh = currentLang === 'zh'; const title = zh ? '理解測驗' : 'Check your understanding'; const sub = isFinal ? (zh ? `故事總測驗` : `Story quiz — the whole story`) : (zh ? `第 ${currentScene + 1} 節後` : `After part ${currentScene + 1}`); const isLast = currentScene === currentStory.scenes.length - 1; const contLabel = isLast ? (zh ? '完成 ✓' : 'Finish ✓') : (zh ? '繼續 →' : 'Continue →'); const backLabel = zh ? '← 返回課文' : '← Back to text'; box.innerHTML = `
${sub}
${title}
` + qs.map((q, qi) => { const qt = zh ? q.q_zh : q.q_en; const opts = zh ? q.options_zh : q.options_en; return `
${qi + 1}. ${qt}
${opts.map((o, oi) => ``).join('')}
`; }).join('') + `
`; } // ---- Narration playback with word highlighting ---- let audio = null; let wordTimings = []; let currentWordEl = null; let currentParaEl = null; let rafId = null; let speedPct = 85; // 70 = Slow, 85 = Medium, 100 = Normal (real generated speeds) function sceneKey() { return `${currentStory.id}-${currentScene}`; } function resetAudio() { if (audio) { audio.pause(); audio = null; } if (rafId) { cancelAnimationFrame(rafId); rafId = null; } if (currentWordEl) { currentWordEl.classList.remove('speaking'); currentWordEl = null; } if (currentParaEl) { currentParaEl.classList.remove('reading'); currentParaEl = null; } wordTimings = []; const seek = document.getElementById('playSeek'); if (seek) seek.value = 0; setPlayIcon(false); const t = document.getElementById('playTime'); if (t) t.textContent = '0:00'; } function setPlayIcon(playing) { document.getElementById('playIcon').textContent = playing ? '❚❚' : '▶'; document.getElementById('playLabel').textContent = playing ? (currentLang === 'zh' ? '暫停' : 'Pause') : (currentLang === 'zh' ? '播放' : 'Play'); refreshParaIcon(playing); } function refreshParaIcon(playing) { if (!currentParaEl) return; const b = currentParaEl.querySelector('.para-btn'); if (b) b.textContent = playing ? '❚❚' : '▶'; } function fmtTime(s) { if (!isFinite(s)) return '0:00'; const m = Math.floor(s / 60); const ss = Math.floor(s % 60).toString().padStart(2, '0'); return `${m}:${ss}`; } async function loadAudioForScene() { resetAudio(); const key = sceneKey(); const btn = document.getElementById('playBtn'); btn.classList.add('loading'); try { const res = await fetch(`audio/${key}-${speedPct}.json?v=16`); if (res.ok) wordTimings = (await res.json()).words || []; } catch (e) { wordTimings = []; } audio = new Audio(`audio/${key}-${speedPct}.mp3?v=16`); audio.preload = 'metadata'; audio.addEventListener('loadedmetadata', () => { document.getElementById('playTime').textContent = fmtTime(audio.duration); }); audio.addEventListener('ended', () => { setPlayIcon(false); if (currentWordEl) { currentWordEl.classList.remove('speaking'); currentWordEl = null; } document.getElementById('playSeek').value = 0; if (rafId) { cancelAnimationFrame(rafId); rafId = null; } // Listened to the end -> unlock Next for this scene markListened(sceneKey()); updateNextGate(); }); // If audio is unavailable (e.g. still generating), don't trap the reader audio.addEventListener('error', () => { markListened(sceneKey()); updateNextGate(); }); btn.classList.remove('loading'); } function highlightLoop() { if (!audio) return; const t = audio.currentTime; // Find the word whose [start,end] contains t let idx = -1; for (let i = 0; i < wordTimings.length; i++) { if (t >= wordTimings[i][0] && t < wordTimings[i][1]) { idx = i; break; } if (t < wordTimings[i][0]) { idx = i; break; } // between words -> next upcoming } if (idx >= 0) { const el = document.querySelector(`#storyText .w[data-wi="${idx}"]`); if (el && el !== currentWordEl) { if (currentWordEl) currentWordEl.classList.remove('speaking'); el.classList.add('speaking'); currentWordEl = el; // Mark the paragraph being read so its play/pause button shows const p = el.closest('.para'); if (p && p !== currentParaEl) { if (currentParaEl) currentParaEl.classList.remove('reading'); p.classList.add('reading'); currentParaEl = p; refreshParaIcon(audio && !audio.paused); } const r = el.getBoundingClientRect(); if (r.top < 90 || r.bottom > window.innerHeight - 90) { el.scrollIntoView({ behavior: 'smooth', block: 'center' }); } } } // Update seek + time const seek = document.getElementById('playSeek'); if (audio.duration) seek.value = Math.round((audio.currentTime / audio.duration) * 1000); document.getElementById('playTime').textContent = fmtTime(audio.duration - audio.currentTime); rafId = requestAnimationFrame(highlightLoop); } function playFromParagraph(pIdx) { if (!audio) return; // First clickable word of this paragraph -> its timing start const firstWord = document.querySelector(`#storyText .w[data-p="${pIdx}"]`); const seekAndPlay = () => { if (firstWord && wordTimings.length) { const wi = parseInt(firstWord.dataset.wi, 10); if (wordTimings[wi]) audio.currentTime = wordTimings[wi][0]; } if (audio.paused) togglePlay(); else { setPlayIcon(true); } }; if (audio.readyState >= 1) seekAndPlay(); else audio.addEventListener('loadedmetadata', seekAndPlay, { once: true }); } function togglePlay() { if (!audio) return; if (audio.paused) { audio.play(); setPlayIcon(true); rafId = requestAnimationFrame(highlightLoop); } else { audio.pause(); setPlayIcon(false); if (rafId) { cancelAnimationFrame(rafId); rafId = null; } } } function showPopup(rawWord, el) { if (activeWord) activeWord.classList.remove('active'); activeWord = el; el.classList.add('active'); const result = lookupWord(rawWord); const popup = document.getElementById('wordPopup'); const overlay = document.getElementById('popupOverlay'); const posEl = document.getElementById('popupPos'); const transEl = document.getElementById('popupTranslation'); const alsoEl = document.getElementById('popupAlso'); const exEl = document.getElementById('popupExamples'); document.getElementById('popupWord').textContent = rawWord; if (result) { const { entry, matched } = result; posEl.textContent = entry.pos || ''; const ctxLabel = currentLang === 'zh' ? '此處意思' : 'Meaning here'; transEl.innerHTML = `${ctxLabel}` + (currentLang === 'zh' ? entry.zh : entry.en); const also = currentLang === 'zh' ? entry.also_zh : entry.also_en; if (also) { const alsoLabel = currentLang === 'zh' ? '其他含義(此處不適用)' : 'Other meanings (not used here)'; alsoEl.innerHTML = `${alsoLabel}: ${also}`; alsoEl.style.display = ''; } else { alsoEl.textContent = ''; alsoEl.style.display = 'none'; } const exs = entry.ex || []; exEl.innerHTML = exs.map(x => { const tr = currentLang === 'zh' ? x.zh : x.en; return `
${boldForm(x.is, rawWord, matched)}
` + (tr ? `
${tr}
` : '') + `
`; }).join(''); // "All forms" button appears only when BÍN has a paradigm for this word const formKey = (matched || rawWord).toLowerCase(); const guid = (typeof FORMID !== 'undefined') ? (FORMID[formKey] || FORMID[rawWord.toLowerCase()]) : null; const fb = document.getElementById('formsBtn'); if (guid && typeof FORMS !== 'undefined' && FORMS[guid]) { fb.style.display = ''; fb.dataset.guid = guid; fb.dataset.form = formKey; fb.textContent = currentLang === 'zh' ? '所有變化形 ⤢' : 'All forms ⤢'; } else { fb.style.display = 'none'; } // Record this lookup into "My Dictionary" (keyed by BÍN-id when available) recordLookup(entry, matched, rawWord, guid); } else { posEl.textContent = ''; transEl.textContent = currentLang === 'zh' ? '(字典中查無此字)' : '(word not found in dictionary)'; alsoEl.textContent = ''; alsoEl.style.display = 'none'; exEl.innerHTML = ''; document.getElementById('formsBtn').style.display = 'none'; } popup.classList.remove('hidden'); overlay.classList.remove('hidden'); } // ---- My Dictionary: persistent record of every word the learner looks up ---- function loadMyDict() { try { return JSON.parse(store.getItem('myDict') || '{}'); } catch (e) { return {}; } } function saveMyDict(db) { store.setItem('myDict', JSON.stringify(db)); } function recordLookup(entry, matched, rawWord, guid) { const id = guid ? ('bin:' + guid) : ('w:' + (matched || rawWord).toLowerCase()); const db = loadMyDict(); const rec = db[id] || { word: matched || rawWord, guid: guid || '', count: 0, times: [] }; rec.count += 1; rec.word = matched || rawWord; rec.guid = guid || rec.guid; rec.en = entry.en; rec.zh = entry.zh; rec.pos = entry.pos; rec.times.push({ t: Date.now(), loc: currentStory ? `${currentStory.id}-${currentScene + 1}` : '' }); if (rec.times.length > 50) rec.times = rec.times.slice(-50); db[id] = rec; saveMyDict(db); updateMyDictCount(); updateTracker(); } function updateMyDictCount() { const el = document.getElementById('myDictCount'); if (el) el.textContent = Object.keys(loadMyDict()).length; } function storyTitleFor(loc) { if (!loc) return ''; const id = loc.split('-')[0]; const s = (typeof STORIES !== 'undefined') ? STORIES.find(x => x.id === id) : null; const scene = loc.split('-')[1]; return s ? `${s.emoji} ${s.title} · ${currentLang === 'zh' ? '第' + scene + '段' : 'part ' + scene}` : loc; } function renderMyDict() { const zh = currentLang === 'zh'; const db = loadMyDict(); const items = Object.entries(db).sort((a, b) => b[1].count - a[1].count || (b[1].times[b[1].times.length - 1]?.t || 0) - (a[1].times[a[1].times.length - 1]?.t || 0)); document.getElementById('myDictTitle').textContent = zh ? '我的生詞本' : 'My Dictionary'; document.getElementById('myDictSub').textContent = zh ? `${items.length} 個字` : `${items.length} words`; const body = document.getElementById('myDictBody'); if (!items.length) { body.innerHTML = `
${zh ? '還沒有生詞。點按故事中的單字即可加入。' : 'No words yet. Tap any word in a story to add it.'}
`; return; } body.innerHTML = items.map(([id, r]) => { const times = r.times.slice().reverse().map(x => { const d = new Date(x.t); const when = d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); return x.loc ? `${when} — ${storyTitleFor(x.loc)}` : when; }).join('
'); const openedLbl = zh ? `開啟 ${r.count} 次` : `opened ${r.count}×`; return `
${r.word} ${openedLbl}
${(zh ? r.zh : r.en) || ''}
${times}
`; }).join(''); } function openMyDict() { renderMyDict(); document.getElementById('myDictModal').classList.remove('hidden'); document.getElementById('myDictOverlay').classList.remove('hidden'); } function closeMyDict() { document.getElementById('myDictModal').classList.add('hidden'); document.getElementById('myDictOverlay').classList.add('hidden'); } // ---- Vocabulary lists (from the MúltíKúltí textbooks) ---- const VCAT = { general: ['General', '一般'], phrase: ['Phrases', '片語'], f: ['Feminine nouns', '陰性名詞'], m: ['Masculine nouns', '陽性名詞'], n: ['Neuter nouns', '中性名詞'], noun: ['Nouns', '名詞'], verb: ['Verbs', '動詞'], adj: ['Adjectives', '形容詞'], adv: ['Adverbs', '副詞'], num: ['Numbers', '數字'], pron: ['Pronouns', '代名詞'], prep: ['Prepositions', '介詞'], question: ['Question words', '疑問詞'], other: ['Other', '其他'] }; const VCAT_ORDER = ['general', 'phrase', 'f', 'm', 'n', 'noun', 'verb', 'adj', 'adv', 'num', 'pron', 'prep', 'question', 'other']; let vocabOpenIdx = null; function escV(s) { return (s || '').replace(/[&<>]/g, c => ({ '&': '&', '<': '<', '>': '>' }[c])); } function renderVocab() { const el = document.getElementById('vocabBooks'); const section = document.getElementById('vocabSection'); if (!el || typeof VOCAB === 'undefined' || !VOCAB.length) { if (section) section.style.display = 'none'; return; } if (section) section.style.display = ''; const zh = currentLang === 'zh'; const byBook = {}; VOCAB.forEach((c, idx) => { (byBook[c.book] = byBook[c.book] || []).push({ idx, ...c }); }); el.innerHTML = Object.entries(byBook).map(([book, chs]) => `
${escV(book)}
${chs.map(c => { const lbl = c.label ? (zh && c.label_zh ? c.label_zh : c.label) : `Kafli ${c.kafli}`; return `
${lbl}${c.items.length} ${zh ? '字' : 'words'}
`; }).join('')}
`).join(''); } function openVocabChapter(idx) { const c = (typeof VOCAB !== 'undefined') ? VOCAB[idx] : null; if (!c) return; vocabOpenIdx = idx; const zh = currentLang === 'zh'; const chLbl = c.label ? (zh && c.label_zh ? c.label_zh : c.label) : `Kafli ${c.kafli}`; document.getElementById('vocabTitle').textContent = `${c.book} · ${chLbl}`; document.getElementById('vocabSub').textContent = `${c.items.length} ${zh ? '個字' : 'words'}`; const groups = {}; c.items.forEach(it => { (groups[it.cat] = groups[it.cat] || []).push(it); }); const cats = Object.keys(groups).sort((a, b) => (VCAT_ORDER.indexOf(a) + 1 || 99) - (VCAT_ORDER.indexOf(b) + 1 || 99)); document.getElementById('vocabBody').innerHTML = cats.map(cat => { const label = VCAT[cat] ? VCAT[cat][zh ? 1 : 0] : cat; const rows = groups[cat].map(it => `
${escV(it.is)}` + `${escV((zh ? it.zh : it.en) || '')}
`).join(''); return `
${label}
${rows}`; }).join(''); document.getElementById('vocabModal').classList.remove('hidden'); document.getElementById('vocabOverlay').classList.remove('hidden'); } function closeVocab() { document.getElementById('vocabModal').classList.add('hidden'); document.getElementById('vocabOverlay').classList.add('hidden'); } // Bold the clicked word (or its lemma) inside an example sentence function boldForm(sentence, raw, lemma) { const esc = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const L = 'a-zA-ZÁÐÉÍÓÚÝÞÆÖáðéíóúýþæöÀ-ÿ'; for (const w of [raw, lemma]) { if (!w) continue; const re = new RegExp(`(?$1'); } return sentence; } function closePopup() { closeForms(); document.getElementById('wordPopup').classList.add('hidden'); document.getElementById('popupOverlay').classList.add('hidden'); if (activeWord) { activeWord.classList.remove('active'); activeWord = null; } } // ---- All-forms modal (BÍN paradigms) ---- const OFL_LABEL = { kk: ['masculine noun', '陽性名詞'], kvk: ['feminine noun', '陰性名詞'], hk: ['neuter noun', '中性名詞'], so: ['verb', '動詞'], lo: ['adjective', '形容詞'], ao: ['adverb', '副詞'], fs: ['preposition', '介詞'], st: ['conjunction', '連詞'], to: ['numeral', '數詞'], gr: ['article', '冠詞'], uh: ['interjection', '感嘆詞'], fn: ['pronoun', '代詞'], pfn: ['pronoun', '代詞'], afn: ['pronoun', '代詞'], rt: ['ordinal', '序數'], nhm: ['infinitive marker', '不定式標記'], }; const TAG_EN = { GM: 'active', MM: 'middle', FH: 'indic.', VH: 'subj.', BH: 'imper.', NH: 'infinitive', NT: 'present', ÞT: 'past', '1P': '1st', '2P': '2nd', '3P': '3rd', ET: 'sg.', FT: 'pl.', KK: 'masc.', KVK: 'fem.', HK: 'neut.', FSB: 'strong', FVB: 'weak', SB: 'strong', VB: 'weak', MST: 'comparative', EST: 'superlative', ESB: 'superlative', EVB: 'superlative', LHNT: 'pres. participle', LHÞT: 'past participle', SAGNB: 'supine', NF: 'nom.', ÞF: 'acc.', ÞGF: 'dat.', EF: 'gen.', OP: 'impersonal', SP: 'question', }; const TAG_ZH = { GM: '主動', MM: '中間', FH: '直陳', VH: '虛擬', BH: '命令', NH: '不定式', NT: '現在', ÞT: '過去', '1P': '第一人稱', '2P': '第二人稱', '3P': '第三人稱', ET: '單數', FT: '複數', KK: '陽性', KVK: '陰性', HK: '中性', FSB: '強變化', FVB: '弱變化', SB: '強變化', VB: '弱變化', MST: '比較級', EST: '最高級', ESB: '最高級', EVB: '最高級', LHNT: '現在分詞', LHÞT: '過去分詞', SAGNB: '動名詞', NF: '主格', ÞF: '賓格', ÞGF: '與格', EF: '屬格', OP: '無人稱', SP: '疑問', }; const CASE_EN = { NF: 'nom.', ÞF: 'acc.', ÞGF: 'dat.', EF: 'gen.' }; const CASE_ZH = { NF: '主格', ÞF: '賓格', ÞGF: '與格', EF: '屬格' }; function humanizeTag(tag) { const zh = currentLang === 'zh'; const map = zh ? TAG_ZH : TAG_EN, caseMap = zh ? CASE_ZH : CASE_EN; const numMap = zh ? { ET: '單數', FT: '複數' } : { ET: 'sg.', FT: 'pl.' }; return tag.split('-').map(p => { if (map[p]) return map[p]; const m = p.match(/^(NF|ÞF|ÞGF|EF)(ET|FT)(gr)?$/); if (m) return caseMap[m[1]] + ' ' + numMap[m[2]] + (m[3] ? (zh ? ' +冠詞' : ' +def.') : ''); if (p === 'gr') return zh ? '帶冠詞' : 'with article'; return p; }).join(zh ? '·' : ' '); } function openForms(guid, form) { const e = (typeof FORMS !== 'undefined') ? FORMS[guid] : null; if (!e) return; const zh = currentLang === 'zh'; document.getElementById('formsWord').textContent = e.word; const ofl = OFL_LABEL[e.ofl] ? OFL_LABEL[e.ofl][zh ? 1 : 0] : e.ofl; document.getElementById('formsSub').textContent = `${ofl} · ${e.forms.length} ${zh ? '個變化形' : 'forms'}`; document.getElementById('formsBody').innerHTML = (typeof renderParadigm !== 'undefined') ? renderParadigm(e, form, currentLang) : e.forms.map(([tag, f]) => `
${f} — ${humanizeTag(tag)}
`).join(''); document.getElementById('formsId').textContent = 'BÍN-id ' + guid; document.getElementById('formsModal').classList.remove('hidden'); const cur = document.querySelector('#formsBody .fc-cur'); if (cur) cur.scrollIntoView({ block: 'center' }); } function closeForms() { const m = document.getElementById('formsModal'); if (m) m.classList.add('hidden'); } // Init document.addEventListener('DOMContentLoaded', () => { // Auth gate document.getElementById('loginBtn').addEventListener('click', tryLogin); document.getElementById('loginPass').addEventListener('keydown', e => { if (e.key === 'Enter') tryLogin(); }); document.getElementById('userChip').addEventListener('click', logout); if (currentUser && USERS[currentUser] !== undefined) { document.getElementById('userName').textContent = currentUser; showLogin(false); } else { showLogin(true); } applyUILang(); renderStoryList(); renderVocab(); updateMyDictCount(); updateTracker(); document.getElementById('vocabBooks').addEventListener('click', e => { const chip = e.target.closest('.vocab-chip'); if (chip) openVocabChapter(parseInt(chip.dataset.idx, 10)); }); document.getElementById('vocabClose').addEventListener('click', closeVocab); document.getElementById('vocabOverlay').addEventListener('click', closeVocab); document.getElementById('myDictBtn').addEventListener('click', openMyDict); document.getElementById('myDictClose').addEventListener('click', closeMyDict); document.getElementById('myDictOverlay').addEventListener('click', closeMyDict); document.getElementById('myDictBody').addEventListener('click', e => { const w = e.target.closest('.mydict-word'); if (w && w.dataset.guid && typeof FORMS !== 'undefined' && FORMS[w.dataset.guid]) { openForms(w.dataset.guid, w.dataset.form); } }); document.getElementById('backBtn').addEventListener('click', () => { resetAudio(); document.getElementById('readerView').classList.add('hidden'); document.getElementById('storyList').classList.remove('hidden'); document.getElementById('vocabSection').classList.remove('hidden'); closePopup(); }); document.getElementById('prevScene').addEventListener('click', () => { if (currentScene > 0) { currentScene--; renderScene(); window.scrollTo(0,0); } }); // Next shows the scene's quiz as an in-between page; Continue advances document.getElementById('nextScene').addEventListener('click', () => { if (quizActive) return; const last = currentScene === currentStory.scenes.length - 1; // On the last scene: a longer quiz covering the whole story if (last && typeof FINAL_QUIZZES !== 'undefined' && FINAL_QUIZZES[currentStory.id]) { enterQuiz(FINAL_QUIZZES[currentStory.id], true); return; } const key = `${currentStory.id}-${currentScene}`; const qs = (typeof QUIZZES !== 'undefined' && QUIZZES[key]) ? QUIZZES[key] : []; if (qs.length) enterQuiz(qs); else advanceScene(); }); // Quiz interstitial clicks: Continue / Back, plus instant answer feedback document.getElementById('quizBox').addEventListener('click', e => { if (e.target.closest('.quiz-continue')) { quizActive = false; advanceScene(); return; } if (e.target.closest('.quiz-back')) { renderScene(); window.scrollTo(0, 0); return; } const opt = e.target.closest('.quiz-opt'); if (!opt) return; const q = opt.closest('.quiz-q'); if (q.classList.contains('answered')) return; q.classList.add('answered'); const ans = parseInt(q.dataset.ans, 10); const chosen = parseInt(opt.dataset.oi, 10); const opts = q.querySelectorAll('.quiz-opt'); opts[ans].classList.add('correct'); if (chosen !== ans) opt.classList.add('wrong'); recordQuizAnswer(chosen === ans); const fb = document.createElement('div'); fb.className = 'quiz-feedback ' + (chosen === ans ? 'ok' : 'no'); fb.textContent = chosen === ans ? (currentLang === 'zh' ? '答對了!' : 'Correct!') : (currentLang === 'zh' ? '再想想。' : 'Not quite.'); q.appendChild(fb); }); document.getElementById('formsBtn').addEventListener('click', e => { openForms(e.currentTarget.dataset.guid, e.currentTarget.dataset.form); }); document.getElementById('formsClose').addEventListener('click', closeForms); document.getElementById('wordHintClose').addEventListener('click', () => { store.setItem('wordHintDismissed', '1'); document.getElementById('wordHint').classList.add('hidden'); }); document.getElementById('playBtn').addEventListener('click', togglePlay); // Per-paragraph buttons: jump the voice to that paragraph (skip fwd/back). // If it's the paragraph already playing, just toggle play/pause. document.getElementById('storyText').addEventListener('click', e => { const b = e.target.closest('.para-btn'); if (!b) return; e.stopPropagation(); const p = b.closest('.para'); if (p === currentParaEl && audio && !audio.paused) { togglePlay(); return; } playFromParagraph(parseInt(b.dataset.p, 10)); }); document.getElementById('speedOpts').addEventListener('click', async e => { const btn = e.target.closest('.speed-opt'); if (!btn) return; const newPct = parseInt(btn.dataset.pct, 10); if (newPct === speedPct) return; speedPct = newPct; document.querySelectorAll('.speed-opt').forEach(b => b.classList.remove('active')); btn.classList.add('active'); // Preserve place by WORD position (durations differ between speeds) const wasPlaying = audio && !audio.paused; let idx = currentWordEl ? parseInt(currentWordEl.dataset.wi, 10) : 0; if (!(idx >= 0)) idx = 0; await loadAudioForScene(); const restore = () => { if (idx > 0 && wordTimings[idx]) audio.currentTime = wordTimings[idx][0]; if (wasPlaying) togglePlay(); }; if (audio.readyState >= 1) restore(); else audio.addEventListener('loadedmetadata', restore, { once: true }); }); document.getElementById('playSeek').addEventListener('input', e => { if (audio && audio.duration) { audio.currentTime = (e.target.value / 1000) * audio.duration; } }); document.getElementById('popupClose').addEventListener('click', closePopup); document.getElementById('popupOverlay').addEventListener('click', closePopup); // Language toggle document.querySelectorAll('.lang-btn').forEach(btn => { btn.addEventListener('click', () => { currentLang = btn.dataset.lang; document.querySelectorAll('.lang-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); applyUILang(); // Re-render whichever view is visible if (!document.getElementById('storyList').classList.contains('hidden')) { renderStoryList(); renderVocab(); } else if (currentStory) { const tagsEl = document.getElementById('storyTags'); const tags = currentLang === 'zh' ? currentStory.tags_zh : currentStory.tags; tagsEl.innerHTML = tags.map(t => `${t}`).join(''); renderScene(); } // If popup open, re-render it fully in the new language const popup = document.getElementById('wordPopup'); if (!popup.classList.contains('hidden') && activeWord) { showPopup(activeWord.dataset.word, activeWord); } // If a vocab chapter is open, re-render it too if (!document.getElementById('vocabModal').classList.contains('hidden') && vocabOpenIdx != null) { openVocabChapter(vocabOpenIdx); } }); }); // Close popup on escape document.addEventListener('keydown', e => { if (e.key === 'Escape') closePopup(); }); });