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>
This commit is contained in:
155
waitlist-server/views/admin.html
Normal file
155
waitlist-server/views/admin.html
Normal file
@ -0,0 +1,155 @@
|
||||
<!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>
|
||||
72
waitlist-server/views/login.html
Normal file
72
waitlist-server/views/login.html
Normal file
@ -0,0 +1,72 @@
|
||||
<!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>Admin · Exbyte Depot</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#0a0a0f; --panel:#12121b; --elevated:#1a1a24; --border:#26263340;
|
||||
--txt:#ececf4; --muted:#a2a2b4; --faint:#6d6d80;
|
||||
--acc:#7c6ef6; --acc-2:#9d93ff; --acc-deep:#5b4ee0; --del:#f2637e;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;min-height:100vh;display:grid;place-items:center;padding:24px;
|
||||
background:radial-gradient(1200px 600px at 50% -10%,#171626 0%,var(--bg) 55%);
|
||||
font:15px/1.5 -apple-system,Segoe UI,Roboto,Inter,system-ui,sans-serif;color:var(--txt)}
|
||||
.card{width:100%;max-width:380px;background:var(--panel);border:1px solid var(--border);
|
||||
border-radius:18px;padding:34px 30px;box-shadow:0 30px 80px -30px #000}
|
||||
.brand{display:flex;align-items:center;gap:11px;margin-bottom:24px}
|
||||
.logo{width:38px;height:38px;border-radius:11px;flex:0 0 auto;
|
||||
background:linear-gradient(145deg,var(--acc-2),var(--acc-deep));
|
||||
box-shadow:0 0 22px rgba(124,110,246,.5);display:grid;place-items:center}
|
||||
.logo svg{width:20px;height:20px}
|
||||
.brand b{font-size:15px;font-weight:650;letter-spacing:.2px}
|
||||
.brand span{display:block;font-size:11.5px;color:var(--faint);font-weight:500}
|
||||
h1{font-size:19px;font-weight:650;margin:0 0 4px}
|
||||
.sub{color:var(--muted);font-size:12.5px;margin:0 0 22px}
|
||||
label{display:block;font-size:11.5px;color:var(--muted);font-weight:600;margin:0 0 6px;
|
||||
text-transform:uppercase;letter-spacing:.4px}
|
||||
.field{margin-bottom:16px}
|
||||
input{width:100%;background:var(--elevated);border:1px solid var(--border);border-radius:11px;
|
||||
padding:11px 13px;color:var(--txt);font-size:14px;outline:none;transition:.15s}
|
||||
input:focus{border-color:var(--acc);box-shadow:0 0 0 3px rgba(124,110,246,.16)}
|
||||
button{width:100%;margin-top:6px;padding:12px;border:0;border-radius:11px;cursor:pointer;
|
||||
background:linear-gradient(145deg,var(--acc-2),var(--acc-deep));color:#fff;
|
||||
font-size:14.5px;font-weight:650;letter-spacing:.2px;transition:.15s}
|
||||
button:hover{filter:brightness(1.08)}
|
||||
.err{display:none;background:rgba(242,99,126,.1);border:1px solid rgba(242,99,126,.35);
|
||||
color:#ff9db0;font-size:12.5px;padding:9px 12px;border-radius:10px;margin-bottom:18px}
|
||||
.err.show{display:block}
|
||||
.foot{margin-top:20px;text-align:center;font-size:11px;color:var(--faint)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<form class="card" method="POST" action="/admin/login" autocomplete="off">
|
||||
<div class="brand">
|
||||
<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><b>Exbyte Depot</b><span>Waitlist admin</span></div>
|
||||
</div>
|
||||
<h1>Sign in</h1>
|
||||
<p class="sub">Restricted area — authorized access only.</p>
|
||||
<div class="err" id="err">Invalid username or password.</div>
|
||||
<div class="field">
|
||||
<label for="u">Username</label>
|
||||
<input id="u" name="username" type="text" autocomplete="username" required autofocus />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="p">Password</label>
|
||||
<input id="p" name="password" type="password" autocomplete="current-password" required />
|
||||
</div>
|
||||
<button type="submit">Sign in</button>
|
||||
<div class="foot">Protected endpoint · rate-limited</div>
|
||||
</form>
|
||||
<script>
|
||||
if (new URLSearchParams(location.search).get("error")) {
|
||||
document.getElementById("err").classList.add("show");
|
||||
history.replaceState(null, "", "/admin/login");
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user