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>
156 lines
7.8 KiB
HTML
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>
|