Files
guestbook/web/index.html
T
Akili 86215f1eb1 feat: add visitor identity cookie and database reset job
Three connected features for the deployment-strategy demo:

1. Visitor identity cookie (v3). A new visitors table backs a first-party
   gb_visitor cookie: middleware on /api checks the cookie's UUID against
   the database on every request and regenerates + persists it when the id
   is missing, malformed, or unknown (fresh browser, pruned identity, or a
   reset database). The identity remembers the name a visitor signed with
   (pre-filled next time, used for unsigned posts), replaces the standalone
   reaction cookie for reaction de-dupe, and feeds a live "N visiting"
   counter (identities seen in the last 5 minutes) shown on the wall, in
   /api/info, the admin overview, and the presence/tick SSE events. The
   admin console gains Visitors/Visiting tiles and a visitors table, plus a
   GET /api/me endpoint and GET /api/admin/visitors.

2. guestbook reset command. Wipes entries (including soft-deleted rows),
   reactions and visitor identities under the schema advisory lock, with
   --dry-run, --hard (also reset wall settings) and --reseed flags; meant
   to run as a one-off Miabi Job. After a reset every outstanding cookie is
   invalidated, so browsers get a fresh identity on their next request.

3. cleanup learns --prune-visitors (default 30 days): the cron job now also
   forgets idle identities together with their reactions.

Docs (README, DEMO) updated: act 8 covers reset, the visitor-identity
talking point is in act 3, and the API tables list the new endpoints.

Akili-Task: tsk_ptdi5ufndrzwlljk4v5g
Akili-Agent: goldfish
2026-10-07 19:06:07 +02:00

665 lines
32 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; }
.clock-meta .you { color: var(--text); font-weight: 600; }
.visitors { 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);
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="visitors" id="visitors" title="Visitor identities active in the last 5 minutes (issued by the identity cookie)" hidden>👤 <span id="visitorCount">0</span></span>
<span class="online" title="Browsers connected to the live stream right now"><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>
<!-- Visitor identity (new in v3): the server recognises this browser -->
<div class="clock-meta" id="visitorMeta" hidden></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()),
me: () => fetch('/api/me').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 setVisitors(n) {
if (!n) return;
$('visitors').hidden = false;
$('visitorCount').textContent = n;
}
// ---- visitor identity ---------------------------------------------------
// The server recognises this browser by the gb_visitor cookie: it checks
// the cookie's id against the database and hands out a new one when it is
// unknown (first visit, pruned identity, or a wiped database). /api/me
// reports who the server thinks we are; the remembered name pre-fills the
// form.
function showVisitor(v) {
if (!v || !v.id) return;
const el = $('visitorMeta');
el.hidden = false;
const bits = [`you are <span class="you">${esc(v.name || 'guest-' + String(v.id).slice(0, 8))}</span>`];
if (v.signatures > 0) bits.push(`${v.signatures} signature${v.signatures === 1 ? '' : 's'}`);
if (v.first_seen) bits.push(`since ${timeAgo(v.first_seen)}`);
el.innerHTML = bits.join(' · ');
if (v.name && !$('name').value) $('name').value = v.name;
}
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); setVisitors(d.visitors); });
es.addEventListener('presence', (e) => { const d = JSON.parse(e.data); setOnline(d.online); setReplicas(d.replicas); setVisitors(d.visitors); });
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); setVisitors(d.visitors); });
// 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); setVisitors(d.visitors);
$('adminLink').hidden = !d.admin;
if (d.broker) { $('brokerPill').hidden = false; $('brokerPill').textContent = d.broker === 'redis' ? 'Redis pub/sub' : 'In-process broker'; }
}).catch(() => {});
api.me().then(showVisitor).catch(() => {});
api.settings().then(applySettings).catch(() => {});
api.time().then(showTime).catch(() => {}); // immediate clock; SSE ticks keep it live
route();
connectSSE();
</script>
</body>
</html>