Home-screen web app; phone layout; reload on deploy
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
9058039eb7
commit
14b4c07208
8 changed files with 121 additions and 25 deletions
|
|
@ -14,6 +14,8 @@ let selected = null; // bank item
|
|||
let pickedClass = null; // registration
|
||||
let refreshing = false;
|
||||
let refreshedFor = null; // action start a completion refresh was sent for
|
||||
let version = null; // server build this page was loaded from
|
||||
let failures = 0; // polls in a row that didn't reach the server
|
||||
|
||||
const $ = (sel, root = document) => root.querySelector(sel);
|
||||
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
||||
|
|
@ -56,14 +58,22 @@ async function refresh() {
|
|||
refreshing = true;
|
||||
try {
|
||||
accept(await api("/api/state"));
|
||||
failures = 0;
|
||||
} catch (err) {
|
||||
toast(err.message, "bad");
|
||||
// A deploy restarts the server for a second or two; only speak up if
|
||||
// the city stays out of reach.
|
||||
if (++failures === 3) toast("Lost contact with the city. Retrying…", "bad");
|
||||
} finally {
|
||||
refreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
function accept(state) {
|
||||
version ??= state.version;
|
||||
if (state.version !== version && !document.querySelector("[data-keep]:focus") && $("#modal").hidden) {
|
||||
location.reload(); // a new build was deployed
|
||||
return;
|
||||
}
|
||||
S = state;
|
||||
skew = state.now - Date.now() / 1000;
|
||||
if (state.summary) showSummary(state.summary);
|
||||
|
|
@ -87,12 +97,18 @@ function clock(t) {
|
|||
return new Date((t - skew) * 1000).toLocaleString("en-GB", { weekday: "short", hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
// Coarse on purpose: the markup stays the same between polls (see setHtml).
|
||||
function ago(t) {
|
||||
const s = serverNow() - t;
|
||||
if (s < 60) return "just now";
|
||||
return dur(s) + " ago";
|
||||
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
|
||||
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
|
||||
return `${Math.floor(s / 86400)}d ago`;
|
||||
}
|
||||
|
||||
// A live "3h 12m" until `t`, filled in and kept current by animate().
|
||||
const countdown = (t) => `<span data-countdown="${t}"></span>`;
|
||||
|
||||
// --- toasts & summaries ---------------------------------------------------
|
||||
|
||||
function toast(text, kind = "") {
|
||||
|
|
@ -101,7 +117,8 @@ function toast(text, kind = "") {
|
|||
el.textContent = text;
|
||||
$("#toasts").append(el);
|
||||
setTimeout(() => el.remove(), kind === "level" ? 6000 : 3500);
|
||||
while ($("#toasts").children.length > 5) $("#toasts").firstChild.remove();
|
||||
const keep = matchMedia("(max-width: 900px)").matches ? 2 : 4;
|
||||
while ($("#toasts").children.length > keep) $("#toasts").firstChild.remove();
|
||||
}
|
||||
|
||||
function showSummary(sum) {
|
||||
|
|
@ -147,9 +164,9 @@ function render() {
|
|||
for (const el of document.querySelectorAll("[data-keep]")) kept[el.dataset.keep] = el.value;
|
||||
const focus = document.activeElement?.dataset?.keep;
|
||||
|
||||
$("#side").innerHTML = S.me ? sidebar() : `<div class="brand"><img src="/static/icon.svg" alt="">Guildhall <small>alpha</small></div>${S.dev ? devbar() : ""}`;
|
||||
$("#top").innerHTML = topbar();
|
||||
$("#main").innerHTML = S.me ? pageHtml() : register();
|
||||
setHtml($("#side"), S.me ? sidebar() : `<div class="brand"><img src="/static/icon.svg" alt="">Guildhall <small>alpha</small></div>${S.dev ? devbar() : ""}`);
|
||||
setHtml($("#top"), topbar());
|
||||
setHtml($("#main"), S.me ? pageHtml() : register());
|
||||
|
||||
for (const [key, value] of Object.entries(kept)) {
|
||||
const el = document.querySelector(`[data-keep="${key}"]`);
|
||||
|
|
@ -161,6 +178,21 @@ function render() {
|
|||
animate();
|
||||
}
|
||||
|
||||
// Only touch the DOM when the markup changed. Replacing an element mid-swipe
|
||||
// cancels the swipe on iOS, and polls arrive every few seconds.
|
||||
function setHtml(el, html) {
|
||||
if (el.dataset.html === html) return;
|
||||
const scroll = el.scrollTop;
|
||||
el.innerHTML = html;
|
||||
el.dataset.html = html;
|
||||
el.scrollTop = scroll;
|
||||
}
|
||||
|
||||
function setMenu(open) {
|
||||
$("#app").classList.toggle("open", open);
|
||||
document.documentElement.classList.toggle("menu-open", open);
|
||||
}
|
||||
|
||||
function nav(id, icon, label, meta = "", extra = "") {
|
||||
return `<button class="nav ${page === id ? "on" : ""}" data-nav="${id}"><span class="ic">${icon}</span><span class="label">${label}</span>${extra}<span class="meta">${meta}</span></button>`;
|
||||
}
|
||||
|
|
@ -229,9 +261,8 @@ function eventIcon(kind) {
|
|||
}
|
||||
|
||||
function eventWhen(ev, short = false) {
|
||||
const now = serverNow();
|
||||
if (ev.exact) return short ? `in ${dur(ev.exact - now)}` : `${clock(ev.exact)} (in ${dur(ev.exact - now)})`;
|
||||
if (short) return `in ${dur(ev.earliest - now)}–${dur(ev.latest - now)}`;
|
||||
if (ev.exact) return short ? `in ${countdown(ev.exact)}` : `${clock(ev.exact)} (in ${countdown(ev.exact)})`;
|
||||
if (short) return ev.earliest <= serverNow() + 60 ? `within ${countdown(ev.latest)}` : `in ${countdown(ev.earliest)}–${countdown(ev.latest)}`;
|
||||
return `between ${clock(ev.earliest)} and ${clock(ev.latest)}`;
|
||||
}
|
||||
|
||||
|
|
@ -397,11 +428,11 @@ function town() {
|
|||
<div class="stat"><div class="k">Raids repelled</div><div class="v">${city.repelled}</div></div>
|
||||
</div>`;
|
||||
if (city.starving) html += `<div class="banner bad">🍞<div class="grow"><b>The granary is empty.</b> People are leaving and the city defends worse. Donate bread, cooked meat or stew.</div></div>`;
|
||||
if (city.merchant_until > serverNow()) html += `<div class="banner">🐪<div class="grow"><b>A merchant caravan is in town.</b> Everything sells for 50% more for another ${dur(city.merchant_until - serverNow())}.</div></div>`;
|
||||
if (city.merchant_until > serverNow()) html += `<div class="banner">🐪<div class="grow"><b>A merchant caravan is in town.</b> Everything sells for 50% more for another ${countdown(city.merchant_until)}.</div></div>`;
|
||||
const work = city.construction;
|
||||
if (work) {
|
||||
const spec = C.buildings[work.building];
|
||||
html += `<div class="banner">🏗️<div class="grow"><b>${work.repair ? "Repairing" : "Building"} ${esc(spec.name)}</b> · done in <span data-countdown="${work.done_at}">${dur(work.done_at - serverNow())}</span>
|
||||
html += `<div class="banner">🏗️<div class="grow"><b>${work.repair ? "Repairing" : "Building"} ${esc(spec.name)}</b> · done in ${countdown(work.done_at)}
|
||||
<div class="bar gold" style="margin-top:6px"><span data-span="${work.start},${work.done_at}"></span></div></div></div>`;
|
||||
}
|
||||
html += `<div class="section-title">Buildings</div><div class="grid wide">`;
|
||||
|
|
@ -575,16 +606,22 @@ document.addEventListener("click", (e) => {
|
|||
if (!t) return;
|
||||
const d = t.dataset;
|
||||
if (t.matches("[data-close]") || e.target === $("#modal")) { $("#modal").hidden = true; return; }
|
||||
if ("menu" in d) { $("#app").classList.toggle("open"); return; }
|
||||
if ("menu" in d) { setMenu(!$("#app").classList.contains("open")); return; }
|
||||
if (d.nav) {
|
||||
page = d.nav; remember("page", page); selected = null;
|
||||
history.replaceState(null, "", "#" + page);
|
||||
$("#app").classList.remove("open");
|
||||
setMenu(false);
|
||||
render(); window.scrollTo(0, 0); return;
|
||||
}
|
||||
if (d.start) return act("/api/action", { id: d.start });
|
||||
if ("stop" in d) return act("/api/stop", {});
|
||||
if ("select" in d) { selected = d.select || null; render(); return; }
|
||||
if ("select" in d) {
|
||||
selected = d.select || null;
|
||||
render();
|
||||
// On a phone the item panel sits under the bank grid: bring it into view.
|
||||
if (selected && matchMedia("(max-width: 900px)").matches) $(".item-panel")?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
return;
|
||||
}
|
||||
const qty = d.qty === "all" ? "all" : Number(d.qty);
|
||||
if (d.donate) return act("/api/donate", { item: d.donate, qty });
|
||||
if (d.sell) return act("/api/sell", { item: d.sell, qty });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue