Spaces:
Running
Running
| /* ===== 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}">▶ 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="▶ 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,"&").replace(/</g,"<").replace(/>/g,">"); | |
| 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,""").replace(/'/g,"'"); } | |
| 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); | |
| })(); | |