studyhub-app / studyhub /web /static /index.html
parhamkhoshsolat's picture
Study features + Academy theme + threads + invite links + auto-ingest
981881b verified
Raw
History Blame Contribute Delete
96.7 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>5G StudyHub — Your course materials, conversational</title>
<style>
/* ============ design tokens ============ */
:root{
--bg:#f3f5fa; --bg2:#e9eef5; --surface:#ffffff; --surface2:#f6f8fb; --raise:#ffffff;
--glass:rgba(255,255,255,.82);
--line:#dfe5ee; --line2:#c8d3e0;
--text:#0e2237; --muted:#516379; --faint:#8694a6;
--brand:#e60000; --brand2:#f59e0b; --on-brand:#ffffff;
--brand-soft:#fdeaea; --brand-text:#b50000;
--amber:#8a6a00; --amber-soft:#fdf4c9;
--good:#0c7a64; --good-soft:#d8f2ea;
--bad:#bf3a3a; --bad-soft:#fbe4e4;
--sky:#0b4f8d; --sky-soft:#e1ecf7;
--ring:#e60000; --ring-soft:#e6000026;
--grad:linear-gradient(135deg,#e60000,#f25c05 55%,#ffc400);
--shadow-1:0 1px 2px rgba(4,59,107,.06),0 3px 10px rgba(4,59,107,.07);
--shadow-2:0 4px 8px rgba(4,59,107,.08),0 16px 36px rgba(4,59,107,.14);
--r-lg:18px; --r-md:13px; --r-sm:9px;
}
[data-theme=dark]{
--bg:#061120; --bg2:#0a1a31; --surface:#0d1e37; --surface2:#13263f; --raise:#152a47;
--glass:rgba(6,17,32,.78);
--line:#1e3954; --line2:#2c4e74;
--text:#e9eff8; --muted:#9db0c6; --faint:#60748f;
--brand:#ff5e54; --brand2:#ffd34d; --on-brand:#26090c;
--brand-soft:#3a1a22; --brand-text:#ffb3ad;
--amber:#ffd900; --amber-soft:#3b3208;
--good:#3ddba4; --good-soft:#0f352b;
--bad:#f87f7f; --bad-soft:#3f1d20;
--sky:#6db5ee; --sky-soft:#11304a;
--ring:#ff5e54; --ring-soft:#ff5e543d;
--grad:linear-gradient(135deg,#e84545,#f59e0b 55%,#ffd34d);
--shadow-1:0 1px 2px rgba(0,0,0,.35),0 3px 12px rgba(0,0,0,.28);
--shadow-2:0 6px 14px rgba(0,0,0,.4),0 22px 48px rgba(0,0,0,.45);
}
/* ============ base ============ */
*{box-sizing:border-box}
html{scroll-behavior:smooth}
body{margin:0;background:var(--bg);color:var(--text);
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
font-size:15px;line-height:1.6;-webkit-font-smoothing:antialiased;
transition:background .25s ease,color .25s ease}
button{font:inherit;color:inherit;background:none;border:none;cursor:pointer;padding:0}
:focus-visible{outline:2px solid var(--ring);outline-offset:2px;border-radius:8px}
.hidden{display:none!important}
.grow{flex:1}
h1{font-size:26px;line-height:1.25;letter-spacing:-.02em;margin:0;font-weight:750}
h2{font-size:19px;letter-spacing:-.01em;margin:0;font-weight:700}
.sub{color:var(--muted);margin:6px 0 0;font-size:14.5px}
.kicker{font-size:11px;font-weight:750;letter-spacing:.1em;text-transform:uppercase;color:var(--faint);margin:30px 0 12px}
a{color:var(--brand);text-decoration:none}
a:hover{text-decoration:underline}
input,select{font:inherit;color:var(--text)}
input,textarea{width:100%;padding:11px 14px;border:1px solid var(--line2);border-radius:12px;
background:var(--surface2);transition:border-color .15s,box-shadow .15s}
input::placeholder,textarea::placeholder{color:var(--faint)}
input:focus,textarea:focus{outline:none;border-color:var(--brand);box-shadow:0 0 0 3px var(--ring-soft)}
/* buttons */
.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;font-weight:650;
border-radius:12px;padding:10px 18px;transition:transform .15s,box-shadow .15s,filter .15s,background .15s;
border:1px solid var(--line2);background:var(--surface);color:var(--text)}
.btn:hover{transform:translateY(-1px);box-shadow:var(--shadow-1)}
.btn:active{transform:translateY(0)}
.btn-primary{background:var(--grad);color:#fff;border:none;box-shadow:0 4px 14px var(--ring-soft)}
.btn-primary:hover{filter:brightness(1.07);transform:translateY(-1px)}
.btn-primary:disabled{opacity:.6;cursor:default;transform:none;filter:none}
.btn-ghost{background:var(--brand-soft);color:var(--brand-text);border:none}
.btn-ghost:hover{filter:brightness(1.04)}
.btn-danger{background:none;border:1px solid var(--line);color:var(--bad);padding:7px 13px;border-radius:10px;font-size:13px;font-weight:600;transition:.15s}
.btn-danger:hover{background:var(--bad-soft);border-color:transparent}
.btn-danger.armed{background:var(--bad);color:#fff;border-color:transparent}
.icon-btn{width:36px;height:36px;flex:none;border-radius:11px;border:1px solid var(--line);
background:var(--surface);display:inline-flex;align-items:center;justify-content:center;
color:var(--muted);transition:.15s}
.icon-btn:hover{color:var(--text);border-color:var(--line2);transform:translateY(-1px);box-shadow:var(--shadow-1)}
.w100{width:100%}
svg{display:block}
/* ============ auth ============ */
#auth{min-height:100vh;display:grid;place-items:center;padding:20px;position:relative;overflow:hidden}
.aur{position:absolute;border-radius:50%;filter:blur(75px);opacity:.5;pointer-events:none;animation:drift 16s ease-in-out infinite alternate}
.a1{width:480px;height:480px;left:-140px;top:-140px;background:#e60000}
.a2{width:420px;height:420px;right:-120px;bottom:-100px;background:#ffc400;animation-duration:21s}
.a3{width:280px;height:280px;right:16%;top:-90px;background:#f59e0b;opacity:.3;animation-duration:13s}
[data-theme=light] .a1,[data-theme=light] .a2{opacity:.22}
[data-theme=light] .a3{opacity:.15}
@keyframes drift{to{transform:translate(40px,30px) scale(1.08)}}
.auth-card{position:relative;width:100%;max-width:404px;background:var(--glass);backdrop-filter:blur(22px);
-webkit-backdrop-filter:blur(22px);border:1px solid var(--line);border-radius:26px;
box-shadow:var(--shadow-2);padding:34px 30px 26px;animation:rise .4s ease}
.brand-row{display:flex;align-items:center;gap:13px}
.mark{width:46px;height:46px;flex:none;filter:drop-shadow(0 4px 10px var(--ring-soft))}
.mark.sm{width:30px;height:30px}
.app-name{font-size:23px;font-weight:800;letter-spacing:-.02em;line-height:1.15;
background:var(--grad);-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;color:var(--brand)}
.app-name.sm{font-size:17px}
.tagline{color:var(--muted);font-size:13.5px;margin-top:1px}
.seg{display:flex;background:var(--bg2);border-radius:13px;padding:4px;gap:4px;margin:24px 0 18px}
.seg button{flex:1;padding:8px;border-radius:10px;font-weight:650;color:var(--muted);transition:.15s}
.seg button.on{background:var(--surface);color:var(--text);box-shadow:var(--shadow-1)}
.field{margin-bottom:13px}
.field label{display:block;font-size:11.5px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--faint);margin:0 2px 6px}
.msg{font-size:13.5px;margin-top:12px;min-height:20px;text-align:center}
.msg.err{color:var(--bad)}
.msg.ok{color:var(--good)}
.auth-foot{margin-top:14px;padding-top:16px;border-top:1px solid var(--line);color:var(--faint);font-size:12.5px;text-align:center}
@keyframes rise{from{opacity:0;transform:translateY(10px)}}
/* ============ topbar ============ */
.topbar{position:sticky;top:0;z-index:40;background:var(--glass);backdrop-filter:blur(16px);
-webkit-backdrop-filter:blur(16px);border-bottom:1px solid var(--line)}
.topbar-in{max-width:1148px;margin:0 auto;display:flex;align-items:center;gap:14px;padding:10px 20px}
.nav{display:flex;gap:3px;background:var(--bg2);padding:4px;border-radius:999px;overflow-x:auto;scrollbar-width:none}
.nav::-webkit-scrollbar{display:none}
.nav button{padding:7px 15px;border-radius:999px;font-weight:650;font-size:13.5px;color:var(--muted);white-space:nowrap;transition:.15s}
.nav button:hover{color:var(--text)}
.nav button.on{background:var(--surface);color:var(--text);box-shadow:var(--shadow-1)}
.who{display:flex;align-items:center;gap:8px;font-size:13.5px;font-weight:600;color:var(--muted)}
.ava{width:30px;height:30px;flex:none;border-radius:50%;background:var(--grad);color:#fff;
display:inline-flex;align-items:center;justify-content:center;font-size:13px;font-weight:750}
/* ============ layout / views ============ */
#views{max-width:1148px;margin:0 auto;padding:30px 20px 70px}
.view{animation:rise .28s ease}
.view-head{margin-bottom:22px}
.back{display:inline-flex;align-items:center;gap:7px;color:var(--muted);font-weight:650;font-size:13.5px;
padding:7px 13px;border-radius:999px;border:1px solid var(--line);background:var(--surface);transition:.15s;margin-bottom:20px}
.back:hover{color:var(--text);transform:translateX(-2px);box-shadow:var(--shadow-1)}
.card{background:var(--surface);border:1px solid var(--line);border-radius:var(--r-lg);box-shadow:var(--shadow-1)}
.panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--r-lg);box-shadow:var(--shadow-1);padding:24px}
.stack{display:flex;flex-direction:column;gap:10px}
/* module cards */
.grid-mods{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(245px,100%),1fr));gap:14px}
.mod-card{position:relative;text-align:left;background:var(--surface);border:1px solid var(--line);
border-radius:var(--r-lg);box-shadow:var(--shadow-1);padding:18px 18px 15px 24px;overflow:hidden;
display:flex;flex-direction:column;gap:8px;transition:transform .15s,box-shadow .15s,border-color .15s}
.mod-card::before{content:"";position:absolute;left:0;top:0;bottom:0;width:6px;background:var(--spine,var(--grad))}
.mod-card:hover{transform:translateY(-3px);box-shadow:var(--shadow-2);border-color:var(--line2)}
.mod-title{font-weight:700;font-size:15.5px;line-height:1.4;letter-spacing:-.01em;
display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;min-height:2.8em}
.mod-lect{color:var(--muted);font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.meta{display:flex;gap:6px;flex-wrap:wrap;margin-top:auto}
.pill{display:inline-flex;align-items:center;gap:5px;font-size:11.5px;font-weight:650;color:var(--muted);
background:var(--surface2);border:1px solid var(--line);border-radius:999px;padding:3px 10px}
.pill b{color:var(--text);font-weight:750}
/* module hero + topic chips */
.mod-hero{margin-bottom:6px}
.grid-topics{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(225px,100%),1fr));gap:10px}
.topic-chip{display:flex;align-items:center;gap:10px;text-align:left;background:var(--surface);
border:1px solid var(--line);border-radius:14px;padding:13px 15px;font-weight:600;font-size:13.8px;
box-shadow:var(--shadow-1);transition:transform .15s,box-shadow .15s,border-color .15s,background .15s}
.topic-chip:hover:not(:disabled){transform:translateY(-2px);border-color:var(--brand);box-shadow:var(--shadow-2)}
.topic-chip.on{background:var(--brand-soft);border-color:var(--brand);color:var(--brand-text)}
.topic-chip:disabled{opacity:.55;cursor:not-allowed;box-shadow:none}
.topic-chip .dot{width:8px;height:8px;flex:none;border-radius:50%;background:var(--good)}
.topic-chip:disabled .dot{background:var(--faint)}
.topic-chip .tname{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.topic-chip .nm{font-size:11px;font-weight:600;color:var(--faint);white-space:nowrap}
/* workspace */
#topic-ws{margin-top:26px}
.ws-head{display:flex;align-items:flex-start;gap:12px;flex-wrap:wrap;justify-content:space-between;margin-bottom:12px}
.badges{display:flex;flex-wrap:wrap;gap:7px;margin:2px 0 18px}
.file-badge{display:inline-flex;align-items:center;gap:7px;background:var(--surface2);border:1px solid var(--line);
border-radius:999px;padding:4px 12px 4px 5px;font-size:12.5px;color:var(--muted);max-width:100%}
.file-badge .fname{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:260px}
.tchip{display:inline-flex;align-items:center;font-size:10.5px;font-weight:750;letter-spacing:.04em;
text-transform:uppercase;border-radius:999px;padding:2.5px 9px}
.tchip.slides{background:var(--brand-soft);color:var(--brand-text)}
.tchip.notes{background:var(--good-soft);color:var(--good)}
.tchip.transcript{background:var(--amber-soft);color:var(--amber)}
.tchip.syllabus{background:var(--sky-soft);color:var(--sky)}
.tchip.material,.tchip.pdf,.tchip.pptx,.tchip.docx{background:var(--bg2);color:var(--muted)}
/* ask box */
.ask-box{display:flex;align-items:center;gap:8px;background:var(--surface);border:1.5px solid var(--line2);
border-radius:17px;padding:7px 7px 7px 17px;transition:border-color .15s,box-shadow .15s}
.ask-box:focus-within{border-color:var(--brand);box-shadow:0 0 0 4px var(--ring-soft)}
.ask-box input{border:none;background:none;box-shadow:none;padding:8px 0;font-size:15px;flex:1;min-width:0}
.ask-box input:focus{box-shadow:none}
.send-btn{width:40px;height:40px;flex:none;border-radius:12px;background:var(--grad);color:#fff;
display:inline-flex;align-items:center;justify-content:center;transition:.15s;box-shadow:0 3px 10px var(--ring-soft)}
.send-btn:hover{filter:brightness(1.08);transform:translateY(-1px)}
.send-btn:disabled{opacity:.55;cursor:default;transform:none}
.ws-actions{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:14px}
/* hero (global ask) */
.hero{max-width:780px;margin:0 auto;text-align:center;padding:46px 0 10px}
.hero h1{font-size:clamp(27px,5vw,40px);font-weight:800;letter-spacing:-.03em;
background:var(--grad);-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;color:var(--brand)}
.hero .sub{font-size:15.5px;margin:12px auto 28px;max-width:560px}
.hero-box{box-shadow:var(--shadow-2)}
.hero-box input{font-size:16px;padding:11px 0}
.hero-box .send-btn{width:46px;height:46px;border-radius:14px}
.ask-out{max-width:780px;margin:22px auto 0}
/* answers & citations */
.answer-card{background:var(--surface);border:1px solid var(--line);border-radius:var(--r-lg);
box-shadow:var(--shadow-1);padding:22px;margin-top:18px;animation:rise .3s ease;text-align:left}
.answer-text{white-space:pre-wrap;line-height:1.75;font-size:15px}
.cite{display:inline-flex;align-items:center;justify-content:center;min-width:19px;height:17px;padding:0 5px;
margin:0 2px;border-radius:7px;background:var(--brand-soft);color:var(--brand-text);
font-size:10.5px;font-weight:800;vertical-align:.25em;line-height:1;transition:.13s}
button.cite:hover{background:var(--brand);color:var(--on-brand);transform:translateY(-1px)}
.cite.inert{cursor:default}
.src-toggle{display:inline-flex;align-items:center;gap:7px;margin-top:16px;font-size:13px;font-weight:650;
color:var(--muted);background:var(--surface2);border:1px solid var(--line);border-radius:999px;padding:6px 14px;transition:.15s}
.src-toggle:hover{color:var(--text);border-color:var(--line2)}
.src-toggle .chev{transition:transform .2s}
.src-toggle.open .chev{transform:rotate(180deg)}
.src-list{margin-top:12px;display:flex;flex-direction:column;gap:8px}
.src-row{display:flex;align-items:center;gap:11px;text-align:left;width:100%;background:var(--surface2);
border:1px solid var(--line);border-radius:12px;padding:9px 13px;transition:.15s;font-size:13.5px}
.src-row:hover{border-color:var(--brand);transform:translateX(2px)}
.src-row .sid{flex:none;min-width:21px;height:19px;display:inline-flex;align-items:center;justify-content:center;
border-radius:7px;background:var(--brand-soft);color:var(--brand-text);font-size:11px;font-weight:800;padding:0 5px}
.src-row .slabel{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:600}
.src-row .loc{color:var(--faint);font-size:12px;white-space:nowrap}
.err-inline{color:var(--bad);background:var(--bad-soft);border-radius:12px;padding:12px 16px;margin-top:16px;font-size:14px}
/* thinking + skeleton */
.thinking{display:flex;align-items:center;gap:12px;color:var(--muted);font-size:14px;font-weight:550;
background:var(--surface);border:1px solid var(--line);border-radius:var(--r-lg);padding:18px 20px;margin-top:18px}
.dots{display:inline-flex;gap:5px}
.dots i{width:7px;height:7px;border-radius:50%;background:var(--brand);animation:bnc 1.05s ease-in-out infinite}
.dots i:nth-child(2){animation-delay:.15s}
.dots i:nth-child(3){animation-delay:.3s}
@keyframes bnc{30%{transform:translateY(-5px);opacity:.55}}
.sk{position:relative;overflow:hidden;background:var(--bg2);border-radius:14px}
.sk::after{content:"";position:absolute;inset:0;transform:translateX(-100%);
background:linear-gradient(90deg,transparent,rgba(255,255,255,.16),transparent);animation:shim 1.4s infinite}
[data-theme=light] .sk::after{background:linear-gradient(90deg,transparent,rgba(255,255,255,.7),transparent)}
@keyframes shim{to{transform:translateX(100%)}}
/* library */
.lib-group{margin-bottom:6px}
.grid-files{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(255px,100%),1fr));gap:11px}
.file-card{display:flex;align-items:center;gap:12px;background:var(--surface);border:1px solid var(--line);
border-radius:14px;padding:12px 13px;box-shadow:var(--shadow-1);transition:transform .15s,box-shadow .15s}
.file-card:hover{transform:translateY(-2px);box-shadow:var(--shadow-2)}
.fico{width:40px;height:40px;flex:none;border-radius:12px;display:inline-flex;align-items:center;justify-content:center}
.fico.slides{background:var(--brand-soft);color:var(--brand-text)}
.fico.notes{background:var(--good-soft);color:var(--good)}
.fico.transcript{background:var(--amber-soft);color:var(--amber)}
.fico.syllabus{background:var(--sky-soft);color:var(--sky)}
.fico.material{background:var(--bg2);color:var(--muted)}
.fmeta{flex:1;min-width:0}
.fmeta .fn{font-weight:650;font-size:13.5px;line-height:1.35;overflow:hidden;display:-webkit-box;
-webkit-line-clamp:2;-webkit-box-orient:vertical;word-break:break-word}
.fmeta .ft{color:var(--faint);font-size:11.5px;margin-top:2px}
/* notebook */
.nb-card{text-align:left;background:var(--surface);border:1px solid var(--line);border-radius:var(--r-lg);
box-shadow:var(--shadow-1);padding:16px 18px;cursor:pointer;transition:transform .15s,box-shadow .15s;width:100%}
.nb-card:hover{transform:translateY(-2px);box-shadow:var(--shadow-2)}
.nb-head{display:flex;align-items:center;gap:9px;flex-wrap:wrap;margin-bottom:7px}
.kbadge{display:inline-flex;align-items:center;gap:5px;font-size:10.5px;font-weight:800;letter-spacing:.05em;
text-transform:uppercase;border-radius:999px;padding:3px 10px}
.kbadge.q{background:var(--brand-soft);color:var(--brand-text)}
.kbadge.s{background:var(--amber-soft);color:var(--amber)}
.kbadge.u{background:var(--good-soft);color:var(--good)}
.nb-topic{font-size:11.5px;color:var(--faint);font-weight:650;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:240px}
.nb-q{font-weight:700;font-size:14.5px;line-height:1.45}
.nb-prev{color:var(--muted);font-size:13.5px;margin-top:5px;display:-webkit-box;-webkit-line-clamp:2;
-webkit-box-orient:vertical;overflow:hidden}
.nb-full{display:none;margin-top:10px;padding-top:12px;border-top:1px solid var(--line);
white-space:pre-wrap;font-size:14px;line-height:1.7}
.nb-card.open .nb-prev{display:none}
.nb-card.open .nb-full{display:block}
/* upload */
.dropzone{display:flex;flex-direction:column;align-items:center;gap:12px;text-align:center;width:100%;
border:2px dashed var(--line2);border-radius:var(--r-lg);background:var(--surface);padding:46px 22px;
cursor:pointer;transition:border-color .15s,background .15s,transform .15s}
.dropzone:hover,.dropzone.drag{border-color:var(--brand);background:var(--brand-soft)}
.dropzone.drag{transform:scale(1.01)}
.dropzone.busy{opacity:.6;pointer-events:none}
.dz-ico{width:52px;height:52px;border-radius:16px;background:var(--grad);color:#fff;
display:flex;align-items:center;justify-content:center;box-shadow:0 6px 16px var(--ring-soft)}
.dz-t{font-weight:700;font-size:15.5px}
.dz-s{color:var(--faint);font-size:13px}
#upload-status{margin-top:14px;background:var(--surface);border:1px solid var(--line);border-radius:14px;padding:14px 16px}
.up-name{font-weight:650;font-size:13.5px;margin-bottom:9px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.bar{height:8px;border-radius:99px;background:var(--bg2);overflow:hidden}
.bar i{display:block;height:100%;width:0;border-radius:99px;background:var(--grad);transition:width .2s}
.note{color:var(--faint);font-size:13px;margin:12px 2px 0}
/* admin */
.person{display:flex;align-items:center;gap:12px;background:var(--surface);border:1px solid var(--line);
border-radius:14px;padding:11px 14px;box-shadow:var(--shadow-1);flex-wrap:wrap}
.person .uname{font-weight:700;font-size:14px}
.obadge{display:inline-flex;font-size:10px;font-weight:800;letter-spacing:.06em;text-transform:uppercase;
background:var(--amber-soft);color:var(--amber);border-radius:999px;padding:3px 9px}
.sel{padding:7px 11px;border-radius:10px;border:1px solid var(--line2);background:var(--surface2);
font-size:13px;font-weight:600;cursor:pointer}
.sel:focus{outline:none;border-color:var(--brand);box-shadow:0 0 0 3px var(--ring-soft)}
.dl-meta{color:var(--faint);font-size:12px}
.ds-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;padding:9px 0;font-size:14px}
.ds-row .lab{min-width:110px;color:var(--faint);font-size:11.5px;font-weight:750;text-transform:uppercase;letter-spacing:.07em}
.invite-wrap{display:flex;gap:8px;flex:1;min-width:220px;max-width:380px}
#invite-code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.05em;font-size:13.5px}
/* practice quiz */
.btn:disabled{opacity:.6;cursor:default;transform:none;box-shadow:none}
.btn.sm{padding:7px 13px;font-size:13px;border-radius:10px}
.qhead{display:flex;align-items:center;gap:12px;margin-bottom:14px}
.qprog{font-size:11px;font-weight:750;letter-spacing:.08em;text-transform:uppercase;color:var(--faint);white-space:nowrap}
.khint{color:var(--faint);font-size:11.5px;white-space:nowrap}
.qtext{font-weight:700;font-size:16.5px;line-height:1.5;margin-bottom:15px}
.qopt{display:flex;align-items:center;gap:11px;width:100%;text-align:left;background:var(--surface2);
border:1px solid var(--line);border-radius:12px;padding:11px 14px;font-size:14.3px;font-weight:550;transition:.15s}
.qopt:hover:not(:disabled){border-color:var(--brand);transform:translateX(2px)}
.qopt:disabled{cursor:default;opacity:.55}
.qopt .qn{flex:none;width:22px;height:22px;display:inline-flex;align-items:center;justify-content:center;
border-radius:8px;background:var(--bg2);color:var(--muted);font-size:11.5px;font-weight:800}
.qopt.correct,.qopt.wrong{opacity:1}
.qopt.correct{background:var(--good-soft);border-color:var(--good)}
.qopt.correct .qn{background:var(--good);color:var(--good-soft)}
.qopt.wrong{background:var(--bad-soft);border-color:var(--bad)}
.qopt.wrong .qn{background:var(--bad);color:var(--bad-soft)}
.quiz-why{margin-top:16px;background:var(--surface2);border:1px solid var(--line);border-radius:12px;
padding:12px 15px;font-size:13.8px;line-height:1.7;color:var(--muted);animation:rise .25s ease}
.quiz-why b{color:var(--text)}
.qfoot{display:flex;align-items:center;gap:10px;margin-top:16px}
.qscore{text-align:center;padding:36px 22px}
.qscore .big{font-size:46px;font-weight:800;letter-spacing:-.03em;line-height:1.1;
background:var(--grad);-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent}
.qscore .qmsg{color:var(--muted);margin:6px 0 20px}
/* flashcards */
.cards-bar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:20px;min-height:30px}
.fc-scene{perspective:1200px;max-width:560px;margin:0 auto}
.fc{position:relative;min-height:280px;transform-style:preserve-3d;transition:transform .55s cubic-bezier(.35,.1,.2,1)}
.fc.flip{transform:rotateY(180deg)}
.fc-face{position:absolute;inset:0;backface-visibility:hidden;-webkit-backface-visibility:hidden;
display:flex;flex-direction:column;align-items:center;justify-content:center;gap:14px;text-align:center;
background:var(--surface);border:1px solid var(--line);border-radius:var(--r-lg);box-shadow:var(--shadow-1);
padding:28px 26px;overflow:auto}
.fc-face.back{transform:rotateY(180deg);background:var(--surface2);border-color:var(--line2)}
.fc-kick{font-size:10.5px;font-weight:800;letter-spacing:.08em;text-transform:uppercase;color:var(--brand-text);
background:var(--brand-soft);border-radius:999px;padding:3px 11px;max-width:100%;overflow:hidden;
text-overflow:ellipsis;white-space:nowrap}
.fc-text{font-size:17px;font-weight:650;line-height:1.55;white-space:pre-wrap}
.fc-face.back .fc-text{font-size:15.5px;font-weight:500}
.fc-acts{display:flex;justify-content:center;gap:10px;margin-top:20px}
.btn-no{background:var(--bad-soft);color:var(--bad);border-color:transparent}
.btn-yes{background:var(--good-soft);color:var(--good);border-color:transparent}
.navb{display:inline-flex;align-items:center;justify-content:center;min-width:17px;height:17px;padding:0 5px;
border-radius:999px;background:var(--grad);color:#fff;font-size:10px;font-weight:800;margin-left:7px;transform:translateY(-1px)}
/* mind map */
.map-wrap{overflow-x:auto;margin-top:18px;padding:20px;background:var(--surface);border:1px solid var(--line);
border-radius:var(--r-lg);box-shadow:var(--shadow-1);color:var(--text);animation:rise .3s ease}
/* notes */
.seg.sub{max-width:300px;margin:0 0 18px}
textarea{font:inherit;color:var(--text);line-height:1.6;resize:vertical;min-height:150px}
.note-row{display:flex;justify-content:flex-end;margin-bottom:12px}
#note-editor{padding:18px;margin-bottom:14px;animation:rise .25s ease}
.note-tgl{display:flex;width:100%;align-items:baseline;gap:10px;text-align:left}
.note-date{flex:none;margin-left:auto;color:var(--faint);font-size:12px;white-space:nowrap}
.note-acts{display:none;gap:8px;margin-top:12px}
.nb-card.open .note-acts{display:flex}
.nb-prev.n3{-webkit-line-clamp:3}
.ghost-pill{display:inline-flex;align-items:center;gap:7px;margin-top:16px;font-size:13px;font-weight:650;
color:var(--brand-text);background:var(--brand-soft);border-radius:999px;padding:6px 14px;transition:.15s}
.ghost-pill:hover:not(:disabled){filter:brightness(1.05);transform:translateY(-1px)}
.ghost-pill:disabled{opacity:.65;cursor:default}
.ghost-pill.ml{margin-left:8px}
/* threads / invite links / upload status */
.kbadge.b{background:var(--bad-soft);color:var(--bad)}
.kbadge.m{background:var(--bg2);color:var(--muted)}
.pulse{animation:pulse 1.2s ease-in-out infinite}
.nb-acts{display:flex;align-items:center;gap:8px;margin-top:12px;flex-wrap:wrap}
.icon-btn.pin{width:30px;height:30px;border-radius:9px}
.icon-btn.pin.on{color:var(--amber);border-color:var(--amber);background:var(--amber-soft)}
.q-card{margin:18px 0 0 auto;width:fit-content;max-width:82%;background:var(--surface2);
border:1px solid var(--line2);border-radius:14px 14px 5px 14px;padding:9px 15px;
font-weight:650;font-size:14.3px;animation:rise .25s ease}
#thread-bar{display:flex;justify-content:center;margin-bottom:14px}
.thread-chip{display:inline-flex;align-items:center;gap:9px;font-size:13px;font-weight:650;
color:var(--amber);background:var(--amber-soft);border-radius:999px;padding:5px 6px 5px 14px;
max-width:100%;min-width:0}
.thread-chip .tl{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.thread-chip button{flex:none;font-size:12px;font-weight:700;border-radius:999px;padding:4px 10px;
background:var(--surface);box-shadow:var(--shadow-1);transition:.15s}
.thread-chip button:hover{transform:translateY(-1px)}
.inv-link{display:block;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11.5px;
color:var(--faint);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
/* toasts / empty */
#toasts{position:fixed;top:16px;right:16px;z-index:120;display:flex;flex-direction:column;gap:9px;max-width:min(360px,calc(100vw - 32px))}
.toast{background:var(--raise);border:1px solid var(--line);border-left:4px solid var(--brand);
border-radius:13px;box-shadow:var(--shadow-2);padding:11px 15px;font-size:13.5px;font-weight:550;
animation:slidein .25s ease;transition:opacity .3s,transform .3s}
.toast.ok{border-left-color:var(--good)}
.toast.err{border-left-color:var(--bad)}
.toast.out{opacity:0;transform:translateX(14px)}
@keyframes slidein{from{opacity:0;transform:translateX(14px)}}
.empty{text-align:center;padding:46px 20px;color:var(--muted)}
.empty .eico{width:54px;height:54px;margin:0 auto 14px;border-radius:17px;background:var(--bg2);
color:var(--faint);display:flex;align-items:center;justify-content:center}
.empty .et{font-weight:700;color:var(--text);margin-bottom:4px}
.empty .es{font-size:13.5px}
.empty .btn{margin-top:16px}
#boot{position:fixed;inset:0;display:grid;place-items:center;background:var(--bg);z-index:200}
#boot .mark{animation:pulse 1.2s ease-in-out infinite}
@keyframes pulse{50%{opacity:.45;transform:scale(.94)}}
/* responsive */
@media (max-width:700px){
.topbar-in{gap:8px;padding:8px 12px}
.who-name{display:none}
#views{padding:20px 14px 60px}
.panel{padding:18px 16px}
.hero{padding:24px 0 6px}
h1{font-size:22px}
.file-badge .fname{max-width:150px}
.ds-row .lab{min-width:100%}
}
</style>
</head>
<body>
<div id="toasts" aria-live="polite"></div>
<div id="boot">
<svg class="mark" width="46" height="46" viewBox="0 0 48 48" aria-hidden="true"><defs><linearGradient id="lg0" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#e60000"/><stop offset=".6" stop-color="#f25c05"/><stop offset="1" stop-color="#ffc400"/></linearGradient></defs><rect width="48" height="48" rx="14" fill="url(#lg0)"/><path d="M24 34c-3-2.4-7-3.1-10-2.2V16c3-.9 7-.2 10 2.2 3-2.4 7-3.1 10-2.2v15.8c-3-.9-7-.2-10 2.2z" fill="none" stroke="#fff" stroke-width="2.4" stroke-linejoin="round"/><path d="M24 18.2V34" stroke="#fff" stroke-width="2.4"/><path d="M17.5 10.5c4-3.4 9-3.4 13 0" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" opacity=".85"/></svg>
</div>
<section id="auth" class="hidden">
<div class="aur a1"></div><div class="aur a2"></div><div class="aur a3"></div>
<div class="auth-card">
<div class="brand-row">
<svg class="mark" viewBox="0 0 48 48" aria-hidden="true"><defs><linearGradient id="lg1" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#e60000"/><stop offset=".6" stop-color="#f25c05"/><stop offset="1" stop-color="#ffc400"/></linearGradient></defs><rect width="48" height="48" rx="14" fill="url(#lg1)"/><path d="M24 34c-3-2.4-7-3.1-10-2.2V16c3-.9 7-.2 10 2.2 3-2.4 7-3.1 10-2.2v15.8c-3-.9-7-.2-10 2.2z" fill="none" stroke="#fff" stroke-width="2.4" stroke-linejoin="round"/><path d="M24 18.2V34" stroke="#fff" stroke-width="2.4"/><path d="M17.5 10.5c4-3.4 9-3.4 13 0" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" opacity=".85"/></svg>
<div>
<div class="app-name">5G StudyHub</div>
<div class="tagline">Your course materials, conversational.</div>
</div>
</div>
<div class="seg" role="tablist">
<button id="tab-login" class="on" role="tab" aria-selected="true">Log in</button>
<button id="tab-register" role="tab" aria-selected="false">Register</button>
</div>
<div id="reg-invite" class="field hidden">
<label for="f-invite">Class invite code</label>
<input id="f-invite" autocomplete="off" placeholder="Shared in the class group"/>
<div id="invite-note" class="note hidden" style="margin:7px 2px 0">Personal invite detected — pick your username.</div>
</div>
<div class="field">
<label for="f-user">Username</label>
<input id="f-user" autocomplete="username" placeholder="your.name"/>
</div>
<div class="field">
<label for="f-pass">Password</label>
<input id="f-pass" type="password" autocomplete="current-password" placeholder="••••••••"/>
</div>
<button id="auth-btn" class="btn btn-primary w100" style="margin-top:6px">Log in</button>
<div id="auth-msg" class="msg" role="status"></div>
<div class="auth-foot">Private hub for the 5G Academy class · English + Italiano</div>
</div>
</section>
<div id="app" class="hidden">
<header class="topbar">
<div class="topbar-in">
<div class="brand-row" style="gap:9px">
<svg class="mark sm" viewBox="0 0 48 48" aria-hidden="true"><defs><linearGradient id="lg2" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#e60000"/><stop offset=".6" stop-color="#f25c05"/><stop offset="1" stop-color="#ffc400"/></linearGradient></defs><rect width="48" height="48" rx="14" fill="url(#lg2)"/><path d="M24 34c-3-2.4-7-3.1-10-2.2V16c3-.9 7-.2 10 2.2 3-2.4 7-3.1 10-2.2v15.8c-3-.9-7-.2-10 2.2z" fill="none" stroke="#fff" stroke-width="2.4" stroke-linejoin="round"/><path d="M24 18.2V34" stroke="#fff" stroke-width="2.4"/><path d="M17.5 10.5c4-3.4 9-3.4 13 0" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" opacity=".85"/></svg>
<span class="app-name sm">5G StudyHub</span>
</div>
<nav class="nav" id="nav" aria-label="Main">
<button id="nav-topics" class="on">Topics</button>
<button id="nav-ask">Ask</button>
<button id="nav-cards">Cards<span id="cards-badge" class="navb hidden"></span></button>
<button id="nav-library">Library</button>
<button id="nav-notebook">Notebook</button>
<button id="nav-upload">Upload</button>
<button id="nav-admin" class="hidden">Admin</button>
</nav>
<div class="grow"></div>
<button id="theme-toggle" class="icon-btn" aria-label="Toggle dark / light theme" title="Theme">
<svg id="ico-sun" class="hidden" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="4.5"/><path d="M12 2.5v2.4M12 19.1v2.4M2.5 12h2.4M19.1 12h2.4M5 5l1.7 1.7M17.3 17.3 19 19M19 5l-1.7 1.7M6.7 17.3 5 19"/></svg>
<svg id="ico-moon" width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M20.6 14.5A8.6 8.6 0 0 1 9.5 3.4a8.6 8.6 0 1 0 11.1 11.1z"/></svg>
</button>
<span class="who"><span class="ava" id="who-ava" aria-hidden="true"></span><span class="who-name" id="who"></span></span>
<button id="logout-btn" class="icon-btn" aria-label="Log out" title="Log out">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><path d="M16 17l5-5-5-5M21 12H9"/></svg>
</button>
</div>
</header>
<main id="views">
<section id="view-topics" class="view">
<div id="modules-pane">
<div class="view-head">
<h1>Course modules</h1>
<p class="sub">Pick a module to browse its topics — then summarize or ask anything.</p>
</div>
<div id="modules" class="grid-mods"></div>
</div>
<div id="module-pane" class="hidden">
<button id="back-modules" class="back"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M15 18l-6-6 6-6"/></svg>All modules</button>
<div class="mod-hero">
<h1 id="module-title"></h1>
<p class="sub" id="module-lect"></p>
</div>
<div class="kicker">Topics</div>
<div id="topics" class="grid-topics"></div>
<div id="topic-ws" class="panel hidden">
<div class="ws-head"><h2 id="t-title"></h2></div>
<div id="t-src" class="badges"></div>
<div class="ws-actions">
<button id="t-sum" class="btn btn-ghost"><svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2.8 13.9 9 20 11l-6.1 2L12 19.2 10.1 13 4 11l6.1-2L12 2.8zM19 15.5l.9 2.6 2.6.9-2.6.9-.9 2.6-.9-2.6-2.6-.9 2.6-.9.9-2.6z"/></svg>Generate summary</button>
<button id="t-quiz" class="btn btn-ghost"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9.2"/><path d="M9.3 9.2a2.7 2.7 0 1 1 3.8 2.5c-.7.3-1.1.8-1.1 1.7M12 16.9h.01"/></svg>Practice quiz</button>
<button id="t-cards" class="btn btn-ghost"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M12 5.5v13M5.5 12h13"/></svg>Flashcards</button>
<button id="t-map" class="btn btn-ghost"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="5" cy="12" r="2.3"/><circle cx="18.6" cy="5.4" r="2"/><circle cx="18.6" cy="12" r="2"/><circle cx="18.6" cy="18.6" r="2"/><path d="M7.1 11.3 16.7 6M7.3 12h9.3M7.1 12.7l9.6 5.3"/></svg>Mind map</button>
</div>
<div class="ask-box">
<input id="t-q" placeholder="Ask about this topic — any language…"/>
<button id="t-ask" class="send-btn" aria-label="Ask about this topic"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5M5 12l7-7 7 7"/></svg></button>
</div>
<div id="t-out"></div>
</div>
</div>
</section>
<section id="view-ask" class="view hidden">
<div class="hero">
<h1>Ask the whole course.</h1>
<p class="sub">One question across every slide, syllabus, note and transcript — answered with citations, in English o in italiano.</p>
<div id="thread-bar" class="hidden"><span class="thread-chip"><span id="thread-label" class="tl"></span><button id="thread-clear" title="Start a new thread">✕ New thread</button></span></div>
<div class="ask-box hero-box">
<input id="ask-q" placeholder="e.g. How does OFDM numerology change in 5G NR?"/>
<button id="ask-btn" class="send-btn" aria-label="Ask"><svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5M5 12l7-7 7 7"/></svg></button>
</div>
</div>
<div id="ask-out" class="ask-out"></div>
</section>
<section id="view-cards" class="view hidden">
<div class="view-head">
<h1>Flashcards</h1>
<p class="sub">Short, spaced reviews of the cards you generate from topics — a little every day sticks best.</p>
</div>
<div id="cards-head" class="cards-bar"></div>
<div id="card-stage"></div>
</section>
<section id="view-library" class="view hidden">
<div class="view-head">
<h1>Library</h1>
<p class="sub">Every indexed file in the hub — download the originals anytime.</p>
</div>
<div id="library-groups"></div>
</section>
<section id="view-notebook" class="view hidden">
<div class="view-head">
<h1>Notebook</h1>
<p class="sub">Everything you have asked, summarized and shared — plus your own saved notes.</p>
</div>
<div class="seg sub" role="tablist">
<button id="nb-tab-history" class="on" role="tab" aria-selected="true">History</button>
<button id="nb-tab-notes" role="tab" aria-selected="false">My notes</button>
</div>
<div id="notebook-list" class="stack"></div>
<div id="notes-pane" class="hidden">
<div class="note-row"><button id="note-new" class="btn btn-ghost"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M12 5.5v13M5.5 12h13"/></svg>New note</button></div>
<div id="note-editor" class="card hidden">
<div class="field"><label for="ne-title">Title</label><input id="ne-title" placeholder="e.g. OFDM cheat sheet"/></div>
<div class="field"><label for="ne-body">Note</label><textarea id="ne-body" placeholder="Write anything worth remembering…"></textarea></div>
<div style="display:flex;gap:10px;justify-content:flex-end">
<button id="ne-cancel" class="btn">Cancel</button>
<button id="ne-save" class="btn btn-primary">Save note</button>
</div>
</div>
<div id="notes-list" class="stack"></div>
</div>
</section>
<section id="view-upload" class="view hidden">
<div class="view-head">
<h1>Share material</h1>
<p class="sub">Add your notes or extra material to the class knowledge base.</p>
</div>
<button id="drop-zone" class="dropzone" type="button">
<span class="dz-ico"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 16V4M6.5 9.5 12 4l5.5 5.5"/><path d="M4 16.5V19a1.5 1.5 0 0 0 1.5 1.5h13A1.5 1.5 0 0 0 20 19v-2.5"/></svg></span>
<span class="dz-t">Drop a file here, or click to browse</span>
<span class="dz-s">PDF, PPTX or DOCX · up to 30 MB</span>
</button>
<input id="file-input" type="file" class="hidden" accept=".pdf,.pptx,.docx"/>
<div id="upload-status" class="hidden">
<div class="up-name" id="up-name"></div>
<div class="bar"><i id="upload-bar"></i></div>
</div>
<p class="note">Uploads join the searchable index at the next rebuild — until then they are queued.</p>
<div class="kicker">My uploads</div>
<div id="my-uploads" class="stack"></div>
</section>
<section id="view-admin" class="view hidden">
<div class="view-head">
<h1>Admin</h1>
<p class="sub">Manage the class, student uploads and the material dataset.</p>
</div>
<div class="kicker">Members</div>
<div id="admin-users" class="stack"></div>
<div class="kicker">Invite links</div>
<div class="panel">
<p class="sub" style="margin:0 0 12px">One link per classmate — dies after first use.</p>
<div class="invite-wrap" style="max-width:480px">
<input id="inv-label" placeholder="Who is this link for?"/>
<button id="inv-create" class="btn btn-primary" style="flex:none">Create link</button>
</div>
<div id="inv-new"></div>
<div id="admin-invites" class="stack" style="margin-top:14px"></div>
</div>
<div class="kicker">Student uploads</div>
<div id="admin-uploads" class="stack"></div>
<div class="kicker">Dataset &amp; invite</div>
<div id="admin-dataset"></div>
</section>
</main>
</div>
<script>
/* ============================================================
StudyHub SPA — id map for smoke tests
Auth: #auth #tab-login #tab-register #f-invite (#reg-invite wrap)
#invite-note (shown when ?invite=<token> is in the URL)
#f-user #f-pass #auth-btn #auth-msg
Shell: #app #nav-topics #nav-ask #nav-cards (+#cards-badge) #nav-library
#nav-notebook #nav-upload #nav-admin #theme-toggle #who #who-ava
#logout-btn
Views: #view-topics #view-ask #view-cards #view-library #view-notebook
#view-upload #view-admin
Topics: #modules (module cards) #module-pane #back-modules
#module-title #module-lect #topics (topic chips)
#topic-ws #t-title #t-src #t-sum #t-quiz #t-cards #t-map
#t-q #t-ask #t-out (quiz renders #quiz-area, map #map-area)
Ask: #ask-q #ask-btn #ask-out
#thread-bar #thread-label #thread-clear (thread mode chip)
Cards: #cards-head #card-stage #card-flip #card-front #card-back
#card-show #card-again #card-good (stage re-renders per card)
Library: #library-groups
Notebook:#nb-tab-history #nb-tab-notes #notebook-list #notes-pane
#note-new #note-editor #ne-title #ne-body #ne-save #ne-cancel
#notes-list
Upload: #drop-zone #file-input #upload-status #upload-bar #my-uploads
Admin: #admin-users #admin-uploads #admin-dataset #invite-code
#inv-label #inv-create #inv-new #admin-invites (invite links)
Misc: #toasts #boot
Dynamic rows carry data-act: mod|topic|cite|src|toggle-src|dl|nb|
rm-user|del-up|copy-invite|goto-ask|goto-topics|qopt|qnext|
quiz-again|quiz-close|card-show|grade|save-note|note|
note-edit|note-del|nb-pin|nb-del|nb-cont|inv-copy|inv-revoke
(one delegated listener)
============================================================ */
'use strict';
const $ = id => document.getElementById(id);
let TOKEN = localStorage.getItem('sh_token') || '';
let MODE = 'login', USER = null, ROLE = null;
let MODULES = [], TOPICS = [], CUR_MOD = null, CUR_TOPIC = null;
const SRC = {}; let SRC_N = 0; // answer-id -> sources[]
let THREAD = null; // Ask view thread mode: null | {id, topic_id, title}
let HIST = []; // raw /api/history entries (append order)
let INVITES = []; // admin invite-link rows
let MY_UPS = [], UP_TIMER = null; // my uploads + status poll timer
async function api(path, opts = {}) {
const headers = {'Content-Type': 'application/json'};
if (TOKEN) headers['Authorization'] = 'Bearer ' + TOKEN; // cookies are blocked in the HF iframe
const r = await fetch(path, {credentials: 'include', headers, ...opts});
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).detail || ('HTTP ' + r.status));
return r.json();
}
function escapeHtml(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, c =>
({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
const base = p => String(p || '').split('/').pop();
const ext = p => (base(p).match(/\.(\w+)$/) || [,''])[1].toLowerCase();
/* ---------- toasts / loaders / empty states ---------- */
function toast(msg, type = '') {
const t = document.createElement('div');
t.className = 'toast ' + type; t.textContent = msg;
$('toasts').appendChild(t);
setTimeout(() => { t.classList.add('out'); setTimeout(() => t.remove(), 350); }, 4200);
}
function skeleton(n, h) {
let s = ''; for (let i = 0; i < n; i++) s += `<div class="sk" style="height:${h}px"></div>`;
return s;
}
const thinking = label =>
`<div class="thinking"><span class="dots"><i></i><i></i><i></i></span>${escapeHtml(label)}</div>`;
function empty(title, hint, extra = '') {
return `<div class="empty"><div class="eico"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 6.5C9.5 4.5 6 4 3.5 4.8V19c2.5-.8 6-.3 8.5 1.7 2.5-2 6-2.5 8.5-1.7V4.8C18 4 14.5 4.5 12 6.5z"/><path d="M12 6.5V20.7"/></svg></div><div class="et">${escapeHtml(title)}</div><div class="es">${escapeHtml(hint)}</div>${extra}</div>`;
}
/* ---------- theme ---------- */
function applyTheme(t) {
document.documentElement.dataset.theme = t;
$('ico-sun').classList.toggle('hidden', t !== 'dark');
$('ico-moon').classList.toggle('hidden', t === 'dark');
}
function initTheme() {
applyTheme(localStorage.getItem('sh_theme') ||
(matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'));
}
function toggleTheme() {
const next = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
localStorage.setItem('sh_theme', next); applyTheme(next);
}
/* ---------- auth ---------- */
function setTab(m) {
MODE = m;
$('tab-login').classList.toggle('on', m === 'login');
$('tab-login').setAttribute('aria-selected', m === 'login');
$('tab-register').classList.toggle('on', m === 'register');
$('tab-register').setAttribute('aria-selected', m === 'register');
$('reg-invite').classList.toggle('hidden', m !== 'register');
$('auth-btn').textContent = m === 'login' ? 'Log in' : 'Create account';
const msg = $('auth-msg'); msg.className = 'msg'; msg.textContent = '';
}
async function doAuth() {
const msg = $('auth-msg'), btn = $('auth-btn');
const user = $('f-user').value.trim(), pass = $('f-pass').value;
if (!user || !pass) { msg.className = 'msg err'; msg.textContent = 'Username and password, please.'; return; }
msg.className = 'msg'; msg.textContent = '';
btn.disabled = true; btn.textContent = MODE === 'login' ? 'Signing in…' : 'Creating account…';
try {
if (MODE === 'register') {
const res = await api('/api/register', {method: 'POST',
body: JSON.stringify({invite: $('f-invite').value.trim(), username: user, password: pass})});
setTab('login');
msg.className = 'msg ok'; msg.textContent = (res.message || 'Account created.') + ' Now log in.';
} else {
const res = await api('/api/login', {method: 'POST',
body: JSON.stringify({username: user, password: pass})});
if (res.token) { TOKEN = res.token; localStorage.setItem('sh_token', TOKEN); }
enterApp(res.username || user, res.role);
}
} catch (e) { msg.className = 'msg err'; msg.textContent = e.message; }
btn.disabled = false;
btn.textContent = MODE === 'login' ? 'Log in' : 'Create account';
}
async function logout() {
try { await api('/api/logout', {method: 'POST'}); } catch (e) {}
TOKEN = ''; localStorage.removeItem('sh_token'); location.reload();
}
function enterApp(user, role) {
USER = user; ROLE = role || null;
$('boot').classList.add('hidden'); $('auth').classList.add('hidden');
$('app').classList.remove('hidden');
$('who').textContent = user;
$('who-ava').textContent = (user || '?').slice(0, 1).toUpperCase();
$('nav-admin').classList.toggle('hidden', ROLE !== 'admin');
showView('topics'); loadModules(); refreshCardBadge();
}
/* ---------- router ---------- */
const VIEWS = ['topics', 'ask', 'cards', 'library', 'notebook', 'upload', 'admin'];
function showView(v) {
for (const k of VIEWS) {
$('view-' + k).classList.toggle('hidden', k !== v);
$('nav-' + k).classList.toggle('on', k === v);
}
if (UP_TIMER && v !== 'upload') { clearTimeout(UP_TIMER); UP_TIMER = null; } // stop status polling
if (v === 'cards') loadCards();
if (v === 'library') loadLibrary();
if (v === 'notebook') { if (NB_TAB === 'notes') loadNotes(); else loadNotebook(); }
if (v === 'upload') loadMine();
if (v === 'admin') loadAdmin();
if (v === 'ask') setTimeout(() => $('ask-q').focus(), 60);
}
/* ---------- modules & topics ---------- */
const SPINES = [['#e60000','#ffc400'],['#043b6b','#e60000'],['#0b4f8d','#ffc400'],
['#ffc400','#e60000'],['#e60000','#043b6b']];
async function loadModules() {
const box = $('modules');
box.innerHTML = skeleton(6, 128);
try {
MODULES = await api('/api/modules');
if (!MODULES.length) {
box.innerHTML = empty('No modules yet', 'Materials appear here after the first index build.');
return;
}
box.innerHTML = MODULES.map((m, i) => {
const [c1, c2] = SPINES[i % SPINES.length];
return `<button class="mod-card" data-act="mod" data-i="${i}"
style="--spine:linear-gradient(180deg,${c1},${c2})" title="${escapeHtml(m.title)}">
<span class="mod-title">${escapeHtml(m.title)}</span>
<span class="mod-lect">${escapeHtml(m.lecturer || '—')}</span>
<span class="meta"><span class="pill"><b>${m.n_topics}</b> topics</span>
<span class="pill"><b>${m.n_sources}</b> sources</span></span>
</button>`;
}).join('');
} catch (e) { box.innerHTML = empty('Could not load modules', e.message); }
}
async function openModule(m) {
CUR_MOD = m; CUR_TOPIC = null;
$('modules-pane').classList.add('hidden');
$('module-pane').classList.remove('hidden');
$('module-title').textContent = m.title; $('module-title').title = m.title;
$('module-lect').textContent = m.lecturer ? 'Lecturer · ' + m.lecturer : '';
$('topic-ws').classList.add('hidden');
const box = $('topics'); box.innerHTML = skeleton(6, 50);
try {
TOPICS = await api(`/api/modules/${encodeURIComponent(m.id)}/topics`);
box.innerHTML = TOPICS.length ? TOPICS.map((t, i) =>
`<button class="topic-chip" data-act="topic" data-i="${i}" title="${escapeHtml(t.title)}"
${t.n_chunks ? '' : 'disabled'}>
<span class="dot"></span><span class="tname">${escapeHtml(t.title)}</span>
${t.n_chunks ? '' : '<span class="nm">no material yet</span>'}
</button>`).join('')
: empty('No topics in this module', 'They appear after the next index rebuild.');
} catch (e) { box.innerHTML = empty('Could not load topics', e.message); }
}
function closeModule() {
$('module-pane').classList.add('hidden');
$('modules-pane').classList.remove('hidden');
CUR_MOD = null; CUR_TOPIC = null;
}
async function selectTopic(t, btn) {
document.querySelectorAll('.topic-chip').forEach(x => x.classList.remove('on'));
if (btn) btn.classList.add('on');
CUR_TOPIC = t.id; QUIZ = null;
const ws = $('topic-ws');
ws.classList.remove('hidden');
$('t-title').textContent = t.title;
$('t-out').innerHTML = ''; $('t-q').value = '';
const sbox = $('t-src'); sbox.innerHTML = skeleton(2, 28);
ws.scrollIntoView({behavior: 'smooth', block: 'nearest'});
try {
const srcs = await api(`/api/topics/${encodeURIComponent(t.id)}/sources`);
sbox.innerHTML = srcs.length ? srcs.map(s =>
`<span class="file-badge" title="${escapeHtml(s.file)}">
<span class="tchip ${escapeHtml(s.type)}">${escapeHtml(s.type)}</span>
<span class="fname">${escapeHtml(base(s.file))}</span>
</span>`).join('')
: '<span class="note" style="margin:0">No files linked to this topic yet.</span>';
} catch (e) { sbox.innerHTML = ''; }
}
async function summarizeTopic() {
if (!CUR_TOPIC) return;
const out = $('t-out');
out.innerHTML = thinking('Reading the materials and writing a summary — usually 10–20 s…');
$('t-sum').disabled = true;
try {
const r = await api(`/api/topics/${encodeURIComponent(CUR_TOPIC)}/summary`);
renderAnswer(out, r, {title: 'Summary: ' + $('t-title').textContent, topic_id: CUR_TOPIC});
} catch (e) { out.innerHTML = `<div class="err-inline">${escapeHtml(e.message)}</div>`; }
$('t-sum').disabled = false;
}
async function askTopic() {
const q = $('t-q').value.trim();
if (!q || !CUR_TOPIC) return;
const out = $('t-out');
out.innerHTML = thinking('Thinking it over with the topic materials…');
$('t-ask').disabled = true;
try {
const r = await api(`/api/topics/${encodeURIComponent(CUR_TOPIC)}/ask`,
{method: 'POST', body: JSON.stringify({query: q})});
renderAnswer(out, r, {title: q, topic_id: CUR_TOPIC});
} catch (e) { out.innerHTML = `<div class="err-inline">${escapeHtml(e.message)}</div>`; }
$('t-ask').disabled = false;
}
/* ---------- answers + citations ---------- */
function locText(s) {
if (!s.locator) return '';
if (s.kind === 'page') return 'p. ' + s.locator;
if (s.kind === 'slide') return 'slide ' + s.locator;
return String(s.locator);
}
function openSrc(s) {
let url = '/api/download?file=' + encodeURIComponent(s.file) + '&t=' + TOKEN;
if (s.kind === 'page' && s.locator) url += '#page=' + s.locator;
window.open(url, '_blank', 'noopener');
}
function citeChip(aid, sid) { // inline [S#] chip, resolved against SRC[aid]
const s = (SRC[aid] || []).find(x => x.sid === sid);
if (!s) return '';
const tip = escapeHtml(s.label + (locText(s) ? ' · ' + locText(s) : ''));
return `<button class="cite" data-act="src" data-a="${aid}" data-s="${escapeHtml(sid)}" title="${tip}">${escapeHtml(String(sid).slice(1))}</button>`;
}
const NOTE_OF = {}; // answer-id -> payload for "Save to notes"
const bmIco = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.5 21l-5.5-3.4L6.5 21V4.9a1.4 1.4 0 0 1 1.4-1.4h8.2a1.4 1.4 0 0 1 1.4 1.4z"/></svg>`;
function renderAnswer(box, r, save) { // save: {title, topic_id?} adds "Save to notes"
const aid = ++SRC_N, sources = r.sources || [];
SRC[aid] = sources;
const text = escapeHtml(r.answer || '').replace(/\[(S\d+)\]/g, (m0, sid) => citeChip(aid, sid) || m0);
let html = `<div class="answer-card"><div class="answer-text">${text}</div>`;
if (sources.length) {
html += `<button class="src-toggle" data-act="toggle-src" data-n="${sources.length}">
<svg class="chev" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg>
<span class="stl">Show sources (${sources.length})</span></button>
<div class="src-list hidden">` + sources.map(s => {
const badge = s.is_notes ? 'notes' : (ext(s.file) || 'material');
return `<button class="src-row" data-act="src" data-a="${aid}" data-s="${s.sid}" title="${escapeHtml(s.file)}">
<span class="sid">${escapeHtml(s.sid.slice(1))}</span>
<span class="slabel">${escapeHtml(s.label)}</span>
<span class="loc">${escapeHtml(locText(s))}</span>
<span class="tchip ${escapeHtml(badge)}">${escapeHtml(badge)}</span>
</button>`;
}).join('') + `</div>`;
}
if (save && (r.answer || '').trim()) {
const pl = {title: save.title, body: String(r.answer).replace(/\s*\[S\d+\]/g, '')};
if (save.topic_id) pl.topic_id = save.topic_id;
NOTE_OF[aid] = pl;
html += `<button class="ghost-pill${sources.length ? ' ml' : ''}" data-act="save-note" data-a="${aid}">${bmIco}<span class="gl">Save to notes</span></button>`;
}
box.innerHTML = html + `</div>`;
}
async function doAsk() {
const q = $('ask-q').value.trim();
if (!q) { $('ask-q').focus(); return; }
const out = $('ask-out'), T = THREAD; // capture: "New thread" mid-flight stays safe
$('ask-btn').disabled = true;
let mount = out;
if (T) {
mount = document.createElement('div');
mount.innerHTML = qCard(q) + thinking('Thinking it over with the conversation so far…');
out.appendChild(mount);
mount.scrollIntoView({behavior: 'smooth', block: 'nearest'});
} else out.innerHTML = thinking('Searching every module and writing a cited answer — 5–20 s…');
try {
const body = {query: q};
let path = '/api/ask';
if (T) {
body.thread_id = T.id;
if (T.topic_id) path = `/api/topics/${encodeURIComponent(T.topic_id)}/ask`;
}
const r = await api(path, {method: 'POST', body: JSON.stringify(body)});
if (T) {
const w = document.createElement('div');
renderAnswer(w, r, {title: q, topic_id: T.topic_id || undefined});
mount.querySelector('.thinking').replaceWith(w.firstElementChild);
if (r.thread_id) T.id = r.thread_id;
$('ask-q').value = '';
} else {
renderAnswer(out, r, {title: q});
if (!THREAD && r.thread_id) THREAD = {id: r.thread_id, topic_id: null, title: ''};
}
setThreadBar(); // first answer flips the label to the thread chip
} catch (e) {
const err = `<div class="err-inline">${escapeHtml(e.message)}</div>`;
if (mount === out) out.innerHTML = err;
else { const th = mount.querySelector('.thinking'); if (th) th.outerHTML = err; }
}
$('ask-btn').disabled = false;
}
/* ---------- ask threads (continue a conversation) ---------- */
const qCard = q => `<div class="q-card">${escapeHtml(q)}</div>`;
function setThreadBar() {
$('thread-bar').classList.toggle('hidden', !THREAD);
if (THREAD) $('thread-label').textContent = THREAD.topic_id
? 'Continuing thread — in topic ' + (THREAD.title || THREAD.topic_id)
: 'Continuing thread — all materials';
}
function clearThread() {
THREAD = null; setThreadBar();
$('ask-out').innerHTML = ''; $('ask-q').focus();
}
async function continueThread(id) {
showView('ask');
const out = $('ask-out');
out.innerHTML = thinking('Reopening the conversation…');
try {
const t = await api('/api/threads/' + encodeURIComponent(id));
THREAD = {id: t.id || id, topic_id: t.topic || null, title: t.topic || ''};
const ex = (t.exchanges && t.exchanges.length) ? t.exchanges
: (t.query ? [{q: t.query, a: t.answer || ''}] : []);
out.innerHTML = ex.map(x => // stored turns have no live sources: text-only
qCard(x.q) + `<div class="answer-card"><div class="answer-text">${escapeHtml(x.a)}</div></div>`).join('');
if (out.lastElementChild) out.lastElementChild.scrollIntoView({behavior: 'smooth', block: 'nearest'});
} catch (e) { out.innerHTML = `<div class="err-inline">${escapeHtml(e.message)}</div>`; }
setThreadBar();
}
/* ---------- library ---------- */
const TYPE_ORDER = ['slides', 'syllabus', 'notes', 'transcript', 'material'];
const TYPE_LABEL = {slides:'Slides', syllabus:'Syllabus', notes:'Notes', transcript:'Transcripts', material:'Other material'};
const docIco = `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2.5H6.5A1.5 1.5 0 0 0 5 4v16a1.5 1.5 0 0 0 1.5 1.5h11A1.5 1.5 0 0 0 19 20V7.5L14 2.5z"/><path d="M14 2.5V7.5H19"/></svg>`;
const dlIco = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round"><path d="M12 4v11M6.5 10 12 15.5 17.5 10"/><path d="M4 17v2a1.5 1.5 0 0 0 1.5 1.5h13A1.5 1.5 0 0 0 20 19v-2"/></svg>`;
async function loadLibrary() {
const box = $('library-groups');
box.innerHTML = skeleton(5, 70);
try {
const files = await api('/api/files');
if (!files.length) {
box.innerHTML = empty('The library is empty', 'Files appear here after the first index build.');
return;
}
const groups = {};
files.forEach(f => (groups[f.type] = groups[f.type] || []).push(f));
const keys = TYPE_ORDER.filter(k => groups[k])
.concat(Object.keys(groups).filter(k => !TYPE_ORDER.includes(k)));
box.innerHTML = keys.map(k => `<div class="lib-group">
<div class="kicker">${escapeHtml(TYPE_LABEL[k] || k)} · ${groups[k].length}</div>
<div class="grid-files">` + groups[k].map(f =>
`<div class="file-card">
<span class="fico ${escapeHtml(f.type)}">${docIco}</span>
<span class="fmeta"><span class="fn" title="${escapeHtml(f.file)}">${escapeHtml(base(f.file))}</span>
<span class="ft">${escapeHtml(ext(f.file).toUpperCase() || f.type)}</span></span>
<button class="icon-btn" data-act="dl" data-file="${escapeHtml(f.file)}"
aria-label="Download ${escapeHtml(base(f.file))}" title="Download">${dlIco}</button>
</div>`).join('') + `</div></div>`).join('');
} catch (e) { box.innerHTML = empty('Could not load the library', e.message); }
}
/* ---------- notebook ---------- */
const KIND = {ask:['Question','q'], topic_ask:['Question','q'], topic_summary:['Summary','s'], upload:['Upload','u']};
const pinIco = on => `<svg width="14" height="14" viewBox="0 0 24 24" fill="${on ? 'currentColor' : 'none'}" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 17v5"/><path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V17h14v-1.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6h1a2 2 0 0 0 0-4H8a2 2 0 0 0 0 4h1z"/></svg>`;
async function loadNotebook() {
const box = $('notebook-list');
box.innerHTML = skeleton(4, 86);
try { HIST = await api('/api/history'); renderNotebook(); }
catch (e) { box.innerHTML = empty('Could not load your notebook', e.message); }
}
function renderNotebook() {
const box = $('notebook-list');
if (!HIST.length) {
box.innerHTML = empty('Your notebook is empty', 'Ask your first question and it will be saved here.',
'<button class="btn btn-primary" data-act="goto-ask">Ask a question</button>');
return;
}
const list = HIST.slice().reverse() // newest first; continued threads
.sort((a, b) => (b.ts || 0) - (a.ts || 0)); // bump ts, so they sort fresh
const pin = list.filter(e => e.pinned), rest = list.filter(e => !e.pinned);
box.innerHTML = (pin.length ? '<div class="kicker" style="margin:0 0 2px">Pinned</div>' + pin.map(nbCard).join('') : '')
+ rest.map(nbCard).join('');
}
function nbCard(it) {
const [label, cls] = KIND[it.kind] || [it.kind, 'q'];
const nx = (it.exchanges || []).length;
const meta = nx ? `<span class="nb-topic">${nx} exchange${nx === 1 ? '' : 's'}</span>` : '';
const topic = it.topic ? `<span class="nb-topic" title="${escapeHtml(it.topic)}">${escapeHtml(it.topic)}</span>` : '';
const acts = `<div class="nb-acts">
<button class="icon-btn pin${it.pinned ? ' on' : ''}" data-act="nb-pin" data-id="${escapeHtml(it.id)}"
title="${it.pinned ? 'Unpin' : 'Pin to top'}" aria-label="${it.pinned ? 'Unpin' : 'Pin to top'}"
aria-pressed="${!!it.pinned}">${pinIco(it.pinned)}</button>
${it.kind === 'ask' || it.kind === 'topic_ask'
? `<button class="btn btn-ghost sm" data-act="nb-cont" data-id="${escapeHtml(it.id)}">Continue</button>` : ''}
<span class="grow"></span>
<button class="btn-danger" data-act="nb-del" data-id="${escapeHtml(it.id)}">Delete</button>
</div>`;
if (it.kind === 'upload') {
return `<div class="nb-card" role="listitem">
<div class="nb-head"><span class="kbadge ${cls}">${escapeHtml(label)}</span></div>
<div class="nb-q" title="${escapeHtml(it.file || '')}">${escapeHtml(base(it.file) || 'File')}</div>
<div class="nb-prev">Shared with the class — searchable after the next rebuild.</div>${acts}
</div>`;
}
const q = it.query || (it.kind === 'topic_summary' ? 'Topic summary' : '');
const ans = it.answer || '';
const full = escapeHtml(ans).replace(/\[S(\d+)\]/g, '<span class="cite inert">$1</span>');
return `<div class="nb-card" data-act="nb" role="listitem">
<span class="nb-head"><span class="kbadge ${cls}">${escapeHtml(label)}</span>${topic}${meta}</span>
<span class="nb-q">${escapeHtml(q)}</span>
<span class="nb-prev">${escapeHtml(ans.replace(/\s*\[S\d+\]/g, '').slice(0, 220))}</span>
<span class="nb-full">${full}</span>${acts}
</div>`;
}
/* ---------- upload ---------- */
const MAX_MB = 30;
function pickFile() { $('file-input').click(); }
function handleFile(f) {
if (!f) return;
if (!/\.(pdf|pptx|docx)$/i.test(f.name)) { toast('Only PDF, PPTX or DOCX files are accepted.', 'err'); return; }
if (f.size > MAX_MB * 1024 * 1024) { toast(`That file is over ${MAX_MB} MB — please shrink it first.`, 'err'); return; }
const dz = $('drop-zone'), st = $('upload-status'), bar = $('upload-bar');
dz.classList.add('busy'); st.classList.remove('hidden');
$('up-name').textContent = f.name; bar.style.width = '3%';
const fd = new FormData(); fd.append('file', f);
const xhr = new XMLHttpRequest();
xhr.open('POST', '/api/upload');
if (TOKEN) xhr.setRequestHeader('Authorization', 'Bearer ' + TOKEN); // no Content-Type: browser sets the boundary
xhr.upload.onprogress = e => { if (e.lengthComputable) bar.style.width = Math.max(3, Math.round(e.loaded / e.total * 100)) + '%'; };
xhr.onload = () => {
dz.classList.remove('busy');
let res = {}; try { res = JSON.parse(xhr.responseText); } catch (e) {}
if (xhr.status >= 200 && xhr.status < 300) {
bar.style.width = '100%';
toast(res.message || 'Uploaded — searchable after the next rebuild.', 'ok');
setTimeout(() => { st.classList.add('hidden'); bar.style.width = '0'; }, 900);
loadMine();
} else {
st.classList.add('hidden'); bar.style.width = '0';
toast(res.detail || ('Upload failed (HTTP ' + xhr.status + ')'), 'err');
}
};
xhr.onerror = () => { dz.classList.remove('busy'); st.classList.add('hidden'); bar.style.width = '0'; toast('Upload failed — network error.', 'err'); };
xhr.send(fd);
$('file-input').value = '';
}
function upBadge(st) {
st = String(st || 'unknown');
if (st === 'indexed') return '<span class="kbadge u">searchable</span>';
if (st === 'processing' || st === 'pending') return '<span class="kbadge s pulse">indexing…</span>';
if (st.indexOf('failed') === 0) return `<span class="kbadge b" title="${escapeHtml(st)}">failed</span>`;
return '<span class="kbadge m">after next rebuild</span>'; // pending-rebuild / unknown
}
async function loadMine(quiet) {
const box = $('my-uploads');
if (!quiet) box.innerHTML = skeleton(2, 56);
try {
MY_UPS = await api('/api/uploads/mine');
box.innerHTML = MY_UPS.length ? MY_UPS.map(u =>
`<div class="file-card">
<span class="fico notes">${docIco}</span>
<span class="fmeta"><span class="fn" title="${escapeHtml(u.path)}">${escapeHtml(u.name)}</span></span>
${upBadge(u.status)}
</div>`).join('')
: empty('Nothing shared yet', 'Your uploaded notes and materials will be listed here.');
} catch (e) { if (!quiet) box.innerHTML = empty('Could not load your uploads', e.message); }
schedUpPoll();
}
function schedUpPoll() { // poll while anything of mine is still indexing
clearTimeout(UP_TIMER); UP_TIMER = null;
if ($('view-upload').classList.contains('hidden')) return;
if (MY_UPS.some(u => u.status === 'processing' || u.status === 'pending'))
UP_TIMER = setTimeout(() => loadMine(true), 8000);
}
/* ---------- admin ---------- */
async function loadAdmin() {
if (ROLE !== 'admin') return;
$('admin-users').innerHTML = skeleton(3, 56);
$('admin-invites').innerHTML = skeleton(2, 56);
$('inv-new').innerHTML = '';
$('admin-uploads').innerHTML = skeleton(2, 56);
$('admin-dataset').innerHTML = skeleton(1, 150);
try {
const ov = await api('/api/admin/overview');
const seed = ov.seed || [];
$('admin-users').innerHTML = (ov.users || []).map(u => {
const isSeed = seed.includes(u.username);
return `<div class="person">
<span class="ava">${escapeHtml((u.username || '?').slice(0, 1).toUpperCase())}</span>
<span class="uname">${escapeHtml(u.username)}</span>
${isSeed ? '<span class="obadge">owner</span>' : ''}
<span class="dl-meta">${escapeHtml(u.created_at || '')}</span>
<span class="grow"></span>
${isSeed ? '<span class="pill">admin</span>'
: `<select class="sel role-sel" data-user="${escapeHtml(u.username)}" aria-label="Role for ${escapeHtml(u.username)}">
<option value="reader"${u.role === 'reader' ? ' selected' : ''}>reader</option>
<option value="admin"${u.role === 'admin' ? ' selected' : ''}>admin</option>
</select>
<button class="btn-danger" data-act="rm-user" data-user="${escapeHtml(u.username)}">Remove</button>`}
</div>`;
}).join('') || empty('No members yet', 'Share the invite code with the class.');
INVITES = ov.invites || [];
renderInvites();
$('admin-uploads').innerHTML = (ov.uploads || []).length ? ov.uploads.map(u =>
`<div class="person">
<span class="fico notes">${docIco}</span>
<span class="fmeta"><span class="fn" title="${escapeHtml(u.path)}">${escapeHtml(u.name)}</span>
<span class="ft">by ${escapeHtml(u.user)}</span></span>
<span class="grow"></span>
${upBadge(u.status)}
<button class="btn-danger" data-act="del-up" data-path="${escapeHtml(u.path)}">Delete</button>
</div>`).join('')
: empty('No student uploads', 'Files shared by the class will appear here.');
const dsUrl = 'https://huggingface.co/datasets/' + ov.dataset;
$('admin-dataset').innerHTML = `<div class="panel">
<div class="ds-row"><span class="lab">Dataset</span>
<a href="${escapeHtml(dsUrl)}" target="_blank" rel="noopener">${escapeHtml(ov.dataset)}</a></div>
<div class="ds-row"><span class="lab">Originals</span>
<span class="pill"><b>${(ov.originals || []).length}</b> indexed files</span></div>
<div class="ds-row"><span class="lab">Invite code</span>
<span class="invite-wrap"><input id="invite-code" readonly value="${escapeHtml(ov.invite)}"/>
<button class="btn" data-act="copy-invite">Copy</button></span></div>
<p class="note">Add or remove original course files on the HF dataset page, then run the
<b>Rebuild index</b> GitHub Action — modules, topics and the library refresh on the next deploy.</p>
</div>`;
} catch (e) {
$('admin-users').innerHTML = empty('Admin data unavailable', e.message);
$('admin-invites').innerHTML = '';
$('admin-uploads').innerHTML = ''; $('admin-dataset').innerHTML = '';
}
}
/* ---------- invite links ---------- */
const invLink = tk => location.origin + '/?invite=' + encodeURIComponent(tk);
function renderInvites() {
const box = $('admin-invites');
box.innerHTML = INVITES.length ? INVITES.map(v => {
const used = !!v.used_by, link = invLink(v.token);
return `<div class="person">
<span class="fmeta"><span class="fn">${escapeHtml(v.label || '—')}</span>
${used ? '' : `<span class="inv-link" title="${escapeHtml(link)}">${escapeHtml(link)}</span>`}</span>
<span class="kbadge ${used ? 'u' : 's'}"${used ? ' style="text-transform:none"' : ''}>${used ? 'used by ' + escapeHtml(v.used_by) : 'unused'}</span>
${used ? '' : `<button class="btn sm" data-act="inv-copy" data-link="${escapeHtml(link)}">Copy</button>
<button class="btn-danger" data-act="inv-revoke" data-token="${escapeHtml(v.token)}">Revoke</button>`}
</div>`;
}).join('') : '<p class="note" style="margin:0">No links yet — mint the first one above.</p>';
}
async function mintInvite() {
const label = $('inv-label').value.trim();
if (!label) { toast('Say who the link is for first.', 'err'); $('inv-label').focus(); return; }
const btn = $('inv-create'); btn.disabled = true;
try {
const r = await api('/api/admin/invites', {method: 'POST', body: JSON.stringify({label})});
const link = location.origin + (r.path || '/?invite=' + encodeURIComponent(r.token));
const nw = $('inv-new'); nw.dataset.token = r.token;
nw.innerHTML = `<div class="person" style="margin-top:12px">
<span class="fmeta"><span class="fn">Fresh link for ${escapeHtml(label)}</span>
<span class="inv-link" title="${escapeHtml(link)}">${escapeHtml(link)}</span></span>
<button class="btn sm" data-act="inv-copy" data-link="${escapeHtml(link)}">Copy link</button>
</div>`;
INVITES.unshift({token: r.token, label, created: Math.floor(Date.now() / 1000), used_by: null, used_at: null});
renderInvites();
$('inv-label').value = '';
toast('Invite link created.', 'ok');
} catch (e) { toast(e.message, 'err'); }
btn.disabled = false;
}
function copyText(v, okMsg) {
const fb = () => { // clipboard API can be blocked in iframes
const ta = document.createElement('textarea');
ta.value = v; ta.style.cssText = 'position:fixed;opacity:0';
document.body.appendChild(ta); ta.select();
try { document.execCommand('copy'); toast(okMsg, 'ok'); }
catch (e) { toast('Copy failed — select the link by hand.', 'err'); }
ta.remove();
};
if (navigator.clipboard && navigator.clipboard.writeText)
navigator.clipboard.writeText(v).then(() => toast(okMsg, 'ok'), fb);
else fb();
}
function armConfirm(btn) {
if (btn.dataset.arm) { delete btn.dataset.arm; btn.classList.remove('armed'); return true; }
btn.dataset.arm = '1'; btn.classList.add('armed');
const old = btn.textContent; btn.textContent = 'Confirm?';
setTimeout(() => { if (btn.dataset.arm) { delete btn.dataset.arm; btn.classList.remove('armed'); btn.textContent = old; } }, 3500);
return false;
}
/* ---------- practice quiz ---------- */
let QUIZ = null; // {qs, aid, i, score, picked, fin}
async function startQuiz() {
if (!CUR_TOPIC) return;
const out = $('t-out'), btn = $('t-quiz');
out.innerHTML = thinking('Writing a 5-question quiz from the materials — usually 10–20 s…');
btn.disabled = true; QUIZ = null;
try {
const r = await api(`/api/topics/${encodeURIComponent(CUR_TOPIC)}/quiz`,
{method: 'POST', body: JSON.stringify({n: 5})});
const qs = (r && r.questions) || [];
if (r.error || !qs.length) {
out.innerHTML = empty('No material for this topic yet',
r.error || 'Add slides or notes, rebuild the index, then try again.');
} else {
const aid = ++SRC_N; SRC[aid] = r.sources || [];
qs.forEach(q => q.answer = +q.answer);
QUIZ = {qs, aid, i: 0, score: 0, picked: null, fin: false};
renderQuiz();
}
} catch (e) { out.innerHTML = `<div class="err-inline">${escapeHtml(e.message)}</div>`; }
btn.disabled = false;
}
function renderQuiz() {
const Q = QUIZ, q = Q.qs[Q.i], n = Q.qs.length, done = Q.picked != null;
const opts = q.options.map((o, j) => {
let cls = 'qopt';
if (done && j === q.answer) cls += ' correct';
else if (done && j === Q.picked) cls += ' wrong';
return `<button class="${cls}" data-act="qopt" data-j="${j}"${done ? ' disabled' : ''}>
<span class="qn">${j + 1}</span><span>${escapeHtml(o)}</span></button>`;
}).join('');
let tail = '';
if (done) {
const seen = {};
const why = escapeHtml(q.why || '').replace(/\[(S\d+)\]/g, (m0, sid) => {
const c = citeChip(Q.aid, sid); if (c) seen[sid] = 1; return c || m0;
});
const extra = (q.sids || []).filter(s => !seen[s]).map(s => citeChip(Q.aid, s)).join(' ');
tail = `<div class="quiz-why"><b>${Q.picked === q.answer ? 'Correct!' : 'Not quite.'}</b> ${why}${extra ? ' ' + extra : ''}</div>
<div class="qfoot"><button class="btn btn-primary" data-act="qnext">${Q.i + 1 < n ? 'Next question' : 'See my score'}</button><span class="khint">Enter ↵</span></div>`;
}
const pct = Math.round((Q.i + (done ? 1 : 0)) / n * 100);
$('t-out').innerHTML = `<div class="answer-card" id="quiz-area">
<div class="qhead"><span class="qprog">Question ${Q.i + 1} / ${n}</span>
<div class="bar grow"><i style="width:${pct}%"></i></div>
<span class="khint">1–4 to answer</span></div>
<div class="qtext">${escapeHtml(q.q)}</div>
<div class="stack">${opts}</div>${tail}</div>`;
}
function pickQuizOpt(j) {
if (!QUIZ || QUIZ.fin || QUIZ.picked != null) return;
const q = QUIZ.qs[QUIZ.i];
if (!q || j < 0 || j >= q.options.length) return;
QUIZ.picked = j;
if (j === q.answer) QUIZ.score++;
renderQuiz();
}
function quizNext() {
if (!QUIZ || QUIZ.fin || QUIZ.picked == null) return;
if (QUIZ.i + 1 < QUIZ.qs.length) { QUIZ.i++; QUIZ.picked = null; renderQuiz(); return; }
QUIZ.fin = true;
const n = QUIZ.qs.length, s = QUIZ.score;
const msg = s === n ? 'perfect — you own this topic' :
s >= n * .7 ? 'well done' : s >= n * .5 ? 'solid — worth one more pass' : 'keep at it — try the summary first';
$('t-out').innerHTML = `<div class="answer-card qscore" id="quiz-area">
<div class="big">${s}/${n}</div><div class="qmsg">${s}/${n}${escapeHtml(msg)}</div>
<div class="qfoot" style="justify-content:center">
<button class="btn btn-primary" data-act="quiz-again">Try another quiz</button>
<button class="btn" data-act="quiz-close">Back to topic</button></div></div>`;
}
document.addEventListener('keydown', e => {
if (!QUIZ || QUIZ.fin) return;
const qa = $('quiz-area');
if (!qa || !qa.offsetParent) return; // quiz not on screen
const tag = (e.target.tagName || '').toLowerCase();
if (tag === 'input' || tag === 'textarea' || tag === 'select') return;
if (QUIZ.picked == null && e.key >= '1' && e.key <= '4') { e.preventDefault(); pickQuizOpt(+e.key - 1); }
else if (QUIZ.picked != null && e.key === 'Enter') { e.preventDefault(); quizNext(); }
});
/* ---------- flashcards ---------- */
let DUE = [], DECK = 0, DI = 0;
function setCardBadge(n) {
const b = $('cards-badge');
b.textContent = n > 99 ? '99+' : String(n);
b.classList.toggle('hidden', !n);
}
async function refreshCardBadge() {
try { const r = await api('/api/flashcards'); setCardBadge(r.due_now || 0); } catch (e) {}
}
async function genCards() {
if (!CUR_TOPIC) return;
const btn = $('t-cards'), old = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<span class="dots"><i></i><i></i><i></i></span>Making cards…';
try {
const r = await api(`/api/topics/${encodeURIComponent(CUR_TOPIC)}/flashcards`,
{method: 'POST', body: JSON.stringify({n: 10})});
if (r.error) toast(r.error, 'err');
else { toast(`Added ${r.added} cards · ${r.due_now} due now`, 'ok'); setCardBadge(r.due_now || 0); }
} catch (e) { toast(e.message, 'err'); }
btn.disabled = false; btn.innerHTML = old;
}
async function loadCards() {
const head = $('cards-head'), stage = $('card-stage');
head.innerHTML = '<div class="sk" style="height:28px;width:230px"></div>';
stage.innerHTML = skeleton(1, 300);
try {
const r = await api('/api/flashcards');
DUE = r.due || []; DECK = r.deck_size || 0; DI = 0;
setCardBadge(r.due_now || 0);
renderCard();
} catch (e) { head.innerHTML = ''; stage.innerHTML = empty('Could not load flashcards', e.message); }
}
function renderCard() {
const c = DUE[DI];
$('cards-head').innerHTML =
`<span class="pill"><b>${Math.max(0, DUE.length - DI)}</b> due now</span>
<span class="pill"><b>${DECK}</b> in deck</span>`;
if (!c) {
if (DI > 0) refreshCardBadge();
$('card-stage').innerHTML = empty(
DI > 0 ? 'Session complete' : 'Nothing due',
DI > 0 ? `You reviewed ${DI} ${DI === 1 ? 'card' : 'cards'} — come back when the next batch is due.`
: 'Generate cards from any topic and your reviews will queue up here.',
'<button class="btn btn-primary" data-act="goto-topics">Browse topics</button>');
return;
}
$('card-stage').innerHTML = `<div class="fc-scene">
<div class="fc" id="card-flip">
<div class="fc-face" id="card-front">
<span class="fc-kick" title="${escapeHtml(c.topic || '')}">${escapeHtml(c.topic || 'Flashcard')}</span>
<div class="fc-text">${escapeHtml(c.front)}</div>
</div>
<div class="fc-face back" id="card-back">
<span class="fc-kick">Answer</span>
<div class="fc-text">${escapeHtml(c.back)}</div>
</div>
</div>
<div class="fc-acts" id="card-acts">
<button id="card-show" class="btn btn-primary" data-act="card-show">Show answer</button>
</div>
<p class="note" style="text-align:center">Card ${DI + 1} of ${DUE.length}</p>
</div>`;
}
function showCardBack() {
$('card-flip').classList.add('flip');
$('card-acts').innerHTML =
`<button id="card-again" class="btn btn-no" data-act="grade" data-good="0">Again</button>
<button id="card-good" class="btn btn-yes" data-act="grade" data-good="1">Got it</button>`;
}
async function gradeCard(good) {
const c = DUE[DI]; if (!c) return;
const bs = $('card-acts').querySelectorAll('button');
bs.forEach(b => b.disabled = true);
try {
await api('/api/flashcards/grade', {method: 'POST', body: JSON.stringify({id: c.id, good: !!good})});
DI++; setCardBadge(Math.max(0, DUE.length - DI)); renderCard();
} catch (e) { toast(e.message, 'err'); bs.forEach(b => b.disabled = false); }
}
/* ---------- mind map ---------- */
async function loadMap() {
if (!CUR_TOPIC) return;
const out = $('t-out'), btn = $('t-map');
out.innerHTML = thinking('Mapping the key concepts of this topic — usually 5–15 s…');
btn.disabled = true;
try {
const r = await api(`/api/topics/${encodeURIComponent(CUR_TOPIC)}/mindmap`);
if (r.error || !r.root) { out.innerHTML = ''; toast(r.error || 'No mind map available for this topic yet.', 'err'); }
else if (!(r.children || []).length) out.innerHTML = empty('No mind map yet', 'This topic needs more material first.');
else out.innerHTML = `<div id="map-area" class="map-wrap" tabindex="0" aria-label="Mind map">${mindmapSvg(r)}</div>`;
} catch (e) { out.innerHTML = ''; toast(e.message, 'err'); }
btn.disabled = false;
}
function mmCurve(x1, y1, x2, y2) {
const mx = (x1 + x2) / 2;
return `<path d="M${x1} ${y1} C ${mx} ${y1}, ${mx} ${y2}, ${x2} ${y2}"/>`;
}
function mindmapSvg(r) {
const trunc = s => { s = String(s || ''); return s.length > 40 ? s.slice(0, 39) + '…' : s; };
const pw = (s, fs) => Math.round(trunc(s).length * fs * .58 + 30);
const pill = (x, y, w, h, fill, stroke, label, fs, fw, tfill) =>
`<g><title>${escapeHtml(label)}</title><rect x="${x}" y="${(y - h / 2).toFixed(1)}" width="${w}" height="${h}" rx="${h / 2}" fill="${fill}"${stroke ? ` stroke="${stroke}"` : ''}/><text x="${x + w / 2}" y="${y}" text-anchor="middle" dominant-baseline="central" font-size="${fs}" font-weight="${fw}" fill="${tfill}">${escapeHtml(trunc(label))}</text></g>`;
const groups = [];
let row = 0, maxBW = 0, maxLW = 0;
(r.children || []).slice(0, 12).forEach(b => {
const ls = (b.children || []).slice(0, 10);
groups.push({b, ls, y0: row});
row += Math.max(1, ls.length);
maxBW = Math.max(maxBW, pw(b.label, 12.5));
ls.forEach(l => maxLW = Math.max(maxLW, pw(l.label, 12)));
});
const ROW = 42, PAD = 14;
const H = Math.max(row * ROW + PAD * 2, 130);
const rootW = pw(r.root, 13.5) + 8;
const x0 = 6, x1 = x0 + rootW + 62, x2 = x1 + maxBW + 54;
const W = x2 + maxLW + 10, cy = H / 2;
let paths = '', nodes = '';
groups.forEach(g => {
const ys = g.ls.map((l, i) => PAD + (g.y0 + i) * ROW + ROW / 2);
const by = ys.length ? ys.reduce((a, b) => a + b, 0) / ys.length : PAD + g.y0 * ROW + ROW / 2;
const bw = pw(g.b.label, 12.5);
paths += mmCurve(x0 + rootW, cy, x1, by);
nodes += pill(x1, by, bw, 28, 'var(--surface2)', 'var(--line2)', g.b.label, 12.5, 650, 'var(--text)');
g.ls.forEach((l, i) => {
paths += mmCurve(x1 + bw, by, x2, ys[i]);
nodes += pill(x2, ys[i], pw(l.label, 12), 26, 'var(--surface)', 'var(--line)', l.label, 12, 550, 'var(--muted)');
});
});
return `<svg width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Mind map of ${escapeHtml(r.root)}">
<defs><linearGradient id="mmg" x1="0" y1="0" x2="1" y2="1"><stop offset="0" style="stop-color:var(--brand)"/><stop offset="1" style="stop-color:var(--brand2)"/></linearGradient></defs>
<g fill="none" stroke="currentColor" stroke-opacity=".22" stroke-width="1.6">${paths}</g>
${pill(x0, cy, rootW, 36, 'url(#mmg)', '', r.root, 13.5, 750, 'var(--on-brand)')}${nodes}</svg>`;
}
/* ---------- notes ---------- */
let NB_TAB = 'history', NOTES = [], NOTE_EDIT = null;
function setNbTab(t) {
NB_TAB = t;
$('nb-tab-history').classList.toggle('on', t === 'history');
$('nb-tab-history').setAttribute('aria-selected', String(t === 'history'));
$('nb-tab-notes').classList.toggle('on', t === 'notes');
$('nb-tab-notes').setAttribute('aria-selected', String(t === 'notes'));
$('notebook-list').classList.toggle('hidden', t !== 'history');
$('notes-pane').classList.toggle('hidden', t !== 'notes');
if (t === 'notes') loadNotes(); else loadNotebook();
}
function fmtDate(s) {
const d = new Date(s);
return isNaN(d) ? String(s || '') : d.toLocaleDateString(undefined, {month: 'short', day: 'numeric'});
}
async function loadNotes() {
const box = $('notes-list');
box.innerHTML = skeleton(3, 86);
try { NOTES = await api('/api/notes'); renderNotes(); }
catch (e) { box.innerHTML = empty('Could not load your notes', e.message); }
}
function renderNotes() {
const box = $('notes-list');
if (!NOTES.length) {
box.innerHTML = empty('No notes yet', 'Start one with “New note”, or hit “Save to notes” under any answer.');
return;
}
box.innerHTML = NOTES.map((n, i) => `<div class="nb-card note-card" data-act="note" data-i="${i}">
<button class="note-tgl" data-act="note" data-i="${i}">
<span class="nb-q grow">${escapeHtml(n.title || 'Untitled note')}</span>
<span class="note-date">${escapeHtml(fmtDate(n.updated || n.created))}</span>
</button>
<div class="nb-prev n3">${escapeHtml((n.body || '').slice(0, 400))}</div>
<div class="nb-full">${escapeHtml(n.body || '')}</div>
<div class="note-acts">
<button class="btn sm" data-act="note-edit" data-i="${i}">Edit</button>
<button class="btn-danger" data-act="note-del" data-i="${i}">Delete</button>
</div>
</div>`).join('');
}
function openNoteEditor(n) {
NOTE_EDIT = n ? n.id : null;
$('ne-title').value = n ? (n.title || '') : '';
$('ne-body').value = n ? (n.body || '') : '';
$('note-editor').classList.remove('hidden');
$('note-editor').scrollIntoView({behavior: 'smooth', block: 'nearest'});
$('ne-title').focus();
}
function closeNoteEditor() {
NOTE_EDIT = null;
$('note-editor').classList.add('hidden');
$('ne-title').value = ''; $('ne-body').value = '';
}
async function saveNote() {
const title = $('ne-title').value.trim() || 'Untitled note';
const body = $('ne-body').value.trim();
if (!body) { toast('Write something first.', 'err'); $('ne-body').focus(); return; }
const btn = $('ne-save'); btn.disabled = true;
try {
if (NOTE_EDIT != null) await api('/api/notes/update', {method: 'POST', body: JSON.stringify({id: NOTE_EDIT, title, body})});
else await api('/api/notes', {method: 'POST', body: JSON.stringify({title, body})});
toast('Note saved.', 'ok'); closeNoteEditor(); loadNotes();
} catch (e) { toast(e.message, 'err'); }
btn.disabled = false;
}
function saveAnswerNote(el) {
const p = NOTE_OF[el.dataset.a]; if (!p) return;
el.disabled = true;
api('/api/notes', {method: 'POST', body: JSON.stringify(p)})
.then(() => { toast('Saved to notes', 'ok'); const l = el.querySelector('.gl'); if (l) l.textContent = 'Saved'; })
.catch(err => { el.disabled = false; toast(err.message, 'err'); });
}
/* ---------- one delegated listener for dynamic content ---------- */
document.addEventListener('click', e => {
const el = e.target.closest('[data-act]');
if (!el) return;
const act = el.dataset.act;
if (act === 'mod') { const m = MODULES[+el.dataset.i]; if (m) openModule(m); }
else if (act === 'topic') { const t = TOPICS[+el.dataset.i]; if (t) selectTopic(t, el); }
else if (act === 'src') { const s = (SRC[el.dataset.a] || []).find(x => x.sid === el.dataset.s); if (s) openSrc(s); }
else if (act === 'toggle-src') {
const list = el.nextElementSibling, hiddenNow = list.classList.toggle('hidden');
el.classList.toggle('open', !hiddenNow);
el.querySelector('.stl').textContent = (hiddenNow ? 'Show' : 'Hide') + ' sources (' + el.dataset.n + ')';
}
else if (act === 'dl') { window.open('/api/download?file=' + encodeURIComponent(el.dataset.file) + '&t=' + TOKEN, '_blank', 'noopener'); }
else if (act === 'nb') { el.classList.toggle('open'); }
else if (act === 'nb-pin') {
const it = HIST.find(x => x.id === el.dataset.id); if (!it) return;
const want = !it.pinned; el.disabled = true;
api('/api/history/pin', {method: 'POST', body: JSON.stringify({id: it.id, pinned: want})})
.then(() => { it.pinned = want; renderNotebook(); toast(want ? 'Pinned to the top of your notebook.' : 'Unpinned.', 'ok'); })
.catch(err => { el.disabled = false; toast(err.message, 'err'); });
}
else if (act === 'nb-del') {
if (!armConfirm(el)) return;
const id = el.dataset.id;
api('/api/history/delete', {method: 'POST', body: JSON.stringify({id})})
.then(() => { HIST = HIST.filter(x => x.id !== id); renderNotebook(); toast('Entry deleted.', 'ok'); })
.catch(err => toast(err.message, 'err'));
}
else if (act === 'nb-cont') { continueThread(el.dataset.id); }
else if (act === 'inv-copy') { copyText(el.dataset.link, 'Link copied'); }
else if (act === 'inv-revoke') {
if (!armConfirm(el)) return;
const tk = el.dataset.token;
api('/api/admin/invites/revoke', {method: 'POST', body: JSON.stringify({token: tk})})
.then(() => {
INVITES = INVITES.filter(v => v.token !== tk); renderInvites();
if ($('inv-new').dataset.token === tk) $('inv-new').innerHTML = '';
toast('Link revoked.', 'ok');
})
.catch(err => toast(err.message, 'err'));
}
else if (act === 'goto-ask') { showView('ask'); }
else if (act === 'goto-topics') { showView('topics'); }
else if (act === 'qopt') { pickQuizOpt(+el.dataset.j); }
else if (act === 'qnext') { quizNext(); }
else if (act === 'quiz-again') { startQuiz(); }
else if (act === 'quiz-close') { QUIZ = null; $('t-out').innerHTML = ''; }
else if (act === 'card-show') { showCardBack(); }
else if (act === 'grade') { gradeCard(el.dataset.good === '1'); }
else if (act === 'save-note') { saveAnswerNote(el); }
else if (act === 'note') { const c = el.closest('.note-card'); if (c) c.classList.toggle('open'); }
else if (act === 'note-edit') { const n = NOTES[+el.dataset.i]; if (n) openNoteEditor(n); }
else if (act === 'note-del') {
if (!armConfirm(el)) return;
const n = NOTES[+el.dataset.i]; if (!n) return;
if (NOTE_EDIT === n.id) closeNoteEditor();
api('/api/notes/delete', {method: 'POST', body: JSON.stringify({id: n.id})})
.then(() => { toast('Note deleted.', 'ok'); loadNotes(); })
.catch(err => toast(err.message, 'err'));
}
else if (act === 'copy-invite') {
const v = $('invite-code').value;
(navigator.clipboard ? navigator.clipboard.writeText(v) : Promise.reject())
.then(() => toast('Invite code copied.', 'ok'))
.catch(() => { $('invite-code').select(); document.execCommand('copy'); toast('Invite code copied.', 'ok'); });
}
else if (act === 'rm-user') {
if (!armConfirm(el)) return;
api('/api/admin/remove', {method: 'POST', body: JSON.stringify({username: el.dataset.user})})
.then(() => { toast('Removed ' + el.dataset.user + '.', 'ok'); loadAdmin(); })
.catch(err => toast(err.message, 'err'));
}
else if (act === 'del-up') {
if (!armConfirm(el)) return;
api('/api/admin/uploads/delete', {method: 'POST', body: JSON.stringify({path: el.dataset.path})})
.then(() => { toast('Upload deleted.', 'ok'); loadAdmin(); })
.catch(err => toast(err.message, 'err'));
}
});
document.addEventListener('change', e => {
const sel = e.target.closest('.role-sel');
if (sel) {
api('/api/admin/role', {method: 'POST', body: JSON.stringify({username: sel.dataset.user, role: sel.value})})
.then(() => toast(sel.dataset.user + ' is now ' + sel.value + '.', 'ok'))
.catch(err => { toast(err.message, 'err'); loadAdmin(); });
}
});
/* ---------- static wiring + boot ---------- */
function wire() {
$('tab-login').onclick = () => setTab('login');
$('tab-register').onclick = () => setTab('register');
$('auth-btn').onclick = doAuth;
for (const id of ['f-invite', 'f-user', 'f-pass'])
$(id).addEventListener('keydown', e => { if (e.key === 'Enter') doAuth(); });
$('logout-btn').onclick = logout;
$('theme-toggle').onclick = toggleTheme;
for (const v of VIEWS) $('nav-' + v).onclick = () => showView(v);
$('back-modules').onclick = closeModule;
$('t-sum').onclick = summarizeTopic;
$('t-quiz').onclick = startQuiz;
$('t-cards').onclick = genCards;
$('t-map').onclick = loadMap;
$('nb-tab-history').onclick = () => setNbTab('history');
$('nb-tab-notes').onclick = () => setNbTab('notes');
$('note-new').onclick = () => openNoteEditor(null);
$('ne-cancel').onclick = closeNoteEditor;
$('ne-save').onclick = saveNote;
$('t-ask').onclick = askTopic;
$('t-q').addEventListener('keydown', e => { if (e.key === 'Enter') askTopic(); });
$('ask-btn').onclick = doAsk;
$('ask-q').addEventListener('keydown', e => { if (e.key === 'Enter') doAsk(); });
$('thread-clear').onclick = clearThread;
$('inv-create').onclick = mintInvite;
$('inv-label').addEventListener('keydown', e => { if (e.key === 'Enter') mintInvite(); });
const dz = $('drop-zone');
dz.onclick = pickFile;
$('file-input').addEventListener('change', e => handleFile(e.target.files[0]));
['dragenter', 'dragover'].forEach(ev => dz.addEventListener(ev, e => { e.preventDefault(); dz.classList.add('drag'); }));
['dragleave', 'drop'].forEach(ev => dz.addEventListener(ev, e => { e.preventDefault(); dz.classList.remove('drag'); }));
dz.addEventListener('drop', e => handleFile(e.dataTransfer.files[0]));
}
function applyInviteParam() { // /?invite=<token> → prefilled register tab
const tk = new URLSearchParams(location.search).get('invite');
if (!tk) return; // param stays in the URL — harmless
setTab('register');
const f = $('f-invite'); f.value = tk; f.readOnly = true;
$('invite-note').classList.remove('hidden');
$('f-user').focus();
}
(async function init() {
initTheme(); wire();
try {
const me = await api('/api/me');
if (me.username) { enterApp(me.username, me.role); return; }
} catch (e) {}
$('boot').classList.add('hidden');
$('auth').classList.remove('hidden');
applyInviteParam();
})();
</script>
</body>
</html>