323 lines
11 KiB
JavaScript
323 lines
11 KiB
JavaScript
"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;
|
|
let currentShareText = "";
|
|
|
|
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 updateShareLinks() {
|
|
if (!currentShareText) return;
|
|
const gameUrl = new URL(location.pathname, location.origin).href;
|
|
const facebookUrl = new URL("https://www.facebook.com/sharer/sharer.php");
|
|
facebookUrl.searchParams.set("u", gameUrl);
|
|
facebookUrl.searchParams.set("quote", currentShareText);
|
|
$("share-facebook").href = facebookUrl.href;
|
|
|
|
const xUrl = new URL("https://twitter.com/intent/tweet");
|
|
xUrl.searchParams.set("text", currentShareText);
|
|
xUrl.searchParams.set("url", gameUrl);
|
|
$("share-x").href = xUrl.href;
|
|
|
|
const blueskyUrl = new URL("https://bsky.app/intent/compose");
|
|
blueskyUrl.searchParams.set("text", `${currentShareText} ${gameUrl}`);
|
|
$("share-bluesky").href = blueskyUrl.href;
|
|
|
|
const instanceInput = $("mastodon-instance");
|
|
const mastodonLink = $("share-mastodon");
|
|
const shareError = $("share-error");
|
|
try {
|
|
const instance = new URL(instanceInput.value.trim());
|
|
if (instance.protocol !== "https:" || !instance.hostname || instance.username || instance.password) {
|
|
throw new Error("Enter an HTTPS Mastodon instance URL.");
|
|
}
|
|
const mastodonUrl = new URL("/share", instance.origin);
|
|
mastodonUrl.searchParams.set("text", `${currentShareText} ${gameUrl}`);
|
|
mastodonLink.href = mastodonUrl.href;
|
|
mastodonLink.removeAttribute("aria-disabled");
|
|
mastodonLink.tabIndex = 0;
|
|
shareError.textContent = "";
|
|
} catch {
|
|
mastodonLink.removeAttribute("href");
|
|
mastodonLink.setAttribute("aria-disabled", "true");
|
|
mastodonLink.tabIndex = -1;
|
|
shareError.textContent = "Enter an HTTPS Mastodon instance URL to enable sharing.";
|
|
}
|
|
}
|
|
|
|
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;
|
|
$("history-global-summary").textContent =
|
|
`There have been ${s.total_presses.toLocaleString()} total presses and there are ${s.total_players.toLocaleString()} total players.`;
|
|
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;
|
|
$("share-controls").hidden = true;
|
|
currentShareText = "";
|
|
try {
|
|
const r = await api("POST", "/api/press");
|
|
game.deadline = r.deadline;
|
|
currentShareText = `I pushed #TheButton at ${formatPrecise(r.press.remaining_ms)}.`;
|
|
$("press-result").textContent =
|
|
`You pressed in the ${r.press.zone} zone with ${formatPrecise(r.press.remaining_ms)} remaining: +${r.press.points} points`;
|
|
updateShareLinks();
|
|
$("share-controls").hidden = false;
|
|
await loadMe();
|
|
} catch (e) {
|
|
$("press-result").textContent = e.message;
|
|
await refreshState().catch(() => {});
|
|
} finally {
|
|
updateButton();
|
|
}
|
|
});
|
|
|
|
$("mastodon-instance").addEventListener("input", updateShareLinks);
|
|
|
|
$("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);
|
|
})();
|