Spaces:
Sleeping
Sleeping
| <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} | |
| .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 & 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) | |
| ============================================================ */ | |
| ; | |
| 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 => | |
| ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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> | |