Files
guestbook/web/index.html
T

512 lines
24 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." />
<style>
:root {
--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;
--radius: 16px;
--shadow: 0 20px 60px -20px rgba(139, 92, 246, 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%, rgba(139, 92, 246, 0.18), transparent 60%),
radial-gradient(700px 500px at 110% 10%, rgba(168, 85, 247, 0.14), 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; } }
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: rgba(0, 0, 0, 0.25); 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: rgba(0,0,0,.2); 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);
}
.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>
</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) }),
};
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);
}
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>
</li>`;
}
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;
}
// ---- 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('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>