Files
guestbook/web/index.html
T
Akili 3a68f64eb6 feat: add emoji reactions, light/dark themes and rollout observability
Add a set of guest-facing and operator-facing features that make the
deployment-strategy demos (recreate, rolling, canary) more visual:

- Emoji reactions (heart/tada/thumbsup/smile): visitors react without an
  account, a first-party cookie keeps counts honest, the tally streams
  live to every tab over SSE. Backed by a new reactions table with an
  idempotent upsert; whitelisted emoji so the endpoint can't be abused.
- Light & dark themes: the wall follows the OS preference, visitors can
  toggle it (persisted locally), and a moderator can force a theme for
  everyone from the admin console, pushed live over SSE.
- Per-replica request counter: a middleware tallies requests answered by
  each replica; surfaced in /healthz, /api/info and the admin replica
  table. Under load-balancing the counts diverge, and a rolling update
  resets them one container at a time — the rollout made visible.
- Rolling-update wave in the Chaos lab: bounces replicas one at a time
  (via the crash endpoint) while polling the replica table, so a training
  session can watch each replica reset and rejoin — what a rolling update
  looks like from the inside.
- Admin console: reactions tile, per-replica Requests column, reaction
  tallies in moderation, and a wall-theme setting.
- Favicon + /favicon.ico redirect.

Tests cover reactions (idempotency, tallies, whitelist) and the theme
setting round-trip.

Akili-Task: tsk_xgg6sfuzkzymyc6gstr4
Akili-Agent: goldfish
2026-10-05 21:36:23 +02:00

