999 lines
43 KiB
JavaScript
999 lines
43 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;
|
|
let isAdmin = false;
|
|
let adminSnapshot = null;
|
|
let adminBusy = false;
|
|
const PRESENCE_KEY = "thebutton-share-presence";
|
|
let sharePresence = localStorage.getItem(PRESENCE_KEY) !== "false";
|
|
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;
|
|
const ALERTS_KEY = "thebutton-zone-alerts";
|
|
const ALERTS_SENT_KEY = "thebutton-zone-alerts-sent";
|
|
const ALERT_ZONES = ["blue", "yellow", "red", "black", "white"];
|
|
let alertSettings = readAlertSettings();
|
|
let alertObservation = null;
|
|
let alertAudio = null;
|
|
|
|
function readAlertSettings() {
|
|
try {
|
|
const value = JSON.parse(localStorage.getItem(ALERTS_KEY));
|
|
return { enabled: value?.enabled === true, zone: ALERT_ZONES.includes(value?.zone) ? value.zone : "blue",
|
|
sound: value?.sound === true, notification: value?.notification === true };
|
|
} catch {
|
|
return { enabled: false, zone: "blue", sound: false, notification: false };
|
|
}
|
|
}
|
|
|
|
function renderAlertSettings() {
|
|
$("alerts-enabled").checked = alertSettings.enabled;
|
|
$("alerts-zone").value = alertSettings.zone;
|
|
$("alerts-sound").checked = alertSettings.sound;
|
|
$("alerts-notification").checked = alertSettings.notification;
|
|
$("alerts-status").textContent = alertSettings.enabled
|
|
? `Watching for the ${alertSettings.zone} zone. Keep this page open.` : "Zone alerts are off.";
|
|
}
|
|
|
|
async function prepareAlertAudio() {
|
|
const Audio = window.AudioContext || window.webkitAudioContext;
|
|
if (!Audio) throw new Error("Sound is not supported by this browser.");
|
|
alertAudio = alertAudio || new Audio();
|
|
await alertAudio.resume();
|
|
if (alertAudio.state !== "running") throw new Error("Click Test chime to allow sound in this browser.");
|
|
}
|
|
|
|
function playAlertChime() {
|
|
if (!alertAudio || alertAudio.state !== "running") throw new Error("Click Test chime in Alerts to enable sound for this visit.");
|
|
const start = alertAudio.currentTime;
|
|
[660, 880].forEach((frequency, index) => {
|
|
const oscillator = alertAudio.createOscillator();
|
|
const gain = alertAudio.createGain();
|
|
const at = start + index * 0.18;
|
|
oscillator.frequency.value = frequency;
|
|
gain.gain.setValueAtTime(0, at);
|
|
gain.gain.linearRampToValueAtTime(0.08, at + 0.02);
|
|
gain.gain.exponentialRampToValueAtTime(0.001, at + 0.28);
|
|
oscillator.connect(gain);
|
|
gain.connect(alertAudio.destination);
|
|
oscillator.start(at);
|
|
oscillator.stop(at + 0.3);
|
|
oscillator.onended = () => { oscillator.disconnect(); gain.disconnect(); };
|
|
});
|
|
}
|
|
|
|
function observeZoneAlerts(snapshot) {
|
|
if (alertObservation && snapshot.now < alertObservation.now) return;
|
|
const remaining = snapshot.deadline - snapshot.now;
|
|
const zone = snapshot.zones.find((entry) => remaining > entry.remaining_to_ms)?.name;
|
|
const previous = alertObservation;
|
|
alertObservation = { now: snapshot.now, deadline: snapshot.deadline, zone, ended: snapshot.ended };
|
|
if (!alertSettings.enabled || !previous || snapshot.ended || previous.ended || remaining <= 0
|
|
|| previous.deadline !== snapshot.deadline || previous.zone === zone || zone !== alertSettings.zone) return;
|
|
const currentIndex = snapshot.zones.findIndex((entry) => entry.name === zone);
|
|
const previousIndex = snapshot.zones.findIndex((entry) => entry.name === previous.zone);
|
|
if (previousIndex < 0 || currentIndex <= previousIndex) return;
|
|
try {
|
|
const sent = JSON.parse(localStorage.getItem(ALERTS_SENT_KEY) || "null");
|
|
const zones = sent?.deadline === snapshot.deadline && Array.isArray(sent.zones) ? sent.zones : [];
|
|
if (zones.includes(zone)) return;
|
|
localStorage.setItem(ALERTS_SENT_KEY, JSON.stringify({ deadline: snapshot.deadline, zones: [...zones, zone] }));
|
|
} catch {
|
|
// The observation still prevents repeated alerts within this tab if storage is unavailable.
|
|
}
|
|
const message = `The countdown entered the ${zone} zone. ${formatPrecise(remaining)} remaining.`;
|
|
$("alerts-status").textContent = message;
|
|
$("alerts-error").textContent = "";
|
|
if (alertSettings.sound) {
|
|
try { playAlertChime(); } catch (error) { $("alerts-error").textContent = error.message; }
|
|
}
|
|
if (alertSettings.notification) {
|
|
try {
|
|
if (!("Notification" in window) || Notification.permission !== "granted") {
|
|
throw new Error("Browser notifications aren't permitted. Save your notification settings in Alerts to try again.");
|
|
}
|
|
const notification = new Notification("The Button: your zone is here", { body: message, tag: "thebutton-zone", silent: true });
|
|
notification.onclick = () => { window.focus(); notification.close(); };
|
|
} catch (error) { $("alerts-error").textContent = error.message; }
|
|
}
|
|
}
|
|
|
|
$("alerts-form").addEventListener("submit", async (event) => {
|
|
event.preventDefault();
|
|
$("alerts-error").textContent = "";
|
|
const settings = { enabled: $("alerts-enabled").checked, zone: $("alerts-zone").value,
|
|
sound: $("alerts-sound").checked, notification: $("alerts-notification").checked };
|
|
if (settings.enabled && !settings.sound && !settings.notification) {
|
|
$("alerts-error").textContent = "Choose a chime, a browser notification, or both.";
|
|
return;
|
|
}
|
|
let permissionRequest = null;
|
|
if (settings.enabled && settings.notification) {
|
|
if (!("Notification" in window) || !window.isSecureContext) {
|
|
$("alerts-error").textContent = "Browser notifications are unavailable here. Choose the chime instead.";
|
|
return;
|
|
}
|
|
// Start permission requests directly in the submit gesture, before awaiting audio.
|
|
try { permissionRequest = Notification.requestPermission().catch(() => "denied"); }
|
|
catch { permissionRequest = Promise.resolve("denied"); }
|
|
}
|
|
if (settings.enabled && settings.sound) {
|
|
try { await prepareAlertAudio(); } catch (error) { $("alerts-error").textContent = error.message; return; }
|
|
}
|
|
if (permissionRequest) {
|
|
const permission = await permissionRequest;
|
|
if (permission !== "granted") {
|
|
$("alerts-error").textContent = "Notifications were not allowed. Enable them in your browser settings, or choose the chime instead.";
|
|
return;
|
|
}
|
|
}
|
|
alertSettings = settings;
|
|
alertObservation = null; // baseline the next server response, never alert immediately on enabling
|
|
try { localStorage.setItem(ALERTS_KEY, JSON.stringify(settings)); }
|
|
catch { $("alerts-error").textContent = "Settings work for this visit but could not be saved in this browser."; }
|
|
renderAlertSettings();
|
|
});
|
|
$("alerts-test").addEventListener("click", async () => {
|
|
try { await prepareAlertAudio(); playAlertChime(); $("alerts-error").textContent = ""; }
|
|
catch (error) { $("alerts-error").textContent = error.message; }
|
|
});
|
|
renderAlertSettings();
|
|
|
|
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 === ALERTS_KEY) {
|
|
alertSettings = readAlertSettings();
|
|
alertObservation = null;
|
|
renderAlertSettings();
|
|
}
|
|
if (event.key === PRESENCE_KEY || event.key === TOKEN_KEY) {
|
|
sharePresence = localStorage.getItem(PRESENCE_KEY) !== "false";
|
|
if (event.key === TOKEN_KEY) isAdmin = false;
|
|
token = localStorage.getItem(TOKEN_KEY);
|
|
updateButton();
|
|
syncPresence();
|
|
if (event.key === TOKEN_KEY && token) loadMe();
|
|
}
|
|
});
|
|
|
|
function renderSections() {
|
|
const available = {
|
|
"account-section": true,
|
|
"leaderboard-section": true,
|
|
"seasons-section": true,
|
|
"chat-section": true,
|
|
"alerts-section": true,
|
|
"admin-section": !!token && isAdmin,
|
|
"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;
|
|
if (!token || !isAdmin) clearAdminData();
|
|
}
|
|
|
|
$("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();
|
|
if (selectedSection === "seasons-section") loadSeasons();
|
|
if (selectedSection === "admin-section") loadAdmin();
|
|
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`;
|
|
}
|
|
|
|
let faviconZone = "green";
|
|
|
|
function updateFavicon(zoneName) {
|
|
if (zoneName === faviconZone) return;
|
|
$("zone-favicon").href = `/favicon-${zoneName}.svg`;
|
|
faviconZone = zoneName;
|
|
}
|
|
|
|
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;
|
|
const changedSeason = game && game.season?.id !== s.season?.id;
|
|
if (changedSeason) {
|
|
statsLoaded = false;
|
|
currentShareText = "";
|
|
alertObservation = null;
|
|
$("press-result").textContent = "";
|
|
$("me-section").hidden = true;
|
|
}
|
|
clockOffset = s.now - (t0 + Date.now()) / 2;
|
|
game = s;
|
|
if (s.season) {
|
|
$("season-subtitle").textContent = s.season.name;
|
|
$("season-label").textContent = `${s.ended ? "Season finished. " : ""}Next season: ${new Date(s.season.ends_at).toISOString().slice(0, 10)} UTC.`;
|
|
}
|
|
observeZoneAlerts(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();
|
|
if (selectedSection === "seasons-section") await loadSeasons();
|
|
}
|
|
|
|
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);
|
|
updateFavicon(zone.name);
|
|
$("zone-label").textContent = game.ended ? "season finished" : `${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`;
|
|
} else if (achievement.scope === "lifetime") {
|
|
progressText = achievement.id === "season_veteran"
|
|
? `${achievement.progress} / ${achievement.target} completed seasons played` : "Awarded when a season finishes. Kept across seasons.";
|
|
}
|
|
if (achievement.unlocked) {
|
|
progressText = `Unlocked ${new Date(achievement.unlocked_at).toLocaleString()}`;
|
|
}
|
|
content.append(el("small", progressText));
|
|
if (["full_spectrum", "keeping_us_alive", "season_veteran"].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;
|
|
const requestedToken = token;
|
|
try {
|
|
const me = await api("GET", "/api/me");
|
|
if (token !== requestedToken) return;
|
|
isAdmin = me.is_admin === true;
|
|
$("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;
|
|
isAdmin = false;
|
|
localStorage.removeItem(TOKEN_KEY);
|
|
updateButton();
|
|
}
|
|
}
|
|
}
|
|
|
|
function clearAdminData() {
|
|
adminSnapshot = null;
|
|
for (const id of ["admin-overview", "admin-players", "admin-messages", "admin-audit"]) $(id).replaceChildren();
|
|
$("admin-status").textContent = "";
|
|
$("admin-error").textContent = "";
|
|
$("admin-player-filter").value = "";
|
|
}
|
|
|
|
function adminButton(text, action, target, value, extra = {}) {
|
|
const button = el("button", text, action === "delete_player" || action === "delete_message" ? "danger-button" : "");
|
|
button.type = "button";
|
|
button.disabled = adminBusy;
|
|
button.addEventListener("click", async () => {
|
|
const confirmation = action === "delete_player"
|
|
? `Delete account ${extra.name}? Login credentials will be removed, messages anonymized, and historical scores kept as Deleted player. This cannot be undone.`
|
|
: action === "delete_message" ? "Permanently remove this message from chat?"
|
|
: `${text} for ${extra.name}? Existing sessions will be revoked and the player must log in again.`;
|
|
if (adminBusy || !window.confirm(confirmation)) return;
|
|
adminBusy = true;
|
|
$("admin-error").textContent = "";
|
|
document.querySelectorAll("#admin-section button").forEach((b) => { b.disabled = true; });
|
|
try {
|
|
await api("POST", "/api/admin/action", { action, target_id: String(target),
|
|
...(value === undefined ? {} : { value }), ...(extra.at === undefined ? {} : { at: extra.at }) });
|
|
$("admin-status").textContent = "Action completed.";
|
|
chatSignature = null;
|
|
await loadAdmin();
|
|
await loadMe();
|
|
} catch (error) {
|
|
$("admin-error").textContent = error.message;
|
|
if (error.status === 401 || error.status === 403) { isAdmin = false; renderSections(); }
|
|
} finally {
|
|
adminBusy = false;
|
|
document.querySelectorAll("#admin-section button").forEach((b) => { b.disabled = false; });
|
|
renderAdminPlayers();
|
|
}
|
|
});
|
|
return button;
|
|
}
|
|
|
|
function renderAdminPlayers() {
|
|
if (!adminSnapshot) return;
|
|
const query = $("admin-player-filter").value.toLowerCase();
|
|
const players = adminSnapshot.players.filter((p) => p.name.toLowerCase().includes(query));
|
|
$("admin-players").replaceChildren(...players.map((player) => {
|
|
const item = el("li", undefined, "admin-card");
|
|
item.append(el("strong", player.name), el("p", `${player.is_admin ? "Admin" : "Player"} / ${player.is_banned ? "Banned" : "Active"} / ${player.total_points} points / ${player.press_count} presses / ${player.sessions} sessions`, "fine-print"));
|
|
const actions = el("div", undefined, "form-actions");
|
|
if (player.id === adminSnapshot.actor_id) {
|
|
actions.append(el("span", "Your account (protected)", "site-badge"));
|
|
} else {
|
|
actions.append(adminButton(player.is_admin ? "Remove admin" : "Make admin", "set_admin", player.id, !player.is_admin, { name: player.name }),
|
|
adminButton(player.is_banned ? "Unban" : "Ban", "set_banned", player.id, !player.is_banned, { name: player.name }),
|
|
adminButton("Delete account", "delete_player", player.id, undefined, { name: player.name }));
|
|
}
|
|
item.append(actions);
|
|
return item;
|
|
}));
|
|
}
|
|
|
|
async function loadAdmin() {
|
|
if (!token || !isAdmin) return;
|
|
const requestedToken = token;
|
|
try {
|
|
const data = await api("GET", "/api/admin");
|
|
if (token !== requestedToken || !isAdmin) return;
|
|
adminSnapshot = data;
|
|
$("admin-overview").replaceChildren(...[
|
|
[data.season.name, data.game.ended ? "Season finished" : "Season active"],
|
|
[data.players.length.toLocaleString(), "Player accounts"],
|
|
[data.game.total_presses.toLocaleString(), "Season presses"],
|
|
[data.game.stored_chat_messages.toLocaleString(), "Stored chat rows"],
|
|
].map(([value, label]) => { const card = el("div", undefined, "metric-card"); card.append(el("span", label), el("strong", value)); return card; }));
|
|
renderAdminPlayers();
|
|
$("admin-messages").replaceChildren(...data.messages.slice().reverse().map((message) => {
|
|
const item = el("li", undefined, "admin-card");
|
|
item.append(el("strong", message.name), el("small", `${new Date(message.at).toLocaleString()} / ${message.kind}`, "fine-print"),
|
|
el("p", message.message), adminButton("Remove message", "delete_message", message.id, undefined, { at: message.at }));
|
|
return item;
|
|
}));
|
|
fillTable($("admin-audit"), ["When", "Actor", "Action", "Target"], data.audit.map((entry) => [
|
|
new Date(entry.at).toLocaleString(), entry.actor_id, entry.action, entry.target_id,
|
|
]));
|
|
$("admin-status").textContent = `Last refreshed ${new Date().toLocaleTimeString()}. Refresh manually to keep controls stable.`;
|
|
} catch (error) {
|
|
$("admin-error").textContent = error.message;
|
|
if (error.status === 401 || error.status === 403) { isAdmin = false; renderSections(); }
|
|
}
|
|
}
|
|
$("admin-refresh").addEventListener("click", () => { if (!adminBusy) loadAdmin(); });
|
|
$("admin-player-filter").addEventListener("input", renderAdminPlayers);
|
|
|
|
function seasonDuration(ms) {
|
|
const days = Math.floor(ms / 86400000);
|
|
return `${days ? `${days}d ` : ""}${formatMs(ms % 86400000).slice(0, -3)}`;
|
|
}
|
|
|
|
async function loadSeasons() {
|
|
try {
|
|
const result = await api("GET", "/api/seasons");
|
|
const current = result.current;
|
|
$("season-current").replaceChildren(
|
|
el("p", "CURRENT SEASON", "eyebrow"), el("h3", current.name),
|
|
el("p", `${current.ended ? "Finished - next countdown" : "Next season"}: ${new Date(current.ends_at).toISOString().slice(0, 10)} at 00:00 UTC`, "fine-print"),
|
|
);
|
|
$("seasons-status").textContent = result.seasons.length ? "" : "No finished seasons yet. This chapter is still being written.";
|
|
$("season-winners").hidden = result.seasons.length === 0;
|
|
fillTable($("season-winners"), ["Season", "Winner(s)", "Points each", "Lasted"], result.seasons.map((season) => [
|
|
season.name, season.winners.map((p) => p.name).join(", ") || "No winner (no presses)",
|
|
season.winners.length ? season.winners[0].total_points.toLocaleString() : "--", seasonDuration(season.duration_ms),
|
|
]));
|
|
const container = $("season-standings");
|
|
// Do not replace focused/open archive details on every poll.
|
|
const signature = JSON.stringify(result.seasons);
|
|
if (container.dataset.signature !== signature) {
|
|
container.dataset.signature = signature;
|
|
container.replaceChildren(...result.seasons.map((season) => {
|
|
const details = el("details");
|
|
details.append(el("summary", `${season.name} - final standings`));
|
|
details.append(el("p", `Played from ${new Date(season.played_from).toLocaleString()} until ${new Date(season.finished_at).toLocaleString()}. Ended by ${season.reason === "boundary" ? "the next season" : "the countdown"}.`, "fine-print"));
|
|
const wrapper = el("div", undefined, "table-scroll");
|
|
const table = el("table");
|
|
fillTable(table, ["#", "Player", "Points", "Presses"], season.leaderboard.map((player, index) => [
|
|
index + 1, player.name, player.total_points.toLocaleString(), player.press_count.toLocaleString(),
|
|
]));
|
|
wrapper.append(table);
|
|
details.append(wrapper);
|
|
return details;
|
|
}));
|
|
}
|
|
} catch {
|
|
$("seasons-status").textContent = "Seasons could not be refreshed. Retrying automatically...";
|
|
}
|
|
}
|
|
|
|
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 = JSON.stringify(messages);
|
|
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;
|
|
}
|
|
}
|
|
|
|
const CHAT_EMOJIS = [
|
|
["smile", "😄"], ["smiley", "😃"], ["grin", "😁"], ["laughing", "😆"],
|
|
["joy", "😂"], ["rofl", "🤣"], ["wink", "😉"], ["blush", "😊"],
|
|
["heart_eyes", "😍"], ["thinking", "🤔"], ["sunglasses", "😎"], ["sweat_smile", "😅"],
|
|
["nervous", "😬"], ["eyes", "👀"], ["cry", "😢"], ["sob", "😭"],
|
|
["neutral", "😐"], ["surprised", "😮"], ["scream", "😱"], ["sleeping", "😴"],
|
|
["heart", "❤️"], ["green_heart", "💚"], ["blue_heart", "💙"], ["yellow_heart", "💛"],
|
|
["purple_heart", "💜"], ["black_heart", "🖤"], ["white_heart", "🤍"],
|
|
["thumbsup", "👍"], ["+1", "👍"], ["thumbsdown", "👎"], ["-1", "👎"],
|
|
["clap", "👏"], ["wave", "👋"], ["pray", "🙏"], ["muscle", "💪"],
|
|
["fire", "🔥"], ["tada", "🎉"], ["party", "🥳"], ["sparkles", "✨"],
|
|
["star", "⭐"], ["trophy", "🏆"], ["medal", "🏅"], ["rocket", "🚀"],
|
|
["hourglass", "⌛"], ["clock", "🕒"], ["alarm", "⏰"], ["stopwatch", "⏱️"],
|
|
["button", "🔘"], ["red_circle", "🔴"], ["blue_circle", "🔵"], ["green_circle", "🟢"],
|
|
["yellow_circle", "🟡"], ["white_circle", "⚪"], ["black_circle", "⚫"],
|
|
["100", "💯"], ["gg", "🤝"], ["skull", "💀"], ["coffee", "☕"],
|
|
];
|
|
let emojiMatches = [];
|
|
let emojiSelection = 0;
|
|
let emojiRange = null;
|
|
|
|
function closeEmojiSuggestions() {
|
|
emojiMatches = [];
|
|
emojiRange = null;
|
|
$("emoji-suggestions").hidden = true;
|
|
$("chat-input").setAttribute("aria-expanded", "false");
|
|
$("chat-input").removeAttribute("aria-activedescendant");
|
|
}
|
|
|
|
function renderEmojiSuggestions() {
|
|
const list = $("emoji-suggestions");
|
|
list.replaceChildren(...emojiMatches.map(([name, emoji], index) => {
|
|
const option = el("li", undefined, "emoji-option");
|
|
option.id = `emoji-option-${index}`;
|
|
option.dataset.index = String(index);
|
|
option.setAttribute("role", "option");
|
|
option.setAttribute("aria-selected", String(index === emojiSelection));
|
|
option.append(el("span", emoji, "emoji-glyph"), el("span", `:${name}:`));
|
|
return option;
|
|
}));
|
|
list.hidden = false;
|
|
$("chat-input").setAttribute("aria-expanded", "true");
|
|
$("chat-input").setAttribute("aria-activedescendant", `emoji-option-${emojiSelection}`);
|
|
}
|
|
|
|
function suggestEmoji() {
|
|
const input = $("chat-input");
|
|
if (input.selectionStart !== input.selectionEnd) return closeEmojiSuggestions();
|
|
const before = input.value.slice(0, input.selectionStart);
|
|
const match = before.match(/(?:^|\s):([a-z0-9_+\-]*):?$/i);
|
|
if (!match) return closeEmojiSuggestions();
|
|
const query = match[1].toLowerCase();
|
|
// A completed shortcode must match exactly; never turn an unknown code into another emoji.
|
|
const complete = before.endsWith(":") && match[0].trim() !== ":";
|
|
emojiMatches = CHAT_EMOJIS.filter(([name]) => complete ? name === query : name.startsWith(query)).slice(0, 6);
|
|
if (!emojiMatches.length) return closeEmojiSuggestions();
|
|
emojiSelection = 0;
|
|
emojiRange = { start: before.lastIndexOf(":", complete ? before.length - 2 : before.length - 1), end: input.selectionStart };
|
|
renderEmojiSuggestions();
|
|
}
|
|
|
|
function insertEmoji(index) {
|
|
if (!emojiRange || !emojiMatches[index]) return;
|
|
const input = $("chat-input");
|
|
const emoji = emojiMatches[index][1];
|
|
const { start, end } = emojiRange;
|
|
if (input.value.length - (end - start) + emoji.length > input.maxLength) {
|
|
$("chat-error").textContent = "This emoji would exceed the message length limit.";
|
|
closeEmojiSuggestions();
|
|
return;
|
|
}
|
|
input.setRangeText(emoji, start, end, "end");
|
|
$("chat-error").textContent = "";
|
|
closeEmojiSuggestions();
|
|
input.focus();
|
|
}
|
|
|
|
$("chat-input").addEventListener("input", (event) => {
|
|
if (!event.isComposing) suggestEmoji();
|
|
});
|
|
$("chat-input").addEventListener("compositionend", suggestEmoji);
|
|
$("chat-input").addEventListener("click", suggestEmoji);
|
|
$("chat-input").addEventListener("blur", closeEmojiSuggestions);
|
|
$("chat-input").addEventListener("keydown", (event) => {
|
|
if (event.isComposing || !emojiMatches.length) return;
|
|
if (["ArrowDown", "ArrowUp"].includes(event.key)) {
|
|
event.preventDefault();
|
|
emojiSelection = (emojiSelection + (event.key === "ArrowDown" ? 1 : -1) + emojiMatches.length) % emojiMatches.length;
|
|
renderEmojiSuggestions();
|
|
} else if (["Enter", "Tab"].includes(event.key)) {
|
|
event.preventDefault();
|
|
insertEmoji(emojiSelection);
|
|
} else if (event.key === "Escape") {
|
|
event.preventDefault();
|
|
closeEmojiSuggestions();
|
|
} else if (["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) {
|
|
closeEmojiSuggestions();
|
|
}
|
|
});
|
|
$("emoji-suggestions").addEventListener("pointerdown", (event) => {
|
|
const option = event.target.closest("[data-index]");
|
|
if (!option) return;
|
|
event.preventDefault(); // keep the caret and avoid blur before insertion
|
|
insertEmoji(Number(option.dataset.index));
|
|
});
|
|
|
|
$("chat-form").addEventListener("submit", async (event) => {
|
|
event.preventDefault();
|
|
const input = $("chat-input");
|
|
closeEmojiSuggestions();
|
|
$("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() {
|
|
const seasonId = game?.season?.id;
|
|
try {
|
|
const s = await api("GET", "/api/stats");
|
|
if (game?.season?.id !== seasonId) return;
|
|
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);
|
|
})();
|