Initial commit
This commit is contained in:
commit
fb7a34a95e
+273
@@ -0,0 +1,273 @@
|
||||
"use strict";
|
||||
|
||||
const TOKEN_KEY = "thebutton-token";
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
let game = null; // latest /api/state payload
|
||||
let clockOffset = 0; // serverTime - clientTime
|
||||
let token = localStorage.getItem(TOKEN_KEY);
|
||||
let statsLoaded = false;
|
||||
let subscribed = false;
|
||||
|
||||
async function api(method, path, body) {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
const res = await fetch(path, { method, headers, body: body ? JSON.stringify(body) : undefined });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw Object.assign(new Error(data.error || res.statusText), { status: res.status });
|
||||
return data;
|
||||
}
|
||||
|
||||
function serverNow() {
|
||||
return Date.now() + clockOffset;
|
||||
}
|
||||
|
||||
function formatMs(ms) {
|
||||
ms = Math.max(0, ms);
|
||||
const h = Math.floor(ms / 3600000);
|
||||
const m = Math.floor((ms % 3600000) / 60000);
|
||||
const s = Math.floor((ms % 60000) / 1000);
|
||||
const cs = Math.floor((ms % 1000) / 10);
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
return `${pad(h)}:${pad(m)}:${pad(s)}.${pad(cs)}`;
|
||||
}
|
||||
|
||||
function formatPrecise(ms) {
|
||||
return `${formatMs(ms).slice(0, -3)}.${String(Math.max(0, ms) % 1000).padStart(3, "0")}`;
|
||||
}
|
||||
|
||||
function zoneFor(remaining) {
|
||||
return game.zones.find((z) => remaining > z.remaining_to_ms) ?? game.zones[game.zones.length - 1];
|
||||
}
|
||||
|
||||
function formatDuration(ms) {
|
||||
const totalMin = Math.round(ms / 60000);
|
||||
const h = Math.floor(totalMin / 60);
|
||||
const m = totalMin % 60;
|
||||
if (h && m) return `${h}h ${m}m`;
|
||||
if (h) return `${h}h`;
|
||||
if (m) return `${m}m`;
|
||||
return `${Math.round(ms / 1000)}s`;
|
||||
}
|
||||
|
||||
function setZoneClass(el, zoneName) {
|
||||
el.className = el.className.replace(/\bzone-\w+/g, "").trim();
|
||||
el.classList.add(`zone-${zoneName}`);
|
||||
}
|
||||
|
||||
function el(tag, text, className) {
|
||||
const e = document.createElement(tag);
|
||||
if (text !== undefined) e.textContent = text;
|
||||
if (className) e.className = className;
|
||||
return e;
|
||||
}
|
||||
|
||||
function fillTable(table, headers, rows) {
|
||||
table.replaceChildren();
|
||||
const head = table.createTHead().insertRow();
|
||||
headers.forEach((h) => head.appendChild(el("th", h)));
|
||||
const tbody = table.createTBody();
|
||||
rows.forEach((cells) => {
|
||||
const tr = tbody.insertRow();
|
||||
cells.forEach((c) => {
|
||||
const td = tr.insertCell();
|
||||
if (c instanceof Node) td.appendChild(c);
|
||||
else td.textContent = c;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function zoneSwatch(name) {
|
||||
return el("span", name, `swatch zone-${name}`);
|
||||
}
|
||||
|
||||
function zoneRows(counts) {
|
||||
return game.zones.map((z) => [zoneSwatch(z.name), z.points, counts[z.name] ?? 0]);
|
||||
}
|
||||
|
||||
function buildStaticUi() {
|
||||
const bar = $("zone-bar");
|
||||
game.zones.forEach((z) => {
|
||||
const seg = el("span", undefined, `zone-${z.name}`);
|
||||
seg.style.flexGrow = z.remaining_from_ms - z.remaining_to_ms;
|
||||
bar.insertBefore(seg, $("zone-marker"));
|
||||
});
|
||||
$("rules-zones").replaceChildren(...game.zones.map((z) => {
|
||||
const s = zoneSwatch(z.name);
|
||||
s.textContent = `${z.name} (${z.points} pt${z.points === 1 ? "" : "s"}, ${formatDuration(z.remaining_from_ms - z.remaining_to_ms)})`;
|
||||
return s;
|
||||
}));
|
||||
}
|
||||
|
||||
async function refreshState() {
|
||||
const t0 = Date.now();
|
||||
const s = await api("GET", "/api/state");
|
||||
const first = !game;
|
||||
clockOffset = s.now - (t0 + Date.now()) / 2;
|
||||
game = s;
|
||||
if (first) buildStaticUi();
|
||||
if (game.ended && !statsLoaded) loadStats();
|
||||
updateButton();
|
||||
}
|
||||
|
||||
function updateButton() {
|
||||
$("the-button").disabled = !token || !game || game.ended;
|
||||
$("register-section").hidden = !!token;
|
||||
$("mailing-list").hidden = !token || subscribed;
|
||||
$("my-subscribed").hidden = !token || !subscribed;
|
||||
}
|
||||
|
||||
function tick() {
|
||||
if (game) {
|
||||
const remaining = game.ended ? 0 : game.deadline - serverNow();
|
||||
const zone = zoneFor(remaining);
|
||||
const cd = $("countdown");
|
||||
cd.textContent = formatMs(remaining);
|
||||
setZoneClass(cd, zone.name);
|
||||
setZoneClass($("the-button"), zone.name);
|
||||
$("zone-label").textContent = game.ended ? "game over" : `${zone.name} zone \u00b7 ${zone.points} pt${zone.points === 1 ? "" : "s"}`;
|
||||
const pct = Math.min(100, Math.max(0, ((game.duration_ms - remaining) / game.duration_ms) * 100));
|
||||
$("zone-marker").style.left = `${pct}%`;
|
||||
if (!game.ended && remaining <= 0) refreshState().catch(() => {});
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
async function loadMe() {
|
||||
if (!token) return;
|
||||
try {
|
||||
const me = await api("GET", "/api/me");
|
||||
$("me-section").hidden = false;
|
||||
$("my-name").textContent = me.name;
|
||||
$("my-points").textContent = me.total_points.toLocaleString();
|
||||
$("my-presses").textContent = me.press_count;
|
||||
subscribed = me.subscribed;
|
||||
updateButton();
|
||||
fillTable($("my-zones"), ["Zone", "Points each", "Your presses"], zoneRows(me.zone_counts));
|
||||
fillTable(
|
||||
$("my-history"),
|
||||
["When", "Remaining", "Zone", "Points"],
|
||||
me.presses.slice().reverse().map((p) => [
|
||||
new Date(p.at).toLocaleString(), formatPrecise(p.remaining_ms), zoneSwatch(p.zone), p.points,
|
||||
]),
|
||||
);
|
||||
} catch (e) {
|
||||
if (e.status === 401) {
|
||||
token = null;
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
updateButton();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const s = await api("GET", "/api/stats");
|
||||
statsLoaded = true;
|
||||
$("stats-section").hidden = false;
|
||||
const summary = $("stats-summary");
|
||||
summary.replaceChildren(
|
||||
el("p", `Lasted ${formatMs(s.ended_at - s.started_at)} \u00b7 ${s.total_players} players \u00b7 ${s.total_presses} presses`),
|
||||
);
|
||||
if (s.last_press) {
|
||||
summary.appendChild(el("p", `Last press: ${s.last_press.name} with ${formatPrecise(s.last_press.remaining_ms)} remaining`));
|
||||
}
|
||||
if (s.lowest_press) {
|
||||
summary.appendChild(el("p", `Closest call: ${s.lowest_press.name} with ${formatPrecise(s.lowest_press.remaining_ms)} remaining`));
|
||||
}
|
||||
fillTable($("stats-zones"), ["Zone", "Points each", "Presses"], zoneRows(s.zone_counts));
|
||||
fillTable(
|
||||
$("stats-leaderboard"),
|
||||
["#", "Player", "Points", "Presses", ...game.zones.map((z) => z.name), "Best remaining"],
|
||||
s.leaderboard.map((p, i) => [
|
||||
i + 1, p.name, p.total_points.toLocaleString(), p.press_count,
|
||||
...game.zones.map((z) => p.zone_counts[z.name]),
|
||||
p.best_remaining_ms == null ? "-" : formatPrecise(p.best_remaining_ms),
|
||||
]),
|
||||
);
|
||||
fillTable(
|
||||
$("stats-log"),
|
||||
["When", "Player", "Remaining", "Zone", "Points"],
|
||||
s.presses.map((p) => [
|
||||
new Date(p.at).toLocaleString(), p.name, formatPrecise(p.remaining_ms), zoneSwatch(p.zone), p.points,
|
||||
]),
|
||||
);
|
||||
} catch {
|
||||
// Not ended yet; ignore.
|
||||
}
|
||||
}
|
||||
|
||||
$("the-button").addEventListener("click", async () => {
|
||||
const btn = $("the-button");
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const r = await api("POST", "/api/press");
|
||||
game.deadline = r.deadline;
|
||||
$("press-result").textContent =
|
||||
`You pressed in the ${r.press.zone} zone with ${formatPrecise(r.press.remaining_ms)} remaining: +${r.press.points} points`;
|
||||
await loadMe();
|
||||
} catch (e) {
|
||||
$("press-result").textContent = e.message;
|
||||
await refreshState().catch(() => {});
|
||||
} finally {
|
||||
updateButton();
|
||||
}
|
||||
});
|
||||
|
||||
$("register-form").addEventListener("submit", async (ev) => {
|
||||
ev.preventDefault();
|
||||
$("register-error").textContent = "";
|
||||
const action = ev.submitter?.value === "register" ? "register" : "login";
|
||||
try {
|
||||
const r = await api("POST", `/api/${action}`, {
|
||||
name: $("name-input").value,
|
||||
password: $("password-input").value,
|
||||
});
|
||||
token = r.token;
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
$("password-input").value = "";
|
||||
updateButton();
|
||||
await loadMe();
|
||||
} catch (e) {
|
||||
$("register-error").textContent = e.message;
|
||||
}
|
||||
});
|
||||
|
||||
$("logout-button").addEventListener("click", async () => {
|
||||
await api("POST", "/api/logout").catch(() => {});
|
||||
token = null;
|
||||
subscribed = false;
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
$("me-section").hidden = true;
|
||||
$("press-result").textContent = "";
|
||||
$("subscribe-result").textContent = "";
|
||||
updateButton();
|
||||
});
|
||||
|
||||
$("subscribe-form").addEventListener("submit", async (ev) => {
|
||||
ev.preventDefault();
|
||||
const result = $("subscribe-result");
|
||||
result.className = "";
|
||||
try {
|
||||
await api("POST", "/api/subscribe", {
|
||||
first_name: $("sub-first").value,
|
||||
last_name: $("sub-last").value,
|
||||
email: $("sub-email").value,
|
||||
phone: $("sub-phone").value,
|
||||
});
|
||||
ev.target.reset();
|
||||
result.textContent = "";
|
||||
subscribed = true;
|
||||
updateButton();
|
||||
} catch (e) {
|
||||
result.className = "error";
|
||||
result.textContent = e.message;
|
||||
}
|
||||
});
|
||||
|
||||
(async function init() {
|
||||
await refreshState();
|
||||
await loadMe();
|
||||
requestAnimationFrame(tick);
|
||||
setInterval(() => refreshState().catch(() => {}), 5000);
|
||||
})();
|
||||
@@ -0,0 +1,87 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>The Button</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>THE BUTTON</h1>
|
||||
|
||||
<section id="timer-section">
|
||||
<div id="countdown" class="zone-green">--:--:--.--</div>
|
||||
<div id="zone-label"></div>
|
||||
<div id="zone-bar"><div id="zone-marker"></div></div>
|
||||
</section>
|
||||
|
||||
<section id="button-section">
|
||||
<button id="the-button" class="zone-green" disabled>PRESS</button>
|
||||
<p id="press-result" aria-live="polite"></p>
|
||||
</section>
|
||||
|
||||
<section id="register-section" hidden>
|
||||
<h2>Log in or join the game</h2>
|
||||
<form id="register-form">
|
||||
<input id="name-input" maxlength="24" placeholder="Your name" required autocomplete="username">
|
||||
<input id="password-input" type="password" minlength="8" maxlength="128" placeholder="Password (8+ chars)" required autocomplete="current-password">
|
||||
<button type="submit" value="login">Log in</button>
|
||||
<button type="submit" value="register">Sign up</button>
|
||||
</form>
|
||||
<p id="register-error" class="error"></p>
|
||||
</section>
|
||||
|
||||
<section id="me-section" hidden>
|
||||
<h2>Your score: <span id="my-name"></span></h2>
|
||||
<button id="logout-button" type="button">Log out</button>
|
||||
<p id="my-subscribed" hidden>You're on the mailing list. Thanks!</p>
|
||||
<p>Total points: <strong id="my-points">0</strong> · Presses: <strong id="my-presses">0</strong></p>
|
||||
<table id="my-zones" class="zone-table"></table>
|
||||
<details>
|
||||
<summary>Your press history</summary>
|
||||
<table id="my-history"></table>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section id="stats-section" hidden>
|
||||
<h2>Game over — final global stats</h2>
|
||||
<div id="stats-summary"></div>
|
||||
<h3>Presses per zone</h3>
|
||||
<table id="stats-zones" class="zone-table"></table>
|
||||
<h3>Leaderboard</h3>
|
||||
<table id="stats-leaderboard"></table>
|
||||
<details>
|
||||
<summary>Full press log</summary>
|
||||
<table id="stats-log"></table>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section id="rules">
|
||||
<h2>Rules</h2>
|
||||
<ul>
|
||||
<li>A 24 hour countdown resets whenever anyone presses the button.</li>
|
||||
<li>If the countdown reaches zero, the game is over forever.</li>
|
||||
<li>The lower the countdown when you press, the more points you score.</li>
|
||||
<li>Zones: <span id="rules-zones"></span></li>
|
||||
<li>Each press scores the fixed point value of the zone it lands in.</li>
|
||||
<li>Global stats are published when the game ends.</li>
|
||||
<li>Inspired by the original <a href="https://en.wikipedia.org/wiki/The_Button_(Reddit)">Reddit "The Button"</a> experiment.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section id="mailing-list" hidden>
|
||||
<h2>Join the mailing list to keep up with <a href="https://www.eom.dev/">Eric</a>'s projects</h2>
|
||||
<form id="subscribe-form">
|
||||
<input id="sub-first" maxlength="50" placeholder="First name" required autocomplete="given-name">
|
||||
<input id="sub-last" maxlength="50" placeholder="Last name (optional)" autocomplete="family-name">
|
||||
<input id="sub-email" type="email" maxlength="254" placeholder="Email" required autocomplete="email">
|
||||
<input id="sub-phone" type="tel" maxlength="25" placeholder="Phone (optional)" autocomplete="tel">
|
||||
<button type="submit">Sign up</button>
|
||||
</form>
|
||||
<p id="subscribe-result" aria-live="polite"></p>
|
||||
</section>
|
||||
</main>
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,107 @@
|
||||
:root {
|
||||
--green: #2ecc40;
|
||||
--blue: #0074d9;
|
||||
--yellow: #ffdc00;
|
||||
--red: #ff4136;
|
||||
--black: #000;
|
||||
--white: #fff;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: #444;
|
||||
color: #eee;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
|
||||
main {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
h1 { letter-spacing: 0.3em; }
|
||||
|
||||
section { margin: 1.5rem 0; }
|
||||
|
||||
#countdown {
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: clamp(2rem, 9vw, 4rem);
|
||||
font-weight: bold;
|
||||
background: none !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
#zone-label { text-transform: uppercase; letter-spacing: 0.2em; }
|
||||
|
||||
#zone-bar {
|
||||
position: relative;
|
||||
display: flex;
|
||||
height: 14px;
|
||||
margin: 0.75rem auto;
|
||||
border: 1px solid #222;
|
||||
}
|
||||
#zone-bar span { flex: 1 1 0; }
|
||||
#zone-marker {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
width: 3px;
|
||||
height: 26px;
|
||||
background: #f0f;
|
||||
transition: left 0.2s linear;
|
||||
}
|
||||
|
||||
#the-button {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
border-radius: 50%;
|
||||
font-size: 1.6rem;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
border: 6px solid #222;
|
||||
box-shadow: 0 8px 0 #222;
|
||||
transition: transform 0.05s;
|
||||
}
|
||||
#the-button:active:not(:disabled) { transform: translateY(6px); box-shadow: 0 2px 0 #222; }
|
||||
#the-button:disabled { cursor: not-allowed; opacity: 0.5; }
|
||||
|
||||
.zone-green { background: var(--green); color: #000; }
|
||||
.zone-blue { background: var(--blue); color: #fff; }
|
||||
.zone-yellow { background: var(--yellow); color: #000; }
|
||||
.zone-red { background: var(--red); color: #fff; }
|
||||
.zone-black { background: var(--black); color: #fff; border-color: #888 !important; }
|
||||
.zone-white { background: var(--white); color: #000; }
|
||||
|
||||
#countdown.zone-green { color: var(--green); }
|
||||
#countdown.zone-blue { color: var(--blue); }
|
||||
#countdown.zone-yellow { color: var(--yellow); }
|
||||
#countdown.zone-red { color: var(--red); }
|
||||
#countdown.zone-black { color: var(--black); }
|
||||
#countdown.zone-white { color: var(--white); }
|
||||
|
||||
.swatch {
|
||||
display: inline-block;
|
||||
padding: 0 0.4em;
|
||||
margin: 0 0.15em;
|
||||
border: 1px solid #222;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
table {
|
||||
margin: 0.5rem auto;
|
||||
border-collapse: collapse;
|
||||
min-width: 60%;
|
||||
}
|
||||
th, td { padding: 0.3rem 0.6rem; border-bottom: 1px solid #555; }
|
||||
|
||||
input, form button {
|
||||
font-size: 1rem;
|
||||
padding: 0.4rem 0.8rem;
|
||||
}
|
||||
|
||||
.error { color: var(--red); }
|
||||
|
||||
#rules { text-align: left; font-size: 0.9rem; color: #ccc; }
|
||||
Reference in New Issue
Block a user