diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-09 04:58:06 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-09 04:58:06 +0200 |
| commit | c9422eaf95090cde6411186c0d62b04286a8477c (patch) | |
| tree | 558378df2af6869a86215bcfe53e893b79c756bd /packages/meshbay-hub/src/meshbay_hub/static | |
| parent | 9b503785afe10849f40a26735aa08e0af24a6efc (diff) | |
| download | meshbay-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.js | 198 |
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,'&').replace(/</g,'<').replace(/>/g,'>') + .replace(/"/g,'"').replace(/'/g,'''); +} + +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); |