swapmyface's picture
Fix teacher cards bug, professional UI overhaul
335173c verified
/* ===== Language Tutor ===== */
(function () {
"use strict";
const LANG_VOICE = {
hindi:"hi-IN", tamil:"ta-IN", telugu:"te-IN", bengali:"bn-IN",
marathi:"mr-IN", gujarati:"gu-IN", kannada:"kn-IN", malayalam:"ml-IN",
english:"en-US", spanish:"es-ES", french:"fr-FR", japanese:"ja-JP",
};
// ===== State =====
const S = {
step:1, targetLang:null, instructionLang:null, level:null,
teacherId:null, topic:null, sessionId:null,
isMuted:false, isRecording:false, mediaRecorder:null, audioChunks:[],
xp:0, curriculum:null, teachers:null,
};
// ===== Speech (Browser TTS) =====
const Speech = {
speaking:false, queue:[],
speak(text){
if(S.isMuted||!text) return;
const clean = text
.replace(/\[CULTURAL NOTE:.*?\]/gs,"")
.replace(/\[DIALOGUE\].*?\[\/DIALOGUE\]/gs,"")
.replace(/\[SENTENCE_BUILDER:.*?\]/gs,"")
.replace(/\*\*/g,"").replace(/\*/g,"")
.replace(/\[.*?\]/g,"").trim();
if(!clean) return;
this.queue.push(clean);
if(!this.speaking) this._next();
},
_next(){
if(!this.queue.length){ this.speaking=false; Avatar.setIdle(); return; }
this.speaking=true; Avatar.setSpeaking();
const text=this.queue.shift();
const u=new SpeechSynthesisUtterance(text);
const code=LANG_VOICE[S.targetLang]||"en-US";
const v=speechSynthesis.getVoices().find(v=>v.lang===code||v.lang.startsWith(code.split("-")[0]));
if(v) u.voice=v;
u.lang=code; u.rate=0.9;
u.onend=()=>this._next();
u.onerror=()=>this._next();
speechSynthesis.speak(u);
},
stop(){ this.queue=[]; speechSynthesis.cancel(); this.speaking=false; Avatar.setIdle(); }
};
// ===== Avatar =====
const Avatar = {
blinkTimer:null,
load(id,color){
const c=document.getElementById("avatarContainer");
c.innerHTML=`<img src="/static/svg/teacher-${id}.svg" alt="${id}" style="width:100%;height:100%">`;
document.getElementById("avatarName").textContent=id[0].toUpperCase()+id.slice(1);
document.getElementById("avatarName").style.color=color;
document.documentElement.style.setProperty("--teacher-color",color);
this._blink();
},
setSpeaking(){ document.getElementById("avatarContainer").classList.add("speaking"); },
setIdle(){ document.getElementById("avatarContainer").classList.remove("speaking"); },
_blink(){
if(this.blinkTimer) clearInterval(this.blinkTimer);
this.blinkTimer=setInterval(()=>{
try{
const svg=document.getElementById("avatarContainer").querySelector("svg");
if(!svg) return;
const eyes=svg.querySelector("#eyes");
if(!eyes) return;
eyes.style.transform="scaleY(0.1)";
setTimeout(()=>{ eyes.style.transform="scaleY(1)"; },150);
}catch(e){}
},3500+Math.random()*2000);
}
};
// ===== API =====
async function api(url,opts={}){
try{ const r=await fetch(url,opts); if(!r.ok) throw new Error(`HTTP ${r.status}`); return await r.json(); }
catch(e){ console.error("API:",e); return {error:e.message}; }
}
// ===== Onboarding =====
function goStep(n){
S.step=n;
document.querySelectorAll(".ob-step").forEach(el=>el.classList.remove("active"));
document.getElementById("step"+n).classList.add("active");
// progress bar
document.getElementById("progressBar").style.width=(n*20)+"%";
// step labels
document.querySelectorAll(".ob-step-label").forEach((lbl,i)=>{
lbl.classList.remove("active","done");
if(i+1===n) lbl.classList.add("active");
else if(i+1<n) lbl.classList.add("done");
});
// back button
const bb=document.getElementById("btnStepBack");
if(n>1) bb.classList.add("visible"); else bb.classList.remove("visible");
}
function renderLangs(containerId, onSelect, preselect){
const c=document.getElementById(containerId);
c.innerHTML="";
if(!S.curriculum) return;
for(const[key,lang] of Object.entries(S.curriculum.languages)){
const d=document.createElement("div");
d.className="lang-card"+(key===preselect?" selected":"");
d.innerHTML=`<span class="lc-flag">${lang.flag}</span><span class="lc-name">${lang.name}</span><span class="lc-native">${lang.native_name}</span>`;
d.onclick=()=>{
c.querySelectorAll(".lang-card").forEach(x=>x.classList.remove("selected"));
d.classList.add("selected");
onSelect(key);
};
c.appendChild(d);
}
}
function renderLevels(){
const c=document.getElementById("levelCards");
c.innerHTML="";
if(!S.curriculum) return;
for(const[key,lv] of Object.entries(S.curriculum.levels)){
const d=document.createElement("div");
d.className="level-card";
d.innerHTML=`<h3>${lv.name}</h3><p>${lv.description}</p>`;
d.onclick=()=>{
c.querySelectorAll(".level-card").forEach(x=>x.classList.remove("selected"));
d.classList.add("selected");
S.level=key;
setTimeout(()=>{ renderTeachers(); goStep(4); },250);
};
c.appendChild(d);
}
}
function renderTeachers(){
const c=document.getElementById("teacherCards");
c.innerHTML="";
if(!S.teachers||!S.teachers.length){ c.innerHTML='<p style="color:rgba(255,255,255,.5);text-align:center">Loading teachers...</p>'; return; }
for(const t of S.teachers){
const d=document.createElement("div");
d.className="teacher-card";
d.innerHTML=`
<div class="tc-avatar" style="background:${t.color_light}">
<img src="/static/svg/${t.avatar}" alt="${t.name}">
</div>
<div class="tc-info">
<div class="tc-name">${t.name}</div>
<div class="tc-title" style="color:${t.color}">${t.title}</div>
<div class="tc-style">${t.style}</div>
<div class="tc-desc">${t.description}</div>
<button class="btn-voice" data-tid="${t.id}">&#9654; Preview voice</button>
</div>`;
d.onclick=(e)=>{
if(e.target.closest(".btn-voice")) return;
c.querySelectorAll(".teacher-card").forEach(x=>x.classList.remove("selected"));
d.classList.add("selected");
S.teacherId=t.id;
document.documentElement.style.setProperty("--teacher-color",t.color);
document.documentElement.style.setProperty("--teacher-light",t.color_light);
setTimeout(()=>{ renderTopics(); goStep(5); },250);
};
c.appendChild(d);
}
// voice previews
c.querySelectorAll(".btn-voice").forEach(btn=>{
btn.onclick=async(e)=>{
e.stopPropagation();
btn.textContent="Playing...";
const d=await api("/api/teacher/voice-sample",{
method:"POST",headers:{"Content-Type":"application/json"},
body:JSON.stringify({teacher_id:btn.dataset.tid,target_lang:S.targetLang})
});
btn.innerHTML="&#9654; Preview voice";
if(d.text){
const u=new SpeechSynthesisUtterance(d.text);
const code=LANG_VOICE[S.targetLang]||"en-US";
const v=speechSynthesis.getVoices().find(v=>v.lang===code||v.lang.startsWith(code.split("-")[0]));
if(v) u.voice=v; u.lang=code; u.rate=0.9;
speechSynthesis.speak(u);
}
};
});
}
function renderTopics(){
const c=document.getElementById("topicGrid");
c.innerHTML="";
if(!S.curriculum||!S.level) return;
const topics=S.curriculum.curriculum[S.level]||[];
for(const t of topics){
const d=document.createElement("div");
d.className="topic-card";
d.innerHTML=`<span class="tc-icon">${t.icon}</span><span class="tc-label">${t.title}</span><span class="tc-sub">${t.description}</span>`;
d.onclick=()=>{
c.querySelectorAll(".topic-card").forEach(x=>x.classList.remove("selected"));
d.classList.add("selected");
S.topic=t.id;
document.getElementById("btnStart").disabled=false;
};
c.appendChild(d);
}
}
// ===== Lesson =====
async function startLesson(){
const btn=document.getElementById("btnStart");
btn.disabled=true; btn.querySelector("span").textContent="Starting...";
const data=await api("/api/session/start",{
method:"POST",headers:{"Content-Type":"application/json"},
body:JSON.stringify({
target_lang:S.targetLang, instruction_lang:S.instructionLang,
level:S.level, topic:S.topic, teacher_id:S.teacherId
})
});
if(data.error){
btn.querySelector("span").textContent="Start Lesson"; btn.disabled=false;
alert("Failed: "+data.error); return;
}
S.sessionId=data.session_id;
document.getElementById("onboarding").classList.add("hidden");
document.getElementById("lesson").classList.remove("hidden");
const langName=S.curriculum?.languages[S.targetLang]?.name||S.targetLang;
const topicInfo=(S.curriculum?.curriculum[S.level]||[]).find(t=>t.id===S.topic);
document.getElementById("lessonLang").textContent=langName;
document.getElementById("lessonTopic").textContent=topicInfo?.title||S.topic;
const teacher=S.teachers?.find(t=>t.id===S.teacherId)||{color:"#6C63FF"};
Avatar.load(S.teacherId,teacher.color);
addMsg("tutor",data.greeting);
Speech.speak(data.greeting);
document.getElementById("inputMessage").focus();
}
// ===== Chat =====
function addMsg(role,text){
const c=document.getElementById("chatMessages");
const d=document.createElement("div");
d.className="msg "+role;
d.innerHTML=fmtMsg(text);
c.appendChild(d);
renderSpecial(d,text);
scrollBottom();
}
function fmtMsg(t){
let h=t.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");
h=h.replace(/\*\*(.+?)\*\*/g,"<strong>$1</strong>");
h=h.replace(/\*(.+?)\*/g,"<em>$1</em>");
h=h.replace(/\n/g,"<br>");
h=h.replace(/\[CULTURAL NOTE:\s*(.*?)\]/gs,'<div class="cultural-note"><div class="cultural-note-title">Cultural Insight</div>$1</div>');
h=h.replace(/\[DIALOGUE\](.*?)\[\/DIALOGUE\]/gs,'<div class="dialogue-block">$1</div>');
return h;
}
function renderSpecial(el,text){
const m=text.match(/\[SENTENCE_BUILDER:\s*(.*?)\]/);
if(!m) return;
const words=m[1].split("|").map(w=>w.trim());
const shuffled=[...words].sort(()=>Math.random()-.5);
const correct=words.join(" ");
const blk=document.createElement("div");
blk.className="sentence-builder";
blk.innerHTML=`
<div class="sentence-builder-title">Arrange the words correctly:</div>
<div class="word-tiles">${shuffled.map(w=>`<span class="word-tile" data-w="${esc(w)}">${w}</span>`).join("")}</div>
<div class="sentence-drop-zone" data-correct="${esc(correct)}"></div>
<div class="sentence-builder-actions"><button class="btn-check-sentence">Check</button><button class="btn-reset-sentence">Reset</button></div>`;
el.appendChild(blk);
const tiles=blk.querySelectorAll(".word-tile");
const zone=blk.querySelector(".sentence-drop-zone");
tiles.forEach(tile=>{
tile.onclick=()=>{
if(tile.classList.contains("placed")) return;
tile.classList.add("placed");
const p=document.createElement("span");
p.className="placed-word"; p.textContent=tile.dataset.w;
p.onclick=()=>{ p.remove(); tile.classList.remove("placed"); };
zone.appendChild(p);
};
});
blk.querySelector(".btn-check-sentence").onclick=()=>{
const user=Array.from(zone.querySelectorAll(".placed-word")).map(e=>e.textContent).join(" ");
if(user===correct){ zone.style.borderColor="var(--success)"; zone.style.background="#F0FDF4"; S.xp+=10; updateXP(); }
else{ zone.style.borderColor="var(--danger)"; zone.style.background="#FEF2F2"; }
};
blk.querySelector(".btn-reset-sentence").onclick=()=>{
zone.innerHTML=""; zone.style.borderColor=""; zone.style.background="";
tiles.forEach(t=>t.classList.remove("placed"));
};
}
function esc(s){ return s.replace(/"/g,"&quot;").replace(/'/g,"&#39;"); }
function showTyping(){
const c=document.getElementById("chatMessages");
const d=document.createElement("div");
d.className="typing-indicator"; d.id="typingIndicator";
d.innerHTML='<span class="typing-dot"></span><span class="typing-dot"></span><span class="typing-dot"></span>';
c.appendChild(d); scrollBottom();
}
function hideTyping(){ const e=document.getElementById("typingIndicator"); if(e) e.remove(); }
function scrollBottom(){ const a=document.getElementById("chatArea"); requestAnimationFrame(()=>{ a.scrollTop=a.scrollHeight; }); }
function updateXP(){ document.getElementById("xpBadge").querySelector("span").textContent=S.xp+" XP"; }
async function sendMessage(){
const inp=document.getElementById("inputMessage");
const text=inp.value.trim();
if(!text||!S.sessionId) return;
inp.value="";
addMsg("student",text);
showTyping();
const data=await api("/api/chat",{
method:"POST",headers:{"Content-Type":"application/json"},
body:JSON.stringify({session_id:S.sessionId,message:text})
});
hideTyping();
if(data.error){ addMsg("tutor","Sorry, something went wrong. Please try again."); return; }
addMsg("tutor",data.reply);
if(data.xp){ S.xp=data.xp; updateXP(); }
Speech.speak(data.reply);
}
// ===== Voice Input =====
async function toggleMic(){
if(S.isRecording){
if(S.mediaRecorder) S.mediaRecorder.stop();
S.isRecording=false;
document.getElementById("btnMic").classList.remove("recording");
return;
}
try{
const stream=await navigator.mediaDevices.getUserMedia({audio:true});
S.audioChunks=[];
S.mediaRecorder=new MediaRecorder(stream);
S.mediaRecorder.ondataavailable=e=>{ if(e.data.size>0) S.audioChunks.push(e.data); };
S.mediaRecorder.onstop=async()=>{
stream.getTracks().forEach(t=>t.stop());
const blob=new Blob(S.audioChunks,{type:"audio/webm"});
showTyping();
const form=new FormData();
form.append("session_id",S.sessionId);
form.append("audio",blob,"audio.webm");
const data=await api("/api/voice",{method:"POST",body:form});
hideTyping();
if(data.transcribed) addMsg("student",data.transcribed);
if(data.reply){ addMsg("tutor",data.reply); Speech.speak(data.reply); }
if(data.xp){ S.xp=data.xp; updateXP(); }
};
S.mediaRecorder.start();
S.isRecording=true;
document.getElementById("btnMic").classList.add("recording");
}catch(e){ console.error("Mic:",e); }
}
// ===== Events =====
function bind(){
document.getElementById("btnSend").onclick=sendMessage;
document.getElementById("inputMessage").onkeydown=e=>{
if(e.key==="Enter"&&!e.shiftKey){ e.preventDefault(); sendMessage(); }
};
document.getElementById("btnMic").onclick=toggleMic;
document.getElementById("btnMute").onclick=()=>{
S.isMuted=!S.isMuted;
document.getElementById("iconSpeaker").classList.toggle("hidden",S.isMuted);
document.getElementById("iconMuted").classList.toggle("hidden",!S.isMuted);
if(S.isMuted) Speech.stop();
};
document.getElementById("btnBack").onclick=()=>{
Speech.stop(); S.sessionId=null;
document.getElementById("chatMessages").innerHTML="";
document.getElementById("lesson").classList.add("hidden");
document.getElementById("onboarding").classList.remove("hidden");
document.getElementById("btnStart").querySelector("span").textContent="Start Lesson";
document.getElementById("btnStart").disabled=true;
document.querySelectorAll("#topicGrid .topic-card").forEach(c=>c.classList.remove("selected"));
};
document.getElementById("btnStart").onclick=startLesson;
// Step back button
document.getElementById("btnStepBack").onclick=()=>{
if(S.step>1) goStep(S.step-1);
};
// Preload voices
if(window.speechSynthesis){
speechSynthesis.getVoices();
speechSynthesis.onvoiceschanged=()=>speechSynthesis.getVoices();
}
}
// ===== Init =====
async function init(){
bind();
const[curr,teach]=await Promise.all([
api("/api/curriculum"),
api("/api/teachers")
]);
if(!curr.error) S.curriculum=curr;
if(!teach.error) S.teachers=teach.teachers;
// Step 1: target language
renderLangs("targetLangGrid",(key)=>{
S.targetLang=key;
setTimeout(()=>{
renderLangs("instructionLangGrid",(k)=>{
S.instructionLang=k;
setTimeout(()=>{ renderLevels(); goStep(3); },250);
},"english");
goStep(2);
},250);
});
}
document.addEventListener("DOMContentLoaded",init);
})();