feat(static): embed labdb/public/* and serve from BridgeDispatch

Drops labdb's index.html / login.html / css / js into the binary via
embed.FS so the single 24 MB fivenode_go binary now ships both the
HTML/JS frontend AND the JSON API. No web server, no Apache,
no asset bundler.

  hbrtl_ext/labdb_static/
    public/                 mirror of fivenode/labdb/public/
    assets.go               //go:embed public + two PRG-callable
                            HB_FUNCs: LABDB_STATIC_FILE(cPath) returns
                            the bytes, LABDB_STATIC_MIME(cPath)
                            returns the Content-Type derived from
                            the resolved (not raw) extension so "/"
                            -> text/html, not application/octet-stream.

  app/bridge_server.prg
    BridgeDispatch now falls through to the static FS for any path
    that isn't /api/*. Missing assets get a 404 instead of being
    routed to the path-to-symbol dispatcher.

Verified:
  GET /                  -> 200 text/html, full index.html body
  GET /login.html        -> 200 text/html, 1850 bytes
  GET /css/app.css       -> 200 text/css
  GET /api/admin-stats   -> 200 JSON {devices:2,...} (still live PG)
  GET /nonexistent.html  -> 404 text/plain

Phase 1a complete: HTTP serves both the labdb frontend and the
real-data labdb API from one binary, end to end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-27 17:01:09 +09:00
parent 3bbfbb7010
commit 47b8f0434a
7 changed files with 736 additions and 1 deletions

View File

@@ -0,0 +1,86 @@
// Package labdb_static embeds labdb's HTML/CSS/JS into the binary so
// the fivenode_go process ships every byte the browser needs —
// no separate web server, no filesystem dependency.
//
// PRG surface
//
// cBody := LABDB_STATIC_FILE(cPath) -> file contents, or "" when missing
// cMime := LABDB_STATIC_MIME(cPath) -> Content-Type guessed from extension
//
// cPath is taken verbatim from hReq["path"], so "/" maps to index.html
// and a missing leading slash is fine.
package labdb_static
import (
"embed"
"path"
"strings"
"five/hbrt"
)
//go:embed public
var fs embed.FS
func init() {
hbrt.HB_FUNC("LABDB_STATIC_FILE", staticFile)
hbrt.HB_FUNC("LABDB_STATIC_MIME", staticMime)
}
// resolve turns "/" -> "public/index.html",
// "/css/app.css" -> "public/css/app.css",
// "css/app.css" -> "public/css/app.css".
func resolve(p string) string {
p = strings.TrimPrefix(p, "/")
if p == "" {
p = "index.html"
}
// Normalize any "..", "//" so an attacker can't escape the embed root.
cleaned := path.Clean("/" + p)
if cleaned == "/" {
return "public/index.html"
}
return "public" + cleaned
}
func staticFile(ctx *hbrt.HBContext) {
if ctx.PCount() < 1 || !ctx.IsChar(1) {
ctx.RetC("")
return
}
data, err := fs.ReadFile(resolve(ctx.ParC(1)))
if err != nil {
ctx.RetC("")
return
}
ctx.RetC(string(data))
}
func staticMime(ctx *hbrt.HBContext) {
if ctx.PCount() < 1 || !ctx.IsChar(1) {
ctx.RetC("application/octet-stream")
return
}
// Use the resolved path so "/" -> .html, not "" -> octet-stream.
ext := strings.ToLower(path.Ext(resolve(ctx.ParC(1))))
switch ext {
case ".html", ".htm":
ctx.RetC("text/html; charset=utf-8")
case ".css":
ctx.RetC("text/css; charset=utf-8")
case ".js", ".mjs":
ctx.RetC("application/javascript; charset=utf-8")
case ".json":
ctx.RetC("application/json; charset=utf-8")
case ".svg":
ctx.RetC("image/svg+xml")
case ".png":
ctx.RetC("image/png")
case ".jpg", ".jpeg":
ctx.RetC("image/jpeg")
case ".ico":
ctx.RetC("image/x-icon")
default:
ctx.RetC("application/octet-stream")
}
}

View File

@@ -0,0 +1,277 @@
/* labdb — Lab Data API admin UI
* Quiet Luxury / Maison Dark theme
* Copyright (c) 2026 Charles KWON OhJun
*/
:root {
--bg-primary: #080808;
--bg-secondary: #121210;
--bg-tertiary: #1c1a18;
--bg-hover: rgba(255, 248, 240, 0.04);
--text-primary: #e8e4df;
--text-secondary: #a8a39d;
--text-tertiary: #6a6560;
--text-inverse: #080808;
--accent: #c9a96e;
--accent-hover: #d4b87a;
--accent-light: rgba(201, 169, 110, 0.08);
--border: rgba(255, 248, 240, 0.06);
--border-light: rgba(255, 248, 240, 0.04);
--status-active: #22c55e;
--status-pending: #eab308;
--status-error: #ef4444;
--status-muted: #71717a;
--r-sm: 8px;
--r-md: 12px;
--sp-1: 4px; --sp-2: 8px; --sp-3: 12px; --sp-4: 16px;
--sp-5: 20px; --sp-6: 24px; --sp-8: 32px; --sp-10: 40px;
}
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
html, body { margin: 0; padding: 0; }
body {
background: var(--bg-primary);
color: var(--text-primary);
font-family: -apple-system, "Segoe UI", "Helvetica Neue", "Noto Sans", sans-serif;
font-size: 14px;
-webkit-font-smoothing: antialiased;
min-height: 100dvh;
}
a { color: inherit; text-decoration: none; }
a:hover { color: var(--accent); }
.hidden { display: none !important; }
/* ── Header ─────────────────────────── */
.header {
display: flex; align-items: center; gap: var(--sp-4);
padding: 0 var(--sp-6);
min-height: 56px;
background: rgba(8,8,8,0.85);
backdrop-filter: saturate(140%) blur(20px);
-webkit-backdrop-filter: saturate(140%) blur(20px);
border-bottom: 1px solid var(--border);
position: sticky; top: 0; z-index: 50;
padding-top: env(safe-area-inset-top);
}
.header h1 {
font-size: 16px; font-weight: 500; margin: 0;
letter-spacing: -0.01em;
}
.header .subtitle {
font-size: 11px; color: var(--text-tertiary);
margin-top: -2px; letter-spacing: 0.04em; text-transform: uppercase;
}
.header-spacer { flex: 1; }
.header-actions { display: flex; align-items: center; gap: var(--sp-2); }
/* ── Layout ─────────────────────────── */
.container {
max-width: 1400px;
margin: 0 auto;
padding: var(--sp-6);
}
.container-sm {
max-width: 480px;
margin: 80px auto;
padding: var(--sp-6);
}
/* ── Cards ─────────────────────────── */
.card {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--r-md);
overflow: hidden;
}
.card-padded { padding: var(--sp-6); }
/* ── Stats grid ─────────────────────── */
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--sp-4);
margin-bottom: var(--sp-6);
}
.stat-card {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--r-md);
padding: var(--sp-5);
}
.stat-label {
font-size: 10px; color: var(--accent);
text-transform: uppercase; letter-spacing: 0.12em;
margin-bottom: var(--sp-2);
}
.stat-value {
font-size: 28px; font-weight: 500;
letter-spacing: -0.02em;
}
@media (max-width: 768px) {
.stats-grid { grid-template-columns: repeat(2, 1fr); }
.container { padding: var(--sp-4); }
}
/* ── Tabs ─────────────────────────── */
.tabs {
display: flex; gap: var(--sp-2);
border-bottom: 1px solid var(--border);
margin-bottom: var(--sp-5);
}
.tab {
padding: var(--sp-3) var(--sp-4);
background: none; border: none;
color: var(--text-tertiary);
cursor: pointer; font-size: 13px;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
}
.tab.active { color: var(--accent); border-bottom-color: var(--accent); }
/* ── Tables ─────────────────────────── */
.table-wrap {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--r-md);
overflow: hidden;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
thead th {
background: var(--bg-tertiary);
color: var(--accent);
font-weight: 500;
text-align: left;
padding: var(--sp-3) var(--sp-4);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
border-bottom: 1px solid var(--border);
}
tbody td {
padding: var(--sp-3) var(--sp-4);
border-bottom: 1px solid var(--border-light);
}
tbody tr:hover { background: var(--bg-hover); }
tbody tr:last-child td { border-bottom: none; }
.text-mono { font-family: "JetBrains Mono", "SF Mono", Menlo, monospace; font-size: 12px; }
.text-meta { color: var(--text-tertiary); font-size: 12px; }
.text-right { text-align: right; }
/* ── Badges ─────────────────────────── */
.badge {
display: inline-block;
padding: 3px 10px;
font-size: 10px;
font-weight: 500;
border-radius: 100px;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.badge-active { background: rgba(34,197,94,0.12); color: var(--status-active); }
.badge-completed { background: rgba(201,169,110,0.12); color: var(--accent); }
.badge-aborted { background: rgba(239,68,68,0.12); color: var(--status-error); }
.badge-muted { background: rgba(113,113,122,0.12); color: var(--status-muted); }
/* ── Buttons ─────────────────────────── */
.btn {
display: inline-flex; align-items: center; gap: 6px;
padding: 8px 16px;
background: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border);
border-radius: var(--r-sm);
font-size: 13px; font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
font-family: inherit;
min-height: 36px;
}
.btn:hover { background: var(--bg-hover); border-color: var(--accent); }
.btn-primary {
background: var(--accent); color: var(--text-inverse);
border-color: var(--accent);
}
.btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
.btn-ghost { background: none; border: none; }
.btn-sm { padding: 4px 10px; font-size: 12px; min-height: 28px; }
/* ── Forms ─────────────────────────── */
.form-group { margin-bottom: var(--sp-4); }
.form-group label {
display: block; font-size: 11px; color: var(--accent);
text-transform: uppercase; letter-spacing: 0.06em;
margin-bottom: 6px;
}
.form-group input, .form-group select, .form-group textarea {
width: 100%;
padding: 12px var(--sp-4);
background: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border);
border-radius: var(--r-sm);
font-size: 16px; /* prevent iOS zoom */
font-family: inherit;
outline: none;
}
.form-group input:focus { border-color: var(--accent); }
.form-row { display: flex; gap: var(--sp-3); align-items: center; }
/* ── Search ─────────────────────────── */
.search-box {
display: flex; align-items: center;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: var(--r-sm);
padding: 0 var(--sp-3);
max-width: 300px;
width: 100%;
}
.search-box input {
background: none; border: none; outline: none;
color: var(--text-primary);
padding: 8px 0;
width: 100%; font-size: 14px;
}
/* ── Login ─────────────────────────── */
.login-card {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--r-md);
padding: var(--sp-8);
}
.login-card h1 {
margin: 0 0 var(--sp-2); font-size: 22px; font-weight: 500;
letter-spacing: -0.01em; text-align: center;
}
.login-card .subtitle {
text-align: center; color: var(--text-tertiary);
font-size: 12px; margin-bottom: var(--sp-6);
letter-spacing: 0.06em; text-transform: uppercase;
}
.error-msg {
background: rgba(239,68,68,0.1); color: var(--status-error);
padding: 10px 14px; border-radius: var(--r-sm);
font-size: 13px; margin-bottom: var(--sp-4);
}
/* ── Footer ─────────────────────────── */
.footer {
text-align: center;
color: var(--text-tertiary);
font-size: 11px;
padding: var(--sp-6);
}
/* ── Page header ───────────────────── */
.page-header {
display: flex; align-items: center; gap: var(--sp-4);
margin-bottom: var(--sp-5);
}
.page-header h2 {
font-size: 18px; font-weight: 500; margin: 0;
}

View File

@@ -0,0 +1,257 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>labdb — Lab Data API Admin</title>
<link rel="stylesheet" href="/css/app.css?v=__V__">
</head>
<body>
<div class="header">
<div>
<h1>labdb</h1>
<div class="subtitle">Lab Data API</div>
</div>
<div class="header-spacer"></div>
<div class="header-actions">
<span id="userBadge" class="text-meta"></span>
<button class="btn btn-ghost btn-sm" onclick="doLogout()">Logout</button>
</div>
</div>
<div class="container">
<!-- Stats -->
<div class="stats-grid" id="statsGrid">
<div class="stat-card">
<div class="stat-label">Devices</div>
<div class="stat-value" id="statDevices"></div>
</div>
<div class="stat-card">
<div class="stat-label">Sessions</div>
<div class="stat-value" id="statSessions"></div>
</div>
<div class="stat-card">
<div class="stat-label">Records</div>
<div class="stat-value" id="statRecords"></div>
</div>
<div class="stat-card">
<div class="stat-label">Active</div>
<div class="stat-value" id="statActive"></div>
</div>
</div>
<!-- Tabs -->
<div class="tabs">
<button class="tab active" data-tab="sessions" onclick="setTab('sessions')">Sessions</button>
<button class="tab" data-tab="devices" onclick="setTab('devices')">Devices</button>
</div>
<!-- Page header -->
<div class="page-header">
<h2 id="pageTitle">Sessions (newest first)</h2>
<button class="btn btn-sm" id="refreshBtn" onclick="refreshCurrent()" title="Refresh this tab only">
<span id="refreshIcon"></span> Refresh
</button>
<div class="header-spacer"></div>
<div class="search-box">
<input type="text" id="searchInput" placeholder="Search session name..." oninput="onSearch()">
</div>
</div>
<!-- Table -->
<div class="table-wrap" id="tableWrap"></div>
</div>
<script src="/js/api.js?v=__V__"></script>
<script>
let currentTab = 'sessions';
let searchTimer = null;
async function init() {
const me = await api.get('/api/admin/me');
if (!me.ok) { location.href = '/login.html'; return; }
document.getElementById('userBadge').textContent = me.user.name + ' (' + me.user.role + ')';
loadStats();
loadCurrent();
}
async function loadStats() {
const s = await api.get('/api/admin/stats');
if (s && !s.error) {
document.getElementById('statDevices').textContent = s.devices ?? '—';
document.getElementById('statSessions').textContent = s.sessions ?? '—';
document.getElementById('statRecords').textContent = s.records ?? '—';
document.getElementById('statActive').textContent = s.active_sessions ?? '—';
}
}
function setTab(tab) {
currentTab = tab;
document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tab));
document.getElementById('pageTitle').textContent = tab === 'sessions' ? 'Sessions (newest first)' : 'Devices';
document.getElementById('searchInput').placeholder = tab === 'sessions' ? 'Search session name...' : 'Search device...';
document.getElementById('searchInput').value = '';
loadCurrent();
}
function onSearch() {
clearTimeout(searchTimer);
searchTimer = setTimeout(loadCurrent, 300);
}
async function loadCurrent() {
const q = document.getElementById('searchInput').value;
if (currentTab === 'sessions') await loadSessions(q);
else await loadDevices(q);
}
async function refreshCurrent() {
// Animate the refresh icon
const icon = document.getElementById('refreshIcon');
const btn = document.getElementById('refreshBtn');
if (icon) {
icon.style.display = 'inline-block';
icon.style.transition = 'transform 0.6s ease';
icon.style.transform = 'rotate(360deg)';
setTimeout(() => { icon.style.transition = 'none'; icon.style.transform = 'rotate(0deg)'; }, 700);
}
btn.disabled = true;
try {
// Refresh ONLY current tab (not stats)
await loadCurrent();
} finally {
btn.disabled = false;
}
}
async function loadSessions(q) {
const url = '/api/admin/sessions?limit=200' + (q ? '&q=' + encodeURIComponent(q) : '');
const r = await api.get(url);
if (!r || r.error) {
document.getElementById('tableWrap').innerHTML = '<div class="card-padded text-meta">Failed to load.</div>';
return;
}
if (!r.sessions || r.sessions.length === 0) {
document.getElementById('tableWrap').innerHTML = '<div class="card-padded text-meta" style="text-align:center; padding:60px;">No sessions yet.</div>';
return;
}
let html = `
<table>
<thead>
<tr>
<th>Session</th>
<th>Device</th>
<th>Start</th>
<th>Duration</th>
<th class="text-right">Records</th>
<th>Status</th>
<th>Note</th>
</tr>
</thead>
<tbody>`;
for (const s of r.sessions) {
html += `
<tr>
<td>
<div>${esc(s.session_name || s.session_id)}</div>
<div class="text-meta text-mono">${esc(s.session_id)}</div>
</td>
<td>${esc(s.device_name || '—')}</td>
<td class="text-mono">${formatDate(s.start_time)}</td>
<td class="text-mono">${formatDuration(s.start_time, s.end_time)}</td>
<td class="text-right text-mono">${s.record_count || 0}</td>
<td><span class="badge badge-${esc(s.status || 'muted')}">${esc(s.status || '—')}</span></td>
<td class="text-meta">${esc(s.note || '')}</td>
</tr>`;
}
html += '</tbody></table>';
document.getElementById('tableWrap').innerHTML = html;
document.getElementById('pageTitle').textContent = `Sessions (${r.total} total, newest first)`;
}
async function loadDevices(q) {
const r = await api.get('/api/admin/devices');
if (!r || r.error) {
document.getElementById('tableWrap').innerHTML = '<div class="card-padded text-meta">Failed to load.</div>';
return;
}
const devices = r.devices || [];
const filtered = q ? devices.filter(d => (d.device_name || '').toLowerCase().includes(q.toLowerCase()) || (d.device_id || '').toLowerCase().includes(q.toLowerCase())) : devices;
if (filtered.length === 0) {
document.getElementById('tableWrap').innerHTML = '<div class="card-padded text-meta" style="text-align:center; padding:60px;">No devices registered.</div>';
return;
}
let html = `
<table>
<thead>
<tr>
<th>Device</th>
<th>App</th>
<th>MAC</th>
<th>Registered</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>`;
for (const d of filtered) {
const did = esc(d.device_id);
let actions;
if (d.is_active) {
actions = `
<button class="btn btn-sm" onclick="deviceAction('${did}', 'revoke')">Revoke</button>
<button class="btn btn-sm" onclick="deviceAction('${did}', 'delete')" style="color:var(--status-error)">Delete</button>`;
} else {
actions = `
<button class="btn btn-sm btn-primary" onclick="deviceAction('${did}', 'approve')">Approve</button>
<button class="btn btn-sm" onclick="deviceAction('${did}', 'delete')" style="color:var(--status-error)">Delete</button>`;
}
html += `
<tr>
<td>
<div>${esc(d.device_name || '—')}</div>
<div class="text-meta text-mono">${esc(d.device_id)}</div>
</td>
<td>${esc(d.app_name || '—')}</td>
<td class="text-mono">${esc(d.device_address || '—')}</td>
<td class="text-mono">${formatDate(d.created_at)}</td>
<td><span class="badge ${d.is_active ? 'badge-active' : 'badge-completed'}">${d.is_active ? 'active' : 'pending'}</span></td>
<td>${actions}</td>
</tr>`;
}
html += '</tbody></table>';
document.getElementById('tableWrap').innerHTML = html;
document.getElementById('pageTitle').textContent = `Devices (${filtered.length})`;
}
async function deviceAction(deviceId, action) {
const messages = {
approve: 'Approve this device? It will be able to call the API.',
revoke: 'Revoke this device? It will lose API access.',
delete: 'PERMANENTLY DELETE this device and ALL its sessions/records?'
};
if (!confirm(messages[action])) return;
let result;
if (action === 'delete') {
result = await api.del('/api/admin/devices/' + encodeURIComponent(deviceId));
} else {
result = await api.post('/api/admin/devices/' + encodeURIComponent(deviceId) + '/' + action);
}
if (result && result.ok) {
loadStats();
loadDevices();
} else {
alert('Failed: ' + ((result && result.error && result.error.message) || 'unknown error'));
}
}
async function doLogout() {
await api.post('/api/admin/logout');
location.href = '/login.html';
}
init();
</script>
</body>
</html>

