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 });
|
||||
|
|
|
|||
BIN
static/apple-touch-icon.png
Normal file
BIN
static/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
BIN
static/icon-192.png
Normal file
BIN
static/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
BIN
static/icon-512.png
Normal file
BIN
static/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.5 KiB |
|
|
@ -4,8 +4,14 @@
|
|||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#14161d">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||
<meta name="apple-mobile-web-app-title" content="Guildhall">
|
||||
<title>Guildhall</title>
|
||||
<link rel="manifest" href="/static/manifest.webmanifest" crossorigin="use-credentials">
|
||||
<link rel="icon" href="/static/icon.svg" type="image/svg+xml">
|
||||
<link rel="apple-touch-icon" href="/static/apple-touch-icon.png">
|
||||
<link rel="stylesheet" href="/static/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
|||
15
static/manifest.webmanifest
Normal file
15
static/manifest.webmanifest
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"name": "Guildhall",
|
||||
"short_name": "Guildhall",
|
||||
"description": "A co-op idle game for a handful of friends.",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#14161d",
|
||||
"theme_color": "#14161d",
|
||||
"icons": [
|
||||
{ "src": "/static/icon-192.png", "sizes": "192x192", "type": "image/png" },
|
||||
{ "src": "/static/icon-512.png", "sizes": "512x512", "type": "image/png" },
|
||||
{ "src": "/static/icon.svg", "sizes": "any", "type": "image/svg+xml" }
|
||||
]
|
||||
}
|
||||
|
|
@ -24,7 +24,7 @@ body {
|
|||
font: 15px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
button, input, textarea, select { font: inherit; color: inherit; }
|
||||
button { cursor: pointer; }
|
||||
button { cursor: pointer; touch-action: manipulation; }
|
||||
a { color: var(--xp); }
|
||||
|
||||
#app { display: flex; min-height: 100vh; }
|
||||
|
|
@ -125,8 +125,8 @@ main { padding: 24px; max-width: 1200px; width: 100%; }
|
|||
.section-title { margin: 26px 0 12px; }
|
||||
.muted { color: var(--muted); }
|
||||
.small { font-size: 13px; }
|
||||
.grid { display: grid; gap: 14px; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); }
|
||||
.grid.wide { grid-template-columns: repeat(auto-fill, minmax(330px, 1fr)); }
|
||||
.grid { display: grid; gap: 14px; grid-template-columns: repeat(auto-fill, minmax(min(250px, 100%), 1fr)); }
|
||||
.grid.wide { grid-template-columns: repeat(auto-fill, minmax(min(330px, 100%), 1fr)); }
|
||||
.cols { display: grid; gap: 18px; grid-template-columns: 1fr 1fr; }
|
||||
.stats { display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); margin-bottom: 18px; }
|
||||
.stat { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 12px 14px; }
|
||||
|
|
@ -264,7 +264,7 @@ table.parts td:last-child { text-align: right; font-variant-numeric: tabular-num
|
|||
|
||||
.welcome { max-width: 760px; }
|
||||
.welcome h2 { font-size: 26px; margin: 0 0 6px; }
|
||||
.classes { display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); margin: 16px 0; }
|
||||
.classes { display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(min(320px, 100%), 1fr)); margin: 16px 0; }
|
||||
.class-card {
|
||||
text-align: left; background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius);
|
||||
padding: 14px; display: flex; gap: 12px;
|
||||
|
|
@ -275,7 +275,7 @@ table.parts td:last-child { text-align: right; font-variant-numeric: tabular-num
|
|||
|
||||
/* --- toasts and modal ------------------------------------------------- */
|
||||
|
||||
#toasts { position: fixed; right: 16px; bottom: 16px; display: flex; flex-direction: column; gap: 8px; z-index: 30; }
|
||||
#toasts { position: fixed; right: 16px; bottom: calc(16px + env(safe-area-inset-bottom)); display: flex; flex-direction: column; gap: 8px; z-index: 30; }
|
||||
.toast {
|
||||
background: var(--panel-2); border: 1px solid var(--line); border-radius: 8px; padding: 8px 14px;
|
||||
font-size: 14px; box-shadow: 0 6px 18px rgba(0, 0, 0, .35); animation: rise .2s ease-out;
|
||||
|
|
@ -294,12 +294,36 @@ table.parts td:last-child { text-align: right; font-variant-numeric: tabular-num
|
|||
|
||||
#scrim { display: none; }
|
||||
@media (max-width: 900px) {
|
||||
#side { position: fixed; left: 0; top: 0; z-index: 20; transform: translateX(-100%); transition: transform .2s; }
|
||||
#app.open #side { transform: none; }
|
||||
/* A drawer pinned to the screen edges (not 100vh, which iOS sizes for the
|
||||
largest viewport) that keeps its scrolling to itself. */
|
||||
#side {
|
||||
position: fixed; left: 0; top: 0; bottom: 0; height: auto; z-index: 20;
|
||||
width: min(280px, 85vw);
|
||||
transform: translateX(-100%); transition: transform .2s;
|
||||
overscroll-behavior: contain; -webkit-overflow-scrolling: touch;
|
||||
padding-top: env(safe-area-inset-top);
|
||||
padding-bottom: calc(24px + env(safe-area-inset-bottom));
|
||||
}
|
||||
#app.open #side { transform: none; box-shadow: 0 0 40px rgba(0, 0, 0, .5); }
|
||||
#app.open #scrim { display: block; position: fixed; inset: 0; z-index: 15; background: rgba(0, 0, 0, .5); }
|
||||
html.menu-open, html.menu-open body { overflow: hidden; }
|
||||
.menu-btn { display: block; }
|
||||
#top { padding: 10px 16px; flex-wrap: wrap; }
|
||||
main { padding: 16px; }
|
||||
#top { padding: 8px max(16px, env(safe-area-inset-right)) 8px max(16px, env(safe-area-inset-left)); flex-wrap: wrap; gap: 8px; }
|
||||
#top h1 { flex: 1 1 auto; }
|
||||
main { padding: 16px max(16px, env(safe-area-inset-right)) calc(32px + env(safe-area-inset-bottom)) max(16px, env(safe-area-inset-left)); }
|
||||
.cols, .bank-wrap { grid-template-columns: 1fr; }
|
||||
.chip.act .label { max-width: 110px; overflow: hidden; text-overflow: ellipsis; }
|
||||
.chip { max-width: 100%; }
|
||||
.chip.act .label { max-width: 120px; overflow: hidden; text-overflow: ellipsis; }
|
||||
#toasts { left: 16px; right: 16px; }
|
||||
.stats { grid-template-columns: repeat(2, 1fr); }
|
||||
.skill-head { flex-wrap: wrap; }
|
||||
.skill-head .info { flex-basis: 60%; }
|
||||
.slots { grid-template-columns: repeat(auto-fill, minmax(64px, 1fr)); }
|
||||
.levels { grid-template-columns: repeat(2, 1fr); }
|
||||
/* iOS zooms into any input under 16px when it's focused. */
|
||||
input[type=text], input[type=number], textarea, select { font-size: 16px; }
|
||||
}
|
||||
@media (pointer: coarse) {
|
||||
.nav { padding: 11px 18px; }
|
||||
.btn.sm { padding: 6px 11px; }
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue