english-trainer/app.js

446 lines
20 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.

(function(){
"use strict";
var CUR = window.CURRICULUM || [];
var N = CUR.length;
var SECTIONS = [
{id:"words", icon:"📖", label:"Слова"},
{id:"dialogue", icon:"💬", label:"Диалог"},
{id:"reading", icon:"📰", label:"Чтение"},
{id:"listening", icon:"🎧", label:"Аудио"},
{id:"speaking", icon:"🎤", label:"Говорю"},
{id:"writing", icon:"✍️", label:"Пишу"}
];
var ACTIVE = "words";
var curIdx = 0;
var todayContentIdx = 0;
var dayKey = 0;
var state = null;
/* ---------- storage ---------- */
function todayStr(){ var d=new Date(); return d.getFullYear()+"-"+String(d.getMonth()+1).padStart(2,"0")+"-"+String(d.getDate()).padStart(2,"0"); }
function dateStr(d){ return d.getFullYear()+"-"+String(d.getMonth()+1).padStart(2,"0")+"-"+String(d.getDate()).padStart(2,"0"); }
function load(){
try{ state = JSON.parse(localStorage.getItem("enTrainer")||"null"); }catch(e){ state=null; }
var today = todayStr();
if(!state){ state={startDate:today, streak:1, lastVisit:today, completed:{}, wordsLearned:[]}; }
// streak
if(state.lastVisit !== today){
var y=new Date(); y.setDate(y.getDate()-1);
state.streak = (state.lastVisit===dateStr(y)) ? state.streak+1 : 1;
state.lastVisit = today;
}
if(!state.completed) state.completed={};
if(!state.completed[today]) state.completed[today]=[];
if(!state.wordsLearned) state.wordsLearned=[];
save();
}
function save(){ localStorage.setItem("enTrainer", JSON.stringify(state)); }
function completedToday(){ return state.completed[todayStr()] || (state.completed[todayStr()]=[]); }
function isDone(id){ return completedToday().indexOf(id)>=0; }
function setDone(id, on){
var c=completedToday();
var i=c.indexOf(id);
if(on && i<0){ c.push(id); }
if(!on && i>=0){ c.splice(i,1); }
save();
}
/* ---------- day math ---------- */
function computeDay(){
var s=new Date(state.startDate+"T00:00:00");
var t=new Date(); t.setHours(0,0,0,0);
dayKey = Math.max(0, Math.round((t-s)/86400000));
todayContentIdx = ((dayKey % N)+N)%N;
curIdx = todayContentIdx;
}
function day(){ return CUR[curIdx]; }
/* ---------- speech ---------- */
var voices=[];
var synth = window.speechSynthesis;
function loadVoices(){ if(synth) voices = synth.getVoices(); }
if(synth){ loadVoices(); synth.onvoiceschanged=loadVoices; }
function enVoice(pref){
var en = voices.filter(function(v){ return /en(-|_)?US/i.test(v.lang) || /en(-|_)?GB/i.test(v.lang); });
if(!en.length) en = voices.filter(function(v){ return /^en/i.test(v.lang); });
if(pref) for(var i=0;i<en.length;i++){ if(new RegExp(pref,"i").test(en[i].name)) return en[i]; }
return en[0]||null;
}
function speak(text, opts){
opts=opts||{};
if(!synth) return Promise.resolve();
return new Promise(function(res){
var u=new SpeechSynthesisUtterance(text);
u.lang="en-US";
var v=enVoice(opts.voice);
if(v) u.voice=v;
u.rate=opts.rate||1; u.pitch=opts.pitch||1;
u.onend=res; u.onerror=res;
synth.speak(u);
});
}
function stopSpeak(){ if(synth) synth.cancel(); }
/* ---------- recognition ---------- */
var REC = window.SpeechRecognition || window.webkitSpeechRecognition || null;
var currentRec=null;
function norm(s){ return (s||"").toLowerCase().replace(/[^a-z0-9\s']/g," ").replace(/\s+/g," ").trim(); }
function similarity(spoken, target){
var a=norm(spoken).split(" ").filter(Boolean);
var b=norm(target).split(" ").filter(Boolean);
if(!b.length) return 0;
var set={}; b.forEach(function(w){ set[w]=1; });
var hit=0; a.forEach(function(w){ if(set[w]){ hit++; delete set[w]; } });
return hit/b.length;
}
function levelOf(sim){ return sim>=0.8?"good":sim>=0.5?"ok":"bad"; }
function stopRec(){ if(currentRec){ try{currentRec.stop();}catch(e){} currentRec=null; } }
function startRec(onResult, onEnd){
if(!REC){ toast("Микрофон не поддерживается в этом браузере 🙁 Попробуй Chrome"); return false; }
stopRec(); stopSpeak();
var r=new REC();
r.lang="en-US"; r.interimResults=false; r.maxAlternatives=3;
r.onresult=function(e){
var t=""; for(var i=0;i<e.results.length;i++){ t+=e.results[i][0].transcript; }
onResult(t);
};
r.onerror=function(){ onEnd&&onEnd(); };
r.onend=function(){ onEnd&&onEnd(); };
currentRec=r;
try{ r.start(); return true; }catch(e){ onEnd&&onEnd(); return false; }
}
/* ---------- utils ---------- */
function $(id){ return document.getElementById(id); }
function esc(s){ return (s||"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;"); }
var toastT;
function toast(msg){
var t=$("toast"); t.textContent=msg; t.classList.add("show");
clearTimeout(toastT); toastT=setTimeout(function(){ t.classList.remove("show"); },2200);
}
/* ---------- render: header + dashboard ---------- */
function renderTop(){
$("streak").innerHTML="🔥 "+state.streak;
$("level").textContent="Intermediate";
}
function renderDashboard(){
var elapsed = dayKey+1;
var total=180;
var pct = Math.min(100, Math.round(elapsed/total*100));
var left = Math.max(0, total-elapsed);
$("goalTitle").textContent = "Цель: свободный английский за 6 месяцев";
$("goalSub").textContent = "Ты на пути! Каждый день — новый шаг к свободной речи с коллегами.";
$("goalBar").style.width = pct+"%";
$("goalMetaL").innerHTML = "День <b>"+elapsed+"</b> из "+total;
$("goalMetaR").innerHTML = "Осталось <b>"+left+"</b> дн.";
// mission chips
var h="";
SECTIONS.forEach(function(s){
var d=isDone(s.id);
h+='<div class="mchip '+(d?"done":"")+'" data-sec="'+s.id+'"><span class="ic">'+s.icon+'</span>'+s.label+'</div>';
});
$("mission").innerHTML=h;
$("mission").querySelectorAll(".mchip").forEach(function(el){
el.addEventListener("click",function(){ switchTab(el.getAttribute("data-sec")); window.scrollTo({top:0,behavior:"smooth"}); });
});
}
/* ---------- render: tabs ---------- */
function renderTabs(){
var h="";
SECTIONS.forEach(function(s){
h+='<button class="tab '+(ACTIVE===s.id?"active":"")+'" data-sec="'+s.id+'"><span class="dot"></span>'+s.icon+" "+s.label+'</button>';
});
$("tabrow").innerHTML=h;
$("tabrow").querySelectorAll(".tab").forEach(function(el){
el.addEventListener("click",function(){ switchTab(el.getAttribute("data-sec")); });
});
}
function switchTab(id){
stopSpeak(); stopRec();
ACTIVE=id;
document.querySelectorAll(".tab").forEach(function(el){ el.classList.toggle("active", el.getAttribute("data-sec")===id); });
document.querySelectorAll(".panel").forEach(function(p){ p.classList.remove("active"); });
$(id).classList.add("active");
renderPanel(id);
renderDoneBar();
}
/* ---------- day bar ---------- */
function renderDayBar(){
$("dayTag").innerHTML = 'День <b>'+(curIdx+1)+'</b> / '+N+(curIdx===todayContentIdx?' · сегодня':' · повторение');
}
function bindDayNav(){
$("prevDay").onclick=function(){ curIdx=(curIdx-1+N)%N; rerender(); };
$("nextDay").onclick=function(){ curIdx=(curIdx+1)%N; rerender(); };
}
/* ---------- panel router ---------- */
function renderPanel(id){
var d=day();
if(id==="words") renderWords(d);
if(id==="dialogue") renderDialogue(d);
if(id==="reading") renderReading(d);
if(id==="listening") renderListening(d);
if(id==="speaking") renderSpeaking(d);
if(id==="writing") renderWriting(d);
}
function renderTopicCard(d){
return '<div class="topic-card"><div class="em">'+d.emoji+'</div><div><div class="t">'+esc(d.topic)+'</div><div class="s">Тема дня · Intermediate</div></div></div>';
}
/* ---------- words ---------- */
function renderWords(d){
var h=renderTopicCard(d);
h+='<div class="card"><h3>📖 Новые слова ('+d.words.length+')</h3><div class="hint">Нажми на карточку, чтобы увидеть значение. ▶ — послушать произношение.</div>';
d.words.forEach(function(w,i){
var learned = state.wordsLearned.indexOf(w.en)>=0;
h+='<div class="word" data-i="'+i+'"><div class="word-top"><div><div class="word-en">'+esc(w.en)+'</div><div class="word-ru">'+esc(w.ru)+'</div></div><button class="play" data-play="'+i+'">▶</button></div><div class="word-def">'+esc(w.def)+'</div><div class="word-ex">"'+esc(w.ex)+'"</div><div class="stars">'+(learned?"⭐ Изучено":"")+'</div></div>';
});
h+='<div class="btnrow" style="margin-top:14px"><button class="btn ghost" id="playAll">▶ Все слова</button></div>';
h+='</div>';
$("words").innerHTML=h;
$("words").querySelectorAll(".word").forEach(function(el){
el.addEventListener("click",function(e){
if(e.target.closest(".play")) return;
el.classList.toggle("open");
});
});
$("words").querySelectorAll(".play").forEach(function(el){
el.addEventListener("click",function(e){
e.stopPropagation();
var i=+el.getAttribute("data-play");
var w=d.words[i];
stopSpeak(); speak(w.en);
if(state.wordsLearned.indexOf(w.en)<0){ state.wordsLearned.push(w.en); save(); }
var card=el.closest(".word"); card.querySelector(".stars").textContent="⭐ Изучено";
});
});
$("playAll").onclick=function(){
stopSpeak();
var p=Promise.resolve();
d.words.forEach(function(w){ p=p.then(function(){ return speak(w.en,{rate:0.95}).then(function(){ return new Promise(function(r){ setTimeout(r,250); }); }); }); });
};
}
/* ---------- dialogue ---------- */
function renderDialogue(d){
var h=renderTopicCard(d);
h+='<div class="card"><h3>💬 Диалог дня</h3><div class="hint">▶ — проиграть. Нажми строку, чтобы увидеть перевод.</div>';
h+='<div class="btnrow" style="margin-bottom:14px"><button class="btn primary" id="playAllDlg">▶ Весь диалог</button><button class="btn ghost" id="toggleTr">Перевод</button></div>';
h+='<div class="lines" id="lines">';
d.dialogue.forEach(function(l,i){
h+='<div class="line '+l.who+'" data-i="'+i+'"><div class="bubble"><div class="who">'+esc(l.name)+'</div>'+esc(l.text)+'<div class="tr">'+esc(l.tr)+'</div></div></div>';
});
h+='</div>';
h+='<div class="tips">Совет: повторяй реплики вслух за диктором — это тренирует произношение и скорость речи.</div>';
h+='</div>';
$("dialogue").innerHTML=h;
$("lines").querySelectorAll(".line").forEach(function(el){
el.addEventListener("click",function(){ el.classList.toggle("show-tr"); });
});
$("toggleTr").onclick=function(){
var all=$("lines").querySelectorAll(".line");
var anyOff=false; all.forEach(function(el){ if(!el.classList.contains("show-tr")) anyOff=true; });
all.forEach(function(el){ el.classList.toggle("show-tr", anyOff); });
};
$("playAllDlg").onclick=function(){
stopSpeak(); stopRec();
var p=Promise.resolve();
d.dialogue.forEach(function(l){
p=p.then(function(){
return speak(l.text,{rate:0.95, pitch: l.who==="A"?1:1.15, voice: l.who==="A"?"Male":"Female"});
}).then(function(){ return new Promise(function(r){ setTimeout(r,350); }); });
});
};
}
/* ---------- reading ---------- */
function renderReading(d){
var r=d.reading;
var h=renderTopicCard(d);
h+='<div class="card"><h3>📰 '+esc(r.title)+'</h3><div class="hint">Прочитай текст и ответь на вопросы.</div>';
h+='<p style="margin-bottom:16px;line-height:1.7">'+esc(r.text)+'</p>';
r.questions.forEach(function(qi,idx){
h+='<div class="q" data-q="'+idx+'"><div class="qt">'+(idx+1)+'. '+esc(qi.q)+'</div><div class="opts">';
qi.options.forEach(function(op,oi){
h+='<button class="opt" data-o="'+oi+'">'+esc(op)+'</button>';
});
h+='</div><div class="explain">'+esc(qi.ex)+'</div></div>';
});
h+='<div class="score" id="rScore"></div></div>';
$("reading").innerHTML=h;
bindQuiz("reading", r.questions);
}
/* ---------- listening ---------- */
function renderListening(d){
var l=d.listening;
var h=renderTopicCard(d);
h+='<div class="card"><h3>🎧 Аудирование</h3><div class="hint">Послушай аудио и ответь на вопросы. Можно менять скорость.</div>';
h+='<div class="audio-controls"><button class="btn primary" id="playL">▶ Слушать</button><div class="speed"><button data-sp="0.7">0.7×</button><button class="on" data-sp="1">1×</button><button data-sp="1.2">1.2×</button></div></div>';
h+='<button class="btn outline" id="showText" style="margin-bottom:14px">Показать текст</button>';
h+='<div class="listen-text" id="lText">'+esc(l.text)+'</div>';
l.questions.forEach(function(qi,idx){
h+='<div class="q" data-q="'+idx+'"><div class="qt">'+(idx+1)+'. '+esc(qi.q)+'</div><div class="opts">';
qi.options.forEach(function(op,oi){
h+='<button class="opt" data-o="'+oi+'">'+esc(op)+'</button>';
});
h+='</div><div class="explain">'+esc(qi.ex)+'</div></div>';
});
h+='<div class="score" id="lScore"></div></div>';
$("listening").innerHTML=h;
var rate=1;
$("playL").onclick=function(){ stopSpeak(); speak(l.text,{rate:rate}); };
$("listening").querySelectorAll(".speed button").forEach(function(b){
b.onclick=function(){
rate=parseFloat(b.getAttribute("data-sp"));
$("listening").querySelectorAll(".speed button").forEach(function(x){ x.classList.remove("on"); });
b.classList.add("on");
};
});
$("showText").onclick=function(){ $("lText").classList.toggle("show"); this.textContent=$("lText").classList.contains("show")?"Скрыть текст":"Показать текст"; };
bindQuiz("listening", l.questions);
}
/* ---------- quiz shared ---------- */
function bindQuiz(panelId, questions){
var root=$(panelId);
root.querySelectorAll(".q").forEach(function(qEl, qIdx){
var answered=false;
qEl.querySelectorAll(".opt").forEach(function(oEl){
oEl.addEventListener("click",function(){
if(answered) return; answered=true;
var o=+oEl.getAttribute("data-o");
var correct=questions[qIdx].answer;
qEl.querySelectorAll(".opt").forEach(function(x){ x.classList.add("disabled"); });
if(o===correct){ oEl.classList.add("correct"); }
else{ oEl.classList.add("wrong"); var c=qEl.querySelector('.opt[data-o="'+correct+'"]'); if(c) c.classList.add("correct"); }
qEl.querySelector(".explain").classList.add("show");
updateScore(root, questions.length, panelId);
});
});
});
}
function updateScore(root, total, panelId){
var got=0;
root.querySelectorAll(".q").forEach(function(qEl){
if(qEl.querySelector(".opt.correct") && !qEl.querySelector(".opt.wrong")) got++;
});
var sc=$(panelId==="reading"?"rScore":"lScore");
if(sc){
sc.textContent="Результат: "+got+" / "+total+(got===total?" 🎉 Отлично!":"");
sc.classList.add("show");
}
}
/* ---------- speaking ---------- */
function renderSpeaking(d){
var s=d.speaking;
var sup = !!REC;
var h=renderTopicCard(d);
h+='<div class="card"><h3>🎤 Практика говорения</h3><div class="hint">Прочитай задание и ответь вслух. Я распознаю речь и сравню с образцом.</div>';
h+='<div class="tips" style="margin-bottom:14px"><b>Задание:</b> '+esc(s.prompt)+'</div>';
h+='<div class="speak-box">';
if(sup){
h+='<button class="mic" id="mic">🎤</button>';
h+='<div id="micState">Нажми на микрофон и говори</div>';
} else {
h+='<div style="font-size:40px">🎤</div>';
h+='<div class="rec-badge">Микрофон недоступен в этом браузере</div>';
h+='<div style="font-size:13px;color:var(--gray-500)">Открой в Chrome на телефоне или компьютере, чтобы практиковать речь.</div>';
}
h+='<div class="transcript" id="tr"><span class="ph">Твой ответ появится здесь…</span></div>';
h+='<div class="btnrow"><button class="btn ghost" id="hearModel">▶ Образец</button><button class="btn outline" id="showTarget">Показать ответ</button></div>';
h+='<div class="target" id="target"><b>Образцовый ответ:</b>'+esc(s.target)+'</div>';
h+='</div>';
h+='<div class="tips">💡 Не бойся ошибок — главное пробовать говорить вслух каждый день. Это ключ к свободной речи.</div>';
h+='</div>';
$("speaking").innerHTML=h;
$("hearModel").onclick=function(){ stopSpeak(); speak(s.target,{rate:0.9}); };
$("showTarget").onclick=function(){ $("target").classList.toggle("show"); };
if(sup){
var mic=$("mic"), state2=$("micState"), tr=$("tr");
mic.onclick=function(){
if(mic.classList.contains("rec")){ stopRec(); return; }
var ok=startRec(function(t){
tr.innerHTML=esc(t);
var sim=similarity(t, s.target);
var lv=levelOf(sim);
var label = lv==="good"?"Отлично! 🎉":lv==="ok"?"Неплохо, почти! 👍":"Попробуй ещё раз 💪";
var tr2=document.createElement("div"); tr2.className="match "+lv; tr2.textContent=label+" ("+Math.round(sim*100)+"% совпадение)";
tr.appendChild(tr2);
},function(){
mic.classList.remove("rec"); mic.textContent="🎤"; state2.textContent="Нажми на микрофон и говори";
});
if(ok){ mic.classList.add("rec"); mic.textContent="⏹"; state2.innerHTML='<span class="rec-badge"><span class="blink"></span>Слушаю…</span>'; }
};
}
}
/* ---------- writing ---------- */
function renderWriting(d){
var w=d.writing;
var key="w_"+todayStr()+"_"+curIdx;
var saved=localStorage.getItem(key)||"";
var h=renderTopicCard(d);
h+='<div class="card"><h3>✍️ Практика письма</h3><div class="hint">Напиши ответ и сравни с образцом.</div>';
h+='<div class="tips" style="margin-bottom:14px"><b>Задание:</b> '+esc(w.prompt)+'</div>';
h+='<textarea id="ta" placeholder="Пиши здесь на английском…">'+esc(saved)+'</textarea>';
h+='<div class="btnrow" style="margin-top:10px"><button class="btn primary" id="saveW">Сохранить</button><button class="btn ghost" id="showModel">Показать образец</button></div>';
h+='<div class="model" id="model"><b>Образцовый ответ:</b>'+esc(w.model)+'</div>';
h+='<div class="tips" style="margin-top:12px">💡 '+esc(w.tips)+'</div>';
h+='</div>';
$("writing").innerHTML=h;
$("saveW").onclick=function(){ localStorage.setItem(key, $("ta").value); toast("Сохранено ✅"); };
$("showModel").onclick=function(){ $("model").classList.toggle("show"); };
}
/* ---------- done bar ---------- */
function renderDoneBar(){
var sec=SECTIONS.filter(function(s){return s.id===ACTIVE;})[0];
var done=isDone(ACTIVE);
var c=completedToday();
$("doneInfo").innerHTML = 'Сегодня выполнено: <b>'+c.length+"/"+SECTIONS.length+'</b> · '+sec.label;
var b=$("doneBtn");
b.textContent=done?"✓ Выполнено":"Отметить выполненным";
b.classList.toggle("done", done);
}
function bindDoneBar(){
$("doneBtn").onclick=function(){
var on=!isDone(ACTIVE);
setDone(ACTIVE, on);
renderDoneBar();
renderDashboard();
if(on){
var c=completedToday();
if(c.length===SECTIONS.length) toast("Все задания дня готовы! Так держать 🚀");
else toast("Готово! +1 к прогрессу 💪");
}
};
}
/* ---------- rerender ---------- */
function rerender(){
renderDayBar();
renderPanel(ACTIVE);
renderDoneBar();
}
/* ---------- init ---------- */
function init(){
load();
computeDay();
renderTop();
renderDashboard();
renderTabs();
renderDayBar();
bindDayNav();
bindDoneBar();
switchTab("words");
}
if(document.readyState!=="loading") init();
else document.addEventListener("DOMContentLoaded", init);
})();