Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 118 additions & 28 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,13 @@ <h2 style="margin: 0 0 0.4rem">This page is talking to its own backend</h2>
Everything below is served by the same instance, through <code>/sdk.js</code>. Open a second tab —
new entries appear in both without a refresh (that's the realtime layer).
</p>
<p id="mt-notice" class="muted small" style="display: none; margin: 0 0 0.8rem; max-width: 64ch; padding: 0.6rem 0.85rem; border: 1px solid var(--border); border-radius: 10px"></p>
<!-- Shown only on a multi-tenant server: paste a project key (kept in this tab only). -->
<form id="key-form" style="display: none; gap: 0.5rem; margin: 0 0 1.3rem; max-width: 64ch; align-items: center">
<input id="key-input" type="password" autocomplete="off" placeholder="Project key (pk_…)" style="flex: 1" />
<button type="submit">Use key</button>
<button type="button" id="key-clear" class="muted" style="display: none">Clear</button>
</form>
<div class="grid" style="grid-template-columns: repeat(auto-fit, minmax(300px, 1fr))">
<section class="card">
<h3 style="margin: 0 0 0.8rem">💬 Guestbook <span class="muted small">data + live sync</span></h3>
Expand Down Expand Up @@ -108,7 +115,21 @@ <h2 style="margin: 0 0 1.3rem">Everything a generated app needs</h2>

<script src="/sdk.js"></script>
<script>
const api = Zero(); // same-origin
// In multi-tenant mode every data/file call needs a project key. The public
// demo has none, so let the operator paste one. It's kept in sessionStorage
// (this tab only, gone when it closes) — never in the URL.
const KEY_STORE = 'zero_demo_key';
let demoKey = sessionStorage.getItem(KEY_STORE) || null;
let api = demoKey ? Zero('', { apiKey: demoKey }) : Zero(); // same-origin
let room = null;
const notice = (html) => {
const el = document.getElementById('mt-notice');
if (!html) return void (el.style.display = 'none');
el.innerHTML = html;
el.style.display = '';
};
const needsKey = (e) => e && e.status === 401;
const keyHint = 'Paste a project key above to use the demo.';
const escapeHtml = (s) =>
String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));

Expand All @@ -119,6 +140,17 @@ <h2 style="margin: 0 0 1.3rem">Everything a generated app needs</h2>
const provider = info.endpoints && info.endpoints.ai && info.endpoints.ai.provider;
document.getElementById('status').innerHTML =
`<span class="dot live"></span> live${info.version ? ' · v' + escapeHtml(info.version) : ''}${provider ? ' · ai:' + escapeHtml(provider) : ''}`;
// Multi-tenant: reveal the key box; explain it if no key is set yet.
if (info.mode === 'multi-tenant') {
document.getElementById('key-form').style.display = 'flex';
updateKeyStatus();
if (!demoKey) {
notice(
'This server runs in <b>multi-tenant</b> mode, so the live demo needs a project key. ' +
'Create one in the <a href="/admin">dashboard</a> and paste it above.',
);
}
}
} catch {
document.getElementById('status').textContent = 'offline';
}
Expand All @@ -135,55 +167,113 @@ <h2 style="margin: 0 0 1.3rem">Everything a generated app needs</h2>
});

async function loadEntries() {
const docs = await api.data('guestbook').list({ sort: '-createdAt', limit: 25 });
const el = document.getElementById('entries');
el.innerHTML = docs.length
? docs
.map(
(d) =>
`<li><span>${escapeHtml(d.message)}</span> <span class="muted small">${new Date(d.createdAt).toLocaleTimeString()}</span></li>`,
)
.join('')
: '<li class="muted">No messages yet — be the first.</li>';
try {
const docs = await api.data('guestbook').list({ sort: '-createdAt', limit: 25 });
el.innerHTML = docs.length
? docs
.map(
(d) =>
`<li><span>${escapeHtml(d.message)}</span> <span class="muted small">${new Date(d.createdAt).toLocaleTimeString()}</span></li>`,
)
.join('')
: '<li class="muted">No messages yet — be the first.</li>';
} catch (e) {
el.innerHTML = `<li class="muted">${needsKey(e) ? keyHint : 'Could not load entries.'}</li>`;
}
}

