posimai-boki/js/app.js

543 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Alpine アプリ本体。単元データは ./data/categories.js と ./data/drills.js。
* 表示系 HTML は safeHtml() 経由DOMPurifyでサニタイズすること。
*/
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
});
}
document.addEventListener('alpine:init', () => {
Alpine.data('bokiApp', () => ({
safeHtml(s) {
return sanitizeTrustedHtml(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};
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()<this.allUnits().length-1; },
prevUnit(){
const i=this.currentIdx();
if(i>0) this.openUnit(this.allUnits()[i-1]);
},
nextUnit(){
const i=this.currentIdx();
const all=this.allUnits();
if(i<all.length-1) this.openUnit(all[i+1]);
},
toggleTheme(){
this.isDark=!this.isDark;
const val=this.isDark?'dark':'light';
document.documentElement.setAttribute('data-theme',val);
localStorage.setItem('posimai-boki-theme',val);
},
// ---- Concept expand ----
get conceptPreview(){
const c=this.currentUnit?.concept||'';
const i=c.indexOf('</p>');
return i===-1 ? c : c.substring(0,i+4);
},
get conceptRest(){
const c=this.currentUnit?.concept||'';
const i=c.indexOf('</p>');
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};
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<this.weakDrillUnits.length-1){
this.weakDrillUnitIdx++;
this.weakDrillQuizState={};
} else {
this.weakDrillDone=true;
}
this.$nextTick(()=>{ 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.quizState={};
this.$nextTick(()=>{
if(window.lucide) lucide.createIcons();
const m=document.getElementById('main');
if(m) m.scrollTo({top:0,behavior:'smooth'});
});
},
exitStepMode(){
this.stepMode=false;
this.$nextTick(()=>{ 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){
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 iw=kp.indexOf('');
const ia=kp.indexOf(':');
if(iw<0&&ia<0) return -1;
if(iw<0) return ia;
if(ia<0) return iw;
return Math.min(iw,ia);
},
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.stepKpIdx<kps.length-1){
this.stepKpIdx++;
this.resetFlashRevealState();
} else {
// All keypoints done — go to step 2 (or 3 if no drills)
const drills=DRILLS[this.currentUnit?.id]||[];
this.stepStep=drills.length>0 ? 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<drills.length-1){
this.stepDrillIdx++;
this.stepDrillAnswered=false;
this.stepDrillSelected=-1;
} else {
// All drills done — go to step 3
this.stepStep=3;
this.$nextTick(()=>{
if(window.lucide) lucide.createIcons();
const m=document.getElementById('main');
if(m) m.scrollTo({top:m.scrollHeight,behavior:'smooth'});
});
}
}
}));
});