View File

@@ -0,0 +1,43 @@
// labdb — Admin web client
const api = {
async _fetch(path, opts) {
const o = Object.assign({ credentials: 'same-origin' }, opts || {});
o.headers = Object.assign({ 'Content-Type': 'application/json' }, o.headers || {});
const res = await fetch(path, o);
if (res.status === 401 && !location.pathname.endsWith('/login.html')) {
location.href = '/login.html';
return { error: 'unauthorized' };
}
try { return await res.json(); } catch (_) { return { error: 'invalid response' }; }
},
get(p) { return this._fetch(p); },
post(p, body) { return this._fetch(p, { method: 'POST', body: JSON.stringify(body || {}) }); },
patch(p, body) { return this._fetch(p, { method: 'PATCH', body: JSON.stringify(body || {}) }); },
del(p) { return this._fetch(p, { method: 'DELETE' }); },
};
function esc(s) {
if (s === null || s === undefined) return '';
return String(s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function formatDate(iso) {
if (!iso) return '—';
const d = new Date(iso);
if (isNaN(d)) return '—';
const pad = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
function formatDuration(start, end) {
if (!start || !end) return '—';
const ms = new Date(end) - new Date(start);
if (isNaN(ms) || ms < 0) return '—';
const sec = Math.floor(ms / 1000);
const h = Math.floor(sec / 3600);
const m = Math.floor((sec % 3600) / 60);
const s = sec % 60;
return `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`;
}

View File

@@ -0,0 +1,55 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>Sign In — labdb</title>
<link rel="stylesheet" href="/css/app.css?v=__V__">
</head>
<body>
<div class="container-sm">
<div class="login-card">
<h1>labdb</h1>
<div class="subtitle">Lab Data API · Admin</div>
<div id="errorBox" class="error-msg hidden"></div>
<form id="loginForm">
<div class="form-group">
<label>Username</label>
<input type="text" id="username" autocomplete="username" required autofocus>
</div>
<div class="form-group">
<label>Password</label>
<input type="password" id="password" autocomplete="current-password" required>
</div>
<button type="submit" class="btn btn-primary" style="width:100%; justify-content:center;">Sign In</button>
</form>
</div>
<div class="footer">
&copy; 2026 MEDiThings Inc. · labdb v1.0
</div>
</div>
<script src="/js/api.js?v=__V__"></script>
<script>
document.getElementById('loginForm').addEventListener('submit', async (e) => {
e.preventDefault();
const errBox = document.getElementById('errorBox');
errBox.classList.add('hidden');
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
const result = await api.post('/api/admin/login', { username, password });
if (result.ok) {
location.href = '/';
} else {
errBox.textContent = (result.error && result.error.message) || result.error || 'Login failed';
errBox.classList.remove('hidden');
}
});
// auto-redirect if already logged in
api.get('/api/admin/me').then(r => { if (r.ok) location.href = '/'; });
</script>
</body>
</html>