/**
* 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()