STREAM_PREDICTOR / index.html
SMKBK1's picture
Update index.html
1b2ecf4 verified
Raw History Blame Contribute Delete
39.3 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AuraStudy // Placement Console</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<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">
<style>
:root {
--bg: #05080f;
--bg-2: #0a101c;
--panel: #0d1524;
--panel-2: #111b2e;
--line: rgba(0, 245, 255, 0.12);
--line-strong: rgba(0, 245, 255, 0.28);
--cyan: #00f5ff;
--cyan-dim: rgba(0, 245, 255, 0.12);
--cyan-glow: rgba(0, 245, 255, 0.45);
--magenta: #ff2bd6;
--magenta-dim: rgba(255, 43, 214, 0.12);
--lime: #b8ff3c;
--lime-dim: rgba(184, 255, 60, 0.12);
--amber: #ffb020;
--amber-dim: rgba(255, 176, 32, 0.12);
--red: #ff4d6d;
--red-dim: rgba(255, 77, 109, 0.14);
--violet: #a78bfa;
--violet-dim: rgba(167, 139, 250, 0.14);
--text: #e6f7ff;
--text-2: #8ba3b8;
--text-3: #4d6478;
--font: "Rajdhani", system-ui, sans-serif;
--display: "Orbitron", sans-serif;
--mono: "Share Tech Mono", ui-monospace, monospace;
--r: 4px;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; }
body {
font-family: var(--font);
font-size: 14.5px;
color: var(--text);
background: var(--bg);
line-height: 1.4;
overflow: hidden;
}
/* cyber grid + scan */
body::before {
content: "";
position: fixed;
inset: 0;
background:
linear-gradient(rgba(0, 245, 255, 0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 245, 255, 0.03) 1px, transparent 1px);
background-size: 48px 48px;
pointer-events: none;
z-index: 0;
}
body::after {
content: "";
position: fixed;
inset: 0;
background: repeating-linear-gradient(
0deg,
transparent,
transparent 2px,
rgba(0, 0, 0, 0.12) 2px,
rgba(0, 0, 0, 0.12) 4px
);
pointer-events: none;
z-index: 50;
opacity: 0.35;
}
.app {
position: relative;
z-index: 1;
display: grid;
grid-template-rows: 54px 1fr;
height: 100vh;
min-width: 1100px;
}
/* ── Titlebar ── */
.titlebar {
display: flex;
align-items: center;
gap: 16px;
padding: 0 18px;
background: linear-gradient(180deg, #0e1628 0%, #0a101c 100%);
border-bottom: 1px solid var(--line-strong);
box-shadow: 0 0 24px rgba(0, 245, 255, 0.06);
z-index: 20;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
min-width: 260px;
}
.logo {
width: 32px;
height: 32px;
clip-path: polygon(20% 0%, 100% 0%, 100% 80%, 80% 100%, 0% 100%, 0% 20%);
background: linear-gradient(135deg, var(--cyan), #007a88);
color: #05080f;
font-family: var(--display);
font-weight: 700;
font-size: 11px;
display: grid;
place-items: center;
box-shadow: 0 0 16px var(--cyan-glow);
}
.brand-text {
font-family: var(--display);
font-size: 13px;
font-weight: 600;
letter-spacing: 0.08em;
color: var(--cyan);
text-shadow: 0 0 12px var(--cyan-glow);
}
.brand-text span {
display: block;
font-family: var(--mono);
font-size: 10px;
font-weight: 400;
letter-spacing: 0.16em;
color: var(--text-3);
text-shadow: none;
margin-top: 1px;
}
.title-center { flex: 1; display: flex; justify-content: center; }
.seg {
display: inline-flex;
gap: 0;
border: 1px solid var(--line-strong);
background: var(--bg);
}
.seg button {
border: none;
background: transparent;
padding: 8px 22px;
font: 600 13px var(--font);
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-3);
cursor: pointer;
position: relative;
}
.seg button + button { border-left: 1px solid var(--line); }
.seg button:hover { color: var(--cyan); }
.seg button.on {
color: var(--bg);
background: var(--cyan);
box-shadow: 0 0 20px var(--cyan-glow);
}
.title-right {
display: flex;
align-items: center;
gap: 14px;
min-width: 260px;
justify-content: flex-end;
}
.status {
display: inline-flex;
align-items: center;
gap: 7px;
font-family: var(--mono);
font-size: 11px;
letter-spacing: 0.06em;
color: var(--text-3);
}
.status i {
width: 8px;
height: 8px;
border-radius: 1px;
background: var(--text-3);
box-shadow: 0 0 0 1px rgba(0,0,0,.5);
}
.status.ok { color: var(--lime); }
.status.ok i {
background: var(--lime);
box-shadow: 0 0 10px var(--lime), 0 0 0 1px rgba(0,0,0,.4);
animation: blink 1.6s step-end infinite;
}
.status.warn { color: var(--amber); }
.status.warn i { background: var(--amber); box-shadow: 0 0 8px var(--amber); }
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.25; }
}
/* ── Body ── */
.body {
display: grid;
grid-template-columns: 300px 1fr;
min-height: 0;
overflow: hidden;
}
.sidebar {
background: var(--panel);
border-right: 1px solid var(--line);
display: flex;
flex-direction: column;
min-height: 0;
position: relative;
}
.sidebar::before {
content: "";
position: absolute;
top: 0; right: 0; bottom: 0;
width: 1px;
background: linear-gradient(180deg, var(--cyan), transparent 40%, var(--magenta));
opacity: 0.35;
pointer-events: none;
}
.sidebar-scroll {
flex: 1;
overflow-y: auto;
padding: 16px 14px 28px;
}
.side-label {
font-family: var(--mono);
font-size: 10px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--cyan);
margin-bottom: 10px;
padding-left: 2px;
text-shadow: 0 0 8px var(--cyan-dim);
}
.side-section { margin-bottom: 22px; }
.drop {
position: relative;
border: 1px dashed var(--line-strong);
background: var(--bg);
padding: 26px 12px;
text-align: center;
cursor: pointer;
clip-path: polygon(0 0, calc(100% - 12px) 0, 100% 12px, 100% 100%, 12px 100%, 0 calc(100% - 12px));
transition: border-color .15s, box-shadow .15s;
}
.drop:hover, .drop.drag {
border-color: var(--cyan);
box-shadow: inset 0 0 24px var(--cyan-dim), 0 0 16px var(--cyan-dim);
}
.drop input { position: absolute; inset: 0; opacity: 0; cursor: pointer; }
.drop-ico {
font-family: var(--display);
font-size: 18px;
color: var(--cyan);
margin-bottom: 8px;
text-shadow: 0 0 12px var(--cyan-glow);
}
.drop-title {
font-weight: 600;
font-size: 14px;
letter-spacing: 0.04em;
color: var(--text);
}
.drop-sub {
font-family: var(--mono);
font-size: 11px;
color: var(--text-3);
margin-top: 4px;
}
.file-row {
display: none;
align-items: center;
gap: 8px;
margin-top: 10px;
padding: 8px 10px;
background: var(--cyan-dim);
border: 1px solid var(--line-strong);
}
.file-row.show { display: flex; }
.file-row .fn {
flex: 1;
font-family: var(--mono);
font-size: 11.5px;
color: var(--cyan);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-row .fs { font-family: var(--mono); font-size: 11px; color: var(--text-3); }
.field { margin-bottom: 12px; }
.field label {
display: block;
font-family: var(--mono);
font-size: 10.5px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-3);
margin-bottom: 5px;
}
.field input {
width: 100%;
height: 34px;
padding: 0 10px;
border: 1px solid var(--line);
background: var(--bg);
font: 500 13px var(--mono);
color: var(--text);
border-radius: 0;
}
.field input:focus {
outline: none;
border-color: var(--cyan);
box-shadow: 0 0 0 1px var(--cyan), 0 0 12px var(--cyan-dim);
}
.field input::placeholder { color: var(--text-3); }
.switch-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 0;
font-size: 13.5px;
font-weight: 500;
color: var(--text-2);
}
.switch {
position: relative;
width: 38px;
height: 18px;
}
.switch input { opacity: 0; width: 0; height: 0; }
.switch span {
position: absolute;
inset: 0;
background: var(--bg);
border: 1px solid var(--line-strong);
cursor: pointer;
transition: .15s;
}
.switch span::after {
content: "";
position: absolute;
width: 10px;
height: 10px;
left: 3px;
top: 3px;
background: var(--text-3);
transition: .15s;
}
.switch input:checked + span {
border-color: var(--cyan);
box-shadow: 0 0 10px var(--cyan-dim);
}
.switch input:checked + span::after {
transform: translateX(18px);
background: var(--cyan);
box-shadow: 0 0 8px var(--cyan);
}
.stack { display: flex; flex-direction: column; gap: 8px; margin-top: 12px; }
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
height: 36px;
padding: 0 14px;
font: 600 13px var(--font);
letter-spacing: 0.08em;
text-transform: uppercase;
cursor: pointer;
border: 1px solid transparent;
transition: .12s;
width: 100%;
clip-path: polygon(0 0, calc(100% - 8px) 0, 100% 8px, 100% 100%, 8px 100%, 0 calc(100% - 8px));
}
.btn:disabled { opacity: .35; cursor: not-allowed; }
.btn-primary {
background: var(--cyan);
color: #05080f;
border-color: var(--cyan);
box-shadow: 0 0 18px var(--cyan-dim);
}
.btn-primary:hover:not(:disabled) {
box-shadow: 0 0 28px var(--cyan-glow);
filter: brightness(1.08);
}
.btn-secondary {
background: transparent;
color: var(--amber);
border-color: rgba(255, 176, 32, 0.45);
}
.btn-secondary:hover:not(:disabled) {
background: var(--amber-dim);
box-shadow: 0 0 14px var(--amber-dim);
}
.btn-ghost {
background: transparent;
color: var(--text-2);
border-color: var(--line);
}
.btn-ghost:hover:not(:disabled) {
color: var(--cyan);
border-color: var(--line-strong);
}
.hint-box {
font-size: 12px;
color: var(--text-3);
line-height: 1.5;
padding: 10px 12px;
background: var(--bg);
border: 1px solid var(--line);
margin-bottom: 12px;
}
.hint-box strong { color: var(--magenta); font-weight: 600; letter-spacing: 0.06em; }
.hint-box code {
font-family: var(--mono);
font-size: 10.5px;
color: var(--cyan);
background: var(--cyan-dim);
padding: 1px 4px;
}
.hint-box ol { margin: 6px 0 0 14px; }
.hint-box li { margin-bottom: 3px; }
.col-guide {
font-size: 12px;
color: var(--text-2);
line-height: 1.55;
font-family: var(--mono);
}
.col-guide .k {
display: inline-block;
font-size: 10.5px;
color: var(--cyan);
border: 1px solid var(--line);
padding: 1px 5px;
margin: 1px 2px 1px 0;
background: var(--bg);
}
.col-guide .h {
font-family: var(--mono);
font-size: 10px;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--text-3);
margin: 10px 0 4px;
}
.col-guide .h:first-child { margin-top: 0; }
/* ── Main ── */
.main {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
background: var(--bg-2);
}
.main-toolbar {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 18px;
background: var(--panel);
border-bottom: 1px solid var(--line);
flex-shrink: 0;
}
.main-toolbar h2 {
font-family: var(--display);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.14em;
color: var(--text);
margin-right: auto;
}
.main-toolbar .meta {
font-family: var(--mono);
font-size: 11px;
color: var(--text-3);
letter-spacing: 0.06em;
}
.progress {
height: 2px;
background: transparent;
flex-shrink: 0;
overflow: hidden;
}
.progress.on { background: rgba(0, 245, 255, 0.15); }
.progress.on::after {
content: "";
display: block;
height: 100%;
width: 28%;
background: linear-gradient(90deg, transparent, var(--cyan), var(--magenta), transparent);
box-shadow: 0 0 12px var(--cyan);
animation: scan 1.1s linear infinite;
}
@keyframes scan {
0% { transform: translateX(-120%); }
100% { transform: translateX(450%); }
}
.main-body {
flex: 1;
overflow: auto;
padding: 18px;
display: flex;
flex-direction: column;
gap: 14px;
}
.empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
min-height: 340px;
background: var(--panel);
border: 1px solid var(--line);
position: relative;
}
.empty::before {
content: "";
position: absolute;
inset: 0;
background:
linear-gradient(var(--line) 1px, transparent 1px),
linear-gradient(90deg, var(--line) 1px, transparent 1px);
background-size: 32px 32px;
opacity: 0.5;
pointer-events: none;
}
.empty-ico {
position: relative;
font-family: var(--display);
font-size: 28px;
color: var(--cyan);
text-shadow: 0 0 20px var(--cyan-glow);
margin-bottom: 14px;
}
.empty h3 {
position: relative;
font-family: var(--display);
font-size: 14px;
letter-spacing: 0.12em;
margin-bottom: 8px;
}
.empty p {
position: relative;
font-size: 13.5px;
color: var(--text-3);
max-width: 360px;
line-height: 1.5;
}
.kpis {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
}
.kpi {
background: var(--panel);
border: 1px solid var(--line);
padding: 14px 16px;
position: relative;
overflow: hidden;
}
.kpi::after {
content: "";
position: absolute;
top: 0; right: 0;
width: 24px; height: 24px;
border-top: 1px solid var(--line-strong);
border-right: 1px solid var(--line-strong);
}
.kpi .l {
font-family: var(--mono);
font-size: 10px;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--text-3);
margin-bottom: 6px;
}
.kpi .v {
font-family: var(--display);
font-size: 24px;
font-weight: 700;
letter-spacing: 0.04em;
color: var(--text);
text-shadow: 0 0 16px rgba(230, 247, 255, 0.15);
}
.kpi .v.g { color: var(--lime); text-shadow: 0 0 14px rgba(184,255,60,.4); }
.kpi .v.r { color: var(--red); text-shadow: 0 0 14px rgba(255,77,109,.35); }
.kpi .v.t { color: var(--cyan); text-shadow: 0 0 14px var(--cyan-glow); }
.panel {
background: var(--panel);
border: 1px solid var(--line);
overflow: hidden;
}
.panel-h {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
border-bottom: 1px solid var(--line);
background: var(--panel-2);
}
.panel-h h3 {
font-family: var(--mono);
font-size: 11px;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--cyan);
}
.panel-b { padding: 14px; }
.dist {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
}
.dist-item .name {
font-size: 12px;
font-weight: 600;
color: var(--text-2);
margin-bottom: 6px;
display: flex;
justify-content: space-between;
letter-spacing: 0.03em;
}
.dist-item .name b {
font-family: var(--mono);
font-weight: 500;
color: var(--text);
}
.dist-bar {
height: 5px;
background: var(--bg);
border: 1px solid var(--line);
overflow: hidden;
}
.dist-bar i {
display: block;
height: 100%;
transition: width .55s cubic-bezier(.22,1,.36,1);
}
.dist-bar i.s { background: var(--cyan); box-shadow: 0 0 10px var(--cyan); }
.dist-bar i.r { background: #3d9eff; box-shadow: 0 0 10px #3d9eff; }
.dist-bar i.a { background: var(--violet); box-shadow: 0 0 10px var(--violet); }
.dist-bar i.o { background: var(--amber); box-shadow: 0 0 10px var(--amber); }
.tools {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-bottom: 1px solid var(--line);
background: var(--panel-2);
}
.search { position: relative; flex: 1; max-width: 260px; }
.search input {
width: 100%;
height: 30px;
padding: 0 10px 0 28px;
border: 1px solid var(--line);
background: var(--bg);
font: 500 12.5px var(--mono);
color: var(--text);
}
.search input:focus {
outline: none;
border-color: var(--cyan);
box-shadow: 0 0 10px var(--cyan-dim);
}
.search::before {
content: "›";
position: absolute;
left: 10px;
top: 50%;
transform: translateY(-50%);
color: var(--cyan);
font-family: var(--mono);
font-size: 14px;
}
.select {
height: 30px;
padding: 0 10px;
border: 1px solid var(--line);
background: var(--bg);
font: 500 12.5px var(--mono);
color: var(--text);
cursor: pointer;
}
.select:focus { outline: none; border-color: var(--cyan); }
.link {
margin-left: auto;
font-family: var(--mono);
font-size: 11.5px;
letter-spacing: 0.06em;
color: var(--cyan);
text-decoration: none;
text-shadow: 0 0 8px var(--cyan-dim);
}
.link:hover { text-decoration: underline; }
.table-wrap {
overflow: auto;
max-height: calc(100vh - 400px);
min-height: 180px;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 12.5px;
}
thead th {
position: sticky;
top: 0;
z-index: 2;
background: #0a1220;
text-align: left;
padding: 9px 12px;
font-family: var(--mono);
font-size: 10px;
font-weight: 500;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-3);
border-bottom: 1px solid var(--line-strong);
white-space: nowrap;
}
tbody td {
padding: 8px 12px;
border-bottom: 1px solid var(--line);
color: var(--text);
white-space: nowrap;
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
font-family: var(--mono);
font-size: 12px;
}
tbody tr:hover td {
background: rgba(0, 245, 255, 0.04);
}
tbody tr:last-child td { border-bottom: none; }
.tag {
display: inline-flex;
align-items: center;
padding: 2px 8px;
font-size: 10.5px;
font-weight: 500;
font-family: var(--mono);
letter-spacing: 0.04em;
border: 1px solid transparent;
}
.tag-ok { background: var(--lime-dim); color: var(--lime); border-color: rgba(184,255,60,.3); }
.tag-fail { background: var(--red-dim); color: var(--red); border-color: rgba(255,77,109,.3); }
.tag-s { background: var(--cyan-dim); color: var(--cyan); border-color: var(--line-strong); }
.tag-r { background: rgba(61,158,255,.12); color: #6bb3ff; border-color: rgba(61,158,255,.3); }
.tag-a { background: var(--violet-dim); color: var(--violet); border-color: rgba(167,139,250,.3); }
.tag-o { background: var(--amber-dim); color: var(--amber); border-color: rgba(255,176,32,.3); }
.tag-high { background: var(--lime-dim); color: var(--lime); border-color: rgba(184,255,60,.3); }
.tag-med { background: var(--amber-dim); color: var(--amber); border-color: rgba(255,176,32,.3); }
.tag-low { background: var(--red-dim); color: var(--red); border-color: rgba(255,77,109,.3); }
.table-foot {
display: flex;
justify-content: space-between;
padding: 8px 14px;
border-top: 1px solid var(--line);
font-family: var(--mono);
font-size: 11px;
letter-spacing: 0.06em;
color: var(--text-3);
background: var(--panel-2);
}
.toasts {
position: fixed;
bottom: 18px;
right: 18px;
z-index: 100;
display: flex;
flex-direction: column;
gap: 8px;
max-width: 360px;
}
.toast {
display: flex;
gap: 10px;
align-items: flex-start;
padding: 12px 14px;
background: var(--panel);
border: 1px solid var(--line-strong);
box-shadow: 0 8px 32px rgba(0,0,0,.5), 0 0 20px var(--cyan-dim);
font-size: 13px;
animation: up .2s ease;
}
@keyframes up {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: none; }
}
.toast.ok { border-left: 3px solid var(--lime); }
.toast.err { border-left: 3px solid var(--red); }
.toast .msg { flex: 1; color: var(--text); line-height: 1.4; font-family: var(--mono); font-size: 12px; }
.toast button {
border: none;
background: none;
color: var(--text-3);
cursor: pointer;
font-size: 14px;
line-height: 1;
}
/* Google Sheets tutorial */
.sheet-tutorial { margin-top: 12px; border: 1px solid var(--line-strong); background: var(--panel-2); }
.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; }
.sheet-tutorial summary::-webkit-details-marker { display: none; }
.sheet-tutorial summary::after { content: '+'; font-size: 20px; color: var(--lime); }
.sheet-tutorial[open] summary::after { content: '−'; }
.sheet-tutorial-content { padding: 0 14px 14px; color: var(--text-2); font-size: 14px; line-height: 1.5; }
.sheet-tutorial-content .tutorial-step { padding: 11px 0; border-top: 1px solid var(--line); }
.sheet-tutorial-content .tutorial-step b { display: block; margin-bottom: 4px; color: var(--text); font-size: 14px; }
.sheet-tutorial-content code { overflow-wrap: anywhere; }
.sheet-tutorial-content a { color: var(--cyan); }
.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; }
.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); }
.service-email { flex:1; min-width:180px; color:var(--cyan); font-family:var(--mono); font-size:13px; overflow-wrap:anywhere; user-select:all; }
.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; }
.copy-email-btn:hover { background:var(--cyan); color:var(--bg); }
.hidden { display: none !important; }
.credit {
position: fixed;
bottom: 6px;
left: 50%;
transform: translateX(-50%);
font-family: var(--mono);
font-size: 8px;
letter-spacing: 0.2em;
text-transform: lowercase;
color: rgba(0, 245, 255, 0.06);
pointer-events: none;
user-select: none;
z-index: 1;
}
</style>
</head>
<body>
<div class="app">
<header class="titlebar">
<div class="brand">
<div class="logo">AS</div>
<div class="brand-text">AURASTUDY<span>PLACEMENT // CONSOLE</span></div>
</div>
<div class="title-center">
<div class="seg">
<button type="button" class="on" data-src="csv">CSV</button>
<button type="button" data-src="sheet">Sheet</button>
</div>
</div>
<div class="title-right">
<div class="status" id="apiStatus"><i></i> API</div>
<div class="status" id="modelStatus"><i></i> MODEL</div>
</div>
</header>
<div class="body">
<aside class="sidebar">
<div class="sidebar-scroll">
<div id="pane-csv">
<div class="side-section">
<div class="side-label">// Import</div>
<div class="drop" id="dropZone">
<input type="file" id="csvFile" accept=".csv,text/csv">
<div class="drop-ico">↑</div>
<div class="drop-title">DROP CSV</div>
<div class="drop-sub">UTF-8 · ≥5 subject cols</div>
</div>
<div class="file-row" id="fileRow">
<span class="fn" id="fileName">—</span>
<span class="fs" id="fileSize"></span>
</div>
<div class="stack">
<button type="button" class="btn btn-ghost" id="btnTemplate">Template</button>
<button type="button" class="btn btn-primary" id="btnRunCsv" disabled>Run predictions</button>
<button type="button" class="btn btn-secondary" id="btnDownload" disabled>Export CSV</button>
</div>
</div>
</div>
<div id="pane-sheet" class="hidden">
<div class="side-section">
<div class="side-label">// Google Sheet</div>
<div class="hint-box">
<strong>SETUP</strong>
<ol>
<li>Share sheet with the app as Editor</li>
<li>Header: Name + scores</li>
</ol>
</div>
<details class="sheet-tutorial" open>
<summary>GOOGLE SHEETS · QUICK GUIDE</summary>
<div class="sheet-tutorial-content">
<div class="tutorial-step">
<b>1. Share your sheet with the app</b>
Click <b>Share</b> in Google Sheets, add this service email, and set access to <b>Editor</b>:
<div class="service-email-row">
<span class="service-email" id="serviceEmail">streampredictor-sheets@solid-pact-472307-k4.iam.gserviceaccount.com</span>
<button type="button" class="copy-email-btn" id="copyServiceEmail">COPY EMAIL</button>
</div>
<div style="margin-top:6px">Keep the sheet restricted; do not make it public.</div>
</div>
<div class="tutorial-step">
<b>2. Prepare your student data</b>
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.
</div>
<div class="tutorial-step">
<b>3. Run and save predictions</b>
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.
</div>
<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>
</div>
</details>
<div class="field">
<label for="sheetUrl">Spreadsheet URL / ID</label>
<input type="url" id="sheetUrl" placeholder="docs.google.com/spreadsheets/d/…">
</div>
<div class="field">
<label for="worksheet">Worksheet tab</label>
<input type="text" id="worksheet" placeholder="optional">
</div>
<div class="switch-row">
<span>Write results back</span>
<label class="switch"><input type="checkbox" id="writeBack" checked><span></span></label>
</div>
<div class="stack">
<button type="button" class="btn btn-primary" id="btnRunSheet">Run on sheet</button>
</div>
</div>
</div>
<div class="side-section">
<div class="side-label">// Columns</div>
<div class="col-guide">
<div class="h">Subjects ≥5</div>
<span class="k">BM</span><span class="k">BI</span><span class="k">Math</span><span class="k">Science</span><br>
<span class="k">Sejarah</span><span class="k">Geo</span><span class="k">RBT</span><span class="k">PSV</span>
<div class="h">Identity</div>
<span class="k">Name</span><span class="k">Class</span><span class="k">ID</span>
<div class="h">Aliases</div>
F3_BM · Bahasa Melayu · Sains
</div>
</div>
</div>
</aside>
<main class="main">
<div class="main-toolbar">
<h2>RESULTS</h2>
<span class="meta" id="runMeta">STANDBY</span>
</div>
<div class="progress" id="progress"></div>
<div class="main-body">
<div class="empty" id="empty">
<div class="empty-ico">◈</div>
<h3>NO BATCH LOADED</h3>
<p>Import class CSV or link a Google Sheet from the left console to run stream placement.</p>
</div>
<div id="results" class="hidden">
<div class="kpis">
<div class="kpi"><div class="l">Students</div><div class="v" id="kpiTotal">0</div></div>
<div class="kpi"><div class="l">Predicted</div><div class="v g" id="kpiOk">0</div></div>
<div class="kpi"><div class="l">Failed</div><div class="v r" id="kpiFail">0</div></div>
<div class="kpi"><div class="l">Subjects</div><div class="v t" id="kpiSubj">—</div></div>
</div>
<div class="panel">
<div class="panel-h"><h3>Stream distribution</h3></div>
<div class="panel-b"><div class="dist" id="dist"></div></div>
</div>
<div class="panel">
<div class="tools">
<div class="search"><input type="search" id="search" placeholder="filter rows…"></div>
<select class="select" id="streamFilter">
<option value="">ALL STREAMS</option>
<option>Science</option>
<option>Reka Cipta</option>
<option>Account</option>
<option>Art / Other</option>
</select>
<a class="link hidden" id="sheetLink" href="#" target="_blank" rel="noopener">OPEN SHEET →</a>
</div>
<div class="table-wrap">
<table>
<thead id="thead"></thead>
<tbody id="tbody"></tbody>
</table>
</div>
<div class="table-foot">
<span id="footLeft">—</span>
<span id="footRight"></span>
</div>
</div>
</div>
</div>
</main>
</div>
</div>
<div class="credit" aria-hidden="true">develop by syuan</div>
<div class="toasts" id="toasts"></div>
<script>
(() => {
const API = "https://syuanteng315-spm-predictor.hf.space";
const $ = (id) => document.getElementById(id);
let file = null, rows = [], cols = [];
$("copyServiceEmail").addEventListener("click", async () => {
const email = $("serviceEmail").textContent.trim();
const button = $("copyServiceEmail");
try {
await navigator.clipboard.writeText(email);
button.textContent = "COPIED!";
} catch {
const temp = document.createElement("textarea");
temp.value = email;
temp.style.position = "fixed";
temp.style.opacity = "0";
document.body.appendChild(temp);
temp.select();
const copied = document.execCommand("copy");
temp.remove();
button.textContent = copied ? "COPIED!" : "SELECT EMAIL";
}
setTimeout(() => { button.textContent = "COPY EMAIL"; }, 1800);
});
async function ping() {
try {
const r = await fetch(API + "/");
const d = await r.json();
if (r.ok) {
$("apiStatus").classList.add("ok");
$("apiStatus").innerHTML = "<i></i> API ONLINE";
}
if (d.model_loaded) {
$("modelStatus").classList.add("ok");
$("modelStatus").innerHTML = "<i></i> MODEL READY";
} else {
$("modelStatus").classList.add("warn");
$("modelStatus").innerHTML = "<i></i> MODEL OFF";
}
} catch {
$("apiStatus").classList.add("warn");
$("apiStatus").innerHTML = "<i></i> API OFF";
}
}
ping();
document.querySelectorAll(".seg button").forEach((b) => {
b.addEventListener("click", () => {
document.querySelectorAll(".seg button").forEach((x) => x.classList.remove("on"));
b.classList.add("on");
const src = b.dataset.src;
$("pane-csv").classList.toggle("hidden", src !== "csv");
$("pane-sheet").classList.toggle("hidden", src !== "sheet");
});
});
function setFile(f) {
if (!f) return;
file = f;
$("fileName").textContent = f.name;
$("fileSize").textContent = (f.size / 1024).toFixed(1) + " KB";
$("fileRow").classList.add("show");
$("btnRunCsv").disabled = false;
$("btnDownload").disabled = false;
}
$("csvFile").addEventListener("change", () => setFile($("csvFile").files[0]));
const dz = $("dropZone");
["dragenter", "dragover"].forEach((e) => dz.addEventListener(e, (ev) => { ev.preventDefault(); dz.classList.add("drag"); }));
["dragleave", "drop"].forEach((e) => dz.addEventListener(e, (ev) => { ev.preventDefault(); dz.classList.remove("drag"); }));
dz.addEventListener("drop", (ev) => { const f = ev.dataTransfer.files[0]; if (f) setFile(f); });
$("btnTemplate").onclick = () => { location.href = API + "/bulk/template.csv"; };
function toast(msg, type = "ok") {
const el = document.createElement("div");
el.className = "toast " + type;
el.innerHTML = '<span class="msg">' + esc(msg) + '</span><button type="button">×</button>';
el.querySelector("button").onclick = () => el.remove();
$("toasts").appendChild(el);
setTimeout(() => el.remove(), 5500);
}
function load(on) {
$("progress").classList.toggle("on", on);
$("runMeta").textContent = on ? "PROCESSING…" : "STANDBY";
}
function esc(s) {
return String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");
}
function streamTag(n) {
const x = (n || "").toLowerCase();
if (x.includes("science") && !x.includes("reka")) return "tag-s";
if (x.includes("reka")) return "tag-r";
if (x.includes("account")) return "tag-a";
if (x.includes("art")) return "tag-o";
return "tag-s";
}
function confTag(c) {
const x = (c || "").toLowerCase();
if (x === "high") return "tag-high";
if (x === "medium") return "tag-med";
if (x === "borderline") return "tag-low";
return "";
}
function barClass(n) {
const x = (n || "").toLowerCase();
if (x.includes("science") && !x.includes("reka")) return "s";
if (x.includes("reka")) return "r";
if (x.includes("account")) return "a";
return "o";
}
function show(data) {
$("empty").classList.add("hidden");
$("results").classList.remove("hidden");
const list = data.rows || data.rows_preview || [];
rows = list;
$("kpiTotal").textContent = data.total ?? list.length;
$("kpiOk").textContent = data.ok ?? 0;
$("kpiFail").textContent = data.failed ?? 0;
$("kpiSubj").textContent = (data.subjects_detected || []).length || "—";
$("runMeta").textContent = "LAST // " + new Date().toLocaleTimeString();
const counts = {};
list.forEach((r) => {
const s = r["Recommended Stream"] || "";
if (s) counts[s] = (counts[s] || 0) + 1;
});
const tot = Object.values(counts).reduce((a, b) => a + b, 0) || 1;
const order = ["Science", "Reka Cipta", "Account", "Art / Other"];
$("dist").innerHTML = order.map((name) => {
const n = counts[name] || 0;
const pct = Math.round((n / tot) * 100);
return '<div class="dist-item"><div class="name">' + esc(name) + "<b>" + n + "</b></div>" +
'<div class="dist-bar"><i class="' + barClass(name) + '" style="width:' + pct + '%"></i></div></div>';
}).join("");
if (data.sheet_url) {
$("sheetLink").href = data.sheet_url;
$("sheetLink").classList.remove("hidden");
} else $("sheetLink").classList.add("hidden");
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"];
let all = data.columns || (list[0] ? Object.keys(list[0]) : []);
const ordered = [];
prefer.forEach((c) => {
const real = all.find((x) => x === c || x.toLowerCase() === c.toLowerCase());
if (real && !ordered.includes(real)) ordered.push(real);
});
all.forEach((c) => { if (!ordered.includes(c)) ordered.push(c); });
cols = ordered.slice(0, 12);
filter();
}
function filter() {
const q = ($("search").value || "").toLowerCase().trim();
const stream = $("streamFilter").value;
let list = rows;
if (stream) list = list.filter((r) => (r["Recommended Stream"] || "") === stream);
if (q) list = list.filter((r) => cols.some((c) => String(r[c] ?? "").toLowerCase().includes(q)));
$("thead").innerHTML = "<tr>" + cols.map((c) => "<th>" + esc(c) + "</th>").join("") + "</tr>";
$("tbody").innerHTML = list.length ? list.map((r) => {
return "<tr>" + cols.map((c) => {
const v = r[c] ?? "";
if (c === "Status") {
const ok = String(v) === "OK";
return '<td><span class="tag ' + (ok ? "tag-ok" : "tag-fail") + '">' + esc(String(v)) + "</span></td>";
}
if (c === "Recommended Stream") {
return '<td><span class="tag ' + streamTag(String(v)) + '">' + esc(String(v)) + "</span></td>";
}
if (c === "Confidence") {
return '<td><span class="tag ' + confTag(String(v)) + '">' + esc(String(v)) + "</span></td>";
}
return '<td title="' + esc(String(v)) + '">' + esc(String(v)) + "</td>";
}).join("") + "</tr>";
}).join("") : '<tr><td colspan="' + cols.length + '" style="text-align:center;color:var(--text-3);padding:28px">NO MATCH</td></tr>';
$("footLeft").textContent = list.length + " SHOWN · " + rows.length + " TOTAL";
$("footRight").textContent = new Date().toLocaleTimeString();
}
$("search").addEventListener("input", filter);
$("streamFilter").addEventListener("change", filter);
async function runCsv() {
if (!file) return;
load(true);
$("btnRunCsv").disabled = true;
$("btnRunCsv").textContent = "Running…";
try {
const fd = new FormData();
fd.append("file", file);
const res = await fetch(API + "/bulk/preview", { method: "POST", body: fd });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(typeof data.detail === "string" ? data.detail : JSON.stringify(data.detail || data));
show(data);
toast("COMPLETE — " + data.ok + " / " + data.total + " predicted.", "ok");
} catch (e) {
toast(e.message || String(e), "err");
} finally {
load(false);
$("btnRunCsv").disabled = false;
$("btnRunCsv").textContent = "Run predictions";
}
}
async function downloadCsv() {
if (!file) return;
load(true);
$("btnDownload").disabled = true;
$("btnDownload").textContent = "Exporting…";
try {
const fd = new FormData();
fd.append("file", file);
const res = await fetch(API + "/bulk/csv", { method: "POST", body: fd });
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(typeof data.detail === "string" ? data.detail : "HTTP " + res.status);
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "aurastudy_predictions.csv";
a.click();
URL.revokeObjectURL(url);
toast("EXPORT OK", "ok");
} catch (e) {
toast(e.message || String(e), "err");
} finally {
load(false);
$("btnDownload").disabled = false;
$("btnDownload").textContent = "Export CSV";
}
}
async function runSheet() {
const url = $("sheetUrl").value.trim();
if (!url) { toast("Paste a Google Sheet URL or ID.", "err"); return; }
load(true);
$("btnRunSheet").disabled = true;
$("btnRunSheet").textContent = "Running…";
try {
const res = await fetch(API + "/bulk/sheet", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sheet_url: url,
worksheet: $("worksheet").value.trim() || null,
write_back: $("writeBack").checked,
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(typeof data.detail === "string" ? data.detail : JSON.stringify(data.detail || data));
show(data);
toast(data.written_back
? "SHEET UPDATED — " + data.ok + "/" + data.total
: "DONE — " + data.ok + "/" + data.total, "ok");
} catch (e) {
toast(e.message || String(e), "err");
} finally {
load(false);
$("btnRunSheet").disabled = false;
$("btnRunSheet").textContent = "Run on sheet";
}
}
$("btnRunCsv").onclick = runCsv;
$("btnDownload").onclick = downloadCsv;
$("btnRunSheet").onclick = runSheet;
})();
</script>
</body>
</html>