feat(ui): static shell + router + api wrapper

Three-column grid (sidebar / main / right rail) with Cradle aesthetic:
blackflame accent on Cinzel display headings + Cormorant Garamond body
in cards, system UI for chrome. Hash-based router covers all entity
routes plus search, inbox, sacred-valley. api.js stores OWNER_TOKEN in
localStorage and prompts via a modal on 401. dom.js provides safe el()
+ mount() builders so no component ever assigns innerHTML from API data
(the only exception is an explicit, scary-named html: opt-in for
sanitizer output, used later by the markdown editor).

state.js is a tiny event bus for shared chrome state (pending count).
Components and views are loaded as ES modules — sidebar / topbar /
rightrail + 9 view stubs that the later Phase E tasks fill in.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
root
2026-06-01 02:12:18 +10:00
parent 69e26ada98
commit 59ad86425d
21 changed files with 575 additions and 0 deletions

70
public/api.js Normal file
View File

@@ -0,0 +1,70 @@
// Thin fetch wrapper. Owner token in localStorage.void_token. On 401 we
// prompt for a token via a modal in modal-root. The wrapper is intentionally
// minimal — no retries, no auto-refresh; the owner-only auth model doesn't
// need them.
import { el, mount, clear } from './dom.js';
const TOKEN_KEY = 'void_token';
function token() { return localStorage.getItem(TOKEN_KEY) || ''; }
async function call(method, path, body) {
const headers = { 'Authorization': 'Bearer ' + token() };
if (body !== undefined) headers['Content-Type'] = 'application/json';
const res = await fetch(path, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body)
});
if (res.status === 401) { await promptForToken(); return call(method, path, body); }
if (res.status === 204) return null;
const ct = res.headers.get('content-type') || '';
const data = ct.includes('application/json') ? await res.json() : await res.text();
if (!res.ok) {
const err = new Error(data?.error?.message || res.statusText);
err.status = res.status;
err.body = data;
throw err;
}
return data;
}
function promptForToken() {
return new Promise((resolve) => {
const root = document.getElementById('modal-root');
const input = el('input', { type: 'password', style: { width: '100%' }, autofocus: true });
const save = () => {
const v = input.value.trim();
if (!v) return;
localStorage.setItem(TOKEN_KEY, v);
clear(root);
resolve();
};
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') save(); });
mount(root,
el('div', { class: 'modal-backdrop' },
el('div', { class: 'modal' },
el('h2', {}, 'Owner Token'),
el('p', { class: 'muted' },
'Paste the OWNER_TOKEN configured for the server. It will be stored in localStorage on this device only.'
),
input,
el('div', { class: 'actions' },
el('button', { class: 'primary', onclick: save }, 'Save')
)
)
)
);
input.focus();
});
}
export const api = {
get: (p) => call('GET', p),
post: (p, body) => call('POST', p, body ?? {}),
patch: (p, body) => call('PATCH', p, body ?? {}),
del: (p) => call('DELETE', p),
setToken: (v) => localStorage.setItem(TOKEN_KEY, v),
hasToken: () => !!token()
};