SMKBK1 commited on
Commit
cc49b6f
·
verified ·
1 Parent(s): f414b58

Update index.html

Browse files
Files changed (1) hide show
  1. index.html +1241 -148
index.html CHANGED
@@ -4,163 +4,1256 @@
4
  <meta charset="UTF-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
  <title>AuraStudy // Placement Console</title>
7
- <link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@600;700&family=Rajdhani:wght@500;600;700&family=Share+Tech+Mono&display=swap" rel="stylesheet">
 
 
8
  <style>
9
- :root { --bg:#05080f; --panel:#0d1524; --line:rgba(0,245,255,.18); --cyan:#00f5ff; --text:#e6f7ff; --muted:#8ba3b8; --faint:#4d6478; --lime:#b8ff3c; --amber:#ffb020; --red:#ff4d6d; }
10
- * { box-sizing:border-box; }
11
- body { margin:0; background:var(--bg); color:var(--text); font-family:Rajdhani,system-ui,sans-serif; }
12
- header, aside, main, section { border-color:var(--line); }
13
- header { display:flex; gap:12px; align-items:center; padding:12px 16px; border-bottom:1px solid var(--line); background:var(--panel); }
14
- .logo { width:36px; height:36px; display:grid; place-items:center; background:var(--cyan); color:#041016; font-family:Orbitron,sans-serif; font-size:12px; font-weight:700; }
15
- h1 { margin:0; font-family:Orbitron,sans-serif; font-size:14px; letter-spacing:.14em; color:var(--cyan); }
16
- .sub { font-family:"Share Tech Mono",monospace; font-size:11px; letter-spacing:.16em; color:var(--faint); }
17
- .wrap { display:grid; grid-template-columns:300px 1fr; min-height:calc(100vh - 62px); }
18
- aside { border-right:1px solid var(--line); background:var(--panel); padding:16px; }
19
- main { padding:16px; }
20
- button, input, select { font:600 14px Rajdhani,sans-serif; }
21
- button { min-height:40px; width:100%; margin-top:8px; border:1px solid var(--line); background:transparent; color:var(--text); letter-spacing:.08em; text-transform:uppercase; cursor:pointer; }
22
- button.primary { background:var(--cyan); color:#041016; border-color:var(--cyan); }
23
- button:disabled { opacity:.4; cursor:not-allowed; }
24
- label.drop { display:block; border:1px dashed var(--line); padding:22px 10px; text-align:center; cursor:pointer; }
25
- input[type=file] { display:none; }
26
- input[type=url], input[type=text], input[type=search], select { width:100%; min-height:36px; margin-top:4px; background:var(--bg); color:var(--text); border:1px solid var(--line); padding:0 8px; }
27
- .kpis, .classes { display:grid; gap:10px; }
28
- .kpis { grid-template-columns:repeat(4,1fr); margin-bottom:12px; }
29
- .classes { grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); }
30
- .card, .kpi { border:1px solid var(--line); background:var(--panel); padding:10px 12px; }
31
- .kpi b { display:block; font-family:Orbitron,sans-serif; font-size:22px; }
32
- table { width:100%; border-collapse:collapse; font-size:13px; }
33
- th, td { text-align:left; padding:6px 8px; border-bottom:1px solid var(--line); white-space:nowrap; }
34
- th { color:var(--faint); font-size:11px; letter-spacing:.08em; text-transform:uppercase; }
35
- .err { color:var(--red); margin-bottom:8px; }
36
- .note { color:var(--muted); font-size:13px; line-height:1.45; }
37
- h2 { margin:0 0 8px; font-family:Orbitron,sans-serif; font-size:12px; letter-spacing:.12em; color:var(--cyan); }
38
- .tools { display:flex; gap:8px; align-items:center; margin:8px 0; }
39
- .tools button { width:auto; padding:0 12px; }
40
- @media (max-width:800px) { .wrap { grid-template-columns:1fr; } .kpis { grid-template-columns:1fr 1fr; } }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
  </style>
42
  </head>
43
  <body>
44
- <header>
45
- <div class="logo">AS</div>
46
- <div><h1>AURASTUDY</h1><div class="sub">PLACEMENT // CONSOLE</div></div>
47
- </header>
48
- <div class="wrap">
49
- <aside>
50
- <label class="drop">DROP CSV
51
- <div class="sub" id="fileName">Name + at least 5 subject scores</div>
52
- <input type="file" id="file" accept=".csv,text/csv">
53
- </label>
54
- <button class="primary" id="run" disabled>Run predictions</button>
55
- <button id="export" disabled>Export class CSV</button>
56
- <p class="note">Science is ordered High, then Medium, then Borderline, then by Science %. The stronger half goes to 4A and the rest to 4B (sizes differ by at most one). Account is 4C, Reka Cipta is 4D, Art / Other is SASTERA.</p>
57
- <label class="note">Sheet URL<input type="url" id="sheet" placeholder="docs.google.com/spreadsheets/d/…"></label>
58
- <button id="runSheet">Run on sheet</button>
59
- <div class="err" id="err"></div>
60
- </aside>
61
- <main>
62
- <div id="empty" class="card"><h2>NO BATCH LOADED</h2><p class="note">Import a class CSV or link a Google Sheet. Five class tables appear after the prediction.</p></div>
63
- <div id="out" hidden>
64
- <div class="kpis" id="kpis"></div>
65
- <section class="card">
66
- <h2>FIVE CLASS LISTS</h2>
67
- <div class="classes" id="classes"></div>
68
- </section>
69
- <section class="card" style="margin-top:12px">
70
- <h2>PREDICTIONS</h2>
71
- <div style="overflow:auto;max-height:360px"><table id="pred"></table></div>
72
- </section>
73
  </div>
74
- </main>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
  </div>
 
 
 
 
76
  <script>
77
- const API = "https://syuanteng315-spm-predictor.hf.space";
78
- const $ = (id) => document.getElementById(id);
79
- let fileText = "", fileName = "class.csv", lastPlan = null;
80
- $("file").onchange = () => {
81
- const f = $("file").files[0]; if (!f) return;
82
- fileName = f.name; $("fileName").textContent = f.name; $("run").disabled = false;
83
- f.text().then((t) => { fileText = t; });
84
- };
85
- function esc(s){ return String(s??"").replace(/[&<>"]/g, (c)=>({"&":"&","<":"<",">":">",'"':"""}[c])); }
86
- function isScience(s){ s=s.toLowerCase(); return s.includes("science") && !s.includes("reka"); }
87
- function nameOf(r){ return String(r.Name||r.Nama||""); }
88
- function assign(rows){
89
- const ok = rows.filter((r)=>String(r.Status||"").toUpperCase()==="OK" && r["Recommended Stream"]);
90
- const rank = {high:0,medium:1,borderline:2,low:3};
91
- const science = ok.filter((r)=>isScience(String(r["Recommended Stream"]))).sort((a,b)=>{
92
- const d=(rank[String(a.Confidence||"").toLowerCase()]??9)-(rank[String(b.Confidence||"").toLowerCase()]??9);
93
- if(d) return d;
94
- return (Number(b["Prob Science %"])||0)-(Number(a["Prob Science %"])||0);
 
 
 
 
95
  });
96
- const cut = Math.ceil(science.length/2);
97
- const byName = (list)=>list.slice().sort((a,b)=>nameOf(a).localeCompare(nameOf(b)));
98
- return {
99
- "4A": science.slice(0,cut),
100
- "4B": science.slice(cut),
101
- "4C": byName(ok.filter((r)=>/account/i.test(r["Recommended Stream"]))),
102
- "4D": byName(ok.filter((r)=>/reka/i.test(r["Recommended Stream"]))),
103
- "SASTERA": byName(ok.filter((r)=>{ const s=String(r["Recommended Stream"]); return !isScience(s)&&!/account|reka/i.test(s); }))
104
- };
105
- }
106
- function classOf(plan, row){
107
- for (const id of Object.keys(plan)) if (plan[id].includes(row)) return id;
108
- return "—";
109
- }
110
- function show(data){
111
- const rows = data.rows || data.rows_preview || [];
112
- const plan = assign(rows); lastPlan = plan;
113
- $("empty").hidden = true; $("out").hidden = false; $("export").disabled = false;
114
- $("kpis").innerHTML = [
115
- ["Students", data.total ?? rows.length],
116
- ["Predicted", data.ok ?? 0],
117
- ["4A / 4B", plan["4A"].length+" / "+plan["4B"].length],
118
- ["Unplaced", rows.length - Object.values(plan).reduce((n,a)=>n+a.length,0)]
119
- ].map(([l,v])=>`<div class="kpi"><span class="sub">${l}</span><b>${esc(v)}</b></div>`).join("");
120
- const blurbs = {"4A":"Science · stronger half","4B":"Science · rest","4C":"Account","4D":"Reka Cipta","SASTERA":"Art / Other"};
121
- $("classes").innerHTML = Object.keys(plan).map((id)=>{
122
- const body = plan[id].map((r,i)=>`<tr><td>${i+1}</td><td>${esc(nameOf(r)||"—")}</td><td>${esc(r.Confidence||"—")}</td></tr>`).join("") || `<tr><td colspan="3">None</td></tr>`;
123
- return `<div class="card"><h2>${id} · ${plan[id].length}</h2><div class="sub">${blurbs[id]}</div><table><tr><th>#</th><th>Name</th><th>Conf</th></tr>${body}</table></div>`;
124
- }).join("");
125
- $("pred").innerHTML = `<tr><th>Class</th><th>Name</th><th>Stream</th><th>Confidence</th><th>Science %</th><th>Status</th></tr>` +
126
- rows.map((r)=>`<tr><td>${classOf(plan,r)}</td><td>${esc(nameOf(r))}</td><td>${esc(r["Recommended Stream"])}</td><td>${esc(r.Confidence)}</td><td>${esc(r["Prob Science %"])}</td><td>${esc(r.Status)}</td></tr>`).join("");
127
- }
128
- async function fail(res){
129
- const d = await res.json().catch(()=>({}));
130
- throw new Error(typeof d.detail==="string"?d.detail:("HTTP "+res.status));
131
- }
132
- $("run").onclick = async () => {
133
- $("err").textContent=""; $("run").disabled=true;
134
- try {
135
- const fd = new FormData();
136
- fd.append("file", new Blob([fileText],{type:"text/csv"}), fileName);
137
- const res = await fetch(API+"/bulk/preview",{method:"POST",body:fd});
138
- if(!res.ok) await fail(res);
139
- show(await res.json());
140
- } catch(e){ $("err").textContent = e.message; }
141
- finally { $("run").disabled=false; }
142
- };
143
- $("runSheet").onclick = async () => {
144
- const url = $("sheet").value.trim();
145
- if(!url){ $("err").textContent="Paste a Google Sheet URL."; return; }
146
- $("err").textContent="";
147
- try {
148
- const res = await fetch(API+"/bulk/sheet",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({sheet_url:url,worksheet:null,write_back:false})});
149
- if(!res.ok) await fail(res);
150
- show(await res.json());
151
- } catch(e){ $("err").textContent = e.message; }
152
- };
153
- $("export").onclick = () => {
154
- if(!lastPlan) return;
155
- const lines = ["Class,Seat,Name,Stream,Confidence"];
156
- for (const id of Object.keys(lastPlan)) lastPlan[id].forEach((r,i)=>{
157
- const cell = (v)=>`"${String(v??"").replace(/"/g,'""')}"`;
158
- lines.push([id,i+1,nameOf(r),r["Recommended Stream"],r.Confidence].map(cell).join(","));
159
  });
160
- const a = document.createElement("a");
161
- a.href = URL.createObjectURL(new Blob([lines.join("\n")],{type:"text/csv"}));
162
- a.download = "aurastudy_classes.csv"; a.click();
163
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
164
  </script>
165
  </body>
166
  </html>
 
4
  <meta charset="UTF-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
  <title>AuraStudy // Placement Console</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
+ <link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@500;600;700&family=Share+Tech+Mono&family=Rajdhani:wght@400;500;600;700&display=swap" rel="stylesheet">
10
  <style>
11
+ :root {
12
+ --bg: #05080f;
13
+ --bg-2: #0a101c;
14
+ --panel: #0d1524;
15
+ --panel-2: #111b2e;
16
+ --line: rgba(0, 245, 255, 0.12);
17
+ --line-strong: rgba(0, 245, 255, 0.28);
18
+ --cyan: #00f5ff;
19
+ --cyan-dim: rgba(0, 245, 255, 0.12);
20
+ --cyan-glow: rgba(0, 245, 255, 0.45);
21
+ --magenta: #ff2bd6;
22
+ --magenta-dim: rgba(255, 43, 214, 0.12);
23
+ --lime: #b8ff3c;
24
+ --lime-dim: rgba(184, 255, 60, 0.12);
25
+ --amber: #ffb020;
26
+ --amber-dim: rgba(255, 176, 32, 0.12);
27
+ --red: #ff4d6d;
28
+ --red-dim: rgba(255, 77, 109, 0.14);
29
+ --violet: #a78bfa;
30
+ --violet-dim: rgba(167, 139, 250, 0.14);
31
+ --text: #e6f7ff;
32
+ --text-2: #8ba3b8;
33
+ --text-3: #4d6478;
34
+ --font: "Rajdhani", system-ui, sans-serif;
35
+ --display: "Orbitron", sans-serif;
36
+ --mono: "Share Tech Mono", ui-monospace, monospace;
37
+ --r: 4px;
38
+ }
39
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
40
+ html, body { height: 100%; }
41
+ body {
42
+ font-family: var(--font);
43
+ font-size: 14.5px;
44
+ color: var(--text);
45
+ background: var(--bg);
46
+ line-height: 1.4;
47
+ overflow: hidden;
48
+ }
49
+ /* cyber grid + scan */
50
+ body::before {
51
+ content: "";
52
+ position: fixed;
53
+ inset: 0;
54
+ background:
55
+ linear-gradient(rgba(0, 245, 255, 0.03) 1px, transparent 1px),
56
+ linear-gradient(90deg, rgba(0, 245, 255, 0.03) 1px, transparent 1px);
57
+ background-size: 48px 48px;
58
+ pointer-events: none;
59
+ z-index: 0;
60
+ }
61
+ body::after {
62
+ content: "";
63
+ position: fixed;
64
+ inset: 0;
65
+ background: repeating-linear-gradient(
66
+ 0deg,
67
+ transparent,
68
+ transparent 2px,
69
+ rgba(0, 0, 0, 0.12) 2px,
70
+ rgba(0, 0, 0, 0.12) 4px
71
+ );
72
+ pointer-events: none;
73
+ z-index: 50;
74
+ opacity: 0.35;
75
+ }
76
+ .app {
77
+ position: relative;
78
+ z-index: 1;
79
+ display: grid;
80
+ grid-template-rows: 54px 1fr;
81
+ height: 100vh;
82
+ min-width: 1100px;
83
+ }
84
+ /* ── Titlebar ── */
85
+ .titlebar {
86
+ display: flex;
87
+ align-items: center;
88
+ gap: 16px;
89
+ padding: 0 18px;
90
+ background: linear-gradient(180deg, #0e1628 0%, #0a101c 100%);
91
+ border-bottom: 1px solid var(--line-strong);
92
+ box-shadow: 0 0 24px rgba(0, 245, 255, 0.06);
93
+ z-index: 20;
94
+ }
95
+ .brand {
96
+ display: flex;
97
+ align-items: center;
98
+ gap: 12px;
99
+ min-width: 260px;
100
+ }
101
+ .logo {
102
+ width: 32px;
103
+ height: 32px;
104
+ clip-path: polygon(20% 0%, 100% 0%, 100% 80%, 80% 100%, 0% 100%, 0% 20%);
105
+ background: linear-gradient(135deg, var(--cyan), #007a88);
106
+ color: #05080f;
107
+ font-family: var(--display);
108
+ font-weight: 700;
109
+ font-size: 11px;
110
+ display: grid;
111
+ place-items: center;
112
+ box-shadow: 0 0 16px var(--cyan-glow);
113
+ }
114
+ .brand-text {
115
+ font-family: var(--display);
116
+ font-size: 13px;
117
+ font-weight: 600;
118
+ letter-spacing: 0.08em;
119
+ color: var(--cyan);
120
+ text-shadow: 0 0 12px var(--cyan-glow);
121
+ }
122
+ .brand-text span {
123
+ display: block;
124
+ font-family: var(--mono);
125
+ font-size: 10px;
126
+ font-weight: 400;
127
+ letter-spacing: 0.16em;
128
+ color: var(--text-3);
129
+ text-shadow: none;
130
+ margin-top: 1px;
131
+ }
132
+ .title-center { flex: 1; display: flex; justify-content: center; }
133
+ .seg {
134
+ display: inline-flex;
135
+ gap: 0;
136
+ border: 1px solid var(--line-strong);
137
+ background: var(--bg);
138
+ }
139
+ .seg button {
140
+ border: none;
141
+ background: transparent;
142
+ padding: 8px 22px;
143
+ font: 600 13px var(--font);
144
+ letter-spacing: 0.1em;
145
+ text-transform: uppercase;
146
+ color: var(--text-3);
147
+ cursor: pointer;
148
+ position: relative;
149
+ }
150
+ .seg button + button { border-left: 1px solid var(--line); }
151
+ .seg button:hover { color: var(--cyan); }
152
+ .seg button.on {
153
+ color: var(--bg);
154
+ background: var(--cyan);
155
+ box-shadow: 0 0 20px var(--cyan-glow);
156
+ }
157
+ .title-right {
158
+ display: flex;
159
+ align-items: center;
160
+ gap: 14px;
161
+ min-width: 260px;
162
+ justify-content: flex-end;
163
+ }
164
+ .status {
165
+ display: inline-flex;
166
+ align-items: center;
167
+ gap: 7px;
168
+ font-family: var(--mono);
169
+ font-size: 11px;
170
+ letter-spacing: 0.06em;
171
+ color: var(--text-3);
172
+ }
173
+ .status i {
174
+ width: 8px;
175
+ height: 8px;
176
+ border-radius: 1px;
177
+ background: var(--text-3);
178
+ box-shadow: 0 0 0 1px rgba(0,0,0,.5);
179
+ }
180
+ .status.ok { color: var(--lime); }
181
+ .status.ok i {
182
+ background: var(--lime);
183
+ box-shadow: 0 0 10px var(--lime), 0 0 0 1px rgba(0,0,0,.4);
184
+ animation: blink 1.6s step-end infinite;
185
+ }
186
+ .status.warn { color: var(--amber); }
187
+ .status.warn i { background: var(--amber); box-shadow: 0 0 8px var(--amber); }
188
+ @keyframes blink {
189
+ 0%, 100% { opacity: 1; }
190
+ 50% { opacity: 0.25; }
191
+ }
192
+ /* ── Body ── */
193
+ .body {
194
+ display: grid;
195
+ grid-template-columns: 300px 1fr;
196
+ min-height: 0;
197
+ overflow: hidden;
198
+ }
199
+ .sidebar {
200
+ background: var(--panel);
201
+ border-right: 1px solid var(--line);
202
+ display: flex;
203
+ flex-direction: column;
204
+ min-height: 0;
205
+ position: relative;
206
+ }
207
+ .sidebar::before {
208
+ content: "";
209
+ position: absolute;
210
+ top: 0; right: 0; bottom: 0;
211
+ width: 1px;
212
+ background: linear-gradient(180deg, var(--cyan), transparent 40%, var(--magenta));
213
+ opacity: 0.35;
214
+ pointer-events: none;
215
+ }
216
+ .sidebar-scroll {
217
+ flex: 1;
218
+ overflow-y: auto;
219
+ padding: 16px 14px 28px;
220
+ }
221
+ .side-label {
222
+ font-family: var(--mono);
223
+ font-size: 10px;
224
+ letter-spacing: 0.18em;
225
+ text-transform: uppercase;
226
+ color: var(--cyan);
227
+ margin-bottom: 10px;
228
+ padding-left: 2px;
229
+ text-shadow: 0 0 8px var(--cyan-dim);
230
+ }
231
+ .side-section { margin-bottom: 22px; }
232
+ .drop {
233
+ position: relative;
234
+ border: 1px dashed var(--line-strong);
235
+ background: var(--bg);
236
+ padding: 26px 12px;
237
+ text-align: center;
238
+ cursor: pointer;
239
+ clip-path: polygon(0 0, calc(100% - 12px) 0, 100% 12px, 100% 100%, 12px 100%, 0 calc(100% - 12px));
240
+ transition: border-color .15s, box-shadow .15s;
241
+ }
242
+ .drop:hover, .drop.drag {
243
+ border-color: var(--cyan);
244
+ box-shadow: inset 0 0 24px var(--cyan-dim), 0 0 16px var(--cyan-dim);
245
+ }
246
+ .drop input { position: absolute; inset: 0; opacity: 0; cursor: pointer; }
247
+ .drop-ico {
248
+ font-family: var(--display);
249
+ font-size: 18px;
250
+ color: var(--cyan);
251
+ margin-bottom: 8px;
252
+ text-shadow: 0 0 12px var(--cyan-glow);
253
+ }
254
+ .drop-title {
255
+ font-weight: 600;
256
+ font-size: 14px;
257
+ letter-spacing: 0.04em;
258
+ color: var(--text);
259
+ }
260
+ .drop-sub {
261
+ font-family: var(--mono);
262
+ font-size: 11px;
263
+ color: var(--text-3);
264
+ margin-top: 4px;
265
+ }
266
+ .file-row {
267
+ display: none;
268
+ align-items: center;
269
+ gap: 8px;
270
+ margin-top: 10px;
271
+ padding: 8px 10px;
272
+ background: var(--cyan-dim);
273
+ border: 1px solid var(--line-strong);
274
+ }
275
+ .file-row.show { display: flex; }
276
+ .file-row .fn {
277
+ flex: 1;
278
+ font-family: var(--mono);
279
+ font-size: 11.5px;
280
+ color: var(--cyan);
281
+ overflow: hidden;
282
+ text-overflow: ellipsis;
283
+ white-space: nowrap;
284
+ }
285
+ .file-row .fs { font-family: var(--mono); font-size: 11px; color: var(--text-3); }
286
+ .field { margin-bottom: 12px; }
287
+ .field label {
288
+ display: block;
289
+ font-family: var(--mono);
290
+ font-size: 10.5px;
291
+ letter-spacing: 0.1em;
292
+ text-transform: uppercase;
293
+ color: var(--text-3);
294
+ margin-bottom: 5px;
295
+ }
296
+ .field input {
297
+ width: 100%;
298
+ height: 34px;
299
+ padding: 0 10px;
300
+ border: 1px solid var(--line);
301
+ background: var(--bg);
302
+ font: 500 13px var(--mono);
303
+ color: var(--text);
304
+ border-radius: 0;
305
+ }
306
+ .field input:focus {
307
+ outline: none;
308
+ border-color: var(--cyan);
309
+ box-shadow: 0 0 0 1px var(--cyan), 0 0 12px var(--cyan-dim);
310
+ }
311
+ .field input::placeholder { color: var(--text-3); }
312
+ .switch-row {
313
+ display: flex;
314
+ align-items: center;
315
+ justify-content: space-between;
316
+ padding: 8px 0;
317
+ font-size: 13.5px;
318
+ font-weight: 500;
319
+ color: var(--text-2);
320
+ }
321
+ .switch {
322
+ position: relative;
323
+ width: 38px;
324
+ height: 18px;
325
+ }
326
+ .switch input { opacity: 0; width: 0; height: 0; }
327
+ .switch span {
328
+ position: absolute;
329
+ inset: 0;
330
+ background: var(--bg);
331
+ border: 1px solid var(--line-strong);
332
+ cursor: pointer;
333
+ transition: .15s;
334
+ }
335
+ .switch span::after {
336
+ content: "";
337
+ position: absolute;
338
+ width: 10px;
339
+ height: 10px;
340
+ left: 3px;
341
+ top: 3px;
342
+ background: var(--text-3);
343
+ transition: .15s;
344
+ }
345
+ .switch input:checked + span {
346
+ border-color: var(--cyan);
347
+ box-shadow: 0 0 10px var(--cyan-dim);
348
+ }
349
+ .switch input:checked + span::after {
350
+ transform: translateX(18px);
351
+ background: var(--cyan);
352
+ box-shadow: 0 0 8px var(--cyan);
353
+ }
354
+ .stack { display: flex; flex-direction: column; gap: 8px; margin-top: 12px; }
355
+ .btn {
356
+ display: inline-flex;
357
+ align-items: center;
358
+ justify-content: center;
359
+ height: 36px;
360
+ padding: 0 14px;
361
+ font: 600 13px var(--font);
362
+ letter-spacing: 0.08em;
363
+ text-transform: uppercase;
364
+ cursor: pointer;
365
+ border: 1px solid transparent;
366
+ transition: .12s;
367
+ width: 100%;
368
+ clip-path: polygon(0 0, calc(100% - 8px) 0, 100% 8px, 100% 100%, 8px 100%, 0 calc(100% - 8px));
369
+ }
370
+ .btn:disabled { opacity: .35; cursor: not-allowed; }
371
+ .btn-primary {
372
+ background: var(--cyan);
373
+ color: #05080f;
374
+ border-color: var(--cyan);
375
+ box-shadow: 0 0 18px var(--cyan-dim);
376
+ }
377
+ .btn-primary:hover:not(:disabled) {
378
+ box-shadow: 0 0 28px var(--cyan-glow);
379
+ filter: brightness(1.08);
380
+ }
381
+ .btn-secondary {
382
+ background: transparent;
383
+ color: var(--amber);
384
+ border-color: rgba(255, 176, 32, 0.45);
385
+ }
386
+ .btn-secondary:hover:not(:disabled) {
387
+ background: var(--amber-dim);
388
+ box-shadow: 0 0 14px var(--amber-dim);
389
+ }
390
+ .btn-ghost {
391
+ background: transparent;
392
+ color: var(--text-2);
393
+ border-color: var(--line);
394
+ }
395
+ .btn-ghost:hover:not(:disabled) {
396
+ color: var(--cyan);
397
+ border-color: var(--line-strong);
398
+ }
399
+ .hint-box {
400
+ font-size: 12px;
401
+ color: var(--text-3);
402
+ line-height: 1.5;
403
+ padding: 10px 12px;
404
+ background: var(--bg);
405
+ border: 1px solid var(--line);
406
+ margin-bottom: 12px;
407
+ }
408
+ .hint-box strong { color: var(--magenta); font-weight: 600; letter-spacing: 0.06em; }
409
+ .hint-box code {
410
+ font-family: var(--mono);
411
+ font-size: 10.5px;
412
+ color: var(--cyan);
413
+ background: var(--cyan-dim);
414
+ padding: 1px 4px;
415
+ }
416
+ .hint-box ol { margin: 6px 0 0 14px; }
417
+ .hint-box li { margin-bottom: 3px; }
418
+ .col-guide {
419
+ font-size: 12px;
420
+ color: var(--text-2);
421
+ line-height: 1.55;
422
+ font-family: var(--mono);
423
+ }
424
+ .col-guide .k {
425
+ display: inline-block;
426
+ font-size: 10.5px;
427
+ color: var(--cyan);
428
+ border: 1px solid var(--line);
429
+ padding: 1px 5px;
430
+ margin: 1px 2px 1px 0;
431
+ background: var(--bg);
432
+ }
433
+ .col-guide .h {
434
+ font-family: var(--mono);
435
+ font-size: 10px;
436
+ letter-spacing: 0.14em;
437
+ text-transform: uppercase;
438
+ color: var(--text-3);
439
+ margin: 10px 0 4px;
440
+ }
441
+ .col-guide .h:first-child { margin-top: 0; }
442
+ /* ── Main ── */
443
+ .main {
444
+ display: flex;
445
+ flex-direction: column;
446
+ min-width: 0;
447
+ min-height: 0;
448
+ background: var(--bg-2);
449
+ }
450
+ .main-toolbar {
451
+ display: flex;
452
+ align-items: center;
453
+ gap: 12px;
454
+ padding: 10px 18px;
455
+ background: var(--panel);
456
+ border-bottom: 1px solid var(--line);
457
+ flex-shrink: 0;
458
+ }
459
+ .main-toolbar h2 {
460
+ font-family: var(--display);
461
+ font-size: 12px;
462
+ font-weight: 600;
463
+ letter-spacing: 0.14em;
464
+ color: var(--text);
465
+ margin-right: auto;
466
+ }
467
+ .main-toolbar .meta {
468
+ font-family: var(--mono);
469
+ font-size: 11px;
470
+ color: var(--text-3);
471
+ letter-spacing: 0.06em;
472
+ }
473
+ .progress {
474
+ height: 2px;
475
+ background: transparent;
476
+ flex-shrink: 0;
477
+ overflow: hidden;
478
+ }
479
+ .progress.on { background: rgba(0, 245, 255, 0.15); }
480
+ .progress.on::after {
481
+ content: "";
482
+ display: block;
483
+ height: 100%;
484
+ width: 28%;
485
+ background: linear-gradient(90deg, transparent, var(--cyan), var(--magenta), transparent);
486
+ box-shadow: 0 0 12px var(--cyan);
487
+ animation: scan 1.1s linear infinite;
488
+ }
489
+ @keyframes scan {
490
+ 0% { transform: translateX(-120%); }
491
+ 100% { transform: translateX(450%); }
492
+ }
493
+ .main-body {
494
+ flex: 1;
495
+ overflow: auto;
496
+ padding: 18px;
497
+ display: flex;
498
+ flex-direction: column;
499
+ gap: 14px;
500
+ }
501
+ .empty {
502
+ flex: 1;
503
+ display: flex;
504
+ flex-direction: column;
505
+ align-items: center;
506
+ justify-content: center;
507
+ text-align: center;
508
+ min-height: 340px;
509
+ background: var(--panel);
510
+ border: 1px solid var(--line);
511
+ position: relative;
512
+ }
513
+ .empty::before {
514
+ content: "";
515
+ position: absolute;
516
+ inset: 0;
517
+ background:
518
+ linear-gradient(var(--line) 1px, transparent 1px),
519
+ linear-gradient(90deg, var(--line) 1px, transparent 1px);
520
+ background-size: 32px 32px;
521
+ opacity: 0.5;
522
+ pointer-events: none;
523
+ }
524
+ .empty-ico {
525
+ position: relative;
526
+ font-family: var(--display);
527
+ font-size: 28px;
528
+ color: var(--cyan);
529
+ text-shadow: 0 0 20px var(--cyan-glow);
530
+ margin-bottom: 14px;
531
+ }
532
+ .empty h3 {
533
+ position: relative;
534
+ font-family: var(--display);
535
+ font-size: 14px;
536
+ letter-spacing: 0.12em;
537
+ margin-bottom: 8px;
538
+ }
539
+ .empty p {
540
+ position: relative;
541
+ font-size: 13.5px;
542
+ color: var(--text-3);
543
+ max-width: 360px;
544
+ line-height: 1.5;
545
+ }
546
+ .kpis {
547
+ display: grid;
548
+ grid-template-columns: repeat(4, 1fr);
549
+ gap: 12px;
550
+ }
551
+ .kpi {
552
+ background: var(--panel);
553
+ border: 1px solid var(--line);
554
+ padding: 14px 16px;
555
+ position: relative;
556
+ overflow: hidden;
557
+ }
558
+ .kpi::after {
559
+ content: "";
560
+ position: absolute;
561
+ top: 0; right: 0;
562
+ width: 24px; height: 24px;
563
+ border-top: 1px solid var(--line-strong);
564
+ border-right: 1px solid var(--line-strong);
565
+ }
566
+ .kpi .l {
567
+ font-family: var(--mono);
568
+ font-size: 10px;
569
+ letter-spacing: 0.14em;
570
+ text-transform: uppercase;
571
+ color: var(--text-3);
572
+ margin-bottom: 6px;
573
+ }
574
+ .kpi .v {
575
+ font-family: var(--display);
576
+ font-size: 24px;
577
+ font-weight: 700;
578
+ letter-spacing: 0.04em;
579
+ color: var(--text);
580
+ text-shadow: 0 0 16px rgba(230, 247, 255, 0.15);
581
+ }
582
+ .kpi .v.g { color: var(--lime); text-shadow: 0 0 14px rgba(184,255,60,.4); }
583
+ .kpi .v.r { color: var(--red); text-shadow: 0 0 14px rgba(255,77,109,.35); }
584
+ .kpi .v.t { color: var(--cyan); text-shadow: 0 0 14px var(--cyan-glow); }
585
+ .panel {
586
+ background: var(--panel);
587
+ border: 1px solid var(--line);
588
+ overflow: hidden;
589
+ }
590
+ .panel-h {
591
+ display: flex;
592
+ align-items: center;
593
+ justify-content: space-between;
594
+ padding: 10px 14px;
595
+ border-bottom: 1px solid var(--line);
596
+ background: var(--panel-2);
597
+ }
598
+ .panel-h h3 {
599
+ font-family: var(--mono);
600
+ font-size: 11px;
601
+ letter-spacing: 0.16em;
602
+ text-transform: uppercase;
603
+ color: var(--cyan);
604
+ }
605
+ .panel-b { padding: 14px; }
606
+ .dist {
607
+ display: grid;
608
+ grid-template-columns: repeat(4, 1fr);
609
+ gap: 16px;
610
+ }
611
+ .dist-item .name {
612
+ font-size: 12px;
613
+ font-weight: 600;
614
+ color: var(--text-2);
615
+ margin-bottom: 6px;
616
+ display: flex;
617
+ justify-content: space-between;
618
+ letter-spacing: 0.03em;
619
+ }
620
+ .dist-item .name b {
621
+ font-family: var(--mono);
622
+ font-weight: 500;
623
+ color: var(--text);
624
+ }
625
+ .dist-bar {
626
+ height: 5px;
627
+ background: var(--bg);
628
+ border: 1px solid var(--line);
629
+ overflow: hidden;
630
+ }
631
+ .dist-bar i {
632
+ display: block;
633
+ height: 100%;
634
+ transition: width .55s cubic-bezier(.22,1,.36,1);
635
+ }
636
+ .dist-bar i.s { background: var(--cyan); box-shadow: 0 0 10px var(--cyan); }
637
+ .dist-bar i.r { background: #3d9eff; box-shadow: 0 0 10px #3d9eff; }
638
+ .dist-bar i.a { background: var(--violet); box-shadow: 0 0 10px var(--violet); }
639
+ .dist-bar i.o { background: var(--amber); box-shadow: 0 0 10px var(--amber); }
640
+ .tools {
641
+ display: flex;
642
+ align-items: center;
643
+ gap: 8px;
644
+ padding: 10px 14px;
645
+ border-bottom: 1px solid var(--line);
646
+ background: var(--panel-2);
647
+ }
648
+ .search { position: relative; flex: 1; max-width: 260px; }
649
+ .search input {
650
+ width: 100%;
651
+ height: 30px;
652
+ padding: 0 10px 0 28px;
653
+ border: 1px solid var(--line);
654
+ background: var(--bg);
655
+ font: 500 12.5px var(--mono);
656
+ color: var(--text);
657
+ }
658
+ .search input:focus {
659
+ outline: none;
660
+ border-color: var(--cyan);
661
+ box-shadow: 0 0 10px var(--cyan-dim);
662
+ }
663
+ .search::before {
664
+ content: "›";
665
+ position: absolute;
666
+ left: 10px;
667
+ top: 50%;
668
+ transform: translateY(-50%);
669
+ color: var(--cyan);
670
+ font-family: var(--mono);
671
+ font-size: 14px;
672
+ }
673
+ .select {
674
+ height: 30px;
675
+ padding: 0 10px;
676
+ border: 1px solid var(--line);
677
+ background: var(--bg);
678
+ font: 500 12.5px var(--mono);
679
+ color: var(--text);
680
+ cursor: pointer;
681
+ }
682
+ .select:focus { outline: none; border-color: var(--cyan); }
683
+ .link {
684
+ margin-left: auto;
685
+ font-family: var(--mono);
686
+ font-size: 11.5px;
687
+ letter-spacing: 0.06em;
688
+ color: var(--cyan);
689
+ text-decoration: none;
690
+ text-shadow: 0 0 8px var(--cyan-dim);
691
+ }
692
+ .link:hover { text-decoration: underline; }
693
+ .table-wrap {
694
+ overflow: auto;
695
+ max-height: calc(100vh - 400px);
696
+ min-height: 180px;
697
+ }
698
+ table {
699
+ width: 100%;
700
+ border-collapse: collapse;
701
+ font-size: 12.5px;
702
+ }
703
+ thead th {
704
+ position: sticky;
705
+ top: 0;
706
+ z-index: 2;
707
+ background: #0a1220;
708
+ text-align: left;
709
+ padding: 9px 12px;
710
+ font-family: var(--mono);
711
+ font-size: 10px;
712
+ font-weight: 500;
713
+ letter-spacing: 0.1em;
714
+ text-transform: uppercase;
715
+ color: var(--text-3);
716
+ border-bottom: 1px solid var(--line-strong);
717
+ white-space: nowrap;
718
+ }
719
+ tbody td {
720
+ padding: 8px 12px;
721
+ border-bottom: 1px solid var(--line);
722
+ color: var(--text);
723
+ white-space: nowrap;
724
+ max-width: 150px;
725
+ overflow: hidden;
726
+ text-overflow: ellipsis;
727
+ font-family: var(--mono);
728
+ font-size: 12px;
729
+ }
730
+ tbody tr:hover td {
731
+ background: rgba(0, 245, 255, 0.04);
732
+ }
733
+ tbody tr:last-child td { border-bottom: none; }
734
+ .tag {
735
+ display: inline-flex;
736
+ align-items: center;
737
+ padding: 2px 8px;
738
+ font-size: 10.5px;
739
+ font-weight: 500;
740
+ font-family: var(--mono);
741
+ letter-spacing: 0.04em;
742
+ border: 1px solid transparent;
743
+ }
744
+ .tag-ok { background: var(--lime-dim); color: var(--lime); border-color: rgba(184,255,60,.3); }
745
+ .tag-fail { background: var(--red-dim); color: var(--red); border-color: rgba(255,77,109,.3); }
746
+ .tag-s { background: var(--cyan-dim); color: var(--cyan); border-color: var(--line-strong); }
747
+ .tag-r { background: rgba(61,158,255,.12); color: #6bb3ff; border-color: rgba(61,158,255,.3); }
748
+ .tag-a { background: var(--violet-dim); color: var(--violet); border-color: rgba(167,139,250,.3); }
749
+ .tag-o { background: var(--amber-dim); color: var(--amber); border-color: rgba(255,176,32,.3); }
750
+ .tag-high { background: var(--lime-dim); color: var(--lime); border-color: rgba(184,255,60,.3); }
751
+ .tag-med { background: var(--amber-dim); color: var(--amber); border-color: rgba(255,176,32,.3); }
752
+ .tag-low { background: var(--red-dim); color: var(--red); border-color: rgba(255,77,109,.3); }
753
+ .table-foot {
754
+ display: flex;
755
+ justify-content: space-between;
756
+ padding: 8px 14px;
757
+ border-top: 1px solid var(--line);
758
+ font-family: var(--mono);
759
+ font-size: 11px;
760
+ letter-spacing: 0.06em;
761
+ color: var(--text-3);
762
+ background: var(--panel-2);
763
+ }
764
+ .toasts {
765
+ position: fixed;
766
+ bottom: 18px;
767
+ right: 18px;
768
+ z-index: 100;
769
+ display: flex;
770
+ flex-direction: column;
771
+ gap: 8px;
772
+ max-width: 360px;
773
+ }
774
+ .toast {
775
+ display: flex;
776
+ gap: 10px;
777
+ align-items: flex-start;
778
+ padding: 12px 14px;
779
+ background: var(--panel);
780
+ border: 1px solid var(--line-strong);
781
+ box-shadow: 0 8px 32px rgba(0,0,0,.5), 0 0 20px var(--cyan-dim);
782
+ font-size: 13px;
783
+ animation: up .2s ease;
784
+ }
785
+ @keyframes up {
786
+ from { opacity: 0; transform: translateY(10px); }
787
+ to { opacity: 1; transform: none; }
788
+ }
789
+ .toast.ok { border-left: 3px solid var(--lime); }
790
+ .toast.err { border-left: 3px solid var(--red); }
791
+ .toast .msg { flex: 1; color: var(--text); line-height: 1.4; font-family: var(--mono); font-size: 12px; }
792
+ .toast button {
793
+ border: none;
794
+ background: none;
795
+ color: var(--text-3);
796
+ cursor: pointer;
797
+ font-size: 14px;
798
+ line-height: 1;
799
+ }
800
+ /* Google Sheets tutorial */
801
+ .sheet-tutorial { margin-top: 12px; border: 1px solid var(--line-strong); background: var(--panel-2); }
802
+ .sheet-tutorial summary { cursor: pointer; list-style: none; padding: 13px 14px; color: var(--cyan); font-family: var(--mono); font-size: 14px; font-weight: 700; letter-spacing: .04em; display: flex; align-items: center; justify-content: space-between; gap: 8px; }
803
+ .sheet-tutorial summary::-webkit-details-marker { display: none; }
804
+ .sheet-tutorial summary::after { content: '+'; font-size: 20px; color: var(--lime); }
805
+ .sheet-tutorial[open] summary::after { content: '−'; }
806
+ .sheet-tutorial-content { padding: 0 14px 14px; color: var(--text-2); font-size: 14px; line-height: 1.5; }
807
+ .sheet-tutorial-content .tutorial-step { padding: 11px 0; border-top: 1px solid var(--line); }
808
+ .sheet-tutorial-content .tutorial-step b { display: block; margin-bottom: 4px; color: var(--text); font-size: 14px; }
809
+ .sheet-tutorial-content code { overflow-wrap: anywhere; }
810
+ .sheet-tutorial-content a { color: var(--cyan); }
811
+ .sheet-tutorial-content .warning { margin-top: 8px; padding: 9px; border-left: 2px solid var(--lime); background: rgba(0,245,255,.04); color: var(--text); font-size: 12px; }
812
+ .service-email-row { display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin-top:8px; padding:10px; border:1px solid var(--line-strong); background:rgba(0,245,255,.04); }
813
+ .service-email { flex:1; min-width:180px; color:var(--cyan); font-family:var(--mono); font-size:13px; overflow-wrap:anywhere; user-select:all; }
814
+ .copy-email-btn { border:1px solid var(--line-strong); background:var(--cyan-dim); color:var(--cyan); padding:7px 10px; font:700 12px var(--font); cursor:pointer; white-space:nowrap; }
815
+ .copy-email-btn:hover { background:var(--cyan); color:var(--bg); }
816
+ .hidden { display: none !important; }
817
+ .credit {
818
+ position: fixed;
819
+ bottom: 6px;
820
+ left: 50%;
821
+ transform: translateX(-50%);
822
+ font-family: var(--mono);
823
+ font-size: 8px;
824
+ letter-spacing: 0.2em;
825
+ text-transform: lowercase;
826
+ color: rgba(0, 245, 255, 0.06);
827
+ pointer-events: none;
828
+ user-select: none;
829
+ z-index: 1;
830
+ }
831
  </style>
832
  </head>
833
  <body>
834
+ <div class="app">
835
+ <header class="titlebar">
836
+ <div class="brand">
837
+ <div class="logo">AS</div>
838
+ <div class="brand-text">AURASTUDY<span>PLACEMENT // CONSOLE</span></div>
839
+ </div>
840
+ <div class="title-center">
841
+ <div class="seg">
842
+ <button type="button" class="on" data-src="csv">CSV</button>
843
+ <button type="button" data-src="sheet">Sheet</button>
844
+ </div>
845
+ </div>
846
+ <div class="title-right">
847
+ <div class="status" id="apiStatus"><i></i> API</div>
848
+ <div class="status" id="modelStatus"><i></i> MODEL</div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
849
  </div>
850
+ </header>
851
+
852
+ <div class="body">
853
+ <aside class="sidebar">
854
+ <div class="sidebar-scroll">
855
+ <div id="pane-csv">
856
+ <div class="side-section">
857
+ <div class="side-label">// Import</div>
858
+ <div class="drop" id="dropZone">
859
+ <input type="file" id="csvFile" accept=".csv,text/csv">
860
+ <div class="drop-ico">↑</div>
861
+ <div class="drop-title">DROP CSV</div>
862
+ <div class="drop-sub">UTF-8 · ≥5 subject cols</div>
863
+ </div>
864
+ <div class="file-row" id="fileRow">
865
+ <span class="fn" id="fileName">—</span>
866
+ <span class="fs" id="fileSize"></span>
867
+ </div>
868
+ <div class="stack">
869
+ <button type="button" class="btn btn-ghost" id="btnTemplate">Template</button>
870
+ <button type="button" class="btn btn-primary" id="btnRunCsv" disabled>Run predictions</button>
871
+ <button type="button" class="btn btn-secondary" id="btnDownload" disabled>Export CSV</button>
872
+ </div>
873
+ </div>
874
+ </div>
875
+
876
+ <div id="pane-sheet" class="hidden">
877
+ <div class="side-section">
878
+ <div class="side-label">// Google Sheet</div>
879
+ <div class="hint-box">
880
+ <strong>SETUP</strong>
881
+ <ol>
882
+ <li>Share sheet with the app as Editor</li>
883
+ <li>Header: Name + scores</li>
884
+ </ol>
885
+ </div>
886
+ <details class="sheet-tutorial" open>
887
+ <summary>GOOGLE SHEETS · QUICK GUIDE</summary>
888
+ <div class="sheet-tutorial-content">
889
+ <div class="tutorial-step">
890
+ <b>1. Share your sheet with the app</b>
891
+ Click <b>Share</b> in Google Sheets, add this service email, and set access to <b>Editor</b>:
892
+ <div class="service-email-row">
893
+ <span class="service-email" id="serviceEmail">streampredictor-sheets@solid-pact-472307-k4.iam.gserviceaccount.com</span>
894
+ <button type="button" class="copy-email-btn" id="copyServiceEmail">COPY EMAIL</button>
895
+ </div>
896
+ <div style="margin-top:6px">Keep the sheet restricted; do not make it public.</div>
897
+ </div>
898
+ <div class="tutorial-step">
899
+ <b>2. Prepare your student data</b>
900
+ Use one student per row, a header row, a student-name column (e.g. <code>Name</code>), and at least five recognised subject-score columns. Enter scores as numbers.
901
+ </div>
902
+ <div class="tutorial-step">
903
+ <b>3. Run and save predictions</b>
904
+ Paste your Google Sheet URL above, optionally enter the worksheet tab name, keep <b>Write results back</b> enabled, then click <b>Run on sheet</b>. Reopen the sheet to check the new prediction columns.
905
+ </div>
906
+ <div class="warning"><b>Before using real student data:</b> test with a copy first and only process data you are authorised to use.</div>
907
+ </div>
908
+ </details>
909
+ <div class="field">
910
+ <label for="sheetUrl">Spreadsheet URL / ID</label>
911
+ <input type="url" id="sheetUrl" placeholder="docs.google.com/spreadsheets/d/…">
912
+ </div>
913
+ <div class="field">
914
+ <label for="worksheet">Worksheet tab</label>
915
+ <input type="text" id="worksheet" placeholder="optional">
916
+ </div>
917
+ <div class="switch-row">
918
+ <span>Write results back</span>
919
+ <label class="switch"><input type="checkbox" id="writeBack" checked><span></span></label>
920
+ </div>
921
+ <div class="stack">
922
+ <button type="button" class="btn btn-primary" id="btnRunSheet">Run on sheet</button>
923
+ </div>
924
+ </div>
925
+ </div>
926
+
927
+ <div class="side-section">
928
+ <div class="side-label">// Columns</div>
929
+ <div class="col-guide">
930
+ <div class="h">Subjects ≥5</div>
931
+ <span class="k">BM</span><span class="k">BI</span><span class="k">Math</span><span class="k">Science</span><br>
932
+ <span class="k">Sejarah</span><span class="k">Geo</span><span class="k">RBT</span><span class="k">PSV</span>
933
+ <div class="h">Identity</div>
934
+ <span class="k">Name</span><span class="k">Class</span><span class="k">ID</span>
935
+ <div class="h">Aliases</div>
936
+ F3_BM · Bahasa Melayu · Sains
937
+ </div>
938
+ </div>
939
+ </div>
940
+ </aside>
941
+
942
+ <main class="main">
943
+ <div class="main-toolbar">
944
+ <h2>RESULTS</h2>
945
+ <span class="meta" id="runMeta">STANDBY</span>
946
+ </div>
947
+ <div class="progress" id="progress"></div>
948
+
949
+ <div class="main-body">
950
+ <div class="empty" id="empty">
951
+ <div class="empty-ico">◈</div>
952
+ <h3>NO BATCH LOADED</h3>
953
+ <p>Import class CSV or link a Google Sheet from the left console to run stream placement.</p>
954
+ </div>
955
+
956
+ <div id="results" class="hidden">
957
+ <div class="kpis">
958
+ <div class="kpi"><div class="l">Students</div><div class="v" id="kpiTotal">0</div></div>
959
+ <div class="kpi"><div class="l">Predicted</div><div class="v g" id="kpiOk">0</div></div>
960
+ <div class="kpi"><div class="l">Failed</div><div class="v r" id="kpiFail">0</div></div>
961
+ <div class="kpi"><div class="l">Subjects</div><div class="v t" id="kpiSubj">—</div></div>
962
+ </div>
963
+
964
+ <div class="panel">
965
+ <div class="panel-h"><h3>Stream distribution</h3></div>
966
+ <div class="panel-b"><div class="dist" id="dist"></div></div>
967
+ </div>
968
+
969
+ <div class="panel">
970
+ <div class="tools">
971
+ <div class="search"><input type="search" id="search" placeholder="filter rows…"></div>
972
+ <select class="select" id="streamFilter">
973
+ <option value="">ALL STREAMS</option>
974
+ <option>Science</option>
975
+ <option>Reka Cipta</option>
976
+ <option>Account</option>
977
+ <option>Art / Other</option>
978
+ </select>
979
+ <a class="link hidden" id="sheetLink" href="#" target="_blank" rel="noopener">OPEN SHEET →</a>
980
+ </div>
981
+ <div class="table-wrap">
982
+ <table>
983
+ <thead id="thead"></thead>
984
+ <tbody id="tbody"></tbody>
985
+ </table>
986
+ </div>
987
+ <div class="table-foot">
988
+ <span id="footLeft">—</span>
989
+ <span id="footRight"></span>
990
+ </div>
991
+ </div>
992
+ </div>
993
+ </div>
994
+ </main>
995
+ </div>
996
  </div>
997
+
998
+ <div class="credit" aria-hidden="true">develop by syuan</div>
999
+ <div class="toasts" id="toasts"></div>
1000
+
1001
  <script>
1002
+ (() => {
1003
+ const API = "https://syuanteng315-spm-predictor.hf.space";
1004
+ const $ = (id) => document.getElementById(id);
1005
+ let file = null, rows = [], cols = [];
1006
+ $("copyServiceEmail").addEventListener("click", async () => {
1007
+ const email = $("serviceEmail").textContent.trim();
1008
+ const button = $("copyServiceEmail");
1009
+ try {
1010
+ await navigator.clipboard.writeText(email);
1011
+ button.textContent = "COPIED!";
1012
+ } catch {
1013
+ const temp = document.createElement("textarea");
1014
+ temp.value = email;
1015
+ temp.style.position = "fixed";
1016
+ temp.style.opacity = "0";
1017
+ document.body.appendChild(temp);
1018
+ temp.select();
1019
+ const copied = document.execCommand("copy");
1020
+ temp.remove();
1021
+ button.textContent = copied ? "COPIED!" : "SELECT EMAIL";
1022
+ }
1023
+ setTimeout(() => { button.textContent = "COPY EMAIL"; }, 1800);
1024
  });
1025
+ async function ping() {
1026
+ try {
1027
+ const r = await fetch(API + "/");
1028
+ const d = await r.json();
1029
+ if (r.ok) {
1030
+ $("apiStatus").classList.add("ok");
1031
+ $("apiStatus").innerHTML = "<i></i> API ONLINE";
1032
+ }
1033
+ if (d.model_loaded) {
1034
+ $("modelStatus").classList.add("ok");
1035
+ $("modelStatus").innerHTML = "<i></i> MODEL READY";
1036
+ } else {
1037
+ $("modelStatus").classList.add("warn");
1038
+ $("modelStatus").innerHTML = "<i></i> MODEL OFF";
1039
+ }
1040
+ } catch {
1041
+ $("apiStatus").classList.add("warn");
1042
+ $("apiStatus").innerHTML = "<i></i> API OFF";
1043
+ }
1044
+ }
1045
+ ping();
1046
+ document.querySelectorAll(".seg button").forEach((b) => {
1047
+ b.addEventListener("click", () => {
1048
+ document.querySelectorAll(".seg button").forEach((x) => x.classList.remove("on"));
1049
+ b.classList.add("on");
1050
+ const src = b.dataset.src;
1051
+ $("pane-csv").classList.toggle("hidden", src !== "csv");
1052
+ $("pane-sheet").classList.toggle("hidden", src !== "sheet");
1053
+ });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1054
  });
1055
+ function setFile(f) {
1056
+ if (!f) return;
1057
+ file = f;
1058
+ $("fileName").textContent = f.name;
1059
+ $("fileSize").textContent = (f.size / 1024).toFixed(1) + " KB";
1060
+ $("fileRow").classList.add("show");
1061
+ $("btnRunCsv").disabled = false;
1062
+ $("btnDownload").disabled = false;
1063
+ }
1064
+ $("csvFile").addEventListener("change", () => setFile($("csvFile").files[0]));
1065
+ const dz = $("dropZone");
1066
+ ["dragenter", "dragover"].forEach((e) => dz.addEventListener(e, (ev) => { ev.preventDefault(); dz.classList.add("drag"); }));
1067
+ ["dragleave", "drop"].forEach((e) => dz.addEventListener(e, (ev) => { ev.preventDefault(); dz.classList.remove("drag"); }));
1068
+ dz.addEventListener("drop", (ev) => { const f = ev.dataTransfer.files[0]; if (f) setFile(f); });
1069
+ $("btnTemplate").onclick = () => { location.href = API + "/bulk/template.csv"; };
1070
+ function toast(msg, type = "ok") {
1071
+ const el = document.createElement("div");
1072
+ el.className = "toast " + type;
1073
+ el.innerHTML = '<span class="msg">' + esc(msg) + '</span><button type="button">×</button>';
1074
+ el.querySelector("button").onclick = () => el.remove();
1075
+ $("toasts").appendChild(el);
1076
+ setTimeout(() => el.remove(), 5500);
1077
+ }
1078
+ function load(on) {
1079
+ $("progress").classList.toggle("on", on);
1080
+ $("runMeta").textContent = on ? "PROCESSING…" : "STANDBY";
1081
+ }
1082
+ function esc(s) {
1083
+ return String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");
1084
+ }
1085
+ function streamTag(n) {
1086
+ const x = (n || "").toLowerCase();
1087
+ if (x.includes("science") && !x.includes("reka")) return "tag-s";
1088
+ if (x.includes("reka")) return "tag-r";
1089
+ if (x.includes("account")) return "tag-a";
1090
+ if (x.includes("art")) return "tag-o";
1091
+ return "tag-s";
1092
+ }
1093
+ function confTag(c) {
1094
+ const x = (c || "").toLowerCase();
1095
+ if (x === "high") return "tag-high";
1096
+ if (x === "medium") return "tag-med";
1097
+ if (x === "borderline") return "tag-low";
1098
+ return "";
1099
+ }
1100
+ function barClass(n) {
1101
+ const x = (n || "").toLowerCase();
1102
+ if (x.includes("science") && !x.includes("reka")) return "s";
1103
+ if (x.includes("reka")) return "r";
1104
+ if (x.includes("account")) return "a";
1105
+ return "o";
1106
+ }
1107
+ function show(data) {
1108
+ $("empty").classList.add("hidden");
1109
+ $("results").classList.remove("hidden");
1110
+ const list = data.rows || data.rows_preview || [];
1111
+ rows = list;
1112
+ $("kpiTotal").textContent = data.total ?? list.length;
1113
+ $("kpiOk").textContent = data.ok ?? 0;
1114
+ $("kpiFail").textContent = data.failed ?? 0;
1115
+ $("kpiSubj").textContent = (data.subjects_detected || []).length || "—";
1116
+ $("runMeta").textContent = "LAST // " + new Date().toLocaleTimeString();
1117
+ const counts = {};
1118
+ list.forEach((r) => {
1119
+ const s = r["Recommended Stream"] || "";
1120
+ if (s) counts[s] = (counts[s] || 0) + 1;
1121
+ });
1122
+ const tot = Object.values(counts).reduce((a, b) => a + b, 0) || 1;
1123
+ const order = ["Science", "Reka Cipta", "Account", "Art / Other"];
1124
+ $("dist").innerHTML = order.map((name) => {
1125
+ const n = counts[name] || 0;
1126
+ const pct = Math.round((n / tot) * 100);
1127
+ return '<div class="dist-item"><div class="name">' + esc(name) + "<b>" + n + "</b></div>" +
1128
+ '<div class="dist-bar"><i class="' + barClass(name) + '" style="width:' + pct + '%"></i></div></div>';
1129
+ }).join("");
1130
+ if (data.sheet_url) {
1131
+ $("sheetLink").href = data.sheet_url;
1132
+ $("sheetLink").classList.remove("hidden");
1133
+ } else $("sheetLink").classList.add("hidden");
1134
+ const prefer = ["Name","Nama","Class","Kelas","ID","Recommended Stream","Second Stream","Confidence","Prob Science %","Prob Reka Cipta %","Prob Account %","Prob Art / Other %","Key Subjects","Status"];
1135
+ let all = data.columns || (list[0] ? Object.keys(list[0]) : []);
1136
+ const ordered = [];
1137
+ prefer.forEach((c) => {
1138
+ const real = all.find((x) => x === c || x.toLowerCase() === c.toLowerCase());
1139
+ if (real && !ordered.includes(real)) ordered.push(real);
1140
+ });
1141
+ all.forEach((c) => { if (!ordered.includes(c)) ordered.push(c); });
1142
+ cols = ordered.slice(0, 12);
1143
+ filter();
1144
+ }
1145
+ function filter() {
1146
+ const q = ($("search").value || "").toLowerCase().trim();
1147
+ const stream = $("streamFilter").value;
1148
+ let list = rows;
1149
+ if (stream) list = list.filter((r) => (r["Recommended Stream"] || "") === stream);
1150
+ if (q) list = list.filter((r) => cols.some((c) => String(r[c] ?? "").toLowerCase().includes(q)));
1151
+ $("thead").innerHTML = "<tr>" + cols.map((c) => "<th>" + esc(c) + "</th>").join("") + "</tr>";
1152
+ $("tbody").innerHTML = list.length ? list.map((r) => {
1153
+ return "<tr>" + cols.map((c) => {
1154
+ const v = r[c] ?? "";
1155
+ if (c === "Status") {
1156
+ const ok = String(v) === "OK";
1157
+ return '<td><span class="tag ' + (ok ? "tag-ok" : "tag-fail") + '">' + esc(String(v)) + "</span></td>";
1158
+ }
1159
+ if (c === "Recommended Stream") {
1160
+ return '<td><span class="tag ' + streamTag(String(v)) + '">' + esc(String(v)) + "</span></td>";
1161
+ }
1162
+ if (c === "Confidence") {
1163
+ return '<td><span class="tag ' + confTag(String(v)) + '">' + esc(String(v)) + "</span></td>";
1164
+ }
1165
+ return '<td title="' + esc(String(v)) + '">' + esc(String(v)) + "</td>";
1166
+ }).join("") + "</tr>";
1167
+ }).join("") : '<tr><td colspan="' + cols.length + '" style="text-align:center;color:var(--text-3);padding:28px">NO MATCH</td></tr>';
1168
+ $("footLeft").textContent = list.length + " SHOWN · " + rows.length + " TOTAL";
1169
+ $("footRight").textContent = new Date().toLocaleTimeString();
1170
+ }
1171
+ $("search").addEventListener("input", filter);
1172
+ $("streamFilter").addEventListener("change", filter);
1173
+ async function runCsv() {
1174
+ if (!file) return;
1175
+ load(true);
1176
+ $("btnRunCsv").disabled = true;
1177
+ $("btnRunCsv").textContent = "Running…";
1178
+ try {
1179
+ const fd = new FormData();
1180
+ fd.append("file", file);
1181
+ const res = await fetch(API + "/bulk/preview", { method: "POST", body: fd });
1182
+ const data = await res.json().catch(() => ({}));
1183
+ if (!res.ok) throw new Error(typeof data.detail === "string" ? data.detail : JSON.stringify(data.detail || data));
1184
+ show(data);
1185
+ toast("COMPLETE — " + data.ok + " / " + data.total + " predicted.", "ok");
1186
+ } catch (e) {
1187
+ toast(e.message || String(e), "err");
1188
+ } finally {
1189
+ load(false);
1190
+ $("btnRunCsv").disabled = false;
1191
+ $("btnRunCsv").textContent = "Run predictions";
1192
+ }
1193
+ }
1194
+ async function downloadCsv() {
1195
+ if (!file) return;
1196
+ load(true);
1197
+ $("btnDownload").disabled = true;
1198
+ $("btnDownload").textContent = "Exporting…";
1199
+ try {
1200
+ const fd = new FormData();
1201
+ fd.append("file", file);
1202
+ const res = await fetch(API + "/bulk/csv", { method: "POST", body: fd });
1203
+ if (!res.ok) {
1204
+ const data = await res.json().catch(() => ({}));
1205
+ throw new Error(typeof data.detail === "string" ? data.detail : "HTTP " + res.status);
1206
+ }
1207
+ const blob = await res.blob();
1208
+ const url = URL.createObjectURL(blob);
1209
+ const a = document.createElement("a");
1210
+ a.href = url;
1211
+ a.download = "aurastudy_predictions.csv";
1212
+ a.click();
1213
+ URL.revokeObjectURL(url);
1214
+ toast("EXPORT OK", "ok");
1215
+ } catch (e) {
1216
+ toast(e.message || String(e), "err");
1217
+ } finally {
1218
+ load(false);
1219
+ $("btnDownload").disabled = false;
1220
+ $("btnDownload").textContent = "Export CSV";
1221
+ }
1222
+ }
1223
+ async function runSheet() {
1224
+ const url = $("sheetUrl").value.trim();
1225
+ if (!url) { toast("Paste a Google Sheet URL or ID.", "err"); return; }
1226
+ load(true);
1227
+ $("btnRunSheet").disabled = true;
1228
+ $("btnRunSheet").textContent = "Running…";
1229
+ try {
1230
+ const res = await fetch(API + "/bulk/sheet", {
1231
+ method: "POST",
1232
+ headers: { "Content-Type": "application/json" },
1233
+ body: JSON.stringify({
1234
+ sheet_url: url,
1235
+ worksheet: $("worksheet").value.trim() || null,
1236
+ write_back: $("writeBack").checked,
1237
+ }),
1238
+ });
1239
+ const data = await res.json().catch(() => ({}));
1240
+ if (!res.ok) throw new Error(typeof data.detail === "string" ? data.detail : JSON.stringify(data.detail || data));
1241
+ show(data);
1242
+ toast(data.written_back
1243
+ ? "SHEET UPDATED — " + data.ok + "/" + data.total
1244
+ : "DONE — " + data.ok + "/" + data.total, "ok");
1245
+ } catch (e) {
1246
+ toast(e.message || String(e), "err");
1247
+ } finally {
1248
+ load(false);
1249
+ $("btnRunSheet").disabled = false;
1250
+ $("btnRunSheet").textContent = "Run on sheet";
1251
+ }
1252
+ }
1253
+ $("btnRunCsv").onclick = runCsv;
1254
+ $("btnDownload").onclick = downloadCsv;
1255
+ $("btnRunSheet").onclick = runSheet;
1256
+ })();
1257
  </script>
1258
  </body>
1259
  </html>