634 lines
30 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Miabi Guestbook</title>
<meta name="description" content="A tiny Okapi + Postgres example app, deployed on Miabi." />
<meta name="theme-color" content="#0b0a12" />
<link rel="icon" href="/badges/icon.svg" type="image/svg+xml" />
<style>
/* Dark is the default look; body.light switches to the light palette.
The moderator can also force one for everyone (wall setting "theme"). */
:root, body:not(.light) {
color-scheme: dark;
--bg: #0b0a12;
--bg-2: #12101d;
--card: rgba(255, 255, 255, 0.04);
--card-border: rgba(255, 255, 255, 0.08);
--text: #ece9f5;
--muted: #9b95b3;
--accent: #8b5cf6;
--accent-2: #a855f7;
--danger: #f43f5e;
--online: #22c55e;
--input-bg: rgba(0, 0, 0, 0.25);
--via-bg: rgba(0, 0, 0, 0.2);
--glow-a: rgba(139, 92, 246, 0.18);
--glow-b: rgba(168, 85, 247, 0.14);
--radius: 16px;
--shadow: 0 20px 60px -20px rgba(139, 92, 246, 0.35);
}
body.light {
color-scheme: light;
--bg: #f4f2fb;
--bg-2: #e9e5f7;
--card: rgba(255, 255, 255, 0.72);
--card-border: rgba(15, 10, 40, 0.1);
--text: #221c3d;
--muted: #645e7d;
--accent: #7c3aed;
--accent-2: #9333ea;
--danger: #e11d48;
--online: #16a34a;
--input-bg: rgba(255, 255, 255, 0.8);
--via-bg: rgba(124, 58, 237, 0.08);
--glow-a: rgba(139, 92, 246, 0.22);
--glow-b: rgba(168, 85, 247, 0.16);
--shadow: 0 20px 50px -24px rgba(124, 58, 237, 0.35);
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
min-height: 100vh;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
color: var(--text);
background:
radial-gradient(900px 500px at 15% -10%, var(--glow-a), transparent 60%),
radial-gradient(700px 500px at 110% 10%, var(--glow-b), transparent 55%),
linear-gradient(180deg, var(--bg), var(--bg-2));
background-attachment: fixed;
-webkit-font-smoothing: antialiased;
}
.wrap { max-width: 780px; margin: 0 auto; padding: 32px 20px 80px; }
.topbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 26px; }
.logo {
display: inline-flex; align-items: center; gap: 8px;
padding: 6px 14px; border-radius: 999px;
background: var(--card); border: 1px solid var(--card-border);
font-size: 12.5px; font-weight: 600; letter-spacing: 0.02em; color: var(--muted);
}
.logo .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent);
box-shadow: 0 0 12px 2px var(--accent); }
.status { display: inline-flex; align-items: center; gap: 8px; }
.ver {
font-size: 12.5px; font-weight: 700; color: #fff; padding: 5px 12px; border-radius: 999px;
background: var(--accent); letter-spacing: 0.02em; white-space: nowrap;
}
.online {
display: inline-flex; align-items: center; gap: 7px; font-size: 12.5px; color: var(--muted);
padding: 5px 12px; border-radius: 999px; background: var(--card); border: 1px solid var(--card-border);
}
.pulse { width: 8px; height: 8px; border-radius: 50%; background: var(--online); position: relative; }
.pulse::after {
content: ""; position: absolute; inset: -4px; border-radius: 50%;
background: var(--online); opacity: .5; animation: ping 1.6s cubic-bezier(0,0,.2,1) infinite;
}
@keyframes ping { 0% { transform: scale(1); opacity: .5; } 75%, 100% { transform: scale(2.4); opacity: 0; } }
.icon-btn {
font: inherit; font-size: 14px; line-height: 1; cursor: pointer; color: var(--text);
display: inline-flex; align-items: center; justify-content: center;
width: 30px; height: 30px; border-radius: 999px;
background: var(--card); border: 1px solid var(--card-border);
transition: border-color .15s, transform .15s;
}
.icon-btn:hover { border-color: var(--accent); transform: translateY(-1px); }
header { text-align: center; margin-bottom: 26px; }
h1 {
font-size: clamp(30px, 6vw, 46px); line-height: 1.05; margin: 0 0 12px;
font-weight: 800; letter-spacing: -0.02em;
}
h1 .grad {
background: linear-gradient(120deg, var(--accent), var(--accent-2) 60%, #f0abfc);
-webkit-background-clip: text; background-clip: text; color: transparent;
}
.sub { color: var(--muted); font-size: 16px; max-width: 520px; margin: 0 auto; line-height: 1.6; }
.tabs { display: inline-flex; gap: 4px; margin-top: 22px; padding: 4px; border-radius: 12px;
background: var(--card); border: 1px solid var(--card-border); }
.tabs a {
text-decoration: none; color: var(--muted); font-size: 14px; font-weight: 600;
padding: 8px 18px; border-radius: 9px; transition: color .15s, background .15s;
}
.tabs a.active { color: #fff; background: linear-gradient(120deg, var(--accent), var(--accent-2)); }
.tabs a:not(.active):hover { color: var(--text); }
.card {
background: var(--card); border: 1px solid var(--card-border);
border-radius: var(--radius); backdrop-filter: blur(12px);
}
.clock {
display: flex; align-items: center; justify-content: space-between; gap: 14px;
padding: 16px 20px; margin-bottom: 18px;
}
.clock-left { display: flex; flex-direction: column; gap: 2px; }
.clock-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); }
.clock-time { font-size: 26px; font-weight: 800; font-variant-numeric: tabular-nums;
letter-spacing: 0.02em; color: var(--text); }
.clock-meta { font-size: 12px; color: var(--muted); text-align: right; line-height: 1.6; }
.clock-meta b { color: var(--text); font-weight: 600; }
.clock.stale { opacity: .5; }
form.compose { padding: 20px; box-shadow: var(--shadow); margin-bottom: 14px; }
.row { display: flex; gap: 12px; flex-wrap: wrap; }
input, textarea {
width: 100%; font: inherit; color: var(--text);
background: var(--input-bg); border: 1px solid var(--card-border);
border-radius: 12px; padding: 13px 15px; outline: none; resize: vertical;
transition: border-color .15s, box-shadow .15s;
}
input:focus, textarea:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.22);
}
.name-field { flex: 1 1 200px; }
textarea { margin-top: 12px; min-height: 84px; }
.actions { display: flex; align-items: center; justify-content: space-between; margin-top: 14px; gap: 12px; }
.count { font-size: 12.5px; color: var(--muted); }
button.primary {
font: inherit; font-weight: 650; color: white; cursor: pointer;
border: 0; border-radius: 12px; padding: 12px 22px;
background: linear-gradient(120deg, var(--accent), var(--accent-2));
box-shadow: 0 8px 20px -6px rgba(139, 92, 246, 0.6);
transition: transform .12s, box-shadow .12s, opacity .12s;
}
button.primary:hover { transform: translateY(-1px); }
button.primary:disabled { opacity: .55; cursor: not-allowed; transform: none; }
.meta { display: flex; align-items: center; justify-content: space-between; margin: 26px 4px 14px; }
.meta h2 { font-size: 15px; font-weight: 600; color: var(--muted); margin: 0; letter-spacing: 0.02em; }
.meta .total {
font-size: 12px; color: var(--muted); background: var(--card);
border: 1px solid var(--card-border); padding: 4px 11px; border-radius: 999px;
}
ul.entries { list-style: none; padding: 0; margin: 0; display: grid; gap: 12px; }
li.entry {
padding: 18px 20px; position: relative;
animation: pop .25s ease both;
}
@keyframes pop { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
.entry .msg { font-size: 15.5px; line-height: 1.6; white-space: pre-wrap; word-break: break-word; }
.entry .foot { display: flex; align-items: center; gap: 10px; margin-top: 12px; font-size: 13px; }
.avatar {
width: 26px; height: 26px; border-radius: 50%; flex: 0 0 auto;
display: grid; place-items: center; font-size: 11px; font-weight: 700; color: white;
}
.entry .who { font-weight: 600; }
.entry .when { color: var(--muted); }
.entry .via {
margin-left: auto; font-size: 11.5px; color: var(--muted); font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
background: var(--via-bg); border: 1px solid var(--card-border); padding: 2px 8px; border-radius: 999px;
max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
li.entry.pinned { border-color: rgba(139, 92, 246, 0.55); background: rgba(139, 92, 246, 0.08); }
.pin {
position: absolute; top: 14px; right: 16px; font-size: 11px; font-weight: 700; letter-spacing: .06em;
text-transform: uppercase; color: var(--accent-2);
}
.entry .reacts { display: flex; gap: 6px; margin-top: 12px; flex-wrap: wrap; }
.react {
font: inherit; font-size: 12.5px; font-weight: 600; cursor: pointer; color: var(--muted);
display: inline-flex; align-items: center; gap: 5px; padding: 4px 10px; border-radius: 999px;
background: var(--card); border: 1px solid var(--card-border);
transition: border-color .15s, color .15s, transform .1s;
}
.react:hover:not(:disabled) { border-color: var(--accent); color: var(--text); }
.react:active:not(:disabled) { transform: scale(.93); }
.react.on { color: var(--accent-2); border-color: rgba(139, 92, 246, .55); background: rgba(139, 92, 246, .1); }
.react:disabled { opacity: .45; cursor: not-allowed; }
.react .n { font-variant-numeric: tabular-nums; }
.react.bump .n { animation: bump .3s ease; }
@keyframes bump { 40% { transform: scale(1.4); } }
.banner {
display: flex; gap: 10px; align-items: flex-start; padding: 13px 18px; margin-bottom: 18px;
border-color: rgba(245, 158, 11, 0.45); background: rgba(245, 158, 11, 0.1); font-size: 14.5px; line-height: 1.5;
}
.banner .icon { flex: 0 0 auto; }
.paused {
text-align: center; padding: 22px 20px; margin-bottom: 14px; color: var(--muted); font-size: 15px;
}
.paused b { color: var(--text); }
.replicas { font-size: 12.5px; color: var(--muted); padding: 5px 12px; border-radius: 999px;
background: var(--card); border: 1px solid var(--card-border); white-space: nowrap; }
.empty, .loading { text-align: center; color: var(--muted); padding: 40px 20px; font-size: 15px; }
.viewall { text-align: center; margin-top: 20px; }
.viewall a { color: var(--accent-2); text-decoration: none; font-weight: 600; font-size: 14px; }
.viewall a:hover { text-decoration: underline; }
.pager { display: flex; align-items: center; justify-content: center; gap: 14px; margin-top: 22px; }
.pager button {
font: inherit; font-size: 14px; font-weight: 600; color: var(--text); cursor: pointer;
background: var(--card); border: 1px solid var(--card-border); border-radius: 10px; padding: 9px 16px;
transition: border-color .15s, opacity .15s;
}
.pager button:hover:not(:disabled) { border-color: var(--accent); }
.pager button:disabled { opacity: .4; cursor: not-allowed; }
.pager .info { font-size: 13px; color: var(--muted); min-width: 96px; text-align: center; }
footer { text-align: center; margin-top: 46px; color: var(--muted); font-size: 13px; line-height: 1.8; }
footer a { color: var(--accent-2); text-decoration: none; }
footer a:hover { text-decoration: underline; }
.miabi-badge { display: block; width: fit-content; margin: 0 auto 16px; line-height: 0; border-radius: 16px; transition: transform .15s, box-shadow .15s; }
.miabi-badge:hover { transform: translateY(-1px); box-shadow: 0 8px 24px -8px rgba(139, 92, 246, 0.6); }
.stack { display: inline-flex; gap: 6px; flex-wrap: wrap; justify-content: center; }
.pill { background: var(--card); border: 1px solid var(--card-border); padding: 3px 10px; border-radius: 999px; }
.toast {
position: fixed; left: 50%; bottom: 26px; transform: translateX(-50%) translateY(20px);
background: var(--danger); color: white; padding: 11px 18px; border-radius: 12px;
font-size: 14px; font-weight: 500; opacity: 0; pointer-events: none;
transition: opacity .2s, transform .2s; box-shadow: 0 12px 30px -8px rgba(0,0,0,.5);
}
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
[hidden] { display: none !important; }
</style>
</head>
<body>
<div class="wrap">
<div class="topbar">
<div class="logo"><span class="dot"></span> Deployed on Miabi</div>
<div class="status">
<span class="ver" id="ver" title="Serving version">v…</span>
<span class="replicas" id="replicas" title="Running replicas" hidden></span>
<span class="online"><span class="pulse"></span> <span id="online">0</span> online</span>
<button class="icon-btn" id="themeBtn" title="Toggle light / dark theme" aria-label="Toggle light / dark theme">🌓</button>
</div>
</div>
<header>
<h1>The <span class="grad">Guestbook</span></h1>
<p class="sub">A tiny example app — Okapi (Go) API + database, in a single container. Sign the wall and watch it update live.</p>
<nav class="tabs">
<a href="/" data-nav>Home</a>
<a href="/all" data-nav>All signatures</a>
</nav>
</header>
<div class="card banner" id="banner" hidden><span class="icon">📣</span><span id="bannerText"></span></div>
<!-- Live server clock (new in v2) -->
<div class="card clock" id="clock">
<div class="clock-left">
<div class="clock-label">Server time</div>
<div class="clock-time" id="clockTime">—</div>
</div>
<div class="clock-meta" id="clockMeta">waiting for server…</div>
</div>
<!-- Home view -->
<section id="view-home">
<div class="card paused" id="paused" hidden>✋ <b>Signing is paused</b> by a moderator. The wall is read-only for now.</div>
<form class="compose card" id="form">
<div class="row">
<input class="name-field" id="name" type="text" placeholder="Your name (optional)" maxlength="60" autocomplete="off" />
</div>
<textarea id="message" placeholder="Write something nice…" maxlength="500" required></textarea>
<div class="actions">
<span class="count" id="count">0 / 500</span>
<button class="primary" id="submit" type="submit">Sign the wall</button>
</div>
</form>
<div class="meta">
<h2>Recent signatures</h2>
<span class="total" id="total">—</span>
</div>
<ul class="entries" id="entries"></ul>
<div class="loading" id="loading">Loading…</div>
<div class="viewall"><a href="/all" data-nav>View all signatures →</a></div>
</section>
<!-- All signatures view (paginated) -->
<section id="view-all" hidden>
<div class="meta">
<h2>All signatures</h2>
<span class="total" id="allTotal">—</span>
</div>
<ul class="entries" id="allEntries"></ul>
<div class="loading" id="allLoading">Loading…</div>
<div class="pager">
<button id="prev">← Newer</button>
<span class="info" id="pageInfo"></span>
<button id="next">Older →</button>
</div>
</section>
<footer>
<a class="miabi-badge" href="https://miabi.io?ref=badge" target="_blank" rel="noopener">
<img src="/badges/running-on-miabi-dark.svg" alt="Running on Miabi" width="156" height="32" />
</a>
<div class="stack">
<span class="pill">Okapi</span>
<span class="pill">Go</span>
<span class="pill">SSE live</span>
<span class="pill">Single container</span>
<span class="pill" id="brokerPill" hidden></span>
</div>
<div style="margin-top:12px">
Ship apps, not infrastructure · <a href="https://github.com/miabi-io/miabi" target="_blank" rel="noopener">github.com/miabi-io/miabi</a>
<span id="adminLink" hidden> · <a href="/admin">Admin</a></span>
</div>
</footer>
</div>
<div class="toast" id="toast"></div>
<script>
const $ = (id) => document.getElementById(id);
const PAGE_SIZE = 20; // "All signatures" page size
const HOME_SIZE = 8; // recent signatures shown on the home page
const api = {
info: () => fetch('/api/info').then(r => r.json()),
time: () => fetch('/api/time').then(r => r.json()),
settings: () => fetch('/api/settings').then(r => r.json()),
list: (limit, offset) => fetch(`/api/entries?limit=${limit}&offset=${offset}`).then(r => r.json()),
create: (body) => fetch('/api/entries', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }),
react: (id, emoji) => fetch(`/api/entries/${id}/reactions?emoji=${emoji}`, { method: 'POST' }),
unreact:(id, emoji) => fetch(`/api/entries/${id}/reactions?emoji=${emoji}`, { method: 'DELETE' }),
};
const EMOJI = { heart: '❤️', tada: '🎉', thumbsup: '👍', smile: '😄' };
let total = 0; // total signatures (kept in sync via SSE)
let allOffset = 0; // current offset on the "All" page
const COLORS = ['#8b5cf6', '#ec4899', '#f59e0b', '#10b981', '#3b82f6', '#ef4444', '#14b8a6', '#a855f7'];
const colorFor = (s) => { let h = 0; for (const c of s) h = (h * 31 + c.charCodeAt(0)) >>> 0; return COLORS[h % COLORS.length]; };
const initials = (s) => s.trim().split(/\s+/).slice(0, 2).map(w => w[0]?.toUpperCase() || '').join('') || '?';
const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
function timeAgo(iso) {
const d = (Date.now() - new Date(iso).getTime()) / 1000;
if (d < 60) return 'just now';
if (d < 3600) return Math.floor(d / 60) + 'm ago';
if (d < 86400) return Math.floor(d / 3600) + 'h ago';
if (d < 604800) return Math.floor(d / 86400) + 'd ago';
return new Date(iso).toLocaleDateString();
}
let toastTimer;
function toast(msg) {
const t = $('toast'); t.textContent = msg; t.classList.add('show');
clearTimeout(toastTimer); toastTimer = setTimeout(() => t.classList.remove('show'), 3200);
}
// ---- appearance (light / dark) -----------------------------------------
// Priority: moderator-forced theme > visitor's own choice > OS preference.
let serverTheme = '';
function applyTheme() {
const local = localStorage.getItem('gb-theme');
const mode = serverTheme || local || (matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark');
document.body.classList.toggle('light', mode === 'light');
}
$('themeBtn').addEventListener('click', () => {
const next = document.body.classList.contains('light') ? 'dark' : 'light';
localStorage.setItem('gb-theme', next);
if (!serverTheme) applyTheme();
else toast(`The moderator forced the ${serverTheme} theme — your choice will apply when it's released`);
});
matchMedia('(prefers-color-scheme: light)').addEventListener('change', applyTheme);
applyTheme();
function cardHTML(e) {
const via = e.replica && e.replica !== 'seed'
? `<span class="via" title="Accepted by replica ${esc(e.replica)}">via ${esc(e.replica)}</span>` : '';
return `<li class="entry card${e.pinned ? ' pinned' : ''}" data-id="${e.id}">
${e.pinned ? '<span class="pin">📌 Pinned</span>' : ''}
<div class="msg">${esc(e.message)}</div>
<div class="foot">
<span class="avatar" style="background:${colorFor(e.name)}">${esc(initials(e.name))}</span>
<span class="who">${esc(e.name)}</span>
<span class="when">· ${timeAgo(e.created_at)}</span>
${via}
</div>
${reactsHTML(e)}
</li>`;
}
// ---- reactions ----------------------------------------------------------
// One tap adds the browser's reaction, tapping again removes it. The own
// choices are kept in localStorage; the authoritative tally comes from the
// server (list payloads + live "reaction" SSE events).
function myReactions() { try { return JSON.parse(localStorage.getItem('gb-reacts') || '{}'); } catch { return {}; } }
function saveMyReaction(id, emoji, on) {
const m = myReactions();
if (on) m[id + ':' + emoji] = 1; else delete m[id + ':' + emoji];
localStorage.setItem('gb-reacts', JSON.stringify(m));
}
function reactsHTML(e) {
const mine = myReactions();
const counts = e.reactions || {};
const chips = Object.keys(EMOJI).map((k) => {
const n = counts[k] || 0;
const on = mine[e.id + ':' + k] ? ' on' : '';
return `<button class="react${on}" data-emoji="${k}" title="React ${EMOJI[k]}">${EMOJI[k]} <span class="n">${n}</span></button>`;
}).join('');
return `<div class="reacts">${chips}</div>`;
}
function updateReaction(id, emoji, count) {
document.querySelectorAll(`.entry[data-id="${id}"]`).forEach((li) => {
const btn = li.querySelector(`.react[data-emoji="${emoji}"]`);
if (!btn) return;
btn.querySelector('.n').textContent = count;
btn.classList.toggle('on', !!myReactions()[id + ':' + emoji]);
btn.classList.remove('bump'); void btn.offsetWidth; btn.classList.add('bump');
});
}
document.addEventListener('click', async (e) => {
const btn = e.target.closest('.react');
if (!btn || btn.disabled) return;
const li = btn.closest('.entry'); if (!li) return;
const id = li.dataset.id, emoji = btn.dataset.emoji;
const adding = !btn.classList.contains('on');
saveMyReaction(id, emoji, adding);
btn.classList.toggle('on', adding); // instant feedback; SSE brings the tally
btn.disabled = true;
try {
const res = adding ? await api.react(id, emoji) : await api.unreact(id, emoji);
if (!res.ok) saveMyReaction(id, emoji, !adding); // revert on failure
} catch { saveMyReaction(id, emoji, !adding); }
btn.disabled = false;
btn.classList.toggle('on', !!myReactions()[id + ':' + emoji]);
});
function renderList(ul, loading, entries, emptyMsg) {
loading.style.display = 'none';
if (!entries.length) { ul.innerHTML = `<div class="empty">${emptyMsg}</div>`; return; }
ul.innerHTML = entries.map(cardHTML).join('');
}
function setTotals(n) {
total = n;
const label = (x) => x === 1 ? '1 signature' : x + ' signatures';
$('total').textContent = label(n);
$('allTotal').textContent = label(n);
}
// ---- version + presence -------------------------------------------------
function setVersion(v) {
const el = $('ver'); el.textContent = 'v' + v;
// Colour the badge deterministically from the version so two canary
// builds (e.g. 1.0.0 vs 2.0.0) are instantly distinguishable.
let h = 0; for (const c of String(v)) h = (h * 31 + c.charCodeAt(0)) >>> 0;
el.style.background = `hsl(${h % 360} 70% 52%)`;
}
function setOnline(n) { $('online').textContent = n || 0; }
function setReplicas(n) {
if (!n) return;
$('replicas').hidden = false;
$('replicas').textContent = n === 1 ? '1 replica' : n + ' replicas';
}
function applySettings(st) {
const banner = (st && st.banner) || '';
$('banner').hidden = !banner;
$('bannerText').textContent = banner;
const paused = !!(st && st.signing_paused);
$('paused').hidden = !paused;
$('form').hidden = paused;
serverTheme = (st && st.theme) || '';
applyTheme();
}
// ---- views --------------------------------------------------------------
async function loadHome() {
try {
const data = await api.list(HOME_SIZE, 0);
setTotals(data.total || 0);
renderList($('entries'), $('loading'), data.entries || [], 'No signatures yet — be the first ✍️');
} catch { $('loading').textContent = 'Could not reach the API.'; }
}
async function loadAll() {
$('allLoading').style.display = 'block';
try {
const data = await api.list(PAGE_SIZE, allOffset);
setTotals(data.total || 0);
renderList($('allEntries'), $('allLoading'), data.entries || [], 'No signatures yet ✍️');
const from = total === 0 ? 0 : allOffset + 1;
const to = Math.min(allOffset + PAGE_SIZE, total);
$('pageInfo').textContent = `${from}–${to} of ${total}`;
$('prev').disabled = allOffset === 0;
$('next').disabled = allOffset + PAGE_SIZE >= total;
} catch { $('allLoading').textContent = 'Could not reach the API.'; }
}
function currentView() { return location.pathname.replace(/\/+$/, '') === '/all' ? 'all' : 'home'; }
function route() {
const view = currentView();
$('view-home').hidden = view !== 'home';
$('view-all').hidden = view !== 'all';
document.querySelectorAll('.tabs a').forEach((a) => {
const target = a.getAttribute('href') === '/all' ? 'all' : 'home';
a.classList.toggle('active', target === view);
});
if (view === 'all') { allOffset = 0; loadAll(); } else { loadHome(); }
}
// Intercept in-app nav links for client-side routing (WebFS serves
// index.html for /all, so a full navigation would also work).
document.addEventListener('click', (e) => {
const a = e.target.closest('a[data-nav]');
if (!a) return;
e.preventDefault();
const href = a.getAttribute('href');
if (href !== location.pathname) history.pushState({}, '', href);
route();
});
window.addEventListener('popstate', route);
// ---- live SSE events ----------------------------------------------------
// Pinned entries stay on top, so a new signature goes right after them.
function onCreated(entry) {
setTotals(total + 1);
if (currentView() === 'home') {
const ul = $('entries');
if (ul.querySelector('.empty')) ul.innerHTML = '';
const pins = ul.querySelectorAll('.entry.pinned');
const html = cardHTML(entry);
if (pins.length) pins[pins.length - 1].insertAdjacentHTML('afterend', html);
else ul.insertAdjacentHTML('afterbegin', html);
while (ul.children.length > HOME_SIZE) ul.lastElementChild.remove();
} else if (allOffset === 0) {
loadAll();
}
}
function reloadView() { currentView() === 'all' ? loadAll() : loadHome(); }
function onDeleted(id) {
document.querySelectorAll(`.entry[data-id="${id}"]`).forEach((el) => el.remove());
reloadView();
}
// ---- live server clock (v2) --------------------------------------------
let clockStaleTimer;
function showTime({ time, version, host }) {
const d = new Date(time);
$('clockTime').textContent = isNaN(d) ? time : d.toLocaleTimeString();
const bits = [];
if (version) bits.push(`<b>v${esc(version)}</b>`);
if (host) bits.push(`host <b>${esc(host)}</b>`);
$('clockMeta').innerHTML = bits.join(' · ') || 'live';
$('clock').classList.remove('stale');
// If ticks stop (server gone / tab backgrounded), dim the card.
clearTimeout(clockStaleTimer);
clockStaleTimer = setTimeout(() => $('clock').classList.add('stale'), 4000);
}
let connectedOnce = false;
function connectSSE() {
const es = new EventSource('/api/stream');
es.addEventListener('welcome', (e) => { const d = JSON.parse(e.data); setVersion(d.version); setOnline(d.online); setReplicas(d.replicas); });
es.addEventListener('presence', (e) => { const d = JSON.parse(e.data); setOnline(d.online); setReplicas(d.replicas); });
es.addEventListener('created', (e) => { const d = JSON.parse(e.data); if (d.entry && !d.entry.hidden) onCreated(d.entry); });
es.addEventListener('updated', () => reloadView());
es.addEventListener('deleted', (e) => { const d = JSON.parse(e.data); onDeleted(d.id); });
es.addEventListener('reaction', (e) => { const d = JSON.parse(e.data); updateReaction(d.id, d.emoji, d.count); });
es.addEventListener('settings', (e) => { const d = JSON.parse(e.data); applySettings(d.settings); });
// Resync after a reconnect: events sent while disconnected were missed.
es.addEventListener('open', () => { if (connectedOnce) { reloadView(); api.settings().then(applySettings).catch(() => {}); } connectedOnce = true; });
es.addEventListener('tick', (e) => { const d = JSON.parse(e.data); showTime(d); setVersion(d.version); });
// EventSource auto-reconnects on error; nothing to do here.
}
// ---- compose + delete ---------------------------------------------------
$('message').addEventListener('input', (e) => { $('count').textContent = e.target.value.length + ' / 500'; });
$('form').addEventListener('submit', async (e) => {
e.preventDefault();
const message = $('message').value.trim();
if (!message) return;
const btn = $('submit'); btn.disabled = true;
try {
const res = await api.create({ name: $('name').value, message });
if (!res.ok) { const j = await res.json().catch(() => ({})); toast(j.error || 'Something went wrong'); return; }
$('message').value = ''; $('count').textContent = '0 / 500';
// The new entry arrives via SSE ("created"), so no manual reload.
} catch { toast('Network error'); }
finally { btn.disabled = false; }
});
$('prev').addEventListener('click', () => { allOffset = Math.max(0, allOffset - PAGE_SIZE); loadAll(); });
$('next').addEventListener('click', () => { allOffset += PAGE_SIZE; loadAll(); });
// ---- boot ---------------------------------------------------------------
api.info().then((d) => {
setVersion(d.version); setOnline(d.online); setReplicas((d.replicas || []).length);
$('adminLink').hidden = !d.admin;
if (d.broker) { $('brokerPill').hidden = false; $('brokerPill').textContent = d.broker === 'redis' ? 'Redis pub/sub' : 'In-process broker'; }
}).catch(() => {});
api.settings().then(applySettings).catch(() => {});
api.time().then(showTime).catch(() => {}); // immediate clock; SSE ticks keep it live
route();
connectSSE();
</script>
</body>
</html>