Initial commit

This commit is contained in:
Jonas Kaninda
2026-07-06 20:02:09 +02:00
commit b4a740590b
19 changed files with 1807 additions and 0 deletions
+427
View File
@@ -0,0 +1,427 @@
<!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);
}
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); }
.del {
position: absolute; top: 14px; right: 14px; cursor: pointer;
background: transparent; border: 0; color: var(--muted); font-size: 17px; line-height: 1;
padding: 4px 7px; border-radius: 8px; opacity: 0; transition: opacity .15s, color .15s, background .15s;
}
li.entry:hover .del { opacity: 1; }
.del:hover { color: var(--danger); background: rgba(244, 63, 94, 0.12); }
.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; }
.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="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>
<!-- Home view -->
<section id="view-home">
<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>
<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>
</div>
<div style="margin-top:12px">
The open-source PaaS for Docker · <a href="https://github.com/miabi-io/miabi" target="_blank" rel="noopener">github.com/miabi-io/miabi</a>
</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()),
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) }),
remove: (id) => fetch('/api/entries/' + id, { method: 'DELETE' }),
};
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) {
return `<li class="entry card" data-id="${e.id}">
<button class="del" title="Delete" aria-label="Delete">✕</button>
<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>
</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; }
// ---- 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 ----------------------------------------------------
function onCreated(entry) {
setTotals(total + 1);
if (currentView() === 'home') {
const ul = $('entries');
if (ul.querySelector('.empty')) ul.innerHTML = '';
ul.insertAdjacentHTML('afterbegin', cardHTML(entry));
while (ul.children.length > HOME_SIZE) ul.lastElementChild.remove();
} else if (allOffset === 0) {
loadAll();
}
}
function onDeleted(id) {
setTotals(Math.max(0, total - 1));
document.querySelectorAll(`.entry[data-id="${id}"]`).forEach((el) => el.remove());
if (currentView() === 'all') loadAll();
}
function connectSSE() {
const es = new EventSource('/api/stream');
es.addEventListener('welcome', (e) => { const d = JSON.parse(e.data); setVersion(d.version); setOnline(d.online); });
es.addEventListener('presence', (e) => { const d = JSON.parse(e.data); setOnline(d.online); });
es.addEventListener('created', (e) => { const d = JSON.parse(e.data); if (d.entry) onCreated(d.entry); });
es.addEventListener('deleted', (e) => { const d = JSON.parse(e.data); onDeleted(d.id); });
// 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; }
});
document.addEventListener('click', async (e) => {
const btn = e.target.closest('.del');
if (!btn) return;
const li = btn.closest('.entry');
const id = li.dataset.id;
li.style.opacity = '.4';
const res = await api.remove(id);
if (!(res.ok || res.status === 404)) { li.style.opacity = '1'; toast('Could not delete'); }
// Removal is reflected via SSE ("deleted").
});
$('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); }).catch(() => {});
route();
connectSSE();
</script>
</body>
</html>