/** * Alpine アプリ本体。単元データは ./data/categories.js と ./data/drills.js。 * * HTML 方針: * - マークアップを描画する箇所は x-html="safeHtml(...)" のみ。 * - タグを出さない一行表示には plainText(...)(サニタイズ後に textContent 化)。 * - 状態に保持する解説文字列は保存時に sanitizeStoredHtml で正規化。 */ import { DRILLS } from './data/drills.js'; import { CATEGORIES } from './data/categories.js'; function sanitizeTrustedHtml(dirty) { if (dirty == null || dirty === '') return ''; const s = String(dirty); if (typeof window.DOMPurify === 'undefined') { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; } return window.DOMPurify.sanitize(s, { USE_PROFILES: { html: true, svg: true, svgFilters: true }, ALLOW_UNKNOWN_PROTOCOLS: false }); } /** localStorage / 画面状態に載せる HTML 断片の正規化(表示前と同じルール) */ function sanitizeStoredHtml(html) { return sanitizeTrustedHtml(html); } /** サニタイズ済み HTML からプレーン文字列(x-text 向け) */ function plainTextFromSanitizedHtml(html) { if (html == null || html === '') return ''; const d = document.createElement('div'); d.innerHTML = sanitizeTrustedHtml(String(html)); return (d.textContent || '').replace(/\s+/g, ' ').trim(); } document.addEventListener('alpine:init', () => { Alpine.data('bokiApp', () => ({ safeHtml(s) { return sanitizeTrustedHtml(s); }, plainText(s) { return plainTextFromSanitizedHtml(s); }, categories:[], currentUnit:null, quizState:{}, search:'', sidebarOpen:false, isDark:true, progress:{}, scores:{}, wrongUnits:{}, keys:['A','B','C','D','E'], // concept fold conceptExpanded:false, // step mode stepMode:false, stepStep:1, stepKpIdx:0, stepFlashRevealed:false, stepDrillIdx:0, stepDrillAnswered:false, stepDrillSelected:-1, // weak drill weakDrillActive:false, weakDrillUnits:[], weakDrillUnitIdx:0, weakDrillQuizState:{}, weakDrillResults:[], weakDrillDone:false, init(){ this.categories = CATEGORIES.map(cat=>({ ...cat, units: cat.units.map(u=>({...u, catLabel:cat.label})) })); try{ this.progress = JSON.parse(localStorage.getItem('posimai-boki-progress')||'{}'); } catch{ this.progress = {}; } try{ this.scores = JSON.parse(localStorage.getItem('posimai-boki-scores')||'{}'); } catch{ this.scores = {}; } try{ this.wrongUnits = JSON.parse(localStorage.getItem('posimai-boki-wrong')||'{}'); } catch{ this.wrongUnits = {}; } const t = localStorage.getItem('posimai-boki-theme')||'system'; this.isDark = t==='dark'||(t==='system'&&matchMedia('(prefers-color-scheme:dark)').matches); const uid=(new URLSearchParams(location.search).get('unit')||'').trim().toLowerCase(); if(uid){ const u=this.allUnits().find(x=>x.id===uid); if(u){ this.currentUnit=u; this.quizState={}; } else{ try{ const url=new URL(window.location.href); url.searchParams.delete('unit'); const q=url.searchParams.toString(); history.replaceState(null,'',url.pathname+(q?'?'+q:'')+url.hash); }catch(e){} } } this.syncUnitToUrl(); this.$nextTick(()=>{ if(window.lucide) lucide.createIcons(); }); if('serviceWorker' in navigator){ navigator.serviceWorker.register('/sw.js').catch(()=>{}); } }, syncUnitToUrl(){ try{ const url=new URL(window.location.href); if(this.currentUnit&&this.currentUnit.id) url.searchParams.set('unit',this.currentUnit.id); else url.searchParams.delete('unit'); const q=url.searchParams.toString(); const path=url.pathname+(q?'?'+q:'')+url.hash; if(path!==window.location.pathname+window.location.search+window.location.hash) history.replaceState(null,'',path); }catch(e){} }, goHome(){ this.currentUnit=null; this.quizState={}; this.conceptExpanded=false; this.stepMode=false; this.weakDrillActive=false; this.syncUnitToUrl(); this.$nextTick(()=>{ if(window.lucide) lucide.createIcons(); }); }, get filteredCats(){ if(!this.search) return this.categories; const q=this.search.toLowerCase(); return this.categories.map(cat=>({ ...cat, units:cat.units.filter(u=>u.title.toLowerCase().includes(q)||u.num.toLowerCase().includes(q)) })).filter(cat=>cat.units.length>0); }, get totalCount(){ return this.categories.reduce((s,c)=>s+c.units.length,0); }, get doneCount(){ return Object.keys(this.progress).length; }, get progressPct(){ return this.totalCount ? Math.round(this.doneCount/this.totalCount*100) : 0; }, get quizAnswered(){ let n=0; Object.values(this.quizState).forEach(s=>{ if(s) n++; }); return n; }, get quizCorrect(){ let n=0; Object.values(this.quizState).forEach(s=>{ if(s?.correct) n++; }); return n; }, catPct(cat){ if(!cat.units.length) return 0; return Math.round(cat.units.filter(u=>this.progress[u.id]).length/cat.units.length*100); }, allUnits(){ return this.categories.flatMap(c=>c.units); }, isDone(id){ return !!this.progress[id]; }, markDone(){ if(!this.currentUnit) return; this.progress[this.currentUnit.id]=true; localStorage.setItem('posimai-boki-progress',JSON.stringify(this.progress)); }, bestScore(id){ const s=this.scores[id]; if(!s) return ''; return s.best+'/'+s.total; }, scoreChipCls(id){ const s=this.scores[id]; if(!s) return 'zero'; if(s.best===s.total) return ''; if(s.best>=s.total*0.6) return 'partial'; return 'zero'; }, saveScore(id,correct,total){ const prev=this.scores[id]; if(!prev||correct>prev.best){ this.scores[id]={best:correct,total}; localStorage.setItem('posimai-boki-scores',JSON.stringify(this.scores)); } }, get freqBadgeCls(){ const f=this.currentUnit?.freq; if(f==='high') return 'badge badge-high'; if(f==='mid') return 'badge badge-mid'; return 'badge badge-base'; }, get freqLabel(){ const f=this.currentUnit?.freq; if(f==='high') return '頻出'; if(f==='mid') return '重要'; return '基礎'; }, get diffLabel(){ const d=this.currentUnit?.diff||1; return '難易度 '+'★'.repeat(d)+'☆'.repeat(3-d); }, openUnit(unit){ this.currentUnit=unit; this.quizState={}; this.conceptExpanded=false; this.stepMode=false; this.syncUnitToUrl(); this.$nextTick(()=>{ if(window.lucide) lucide.createIcons(); const m=document.getElementById('main'); if(m) m.scrollTo({top:0,behavior:'smooth'}); }); }, // Weak unit tracking hasWrong(id){ return !!this.wrongUnits[id]; }, _onAllAnswered(){ if(!this.currentUnit) return; const uid=this.currentUnit.id; const total=this.currentUnit.quiz?.length||0; const correct=Object.values(this.quizState).filter(s=>s?.correct).length; if(correct>=total){ delete this.wrongUnits[uid]; } else { this.wrongUnits[uid]=(this.wrongUnits[uid]||0)+1; } localStorage.setItem('posimai-boki-wrong',JSON.stringify(this.wrongUnits)); this.wrongUnits={...this.wrongUnits}; }, get weakUnits(){ return this.allUnits().filter(u=>this.wrongUnits[u.id]>0) .sort((a,b)=>(this.wrongUnits[b.id]||0)-(this.wrongUnits[a.id]||0)); }, get todayUnits(){ const result=[]; // P0-1: pick worst wrong unit const weak=this.weakUnits; if(weak.length>0) result.push({...weak[0], todayTag:'苦手'}); // P0-2: pick first undone unit (skip if same as weak) const all=this.allUnits(); const weakId=result[0]?.id; // First try units with wrong answers but done (review) const review=all.find(u=>!this.wrongUnits[u.id] && this.scores[u.id] && u.id!==weakId && !this.progress[u.id]); if(review) result.push({...review, todayTag:'復習'}); // Otherwise first unlearned if(result.length<2){ const next=all.find(u=>!this.progress[u.id] && u.id!==weakId && !result.find(r=>r.id===u.id)); if(next) result.push({...next, todayTag:'未学習'}); } return result; }, // Quiz answered(qi){ return !!this.quizState[qi]; }, doAnswer(qi,ci,correct,exp){ if(this.quizState[qi]) return; this.quizState[qi]={selected:ci,correct:ci===correct,exp:sanitizeStoredHtml(exp)}; this.quizState={...this.quizState}; // Check if all questions answered const total=this.currentUnit?.quiz?.length||0; if(total>0 && Object.keys(this.quizState).length>=total){ this._onAllAnswered(); } }, qCardCls(qi){ const s=this.quizState[qi]; if(!s) return ''; return s.correct?'correct':'wrong'; }, choiceCls(qi,ci,correct){ const s=this.quizState[qi]; if(!s) return ''; if(ci===s.selected && s.correct) return 'sel-ok'; if(ci===s.selected && !s.correct) return 'sel-ng'; if(ci===correct && !s.correct) return 'rev-ok'; return ''; }, get scoreLabel(){ const total=this.currentUnit?.quiz?.length||0; const answered=Object.keys(this.quizState).length; const correct=Object.values(this.quizState).filter(s=>s?.correct).length; if(!answered) return total+'問'; return correct+'/'+answered+'問正解'; }, get allAnswered(){ const total=this.currentUnit?.quiz?.length||0; return total>0 && Object.keys(this.quizState).length>=total; }, get resultScore(){ const correct=Object.values(this.quizState).filter(s=>s?.correct).length; const total=this.currentUnit?.quiz?.length||0; if(this.currentUnit) this.saveScore(this.currentUnit.id,correct,total); return correct+' / '+total; }, get resultMsg(){ const correct=Object.values(this.quizState).filter(s=>s?.correct).length; const total=this.currentUnit?.quiz?.length||0; if(correct===total) return '完璧です!次の単元へ進みましょう。'; if(correct>=total*0.8) return 'よくできました。あと少しで完璧です。'; if(correct>=total*0.6) return '合格ラインです。間違えた問題の解説を確認しましょう。'; return '解説をよく読んで、もう一度チャレンジしてみましょう。'; }, resetQuiz(){ this.quizState={}; }, // Navigation currentIdx(){ return this.allUnits().findIndex(u=>u.id===this.currentUnit?.id); }, get hasPrev(){ return this.currentIdx()>0; }, get hasNext(){ return this.currentIdx()0) this.openUnit(this.allUnits()[i-1]); }, nextUnit(){ const i=this.currentIdx(); const all=this.allUnits(); if(i'); return i===-1 ? c : c.substring(0,i+4); }, get conceptRest(){ const c=this.currentUnit?.concept||''; const i=c.indexOf('

'); return i===-1 ? '' : c.substring(i+4); }, // ---- Weak drill ---- get weakDrillCandidates(){ return this.allUnits() .filter(u=>this.hasWrong(u.id)) .sort((a,b)=>{ const sa=this.scores[a.id]?.best||0; const sb=this.scores[b.id]?.best||0; return sa-sb; }) .slice(0,5); }, startWeakDrill(){ this.weakDrillUnits=[...this.weakDrillCandidates]; this.weakDrillUnitIdx=0; this.weakDrillQuizState={}; this.weakDrillResults=[]; this.weakDrillDone=false; this.weakDrillActive=true; this.$nextTick(()=>{ if(window.lucide) lucide.createIcons(); }); }, exitWeakDrill(){ this.weakDrillActive=false; this.weakDrillDone=false; this.$nextTick(()=>{ if(window.lucide) lucide.createIcons(); }); }, get weakDrillCurrentUnit(){ return this.weakDrillUnits[this.weakDrillUnitIdx]||null; }, wdAnswered(qi){ return !!this.weakDrillQuizState[qi]; }, wdQCardCls(qi){ const s=this.weakDrillQuizState[qi]; if(!s) return ''; return s.correct?'correct':'wrong'; }, wdChoiceCls(qi,ci,correct){ const s=this.weakDrillQuizState[qi]; if(!s) return ''; if(ci===s.selected&&s.correct) return 'sel-ok'; if(ci===s.selected&&!s.correct) return 'sel-ng'; if(ci===correct&&!s.correct) return 'rev-ok'; return ''; }, doWeakDrillAnswer(qi,ci,correct,exp){ if(this.weakDrillQuizState[qi]) return; this.weakDrillQuizState[qi]={selected:ci,correct:ci===correct,exp:sanitizeStoredHtml(exp)}; this.weakDrillQuizState={...this.weakDrillQuizState}; }, get weakDrillAllAnswered(){ const total=this.weakDrillCurrentUnit?.quiz?.length||0; return total>0 && Object.keys(this.weakDrillQuizState).length>=total; }, get weakDrillUnitScore(){ const correct=Object.values(this.weakDrillQuizState).filter(s=>s?.correct).length; const total=this.weakDrillCurrentUnit?.quiz?.length||0; return correct+' / '+total; }, nextWeakDrillUnit(){ const unit=this.weakDrillCurrentUnit; if(!unit) return; const correct=Object.values(this.weakDrillQuizState).filter(s=>s?.correct).length; const total=unit.quiz?.length||0; this.weakDrillResults.push({unitId:unit.id,num:unit.num,unitTitle:unit.title,correct,total}); // Update wrong tracking if(correct>=total){ delete this.wrongUnits[unit.id]; localStorage.setItem('posimai-boki-wrong',JSON.stringify(this.wrongUnits)); this.wrongUnits={...this.wrongUnits}; } if(this.weakDrillUnitIdx{ if(window.lucide) lucide.createIcons(); }); }, get weakDrillClearCount(){ return this.weakDrillResults.filter(r=>r.correct>=r.total).length; }, // ---- Step mode ---- startStepMode(){ this.stepMode=true; this.stepStep=1; this.stepKpIdx=0; this.resetFlashRevealState(); this.stepDrillIdx=0; this.stepDrillAnswered=false; this.stepDrillSelected=-1; this.$nextTick(()=>{ if(window.lucide) lucide.createIcons(); const m=document.getElementById('main'); if(m) m.scrollTo({top:0,behavior:'smooth'}); }); }, /** 理解度を空にしてから 3 ステップ(Step3 で一貫した初回解答) */ startStepModeClearQuiz(){ this.quizState={}; this.startStepMode(); }, exitStepMode(){ this.stepMode=false; this.$nextTick(()=>{ if(window.lucide) lucide.createIcons(); }); }, /** ガイド付き学習をやめ、単元画面に戻す(理解度の回答は保持) */ finishStepMode(){ this.stepMode=false; this.$nextTick(()=>{ if(window.lucide) lucide.createIcons(); const el=document.getElementById('comprehension-quiz'); if(el) el.scrollIntoView({behavior:'smooth',block:'start'}); }); }, scrollToComprehension(){ this.$nextTick(()=>{ const el=document.getElementById('comprehension-quiz'); if(el) el.scrollIntoView({behavior:'smooth',block:'start'}); if(window.lucide) lucide.createIcons(); }); }, /** ヘッダ「戻る」: 同一ステップ内なら1枚前、先頭なら3ステップを終了 */ stepGoBack(){ if(!this.stepMode) return; if(this.stepStep===1){ if(this.stepKpIdx>0){ this.stepKpIdx--; this.resetFlashRevealState(); } else { this.exitStepMode(); } } else if(this.stepStep===2){ if(this.stepDrillIdx>0){ this.stepDrillIdx--; this.stepDrillAnswered=false; this.stepDrillSelected=-1; } else { const kps=this.currentUnit?.keypoints||[]; this.stepStep=1; this.stepKpIdx=Math.max(0,kps.length-1); this.resetFlashRevealState(); } } else if(this.stepStep===3){ this.quizState={}; const drills=this.unitDrills; if(drills.length>0){ this.stepStep=2; this.stepDrillIdx=drills.length-1; this.stepDrillAnswered=false; this.stepDrillSelected=-1; } else { const kps=this.currentUnit?.keypoints||[]; this.stepStep=1; this.stepKpIdx=Math.max(0,kps.length-1); this.resetFlashRevealState(); } } this.$nextTick(()=>{ if(window.lucide) lucide.createIcons(); const m=document.getElementById('main'); if(m) m.scrollTo({top:0,behavior:'smooth'}); }); }, stepPipCls(n){ if(this.stepStep===n) return 's-active'; if(this.stepStep>n) return 's-done'; return ''; }, _kpDelimiterIndex(kp){ if(!kp||typeof kp!=='string') return -1; const ja=kp.indexOf(':'); if(ja>=0) return ja; let depth=0; for(let i=0;i') depth=Math.max(0,depth-1); else if(ch===':'&&depth===0) return i; } return -1; }, keypointHasFlip(idx){ const kp=(this.currentUnit?.keypoints||[])[idx]; const i=this._kpDelimiterIndex(kp); return i>0; }, keypointTerm(idx){ const kp=(this.currentUnit?.keypoints||[])[idx]; const i=this._kpDelimiterIndex(kp); return i>0 ? kp.slice(0,i).trim() : ''; }, resetFlashRevealState(){ this.stepFlashRevealed=!this.keypointHasFlip(this.stepKpIdx); }, advanceStep1Card(){ if(this.stepStep!==1) return; if(this.keypointHasFlip(this.stepKpIdx)&&!this.stepFlashRevealed){ this.stepFlashRevealed=true; return; } this.nextStepFlash(); }, nextStepFlash(){ const kps=this.currentUnit?.keypoints||[]; if(this.stepKpIdx0 ? 2 : 3; this.stepDrillIdx=0; this.stepDrillAnswered=false; this.stepDrillSelected=-1; if(this.stepStep===3){ this.$nextTick(()=>{ const m=document.getElementById('main'); if(m) m.scrollTo({top:m.scrollHeight,behavior:'smooth'}); }); } this.$nextTick(()=>{ if(window.lucide) lucide.createIcons(); }); } }, get unitDrills(){ return DRILLS[this.currentUnit?.id]||[]; }, get unitVizHtml(){ const c=this.currentUnit?.concept||''; if(!c) return ''; const d=document.createElement('div'); d.innerHTML=sanitizeTrustedHtml(c); const v=d.querySelector('.viz-taccount'); return v?v.outerHTML:''; }, doStepDrill(ci){ if(this.stepDrillAnswered) return; this.stepDrillSelected=ci; this.stepDrillAnswered=true; }, nextStepDrill(){ const drills=this.unitDrills; if(this.stepDrillIdx{ if(window.lucide) lucide.createIcons(); const m=document.getElementById('main'); if(m) m.scrollTo({top:m.scrollHeight,behavior:'smooth'}); }); } } })); });