Files
exbyte-depot-viewer-perforce/waitlist-server/views/admin.html
Bonchellon 3954082e8d v0.3.2 — commit/submit progress, cancel, thumbnails, waitlist landing+admin
Client:
- Commit: batch `p4 reopen` so huge changelists (63k+) no longer blow the
  Windows command-line limit; live "N / total" progress + spinner during commit.
- Submit dialog: white arrow, "N of total" + % bar, running file log,
  Cancel button (aborts pre-commit; changelist stays pending).
- Disk scan: streaming reconcile with a live "found N files" counter + Cancel.
- Thumbnails: only decode browser-renderable image formats; glyph fallback
  (+ onError) instead of a broken-image icon for bmp/tga/dds/etc.

Landing + waitlist:
- waitlist-server: Express + SQLite email collector (dedup) with a secure
  admin panel (scrypt auth, rate-limited login, signed cookies) and CSV export.
- landing form now POSTs signups to the waitlist API.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 20:40:53 +03:00

156 lines
7.8 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex, nofollow" />
<title>Waitlist · Exbyte Depot Admin</title>
<style>
:root{
--bg:#0a0a0f; --bg-2:#0d0d14; --panel:#12121b; --panel-2:#16161f; --elevated:#1a1a24;
--border:#26263340; --txt:#ececf4; --muted:#a2a2b4; --faint:#6d6d80;
--acc:#7c6ef6; --acc-2:#9d93ff; --acc-deep:#5b4ee0; --ok:#3ecf8e; --del:#f2637e;
}
*{box-sizing:border-box}
body{margin:0;min-height:100vh;background:var(--bg);color:var(--txt);
font:14.5px/1.5 -apple-system,Segoe UI,Roboto,Inter,system-ui,sans-serif}
.wrap{max-width:1000px;margin:0 auto;padding:24px 22px 60px}
header{display:flex;align-items:center;gap:12px;padding:6px 0 22px}
.logo{width:34px;height:34px;border-radius:10px;flex:0 0 auto;
background:linear-gradient(145deg,var(--acc-2),var(--acc-deep));
box-shadow:0 0 18px rgba(124,110,246,.45);display:grid;place-items:center}
.logo svg{width:18px;height:18px}
.htitle b{font-size:15px;font-weight:650}
.htitle span{display:block;font-size:11.5px;color:var(--faint)}
.spacer{flex:1}
.who{font-size:12.5px;color:var(--muted);margin-right:4px}
.who b{color:var(--txt);font-weight:600}
.btn{display:inline-flex;align-items:center;gap:7px;font-size:13px;font-weight:600;cursor:pointer;
background:var(--panel-2);border:1px solid var(--border);color:var(--txt);
border-radius:10px;padding:8px 13px;transition:.15s;text-decoration:none}
.btn:hover{border-color:var(--acc);color:#fff}
.btn svg{width:15px;height:15px}
.btn.accent{background:linear-gradient(145deg,var(--acc-2),var(--acc-deep));border:0;color:#fff}
.btn.accent:hover{filter:brightness(1.08)}
.btn.ghost{background:transparent}
.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:14px;margin-bottom:20px}
.stat{background:var(--panel);border:1px solid var(--border);border-radius:14px;padding:16px 18px}
.stat .n{font-size:28px;font-weight:700;letter-spacing:-.5px;font-variant-numeric:tabular-nums}
.stat .l{font-size:11.5px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;margin-top:2px}
.stat.accent .n{color:var(--acc-2)}
.toolbar{display:flex;gap:10px;align-items:center;margin-bottom:14px;flex-wrap:wrap}
.search{flex:1;min-width:200px;display:flex;align-items:center;gap:8px;
background:var(--elevated);border:1px solid var(--border);border-radius:10px;padding:0 12px}
.search svg{width:15px;height:15px;color:var(--faint);flex:0 0 auto}
.search input{flex:1;background:none;border:0;outline:none;color:var(--txt);font-size:14px;padding:10px 0}
.panel{background:var(--panel);border:1px solid var(--border);border-radius:14px;overflow:hidden}
table{width:100%;border-collapse:collapse}
th,td{text-align:left;padding:11px 16px;font-size:13.5px}
th{background:var(--panel-2);color:var(--muted);font-weight:600;font-size:11.5px;
text-transform:uppercase;letter-spacing:.4px;position:sticky;top:0}
tbody tr{border-top:1px solid var(--border)}
tbody tr:hover{background:#ffffff06}
td.email{font-weight:550}
td.mono{font-variant-numeric:tabular-nums;color:var(--muted);white-space:nowrap}
.tag{display:inline-block;font-size:11px;color:var(--muted);background:var(--panel-2);
border:1px solid var(--border);border-radius:6px;padding:1px 8px}
.idx{color:var(--faint);font-variant-numeric:tabular-nums;width:42px}
.empty{padding:44px;text-align:center;color:var(--faint);font-size:13.5px}
.foot{margin-top:16px;font-size:11.5px;color:var(--faint);text-align:center}
</style>
</head>
<body>
<div class="wrap">
<header>
<span class="logo"><svg viewBox="0 0 24 24" fill="none"><path d="M4 7l8-4 8 4v10l-8 4-8-4V7Z" stroke="#fff" stroke-width="1.6" stroke-linejoin="round"/><path d="M4 7l8 4 8-4M12 11v10" stroke="#fff" stroke-width="1.6" stroke-linejoin="round"/></svg></span>
<div class="htitle"><b>Waitlist</b><span>Exbyte Depot · admin</span></div>
<div class="spacer"></div>
<span class="who">Signed in as <b>{{USER}}</b></span>
<form method="POST" action="/admin/logout" style="display:inline">
<button class="btn ghost" type="submit">Log out</button>
</form>
</header>
<div class="stats">
<div class="stat accent"><div class="n" id="statTotal"></div><div class="l">Total signups</div></div>
<div class="stat"><div class="n" id="statShown"></div><div class="l">Showing</div></div>
<div class="stat"><div class="n" id="statLatest"></div><div class="l">Latest signup</div></div>
</div>
<div class="toolbar">
<div class="search">
<svg viewBox="0 0 24 24" fill="none"><circle cx="11" cy="11" r="7" stroke="currentColor" stroke-width="1.7"/><path d="m20 20-3-3" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>
<input id="search" type="search" placeholder="Filter emails…" autocomplete="off" />
</div>
<button class="btn" id="refresh" type="button">
<svg viewBox="0 0 24 24" fill="none"><path d="M20 11a8 8 0 1 0-.5 3M20 5v6h-6" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg>
Refresh
</button>
<a class="btn accent" href="/admin/export.csv">
<svg viewBox="0 0 24 24" fill="none"><path d="M12 3v12m0 0 4-4m-4 4-4-4M5 21h14" stroke="#fff" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg>
Export CSV
</a>
</div>
<div class="panel">
<table>
<thead>
<tr><th class="idx">#</th><th>Email</th><th>Source</th><th>Date (UTC)</th></tr>
</thead>
<tbody id="rows"></tbody>
</table>
<div class="empty" id="empty" style="display:none">No signups yet.</div>
</div>
<div class="foot" id="foot"></div>
</div>
<script>
let all = [];
const $ = (id) => document.getElementById(id);
function fmtDate(s){ return (s || "").replace("T"," ").replace("Z",""); }
function render(){
const q = $("search").value.trim().toLowerCase();
const list = q ? all.filter(r => r.email.toLowerCase().includes(q)) : all;
const tbody = $("rows");
tbody.textContent = "";
$("empty").style.display = list.length ? "none" : "block";
list.forEach((r, i) => {
const tr = document.createElement("tr");
const idx = document.createElement("td"); idx.className = "idx"; idx.textContent = i + 1;
const em = document.createElement("td"); em.className = "email"; em.textContent = r.email;
const src = document.createElement("td");
const tag = document.createElement("span"); tag.className = "tag"; tag.textContent = r.source || "—";
src.appendChild(tag);
const dt = document.createElement("td"); dt.className = "mono"; dt.textContent = fmtDate(r.created_at);
tr.append(idx, em, src, dt);
tbody.appendChild(tr);
});
$("statShown").textContent = list.length;
$("foot").textContent = list.length
? `${list.length} of ${all.length} shown` : "";
}
async function load(){
try {
const res = await fetch("/admin/api/emails", { headers: { "Accept": "application/json" } });
if (res.status === 401) { location.href = "/admin/login"; return; }
const data = await res.json();
all = data.emails || [];
$("statTotal").textContent = data.count ?? all.length;
$("statLatest").textContent = all.length ? fmtDate(all[0].created_at).slice(0,10) : "—";
render();
} catch (e) {
$("empty").style.display = "block";
$("empty").textContent = "Failed to load. " + e.message;
}
}
$("search").addEventListener("input", render);
$("refresh").addEventListener("click", load);
load();
</script>
</body>
</html>