${g}, ${esc(n)}! Ready to learn something new? 🔥 ${streak()}-day streak
`;document.body.appendChild(p);setTimeout(()=>p.classList.add('out'),4500);setTimeout(()=>p.remove(),5100)}
const wkSec=(subj,off)=>{let t=0;for(let i=0;i<7;i++){const d=new Date();d.setDate(d.getDate()-i-off*7);S.sessions.forEach(x=>{if(x.day===dkey(d)&&(!subj||x.subject===subj))t+=x.sec})}return t};
const wkDays=()=>{let n=0;for(let i=0;i<7;i++){const d=new Date();d.setDate(d.getDate()-i);if(secOn(dkey(d)))n++}return n};
function repData(){const names=[...new Set(S.sessions.map(x=>x.subject))];const rows=names.map(n=>{const sb=S.subjects.find(s=>s.name===n);return{n,w:wkSec(n,0),p:wkSec(n,1),pr:sb?subjProg(sb):0}});const acc=S.results.length?Math.round(S.results.reduce((a,x)=>a+(x.total?(x.correct??x.score??0)/x.total*100:0),0)/S.results.length):0;return{rows,tot:wkSec('',0),prev:wkSec('',1),days:wkDays(),acc,tests:S.results.length,lessons:allLessons().filter(x=>x.done).length}}
function repText(){const r=repData();return `ScholarX weekly progress report - ${new Date().toLocaleDateString()}\nStudent: ${S.user.name}\n\nOVERALL\nStudy time this week: ${fmt(r.tot)} (last week ${fmt(r.prev)})\nActive days: ${r.days}/7\nStreak: ${streak()} days\nLessons completed: ${r.lessons}\nTests: ${r.tests}, average accuracy ${r.acc}%\n\nSUBJECT-WISE\n`+(r.rows.length?r.rows.map(x=>`${x.n}: ${fmt(x.w)} this week, ${fmt(x.p)} last week, course progress ${x.pr}%`).join('\n'):'No study sessions recorded yet.')}
function repCard(){const r=repData(),f=UI.rep||'all',rows=f==='all'?r.rows:r.rows.filter(x=>x.n===f);const dl=(a,b)=>a===b?'same as last week':a>b?'up from last week':'down from last week';return `
Weekly progress report
${r.rows.map(x=>``).join('')}
`+(f==='all'?`
${fmt(r.tot)}Study time this week
${r.days}/7Active days
${r.lessons}Lessons done
${r.acc}%Test accuracy
This week is ${dl(r.tot,r.prev)}.
`:'')+(rows.length?`
Subject
This week
Last week
Course progress
${rows.map(x=>`
${esc(x.n)}
${fmt(x.w)}
${fmt(x.p)}
${x.pr}%
`).join('')}
`:'
Record a study session to see subject-wise numbers.
')+`
`}
const DEMO={u1:310*60,u2:220*60,u3:90*60};
function boardCard(){const me=meUser();const list=S.users.map(u=>({n:u.name,tag:u.tag,s:u.me?wkSec('',0):(DEMO[u.id]||0),me:!!u.me,demo:!u.me})).sort((a,b)=>b.s-a.s);return `
Leaderboard · this week
Ranked by study time in the last 7 days. Other students are demo entries in this offline file; a real shared ranking needs the server step.
Add as many other exams as you like, one by one or separated by commas.
`;
/* login: ScholarX login page (runs in its own layer, talks to the app by message) */
let SX_PENDING=null;
function sxMountLogin(){const app=$('#app');if(app.querySelector('#sxlgf'))return;app.innerHTML='';const f=sxFrame('login');f.id='sxlgf';app.appendChild(f)}
function sxAuthLogin(d){
const un=String(d.username||'').trim().toLowerCase(),pw=String(d.password||'');
const u=S.users.find(x=>(x.name||'').trim().toLowerCase()===un)||(un.includes('@')?S.users.find(x=>(x.email||'').trim().toLowerCase()===un):null);
if(!u)return{ok:false,field:'username',message:'No account with that username or email. Use Create account first.'};
if(u.blocked)return{ok:false,field:'username',message:'This account is blocked.'};
let msg='Login successful.';
if(u.pw){if(hashPw(pw,u.salt)!==u.pw)return{ok:false,field:'password',message:'Wrong password.'}}
else{
if(pw.length<8)return{ok:false,field:'password',message:'This account has no password yet. Enter a new one (at least 8 characters) to set it.'};
u.salt=newSalt();u.pw=hashPw(pw,u.salt);save();msg='Login successful. Your password is now set.'}
SX_PENDING={kind:'student',id:u.id};return{ok:true,message:msg}}
function sxAuthSignup(d){
const fn=String(d.fullName||'').trim(),em=String(d.email||'').trim().toLowerCase(),un=String(d.username||'').trim(),pw=String(d.password||'');
if(fn.length<2)return{ok:false,field:'fullName',message:'Enter your full name.'};
if(!okEmail(em))return{ok:false,field:'email',message:'Enter a valid email address.'};
if(un.includes('@'))return{ok:false,field:'username',message:'A username cannot contain @. Your email goes in the Email box.'};
if(un.length<2)return{ok:false,field:'username',message:'Username must be at least 2 characters.'};
if(pw.length<8)return{ok:false,field:'password',message:'Use at least 8 characters for your password.'};
if(S.users.some(x=>(x.name||'').trim().toLowerCase()===un.toLowerCase()))return{ok:false,field:'username',message:'That username is taken. Pick another, or log in.'};
if(S.users.some(x=>(x.email||'').toLowerCase()===em))return{ok:false,field:'email',message:'This email is already registered. Log in instead.'};
const salt=newSalt();S.users.push({id:uid(),name:un,fullName:fn,email:em,role:'student',blocked:false,muted:false,salt,pw:hashPw(pw,salt)});save();
return{ok:true,message:'Account created. Now log in.'}}
function sxAuthReset(d){
const un=String(d.username||'').trim().toLowerCase(),em=String(d.email||'').trim().toLowerCase(),pw=String(d.password||'');
const u=S.users.find(x=>(x.name||'').trim().toLowerCase()===un&&(x.email||'').toLowerCase()===em);
if(!u)return{ok:false,field:'username',message:'That username and email do not match an account.'};
if(pw.length<8)return{ok:false,field:'password',message:'Use at least 8 characters for your password.'};
u.salt=newSalt();u.pw=hashPw(pw,u.salt);save();
return{ok:true,message:'Password updated. Now log in.'}}
function sxAuthOwner(d){
if(!OWN)return{ok:false,message:'Owner login is not set up on this device yet. Log in as a user, tap the Pokeball 5 times, and create it.'};
if(Date.now()=5){ownerLockUntil=Date.now()+30000;ownerFails=0}
return{ok:false,message:'Email or password is wrong.'}}
function sxEnter(){
const p=SX_PENDING;SX_PENDING=null;if(!p)return;
if(p.kind==='student'){const u=S.users.find(x=>x.id===p.id);if(!u)return;S.users.forEach(x=>{delete x.me});u.me=true;S.user.name=u.name;S.role='student';UI.page='dash';sessionStorage.removeItem('sx_out');save();location.reload()}
else{ownerUnlocked=true;sessionStorage.removeItem('sx_out');S.role='owner';UI.page='owner';save();render()}}
function sxHandle(d){
try{
if(d.t==='introend'){const f=document.getElementById('sxintro');if(f&&f._end)f._end();return true}
if(d.t==='login')return sxAuthLogin(d);
if(d.t==='signup')return sxAuthSignup(d);
if(d.t==='reset')return sxAuthReset(d);
if(d.t==='owner')return sxAuthOwner(d);
if(d.t==='enter'){sxEnter();return true}
}catch(err){return{ok:false,message:'Error: '+(err&&err.message||err)}}
return{ok:false,message:'Unknown request.'}}
window.__sxHandle=sxHandle;
window.addEventListener('message',ev=>{
const d=ev.data;if(!d||d.sx!==1||d.t==='result')return;
const fr=[...document.querySelectorAll('iframe[data-sx]')].find(x=>x.contentWindow===ev.source);if(!fr)return;
if(fr.dataset.sx==='intro'&&d.t!=='introend')return;
if(fr.dataset.sx==='login'&&d.t==='introend')return;
const r=sxHandle(d);
if(d.id){try{ev.source.postMessage(Object.assign({sx:1,t:'result',id:d.id},r&&typeof r==='object'?r:{ok:true}),'*')}catch(e){}}});
/* PDF + download */
function sxPdf(text){const wrap=[];text.split('\n').forEach(l=>{l=l.replace(/[^\x20-\x7E]/g,'?');if(!l){wrap.push('');return}while(l.length>88){let k=l.lastIndexOf(' ',88);if(k<20)k=88;wrap.push(l.slice(0,k));l=l.slice(k).trimStart()}wrap.push(l)});
const per=46,pages=[];for(let i=0;it.replace(/\\/g,'\\\\').replace(/\(/g,'\\(').replace(/\)/g,'\\)');const objs=[];const add=o=>{objs.push(o);return objs.length};
add('<< /Type /Catalog /Pages 2 0 R >>');add('');add('<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>');add('<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >>');const kids=[];
pages.forEach((pg,pi)=>{let c='BT 15 TL 50 800 Td\n';pg.forEach((ln,i)=>{const h=pi===0&&i===0;c+='/'+(h?'F2 14':'F1 11')+' Tf ('+q(ln)+') Tj T*\n'});c+='ET';const cid=add('<< /Length '+c.length+' >>\nstream\n'+c+'\nendstream');kids.push(add('<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R /F2 4 0 R >> >> /Contents '+cid+' 0 R >>')+' 0 R')});
objs[1]='<< /Type /Pages /Kids ['+kids.join(' ')+'] /Count '+kids.length+' >>';let out='%PDF-1.4\n';const off=[];objs.forEach((o,i)=>{off.push(out.length);out+=(i+1)+' 0 obj\n'+o+'\nendobj\n'});const x=out.length;
out+='xref\n0 '+(objs.length+1)+'\n0000000000 65535 f \n'+off.map(o=>String(o).padStart(10,'0')+' 00000 n \n').join('')+'trailer\n<< /Size '+(objs.length+1)+' /Root 1 0 R >>\nstartxref\n'+x+'\n%%EOF';return new Blob([out],{type:'application/pdf'})}
function sxSave(blob,name){const url=URL.createObjectURL(blob),a=document.createElement('a');a.href=url;a.download=name;a.rel='noopener';a.style.display='none';document.body.appendChild(a);a.click();setTimeout(()=>{a.remove();URL.revokeObjectURL(url)},5000);toast('Downloading '+name)}
/* doubts to administrator / moderator */
const canSeeDoubt=d=>{if(S.role==='owner')return true;const me=meUser();if(d.to===me.id)return true;if(me.role==='administrator')return !d.to||d.to==='administrator'||d.to==='moderator';if(me.role==='moderator')return !d.to||d.to==='moderator';return false};
function doubtInbox(f,title){const list=S.doubts.filter(canSeeDoubt).filter(f||(()=>true)).slice().reverse();return `
Reply by ${esc(d.repliedBy||'staff')}: ${esc(d.reply)}
`:``}
`}).join(''):'
No doubts here yet.
'}
`}
const _mod2=moderation;moderation=function(){return _mod2()+doubtInbox(d=>d.to!=='owner')};
/* owner: user search, dashboard notes, display name */
const ownerUsers=()=>{const q=(UI.ouq||'').trim().toLowerCase();return q?S.users.filter(u=>(u.name||'').toLowerCase().includes(q)||(u.email||'').toLowerCase().includes(q)):S.users};
function ownerDescs(){const ed=UI.editDesc&&S.descs.find(x=>x.id===UI.editDesc);return `
${ed?'Edit description':'Add a description'}
Whatever you write here appears in a box on every student's dashboard. Add as many descriptions as you need.
${ed?'':''}
Published descriptions (${S.descs.length})
${S.descs.length?`
${S.descs.map(d=>`
${d.title?`${esc(d.title)}`:''}
${esc(d.body)}
${new Date(d.ts).toLocaleString()}
`).join('')}
`:'
No descriptions yet.
'}
`}
function descBox(){if(!S.descs.length)return '';return `
From the owner
${S.descs.map(d=>`
${d.title?`${esc(d.title)}`:''}
${esc(d.body)}
${new Date(d.ts).toLocaleDateString()}
`).join('')}
`}
const ownerNameCard=()=>`
Your name in chat
This name (with an Owner badge) shows on your community chat messages.
`;
Object.assign(ACT,{
addotherexam:v=>{const el=$('#ox-n'),names=((el&&el.value)||'').split(',').map(x=>x.trim().slice(0,40)).filter(Boolean);if(!names.length){toast('Type an exam name first');return}
if(v==='modal')S.exams=[...document.querySelectorAll('#exm .chip.on')].map(x=>x.dataset.v);
let n=0;names.forEach(nm=>{const ex=examList().find(x=>x.toLowerCase()===nm.toLowerCase());const name=ex||nm;if(!ex)S.customExams.push(name);if(!S.exams.includes(name)){S.exams.push(name);n++}});
save();if(v==='modal')examModal();else render();toast(n?n+' exam'+(n>1?'s':'')+' added':'Already in your list')},
sxdl:()=>sxSave(sxPdf(repText()),'ScholarX-weekly-report.pdf'),
sxdltxt:()=>sxSave(new Blob([repText()],{type:'text/plain'}),'ScholarX-weekly-report.txt'),
submitdoubt:()=>{const t=val('doubt-text').trim();if(!t){toast('Write your doubt first');return}const u=meUser();if(isMuted(u)){toast('You are muted from chat');return}S.doubts.push({id:uid(),userId:u.id,name:u.name,to:val('doubt-to')||'moderator',text:t,ts:Date.now(),reply:''});save();render();toast('Doubt sent')},
replydoubt:v=>{const d=S.doubts.find(x=>x.id===v),r=val('reply-'+v).trim();if(!d||!r)return;d.reply=r;d.repliedAt=Date.now();d.repliedBy=S.role==='owner'?chatName()+' (owner)':meUser().name+' ('+meUser().role+')';own('Replied to doubt from '+(d.name||'student'));save();render();toast('Reply sent')},
descsave:()=>{const t=val('ds-t').trim(),b=val('ds-b').trim();if(!b){toast('Write the description first');return}if(UI.editDesc){const d=S.descs.find(x=>x.id===UI.editDesc);if(d){d.title=t;d.body=b}UI.editDesc=null;own('Edited dashboard description')}else{S.descs.unshift({id:uid(),title:t,body:b,ts:Date.now()});own('Added dashboard description')}save();render();toast('Saved')},
descedit:v=>{UI.editDesc=v||null;render()},
descdel:v=>{if(!confirm('Delete this description?'))return;S.descs=S.descs.filter(x=>x.id!==v);if(UI.editDesc===v)UI.editDesc=null;own('Deleted dashboard description');save();render()},
savownername:()=>{S.ownerName=val('on-n').trim().slice(0,40);own('Updated owner display name');save();render();toast('Name saved')},
send:()=>{const t=val('cm-t').trim();if(!t)return;const r=UI.tab.comm;if(S.role==='owner'&&!ownerName())toast('Tip: set your name in Owner > Account');(S.chat[r]=S.chat[r]||[]).push({n:chatName(),t,me:true,role:chatRole(),u:S.role==='owner'?'':meUser().id});save();render();const m=$('#msgs');if(m)m.scrollTop=1e5}
});
document.addEventListener('input',e=>{if(e.target.dataset.in==='ou'){UI.ouq=e.target.value;const p=e.target.selectionStart;render();const el=$('#ou-q');if(el){el.focus();el.setSelectionRange(p,p)}}});
document.addEventListener('keydown',e=>{if(e.key!=='Enter')return;const id=e.target.id;if(id==='ox-n')ACT.addotherexam(e.target.closest('#modal')?'modal':'page')});
/* ===== v3: owner Ask Doubt tab, upload power, secret Pokeball ===== */
const POKE='';
(function(){const st=document.createElement('style');st.textContent='.sxpoke{background:none;border:0;padding:2px;cursor:pointer;line-height:0;border-radius:50%;vertical-align:middle;margin-right:6px}.sxpoke.wig svg{animation:sxwig .35s}@keyframes sxwig{25%{transform:rotate(-25deg)}75%{transform:rotate(25deg)}}';document.head.appendChild(st)})();
function doubtPending(){const n=S.doubts.filter(d=>!d.reply&&d.to==='owner').length;return n?' ('+n+')':''}
const hasUploadPower=u=>!!(u&&u.directUpload&&['administrator','moderator'].includes(u.role));
function uploadPowerCard(){const staff=S.users.filter(u=>['administrator','moderator'].includes(u.role));return `
Upload power for administrators and moderators
By default their study material waits for your approval. Give upload power to publish material straight to students without a request. You can take it back at any time.
The owner has given you upload power. Material you submit here is published to students immediately.
':'')+_am().replace('Send to owner for approval',hasUploadPower(me)?'Publish now':'Send to owner for approval')};
let _pk={n:0,t:0};
Object.assign(ACT,{
uppower:v=>{if(S.role!=='owner'){toast('Only the owner can do this');return}const u=S.users.find(x=>x.id===v);if(!u||!['administrator','moderator'].includes(u.role)){toast('Only administrators and moderators can get upload power');return}u.directUpload=!u.directUpload;own((u.directUpload?'Gave upload power to ':'Removed upload power from ')+u.name);save();render();toast(u.directUpload?'Upload power given':'Upload power removed')},
submitmaterial:()=>{const title=val('mat-title').trim(),url=val('mat-url').trim();if(!title||!/^https?:\/\//.test(url)){toast('Enter a title and valid material URL');return}const me=meUser(),direct=S.role==='owner'||hasUploadPower(me);const x={id:uid(),title,type:val('mat-type'),url,description:val('mat-desc').trim(),by:me.id,byName:me.name,status:direct?'Approved':'Pending',ts:Date.now()};S.materialRequests.push(x);if(direct){S.materials.push({...x,approvedAt:Date.now()});own('Published material directly: '+title)}save();render();toast(direct?'Material published to students':'Sent to owner for approval')},
sxpoke:(v,t)=>{const now=Date.now();if(now-_pk.t>3000)_pk.n=0;_pk.t=now;_pk.n++;if(t){t.classList.remove('wig');void t.offsetWidth;t.classList.add('wig')}if(_pk.n>=5){_pk.n=0;if(OWN)ownerPrompt();else{toast('Set up your owner email and password under Account');ACT.role('owner')}}}
});
/* ===== v4: separate Ask Owner section, member tags ===== */
const tagHtml=t=>t?` ${esc(t)}`:'';
const nameTag=m=>{if(m.role==='owner')return '';const u=(m.u&&S.users.find(x=>x.id===m.u))||S.users.find(x=>x.name===m.n);return tagHtml(u&&u.tag)};
function askOwnerPage(){const me=meUser(),mine=S.doubts.filter(d=>d.userId===me.id&&d.to==='owner'),muted=isMuted(me);
return `
Ask the Owner
This goes only to the owner. For subject doubts you can also use Doubt to Admin / Mod.
${muted?`
Your chat permissions are muted${me.mutedUntil?' until '+new Date(me.mutedUntil).toLocaleString():''}.
`:``}
My messages to the owner
${mine.length?mine.slice().reverse().map(d=>`
${esc(d.text)}
${new Date(d.ts).toLocaleString()}
${d.reply?`
Owner reply
${esc(d.reply)}
`:'Waiting for owner'}
`).join(''):'
Nothing sent yetYour messages and the owner replies appear here.
'}
`}
function tagsCard(){const list=ownerUsers();return `
Member tags
Give any user a tag, or write your own text. It shows beside their name in chat, leaderboard and the users list. Leave it empty and save to remove.
${list.length?`
${list.map(u=>`
${esc(u.name)}${tagHtml(u.tag)}
${esc(u.email||'')}
${u.tag?``:''}
`).join('')}
`:'
No user matches this search.
'}
`}
Object.assign(ACT,{
submitaskowner:()=>{const t=val('ao-text').trim();if(!t){toast('Write your message first');return}const u=meUser();if(isMuted(u)){toast('You are muted from chat');return}S.doubts.push({id:uid(),userId:u.id,name:u.name,to:'owner',text:t,ts:Date.now(),reply:''});save();render();toast('Sent to the owner')},
savetag:v=>{if(S.role!=='owner')return;const u=S.users.find(x=>x.id===v);if(!u)return;u.tag=val('tg-'+v).trim().slice(0,24);own('Set tag for '+u.name+(u.tag?': '+u.tag:' (removed)'));save();render();toast(u.tag?'Tag saved':'Tag removed')},
mkmember:v=>{if(S.role!=='owner')return;const u=S.users.find(x=>x.id===v);if(!u)return;u.tag='Member';own('Gave Member tag to '+u.name);save();render();toast('Member tag given')},
cleartag:v=>{if(S.role!=='owner')return;const u=S.users.find(x=>x.id===v);if(!u)return;delete u.tag;own('Removed tag from '+u.name);save();render()}
});
/* ===== v5: push notifications (announcements + doubt replies) and user activity tracking ===== */
addNotif=function(t,b,to){S.notifs.unshift({id:uid(),t,b,to:to||'all',ts:Date.now(),rb:[]});S.notifs=S.notifs.slice(0,200);save()};
const nVisible=(n,me)=>!n.to||n.to==='all'||n.to===me.id;
const nRead=(n,me)=>n.rb?n.rb.includes(me.id):!!n.read;
function unreadCount(){const me=meUser();return S.notifs.filter(n=>nVisible(n,me)&&!nRead(n,me)).length}
function pushLocal(t,b){toast(t);notify(t,b)}
Object.assign(ACT,{
notifs:()=>{const me=meUser();const list=S.notifs.filter(n=>nVisible(n,me));const canAsk='Notification' in window&&Notification.permission==='default';
openModal(`
Notifications
${canAsk?'
Turn on push alerts to get a pop-up when the owner announces something or replies to you.
':''}${list.length?`
${list.map(n=>`
${esc(n.t)}
${esc(n.b)}
${new Date(n.ts).toLocaleString()}
${nRead(n,me)?'':'New'}
`).join('')}
`:'
No notifications.
'}`);
S.notifs.forEach(n=>{if(nVisible(n,me)){if(n.rb){if(!n.rb.includes(me.id))n.rb.push(me.id)}else n.read=true}});save();render();$('#modal').classList.add('show')},
announce:()=>{if(S.role!=='owner')return;const t=val('o-nt').trim();if(!t){toast('Write a title first');return}addNotif(t,val('o-nb').trim(),'all');own('Announcement: '+t);render();toast('Announcement sent as a notification to all users')}
});
(function(){const _rd=ACT.replydoubt;ACT.replydoubt=function(v,...a){const d=S.doubts.find(x=>x.id===v);const had=d&&d.reply;_rd(v,...a);if(d&&d.reply&&!had){const who=S.role==='owner'?'The owner':(meUser().name||'Staff');addNotif(who+' replied to your doubt',d.reply.length>140?d.reply.slice(0,137)+'...':d.reply,d.userId);render()}}})();
/* sync between tabs on this device: new notifications arrive instantly as push */
window.addEventListener('storage',e=>{if(e.key!==KEY||!e.newValue)return;let n;try{n=JSON.parse(e.newValue)}catch(x){return}
const old=new Set(S.notifs.map(x=>x.id)),myRole=S.role;S=Object.assign(defaults(),n);S.role=myRole;S.descs=S.descs||[];S.customExams=S.customExams||[];S.doubts=S.doubts||[];S.materials=S.materials||[];S.materialRequests=S.materialRequests||[];S.reports=S.reports||[];S.results=S.results||[];
if(!sessionStorage.getItem('sx_out')&&S.role==='student'){const me=meUser();S.notifs.filter(x=>x.to&&!old.has(x.id)&&nVisible(x,me)&&!nRead(x,me)).slice(0,3).forEach(x=>pushLocal(x.t,x.b))}
const a=document.activeElement;if(!a||!/INPUT|TEXTAREA|SELECT/.test(a.tagName))render()});
/* user activity: opens + time on site (kept under its own key so it never overwrites app data) */
const ACTKEY='scholarx_activity_v1';
const actRead=()=>{try{return JSON.parse(localStorage.getItem(ACTKEY)||'{}')||{}}catch(e){return {}}};
function actUpdate(id,f){if(!id)return;const m=actRead();const r=m[id]||(m[id]={opens:0,secs:0,last:0});f(r);try{localStorage.setItem(ACTKEY,JSON.stringify(m))}catch(e){}}
function trackOpen(){if(sessionStorage.getItem('sx_out'))return;const u=meUser();if(!u.id||S.role!=='student')return;const k='sx_c_'+u.id;if(sessionStorage.getItem(k))return;sessionStorage.setItem(k,'1');actUpdate(u.id,r=>{r.opens++;r.last=Date.now()})}
let _lt=Date.now();
setInterval(()=>{const now=Date.now(),dt=Math.min(15,Math.round((now-_lt)/1000));_lt=now;if(document.visibilityState!=='visible'||sessionStorage.getItem('sx_out')||S.role!=='student')return;const u=meUser();if(!u.id)return;if(!sessionStorage.getItem('sx_c_'+u.id))trackOpen();actUpdate(u.id,r=>{r.secs+=dt;r.last=now})},5000);
document.addEventListener('visibilitychange',()=>{_lt=Date.now()});
const fmtDur=sec=>{sec=Math.round(sec||0);if(sec<60)return sec+'s';const h=Math.floor(sec/3600),m=Math.floor(sec%3600/60);return h?h+'h '+m+'m':m+'m '+(sec%60)+'s'};
function activityCard(){const m=actRead();const rows=S.users.map(u=>{const r=m[u.id]||{opens:0,secs:0,last:0};return{u,...r}}).sort((a,b)=>b.secs-a.secs);const tot=rows.reduce((a,x)=>a+x.secs,0),op=rows.reduce((a,x)=>a+x.opens,0);
return `
User activity on the website
Time spent on the site (counted while the page is open and visible) and how many times each user opened it. Only users who used this website on this device are counted until the Supabase step.
${rows.filter(x=>x.opens).length}Users who opened the site
Everyone gets it as a notification. The owner can turn this power off at any time.
My published messages
${mine.length?`
${mine.map(n=>`
${esc(n.t)}
${esc(n.b)}
${new Date(n.ts).toLocaleString()}
`).join('')}
`:'
You have not published anything yet.
'}
`}
const swRow=(title,sub,on,act)=>`
${title}
${sub}
`;
const controlsCard=()=>`
Chat and publishing controls
${swRow('Administrators can publish messages','When on, administrators get a Publish message section and can send notifications to all users. Turn off to remove the power.',adminPubOn(),'adminpubtoggle')}${swRow('Emoji in community chat','When on, everyone gets an emoji picker in chat. When off, the picker is hidden and emoji in new messages are removed.',emojiOn(),'emojitoggle')}
No owner login exists on this device yet. Choose the owner email and a password (8+ characters). You will have to enter them every time you open the owner panel.
`);
setTimeout(()=>{const x=$('#of-e');if(x)x.focus()},50)};
let _pk7={n:0,t:0};
const fiveTaps=(c,t)=>{const now=Date.now();if(now-c.t>3000)c.n=0;c.t=now;c.n++;if(t){t.classList.remove('wig');void t.offsetWidth;t.classList.add('wig')}if(c.n>=5){c.n=0;return true}return false};
Object.assign(ACT,{
ownerfirst:()=>{if(OWN)return;const e=val('of-e').trim(),p=val('of-p'),c=val('of-c'),er=$('#of-err');if(!okEmail(e)){er.textContent='Enter a valid email.';return}if(p.length<8){er.textContent='Use at least 8 characters.';return}if(p!==c){er.textContent='The passwords do not match.';return}const salt=newSalt();OWN={email:e,salt,pw:hashPw(p,salt)};saveOwn();ownerUnlocked=true;own('Owner login created');closeModal();ACT.role('owner')},
sxpoke:(v,t)=>{if(fiveTaps(_pk,t))ownerPrompt()},
sxmaint:(v,t)=>{if(fiveTaps(_pk7,t))ownerPrompt()}
});
/* --- maintenance screen --- */
function sxMaintScreen(){
['sxpet','tg-float'].forEach(i=>{const e=document.getElementById(i);if(e)e.remove()});
$('#app').innerHTML=`
The site is under maintenance. We will be back soon.
`}
/* --- restrictions --- */
S.restrictAll=S.restrictAll||[];S.uploads=S.uploads||[];
const SECS=[['learn','Learn'],['practice','Practice'],['tools','Study tools'],['analytics','Analytics'],['current','Current affairs'],['library','Library'],['comm','Community chat'],['aichat','AI Study Assistant'],['materials','Materials'],['askdoubt','Doubt to Admin / Mod'],['askowner','Ask Owner'],['profile','Profile'],['maglinks','Magazine links (staff)'],['adminmaterials','Submit material (staff)'],['publish','Publish message (staff)'],['moderation','Moderation (staff)']];
const secName=id=>(SECS.find(x=>x[0]===id)||[0,id])[1];
function isRestricted(page,u){if(S.role==='owner')return false;u=u||meUser();return (S.restrictAll||[]).includes(page)||((u&&u.restrict)||[]).includes(page)}
function sxGate(fn){if(UI.page!=='owner'&&isRestricted(UI.page))return `
🔒 Section restricted
The owner has restricted your access to ${esc(secName(UI.page))}. Contact the owner if you think this is a mistake.
Choose a user (or everyone), then switch off the sections they must not open. They will see a locked message instead. You can switch it back on at any time.
${SECS.map(x=>`
${esc(x[1])}
${cur.includes(x[0])?'Restricted':'Allowed'}
`).join('')}
Switch ON = restricted. The owner is never restricted.
`}
Object.assign(ACT,{
rsel:()=>{UI.rsel=val('rs-u')||'all';render()},
rsec:(v,t)=>{if(S.role!=='owner'){render();return}const sel=UI.rsel||'all',u=sel==='all'?null:S.users.find(x=>x.id===sel);if(sel!=='all'&&!u)return;const arr=sel==='all'?S.restrictAll:(u.restrict=u.restrict||[]);const i=arr.indexOf(v);if(t.checked&&i<0)arr.push(v);if(!t.checked&&i>=0)arr.splice(i,1);own((t.checked?'Restricted ':'Allowed ')+secName(v)+' for '+(u?u.name:'everyone'));save();render();toast((t.checked?'Restricted: ':'Allowed: ')+secName(v))}
});
/* --- staff powers on the dashboard (administrators and moderators only) --- */
const isStaff=()=>S.role!=='owner'&&['administrator','moderator'].includes(meUser().role);
function pwCard(icon,title,desc,page,btn){const lock=isRestricted(page);return `
${icon} ${esc(title)}
${desc}
`}
function staffCard(){
const me=meUser(),adm=me.role==='administrator',lim=muteLimit();
const c=[pwCard('🛡️','Moderation',`Mute chat for up to ${lim} day${lim>1?'s':''} and block users for up to ${lim} day${lim>1?'s':''}. You cannot act on equal or higher roles.`,'moderation','Open moderation'),
pwCard('💬','Student doubts','Answer doubts sent to you or to all '+(adm?'administrators and moderators':'moderators')+'. They are in the Moderation section.','moderation','Open doubts'),
pwCard('📤','Upload material',hasUploadPower(me)?'You have upload power. Publish to everyone straight away.':'Save drafts and send them to the owner to publish.','adminmaterials','Open uploads')];
if(adm&&adminPubOn())c.push(pwCard('📢','Publish message','Send a notification to all users.','publish','Publish a message'));
return `
Your ${esc(me.role)} powers
${esc(me.role)}
Only administrators and moderators see this section.
${c.join('')}
`}
const _dash7=dash;dash=function(){return (isStaff()?staffCard():'')+_dash7()};
/* --- uploads with "Publish to everyone" --- */
const upDirect=()=>S.role==='owner'||hasUploadPower(meUser());
const upBadge=s=>`${s==='published'?'Published to everyone':s==='pending'?'Waiting for owner':s==='rejected'?'Rejected':'Draft'}`;
function uploadsPanel(){
const me=meUser(),owner=S.role==='owner',direct=upDirect(),mine=owner?S.uploads:S.uploads.filter(x=>x.by===me.id);
const note=owner?'As the owner you can save a draft or publish to everyone straight away. Staff uploads that wait for approval show here too.':direct?'You have upload power: you can publish to everyone straight away.':'Save a draft, then send it to the owner. It reaches everyone only after the owner publishes it.';
return `
${note}
New upload
${owner?'All uploads':'My uploads'}
${mine.length?mine.slice().reverse().map(x=>`
${esc(x.title)}
${esc(x.type)}${owner?' · by '+esc(x.byName||'owner'):''}
`}
function u7publish(x){x.status='published';x.pubTs=Date.now();S.materials=S.materials.filter(m=>m.id!==x.id);S.materials.push({id:x.id,title:x.title,type:x.type,url:x.url,description:x.desc,by:x.by,byName:x.byName,status:'Approved',approvedAt:Date.now()});addNotif('New '+x.type.toLowerCase()+': '+x.title,x.desc||('Published by '+(x.byName||'the owner')));own('Published to everyone: '+x.title)}
const u7find=id=>S.uploads.find(x=>x.id===id);
Object.assign(ACT,{
u7save:v=>{if(!(S.role==='owner'||isStaff())){toast('Not allowed');return}const title=val('u7-t').trim(),url=val('u7-u').trim();if(!title||!/^https?:\/\//i.test(url)){toast('Enter a title and a valid https:// link');return}
const me=meUser(),x={id:uid(),title,type:val('u7-y'),url,desc:val('u7-d').trim(),by:S.role==='owner'?'owner':me.id,byName:S.role==='owner'?(ownerName()||'Owner'):me.name,status:'draft',ts:Date.now()};S.uploads.push(x);
if(v==='pub'){if(upDirect()){u7publish(x);toast('Published to everyone')}else{x.status='pending';toast('Sent to the owner to publish')}}else toast('Saved as draft');save();render()},
u7pub:v=>{const x=u7find(v);if(!x)return;if(S.role!=='owner'&&x.by!==meUser().id)return;if(upDirect()){u7publish(x);toast('Published to everyone')}else{x.status='pending';toast('Sent to the owner to publish')}save();render()},
u7ok:v=>{if(S.role!=='owner')return;const x=u7find(v);if(!x)return;u7publish(x);save();render();toast('Published to everyone')},
u7no:v=>{if(S.role!=='owner')return;const x=u7find(v);if(!x)return;x.status='rejected';own('Rejected upload: '+x.title);save();render()},
u7unpub:v=>{const x=u7find(v);if(!x||!(S.role==='owner'||(x.by===meUser().id&&upDirect())))return;S.materials=S.materials.filter(m=>m.id!==x.id);x.status='draft';own('Unpublished: '+x.title);save();render();toast('Removed from everyone, back to draft')},
u7del:v=>{const x=u7find(v);if(!x)return;if(S.role!=='owner'&&(x.by!==meUser().id||x.status==='published'&&!upDirect()))return;S.materials=S.materials.filter(m=>m.id!==x.id);S.uploads=S.uploads.filter(q=>q.id!==v);save();render()}
});
adminmaterials=function(){return uploadsPanel()};
/* --- test links on lessons (owner adds, everyone opens) --- */
function testsView(){
const rows=[];S.subjects.forEach(s=>s.chapters.forEach(c=>c.lessons.forEach(l=>{if(l.test&&l.test.pub)rows.push({s,c,l})})));
if(!rows.length)return '
No tests added yetTests the owner links to lessons appear here.
',a)+6;const t=UI.tab.practice;
let strip=h.slice(a,b);if(t==='tests')strip=strip.replace('class="tab on"','class="tab "');
strip=strip.replace(/<\/div>$/,``);
return t==='tests'?h.slice(0,a)+strip+testsView():h.slice(0,a)+strip+h.slice(b)};
function testCard(l){return l&&l.test&&l.test.pub?`
Test for this lesson
`:''}
function testLinksTab(){
const lessons=[];S.subjects.forEach(s=>s.chapters.forEach(c=>c.lessons.forEach(l=>lessons.push({s,c,l}))));
const withT=lessons.filter(x=>x.l.test);
return `
Add a test link to a lesson
Pick a lesson, then link it to a quiz on this site or to any web page. It reaches everyone only after you publish it.
${lessons.length?`
`:'
No lessons yetAdd a lesson under Content first.
'}
Lessons with a test link
${withT.length?withT.map(x=>`
${esc(x.l.name)}
${esc(x.s.name)} · ${x.l.test.kind==='quiz'?'Quiz on this site ('+esc(x.l.test.subject||'all subjects')+', '+esc(x.l.test.qtype)+')':esc(x.l.test.url)}
${upBadge(x.l.test.pub?'published':'draft')}
`).join(''):'
No test links yet.
'}
`}
const lessonById=id=>{let L=null;allLessons().forEach(l=>{if(l.id===id)L=l});return L};
function tlPublish(L){L.test.pub=true;addNotif('New test: '+L.name,'A test was added to this lesson. Find it under Practice, then Tests.');own('Published test link on '+L.name)}
Object.assign(ACT,{
tladd:v=>{if(S.role!=='owner')return;const L=lessonById(val('tl-l')),kind=val('tl-k'),url=val('tl-u').trim();if(!L){toast('Choose a lesson first');return}if(kind==='link'&&!okLink(url)){toast('Enter a valid link (https://... or a page of this site)');return}
L.test={kind,url:kind==='link'?url:'',subject:val('tl-s'),qtype:val('tl-t')||'MCQ',newTab:val('tl-n')!=='0',pub:false,ts:Date.now()};
if(v==='pub'){tlPublish(L);toast('Published to everyone')}else{own('Saved test link draft on '+L.name);toast('Saved as draft')}save();render()},
tlpub:v=>{if(S.role!=='owner')return;const L=lessonById(v);if(!L||!L.test)return;if(L.test.pub){L.test.pub=false;own('Unpublished test link on '+L.name);toast('Removed from everyone')}else{tlPublish(L);toast('Published to everyone')}save();render()},
tldel:v=>{if(S.role!=='owner')return;const L=lessonById(v);if(!L)return;delete L.test;own('Removed test link from '+L.name);save();render()},
taketest:v=>{const L=lessonById(v),T=L&&L.test;if(!T||!T.pub)return;
if(T.kind==='quiz'){const ty=T.qtype||'MCQ';let list=S.questions.filter(q=>q.type===ty&&(!T.subject||q.s===T.subject));if(!list.length){toast('No questions in this test yet');return}list=list.slice().sort(()=>Math.random()-.5);Q={list,i:0,ans:{},mark:{},done:false,start:Date.now(),end:0,set:(T.subject||'All subjects')+' · '+ty,practice:ty==='MCQ'};UI.page='practice';UI.tab.practice=ty==='PYQ'?'pyq':'mcq';render();return}
if(!okLink(T.url))return;if(T.newTab===false)location.href=T.url;else window.open(T.url,'_blank','noopener')}
});
/* --- owner panel: extra tabs --- */
const _owner7=owner;owner=function(){
const t=UI.tab.owner,extra=[['uploads','Uploads'],['testlinks','Test links'],['restrict','Restrictions']],mine=extra.some(x=>x[0]===t);
const h=_owner7(),a=h.indexOf('
');if(a<0)return h;const b=h.indexOf('
',a)+6;
let strip=h.slice(a,b);if(mine)strip=strip.replace('class="tab on"','class="tab "');
strip=strip.replace(/<\/div>$/,extra.map(x=>``).join('')+'');
const head=h.slice(0,a)+strip;
if(t==='uploads')return head+uploadsPanel();
if(t==='testlinks')return head+testLinksTab();
if(t==='restrict')return head+restrictTab();
return head+h.slice(b)};
/* --- outermost render: lock, maintenance --- */
const _r7=render;render=function(){
if(S.role==='owner'&&!ownerUnlocked){S.role='student';save()}
if(UI.page==='owner'&&S.role!=='owner')UI.page='dash';
if(S.flags.maint&&!(S.role==='owner'&&ownerUnlocked)){sxMaintScreen();return}
_r7()};
/* ---------- start ---------- */
if(S.role==='owner'){S.role='student';UI.page='dash';save()}
magCheck();magFeed();setInterval(magCheck,6*3600*1000);document.addEventListener('visibilitychange',()=>{if(!document.hidden)magCheck()});
trackOpen();render();
if(!sessionStorage.getItem('sx_out'))sxIntro();
setTimeout(()=>{if(!sessionStorage.getItem('sx_out')&&S.role==='student'){const c=unreadCount();if(c)pushLocal('You have '+c+' new notification'+(c>1?'s':''),'Tap the bell to read')}},6000);
if(!S.flags.onboarded)setTimeout(()=>{if(!sessionStorage.getItem('sx_out'))examModal()},5200);else if(!S.flags.tg)setTimeout(()=>{if(!sessionStorage.getItem('sx_out'))tgModal()},5200);