posimai-root/posimai-sc/js/app.js

552 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.

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
});
}
function sanitizeStoredHtml(html) {
return sanitizeTrustedHtml(html);
}
document.addEventListener('alpine:init', () => {
Alpine.data('scApp', () => ({
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-sc-progress')||'{}'); }
catch{ this.progress = {}; }
try{ this.scores = JSON.parse(localStorage.getItem('posimai-sc-scores')||'{}'); }
catch{ this.scores = {}; }
try{ this.wrongUnits = JSON.parse(localStorage.getItem('posimai-sc-wrong')||'{}'); }
catch{ this.wrongUnits = {}; }
const t = localStorage.getItem('posimai-sc-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(); });
this.$watch('search', ()=>{
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-sc-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-sc-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;
this.saveScore(uid,correct,total);
if(correct>=total){
delete this.wrongUnits[uid];
} else {
this.wrongUnits[uid]=(this.wrongUnits[uid]||0)+1;
}
localStorage.setItem('posimai-sc-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=[];
const weak=this.weakUnits;
if(weak.length>0) result.push({...weak[0], todayTag:'苦手'});
const all=this.allUnits();
const weakId=result[0]?.id;
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:'復習'});
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};
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;
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-sc-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: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.saveScore(unit.id,correct,total);
this.weakDrillResults.push({unitId:unit.id,num:unit.num,unitTitle:unit.title,correct,total});
if(correct>=total){
delete this.wrongUnits[unit.id];
localStorage.setItem('posimai-sc-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.$nextTick(()=>{
if(window.lucide) lucide.createIcons();
const m=document.getElementById('main');
if(m) m.scrollTo({top:0,behavior:'smooth'});
});
},
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();
});
},
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 ja=kp.indexOf('');
if(ja>=0) return ja;
let depth=0;
for(let i=0;i<kp.length;i++){
const ch=kp[i];
if(ch==='<') depth++;
else if(ch==='>') 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.stepKpIdx<kps.length-1){
this.stepKpIdx++;
this.resetFlashRevealState();
} else {
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]||[];
},
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 {
this.stepStep=3;
this.$nextTick(()=>{
if(window.lucide) lucide.createIcons();
const m=document.getElementById('main');
if(m) m.scrollTo({top:m.scrollHeight,behavior:'smooth'});
});
}
}
}));
});