async function loadFiles() {
const files = await api.files.list();
const el = document.getElementById('files');
el.innerHTML = files.length
? files
.map(
(f) =>
`<li><a href="${api.files.url(f.id)}" target="_blank" rel="noopener">${escapeHtml(f.name)}</a> <span class="muted small">${f.size} bytes</span></li>`,
)
.join('')
: '<li class="muted">No files yet.</li>';
try {
const files = await api.files.list();
el.innerHTML = files.length
? files
.map(
(f) =>
`<li><a href="${api.files.url(f.id)}" target="_blank" rel="noopener">${escapeHtml(f.name)}</a> <span class="muted small">${f.size} bytes</span></li>`,
)
.join('')
: '<li class="muted">No files yet.</li>';
} catch (e) {
el.innerHTML = `<li class="muted">${needsKey(e) ? keyHint : 'Could not load files.'}</li>`;
}
}

document.getElementById('post-form').addEventListener('submit', async (e) => {
e.preventDefault();
const input = document.getElementById('msg');
if (!input.value.trim()) return;
await api.data('guestbook').create({ message: input.value });
try {
await api.data('guestbook').create({ message: input.value });
input.value = '';
} catch (e) {
notice(needsKey(e) ? 'Saving needs a project key — paste one above.' : 'Could not save: ' + e.message);
}
});

// (Re)connect the SDK with the current key: refresh both lists and the live
// guestbook subscription. Called on load and whenever the key changes.
function connect() {
api = demoKey ? Zero('', { apiKey: demoKey }) : Zero();
if (room) {
try {
room.close();
} catch {}
}
room = api.data('guestbook').subscribe((change) => {
if (change.type === 'created' || change.type === 'deleted') loadEntries();
});
loadEntries();
loadFiles();
}

function updateKeyStatus() {
const clear = document.getElementById('key-clear');
const input = document.getElementById('key-input');
if (demoKey) {
input.placeholder = 'Using a project key';
clear.style.display = '';
} else {
input.placeholder = 'Project key (pk_…)';
clear.style.display = 'none';
}
}

document.getElementById('key-form').addEventListener('submit', (e) => {
e.preventDefault();
const input = document.getElementById('key-input');
const v = input.value.trim();
if (!v) return;
demoKey = v;
sessionStorage.setItem(KEY_STORE, v);
input.value = '';
notice('');
updateKeyStatus();
connect();
});

// Live: new entries from any browser tab show up here without a refresh.
api.data('guestbook').subscribe((change) => {
if (change.type === 'created' || change.type === 'deleted') loadEntries();
document.getElementById('key-clear').addEventListener('click', () => {
demoKey = null;
sessionStorage.removeItem(KEY_STORE);
updateKeyStatus();
connect();
});

document.getElementById('file-form').addEventListener('submit', async (e) => {
e.preventDefault();
const input = document.getElementById('file');
if (!input.files[0]) return;
await api.files.upload(input.files[0]);
input.value = '';
loadFiles();
try {
await api.files.upload(input.files[0]);
input.value = '';
loadFiles();
} catch (e) {
notice(needsKey(e) ? 'Uploading needs a project key — paste one above.' : 'Upload failed: ' + e.message);
}
});

loadEntries();
loadFiles();
connect();
</script>
</body>
</html>
12 changes: 12 additions & 0 deletions test/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,18 @@ test('serves the SDK and llms.txt', async () => {
assert.equal(llms.status, 200);
});

test('serves the homepage demo, which is multi-tenant aware (accepts a ?key=)', async () => {
const res = await fetch(`${base}/`);
assert.equal(res.status, 200);
assert.match(res.headers.get('content-type') ?? '', /text\/html/);
const html = await res.text();
// The demo lets you paste a project key (kept in sessionStorage) so it works on
// a multi-tenant server, and shows a notice explaining how.
assert.match(html, /key-form/);
assert.match(html, /sessionStorage/);
assert.match(html, /mt-notice/);
});

test('serves the examples gallery and apps', async () => {
for (const p of ['/examples.html', '/examples/todo.html', '/examples/chat.html', '/examples/notes.html']) {
const res = await fetch(`${base}${p}`);
Expand Down
Loading