428 lines
19 KiB
HTML
428 lines
19 KiB
HTML
<!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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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>
|