Files
the-button/static/app.js
T
2026-10-21 13:26:16 -04:00

580 lines
23 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 = "";
let selectedSection = null;
let chatSignature = null;
let chatLoading = false;
const PRESENCE_KEY = "thebutton-share-presence";
let sharePresence = localStorage.getItem(PRESENCE_KEY) === "true";
let presenceTab = sessionStorage.getItem("thebutton-presence-tab");
if (!presenceTab) {
presenceTab = Array.from(crypto.getRandomValues(new Uint8Array(16)), (n) => n.toString(16).padStart(2, "0")).join("");
sessionStorage.setItem("thebutton-presence-tab", presenceTab);
}
let presenceQueue = Promise.resolve();
let lastPresenceUpdate = null;
function renderPresence(data) {
const count = data.online_count;
const countText = `${count.toLocaleString()} ${count === 1 ? "player" : "players"} watching`;
if ($("presence-count").textContent !== countText) $("presence-count").textContent = countText;
$("chat-presence-summary").textContent = data.chat_players.length
? `${data.chat_players.length} ${data.chat_players.length === 1 ? "player" : "players"} in the lounge`
: "No players sharing chat presence yet";
const list = $("chat-presence-names");
// Player names are text, never HTML.
if (list.dataset.names !== JSON.stringify(data.chat_players)) {
list.replaceChildren(...data.chat_players.map((name) => el("li", name)));
list.dataset.names = JSON.stringify(data.chat_players);
}
}
function syncPresence() {
presenceQueue = presenceQueue.then(async () => {
const active = !!token && sharePresence && !document.hidden;
const inChat = active && selectedSection === "chat-section";
const changed = !lastPresenceUpdate || lastPresenceUpdate.token !== token
|| lastPresenceUpdate.active !== active || lastPresenceUpdate.inChat !== inChat;
const due = active && (!lastPresenceUpdate || performance.now() - lastPresenceUpdate.at >= 15000);
if (token && (changed || due) && (active || lastPresenceUpdate?.active)) {
const data = await api("POST", "/api/presence", { tab_id: presenceTab, active, in_chat: inChat });
lastPresenceUpdate = { token, active, inChat, at: performance.now() };
$("presence-error").textContent = "";
renderPresence(data);
} else if (!document.hidden) {
renderPresence(await api("GET", "/api/presence"));
}
}).catch((error) => {
$("presence-count").textContent = "Online status temporarily unavailable";
if (token && sharePresence) $("presence-error").textContent = "Couldn't update online status. Retrying automatically.";
if (error.status === 401) {
token = null;
localStorage.removeItem(TOKEN_KEY);
lastPresenceUpdate = null;
updateButton();
}
});
return presenceQueue;
}
$("presence-opt-in").addEventListener("change", () => {
sharePresence = $("presence-opt-in").checked;
localStorage.setItem(PRESENCE_KEY, String(sharePresence));
syncPresence();
});
document.addEventListener("visibilitychange", syncPresence);
window.addEventListener("storage", (event) => {
if (event.key === PRESENCE_KEY || event.key === TOKEN_KEY) {
sharePresence = localStorage.getItem(PRESENCE_KEY) === "true";
token = localStorage.getItem(TOKEN_KEY);
updateButton();
syncPresence();
}
});
function renderSections() {
const available = {
"account-section": true,
"leaderboard-section": true,
"chat-section": true,
"stats-section": statsLoaded,
rules: true,
"share-controls": !!currentShareText,
more: true,
donate: true,
};
if (!available[selectedSection]) selectedSection = null;
document.querySelectorAll("[data-section]").forEach((button) => {
const id = button.dataset.section;
button.hidden = !available[id];
button.setAttribute("aria-expanded", String(selectedSection === id));
if (id === "account-section") button.textContent = token ? "Your score" : "Log in / Join";
});
document.querySelectorAll("[data-panel]").forEach((panel) => {
panel.hidden = panel.id !== selectedSection;
});
$("mailing-list").hidden = !token || subscribed;
$("chat-form").hidden = !token;
$("chat-login").hidden = !!token;
$("section-hint").hidden = selectedSection !== null;
$("presence-opt-in").checked = sharePresence;
}
$("section-nav").addEventListener("click", (event) => {
const button = event.target.closest("[data-section]");
if (!button) return;
selectedSection = selectedSection === button.dataset.section ? null : button.dataset.section;
renderSections();
if (selectedSection === "account-section") loadMe();
if (selectedSection === "leaderboard-section") loadLeaderboard();
if (selectedSection === "chat-section") loadChat();
syncPresence();
});
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();
await syncPresence();
await loadLeaderboard();
if (selectedSection === "chat-section") await loadChat();
if (selectedSection === "account-section") await loadMe();
}
function updateButton() {
$("the-button").disabled = !token || !game || game.ended;
$("register-section").hidden = !!token;
$("me-section").hidden = !token;
$("my-subscribed").hidden = !token || !subscribed;
renderSections();
}
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);
}
function renderAchievements(achievements) {
const list = $("my-achievements");
const signature = JSON.stringify(achievements);
if (list.dataset.achievements === signature) return;
list.dataset.achievements = signature;
$("my-achievement-count").textContent = `${achievements.filter((a) => a.unlocked).length} / ${achievements.length} unlocked`;
list.replaceChildren(...achievements.map((achievement) => {
const item = el("li", undefined, `achievement-card${achievement.unlocked ? " is-unlocked" : ""}`);
const icon = el("span", achievement.unlocked ? "\u2713" : "\u25c7", "achievement-icon");
icon.setAttribute("aria-hidden", "true");
const content = el("div", undefined, "achievement-content");
const heading = el("div", undefined, "achievement-title");
heading.append(el("h4", achievement.name), el("span", achievement.unlocked ? "Unlocked" : "Locked", "achievement-state"));
content.append(heading, el("p", achievement.description));
let progressText = "One well-timed press unlocks this badge.";
if (achievement.id === "full_spectrum") {
progressText = `${achievement.progress} / ${achievement.target} zones visited`;
} else if (achievement.id === "keeping_us_alive") {
progressText = `Longest quiet stretch before your press: ${formatPrecise(achievement.progress)} / ${formatPrecise(achievement.target)} needed`;
}
if (achievement.unlocked) {
progressText = `Unlocked ${new Date(achievement.unlocked_at).toLocaleString()}`;
}
content.append(el("small", progressText));
if (["full_spectrum", "keeping_us_alive"].includes(achievement.id) && !achievement.unlocked) {
const progress = el("progress");
progress.max = achievement.target;
progress.value = achievement.progress;
progress.setAttribute("aria-label", `${achievement.name} progress`);
content.append(progress);
}
item.append(icon, content);
return item;
}));
}
async function loadMe() {
if (!token) return;
try {
const me = await api("GET", "/api/me");
$("my-name").textContent = me.name;
$("my-points").textContent = me.total_points.toLocaleString();
$("my-presses").textContent = me.press_count;
renderAchievements(me.achievements);
const closest = me.personal_bests.closest_call;
const best = me.personal_bests.highest_scoring_press;
$("my-closest-call").textContent = closest ? formatPrecise(closest.remaining_ms) : "--";
$("my-closest-detail").textContent = closest
? `${closest.zone} zone / ${new Date(closest.at).toLocaleDateString()}`
: "Your first press starts the story.";
$("my-best-press").textContent = best ? `${best.points.toLocaleString()} pts` : "--";
$("my-best-detail").textContent = best
? `${best.zone} zone / ${formatPrecise(best.remaining_ms)} left`
: "No presses yet. Wait for your moment.";
const ranking = me.ranking;
$("my-position").textContent = `#${ranking.position} of ${ranking.total_players}`;
const rival = ranking.rival;
if (rival) {
$("my-rival-title").textContent = rival.points_to_tie === 0 ? `Tied with ${rival.name}` : `Next up: ${rival.name}`;
$("my-rival-description").textContent = rival.points_to_tie === 0
? `You're both on ${me.total_points.toLocaleString()} points. Gain points to move ahead.`
: `${rival.points_to_tie.toLocaleString()} more points to match their ${rival.total_points.toLocaleString()}; ${rival.points_to_pass.toLocaleString()} to beat it.`;
} else {
$("my-rival-title").textContent = ranking.total_players === 1 ? "The first to take a seat." : ranking.tied_for_lead ? "Sharing the lead." : "You're setting the pace.";
$("my-rival-description").textContent = ranking.total_players === 1
? "You're the only player so far. Your next challenge is your own best." : ranking.tied_for_lead
? "You're first alphabetically among equal top scores. Gain points to take a clear lead."
: "You're at the top of the leaderboard. Make your next 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 loadLeaderboard() {
try {
const { leaderboard } = await api("GET", "/api/leaderboard");
fillTable(
$("leaderboard"),
["Rank", "Player", "Points", "Presses"],
leaderboard.map((player, index) => [
index + 1, player.name, player.total_points.toLocaleString(), player.press_count.toLocaleString(),
]),
);
$("leaderboard").hidden = leaderboard.length === 0;
$("leaderboard-status").textContent = leaderboard.length ? "" : "No players yet. Be the first to join!";
} catch {
$("leaderboard-status").textContent = "Scores could not be refreshed. Retrying automatically...";
}
}
async function loadChat() {
if (chatLoading) return;
chatLoading = true;
try {
const { messages } = await api("GET", "/api/chat");
const signature = messages.map((message) => message.id).join(",");
const list = $("chat-messages");
if (signature !== chatSignature) {
const atBottom = list.scrollHeight - list.scrollTop - list.clientHeight < 40;
const firstLoad = chatSignature === null;
const previousScroll = list.scrollTop;
list.replaceChildren(...messages.map((message) => {
const item = el("li");
if (message.kind === "press") item.classList.add("chat-press");
if (message.kind === "milestone") item.classList.add("chat-milestone");
const avatar = el("span", Array.from(message.name.trim())[0]?.toUpperCase() || "?", "chat-avatar");
avatar.setAttribute("aria-hidden", "true");
const content = el("div", undefined, "chat-message-content");
const heading = el("div", undefined, "chat-heading");
const date = new Date(message.at);
const timestamp = el("time", date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }));
timestamp.dateTime = new Date(message.at).toISOString();
timestamp.title = date.toLocaleString();
heading.append(el("strong", message.name), timestamp);
// Names and messages are untrusted text, never HTML.
content.append(heading, el("p", message.message));
item.append(avatar, content);
return item;
}));
chatSignature = signature;
list.scrollTop = firstLoad || atBottom ? list.scrollHeight : previousScroll;
}
$("chat-status").textContent = messages.length ? "" : "No messages yet. Start the conversation!";
} catch {
$("chat-status").textContent = "Messages could not be refreshed. Retrying automatically...";
} finally {
chatLoading = false;
}
}
$("chat-form").addEventListener("submit", async (event) => {
event.preventDefault();
const input = $("chat-input");
$("chat-send").disabled = true;
$("chat-error").textContent = "";
try {
await api("POST", "/api/chat", { message: input.value });
input.value = "";
await loadChat();
} catch (error) {
$("chat-error").textContent = error.message;
if (error.status === 401) {
token = null;
localStorage.removeItem(TOKEN_KEY);
updateButton();
}
} finally {
$("chat-send").disabled = false;
}
});
async function loadStats() {
try {
const s = await api("GET", "/api/stats");
statsLoaded = true;
renderSections();
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;
currentShareText = "";
renderSections();
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();
await loadMe();
await loadLeaderboard();
if (selectedSection === "chat-section") await loadChat();
} 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);
syncPresence();
$("password-input").value = "";
updateButton();
await loadMe();
await loadLeaderboard();
} 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();
syncPresence();
});
$("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);
})();