aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-09 04:58:06 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-09 04:58:06 +0200
commitc9422eaf95090cde6411186c0d62b04286a8477c (patch)
tree558378df2af6869a86215bcfe53e893b79c756bd /packages/meshbay-hub/src/meshbay_hub/static
parent9b503785afe10849f40a26735aa08e0af24a6efc (diff)
downloadmeshbay-c9422eaf95090cde6411186c0d62b04286a8477c.tar.gz
feat(hub): add web app + public group listing endpoint
webapp.py: serves / (HTML SPA) and /app.js (JS client). app.js: login, hub API calls, node HTTP API integration, file browser, HLS video streaming via <video> tag. groups.py: GET /v1/groups — public group listing (no auth). Deployed on https://meshbay.org — 200 OK. 47/47 tests. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js198
1 files changed, 198 insertions, 0 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
new file mode 100644
index 0000000..1b284bb
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -0,0 +1,198 @@
+/* MeshBay Web Client — Phase 4
+ * Communicates with: Hub API (auth, groups) + Node HTTP API (files, streaming)
+ * Requires: hub at same origin, node at configured URL
+ */
+
+const HUB = ''; // same origin — hub serves this file
+
+// ── State ─────────────────────────────────────────────────────────────────────
+
+let state = {
+ token: localStorage.getItem('mb_token') || null,
+ refreshToken: localStorage.getItem('mb_rt') || null,
+ username: localStorage.getItem('mb_user') || null,
+ nodeUrl: localStorage.getItem('mb_node') || null,
+};
+
+// ── Hub API helpers ───────────────────────────────────────────────────────────
+
+async function hubGet(path) {
+ const headers = state.token ? { Authorization: `Bearer ${state.token}` } : {};
+ const r = await fetch(HUB + path, { headers });
+ if (!r.ok) throw new Error(`${r.status} ${await r.text()}`);
+ return r.json();
+}
+
+async function hubPost(path, body) {
+ const headers = {
+ 'Content-Type': 'application/json',
+ ...(state.token ? { Authorization: `Bearer ${state.token}` } : {}),
+ };
+ const r = await fetch(HUB + path, { method: 'POST', headers, body: JSON.stringify(body) });
+ if (!r.ok) throw new Error(`${r.status} ${await r.text()}`);
+ return r.json();
+}
+
+// ── Auth ──────────────────────────────────────────────────────────────────────
+
+async function login(username, password) {
+ const data = await hubPost('/v1/users/login', { username, password });
+ state.token = data.access_token;
+ state.refreshToken = data.refresh_token;
+ state.username = username;
+ localStorage.setItem('mb_token', state.token);
+ localStorage.setItem('mb_rt', state.refreshToken);
+ localStorage.setItem('mb_user', username);
+ return data;
+}
+
+async function register(username, email, password, pkEd, pkX) {
+ return hubPost('/v1/users/register', {
+ username, email, password,
+ pk_user_ed25519: pkEd,
+ pk_user_x25519: pkX,
+ });
+}
+
+function logout() {
+ state = { token: null, refreshToken: null, username: null, nodeUrl: null };
+ localStorage.clear();
+ render();
+}
+
+// ── Node API helpers ──────────────────────────────────────────────────────────
+
+async function nodeGet(path) {
+ if (!state.nodeUrl) throw new Error('No node configured');
+ const sep = path.includes('?') ? '&' : '?';
+ const url = state.nodeUrl + path + (state.token ? `${sep}token=${state.token}` : '');
+ const r = await fetch(url);
+ if (!r.ok) throw new Error(`Node ${r.status}`);
+ return r.json();
+}
+
+// ── Pages ─────────────────────────────────────────────────────────────────────
+
+async function pageHome() {
+ const groups = await hubGet('/v1/groups');
+ const items = groups.groups.map(g => `
+ <div class="card" onclick="pageGroup('${g.id}')">
+ <b>${esc(g.name)}</b>
+ <span class="badge">${g.join_policy}</span>
+ </div>`).join('');
+
+ return `
+ <h2>Public Groups</h2>
+ ${items || '<p>No public groups yet.</p>'}
+ ${state.nodeUrl ? `
+ <h2>My Node (<a href="${esc(state.nodeUrl)}" target="_blank">${esc(state.nodeUrl)}</a>)</h2>
+ <button onclick="pageNodeBrowser()">Browse My Node</button>
+ ` : `
+ <h2>Connect to a Node</h2>
+ <input id="nodeUrl" placeholder="http://node-ip:19001" style="width:300px">
+ <button onclick="connectNode()">Connect</button>
+ `}`;
+}
+
+async function pageGroup(groupId) {
+ // TODO: fetch group info + node from hub
+ return `<p>Group ${groupId} — coming soon</p><button onclick="render()">Back</button>`;
+}
+
+async function pageNodeBrowser() {
+ const data = await nodeGet('/index');
+ const rows = data.entries.map(e => `
+ <tr>
+ <td>${esc(e.name)}</td>
+ <td>${e.type}</td>
+ <td>${fmtSize(e.size)}</td>
+ <td>
+ ${e.type === 'video' ? `<button onclick="streamVideo('${e.id}','${esc(e.name)}')">▶ Play</button>` : ''}
+ <a href="${state.nodeUrl}/file/${e.id}" target="_blank">⬇ Download</a>
+ </td>
+ </tr>`).join('');
+
+ return `
+ <h2>📁 ${esc(data.group_name)}</h2>
+ <p>${data.entries.length} files — index v${data.version}</p>
+ <table>
+ <thead><tr><th>Name</th><th>Type</th><th>Size</th><th>Actions</th></tr></thead>
+ <tbody>${rows}</tbody>
+ </table>
+ <button onclick="render()">← Back</button>`;
+}
+
+function pageStream(fileId, name) {
+ const src = `${state.nodeUrl}/hls/${fileId}/playlist.m3u8${state.token ? '?token=' + state.token : ''}`;
+ return `
+ <h2>▶ ${esc(name)}</h2>
+ <video controls autoplay style="max-width:100%;width:800px">
+ <source src="${esc(src)}" type="application/vnd.apple.mpegurl">
+ Your browser does not support HLS. <a href="${state.nodeUrl}/file/${fileId}">Download instead</a>.
+ </video>
+ <br><button onclick="pageNodeBrowser().then(setMain)">← Back to files</button>`;
+}
+
+// ── Actions ───────────────────────────────────────────────────────────────────
+
+async function connectNode() {
+ const url = document.getElementById('nodeUrl')?.value?.trim();
+ if (!url) return;
+ state.nodeUrl = url;
+ localStorage.setItem('mb_node', url);
+ await pageNodeBrowser().then(setMain);
+}
+
+async function streamVideo(fileId, name) {
+ setMain(pageStream(fileId, name));
+}
+
+async function doLogin() {
+ const u = document.getElementById('lu').value;
+ const p = document.getElementById('lp').value;
+ try {
+ await login(u, p);
+ render();
+ } catch(e) { alert('Login failed: ' + e.message); }
+}
+
+// ── Router / render ───────────────────────────────────────────────────────────
+
+function setMain(html) {
+ document.getElementById('main').innerHTML = html;
+}
+
+async function render() {
+ const nav = document.getElementById('nav');
+ if (state.username) {
+ nav.innerHTML = `<b>MeshBay</b> | Logged in as <b>${esc(state.username)}</b>
+ <button onclick="logout()" style="float:right">Logout</button>`;
+ setMain('<p>Loading…</p>');
+ setMain(await pageHome());
+ } else {
+ nav.innerHTML = '<b>MeshBay</b>';
+ setMain(`
+ <h2>Login</h2>
+ <input id="lu" placeholder="Username" autocomplete="username">
+ <input id="lp" type="password" placeholder="Password" autocomplete="current-password">
+ <button onclick="doLogin()">Login</button>
+ <p><small>No account? Register via the API for now.</small></p>`);
+ }
+}
+
+// ── Utils ─────────────────────────────────────────────────────────────────────
+
+function esc(s) {
+ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
+ .replace(/"/g,'&quot;').replace(/'/g,'&#39;');
+}
+
+function fmtSize(bytes) {
+ if (bytes < 1024) return bytes + ' B';
+ if (bytes < 1024**2) return (bytes/1024).toFixed(1) + ' KB';
+ if (bytes < 1024**3) return (bytes/1024**2).toFixed(1) + ' MB';
+ return (bytes/1024**3).toFixed(2) + ' GB';
+}
+
+// ── Boot ──────────────────────────────────────────────────────────────────────
+document.addEventListener('DOMContentLoaded', render);