summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/app.js
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/app.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js1210
1 files changed, 1060 insertions, 150 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index 1b284bb..2179e99 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -1,198 +1,1108 @@
-/* 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
- */
+import {
+ html, render, useState, useEffect, useCallback, useRef,
+ createContext, useContext,
+} from './vendor/htm-preact.js';
+import { t, getLocale, setLocale, LOCALES } from './i18n.js';
-const HUB = ''; // same origin — hub serves this file
+// ── Constants ────────────────────────────────────────────────────────────────
-// ── State ─────────────────────────────────────────────────────────────────────
+const HUB = '';
+const AUTH_KEY = 'mb_auth';
+const THEME_KEY = 'mb_theme';
-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,
-};
+// ── Auth persistence ─────────────────────────────────────────────────────────
-// ── Hub API helpers ───────────────────────────────────────────────────────────
+let _sessionKeys = null;
-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();
+function loadAuth() {
+ try {
+ return JSON.parse(localStorage.getItem(AUTH_KEY));
+ } catch {
+ return null;
+ }
}
-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()}`);
+function saveAuth(auth) {
+ if (auth) {
+ localStorage.setItem(AUTH_KEY, JSON.stringify(auth));
+ } else {
+ localStorage.removeItem(AUTH_KEY);
+ _sessionKeys = null;
+ }
+}
+
+// ── Theme ────────────────────────────────────────────────────────────────────
+
+function getInitialTheme() {
+ const stored = localStorage.getItem(THEME_KEY);
+ if (stored === 'dark' || stored === 'light' || stored === 'system') return stored;
+ return 'system';
+}
+
+function resolveTheme(pref) {
+ if (pref === 'system') {
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
+ }
+ return pref;
+}
+
+// ── Hub API ──────────────────────────────────────────────────────────────────
+
+async function hubFetch(path, { method = 'GET', body, token } = {}) {
+ const headers = {};
+ if (body) headers['Content-Type'] = 'application/json';
+ if (token) headers['Authorization'] = `Bearer ${token}`;
+ const opts = { method, headers };
+ if (body) opts.body = JSON.stringify(body);
+ const r = await fetch(HUB + path, opts);
+ if (!r.ok) {
+ const err = await r.json().catch(() => ({ detail: r.statusText }));
+ throw new Error(err.detail || r.statusText);
+ }
return r.json();
}
-// ── Auth ──────────────────────────────────────────────────────────────────────
+// ── Router ───────────────────────────────────────────────────────────────────
-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;
+function useRoute() {
+ const [hash, setHash] = useState(window.location.hash.slice(1) || '/');
+ useEffect(() => {
+ const onHash = () => setHash(window.location.hash.slice(1) || '/');
+ window.addEventListener('hashchange', onHash);
+ return () => window.removeEventListener('hashchange', onHash);
+ }, []);
+ return hash;
}
-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 navigate(path) {
+ window.location.hash = path;
}
-function logout() {
- state = { token: null, refreshToken: null, username: null, nodeUrl: null };
- localStorage.clear();
- render();
+// ── Context ──────────────────────────────────────────────────────────────────
+
+const AuthContext = createContext(null);
+function useAuth() { return useContext(AuthContext); }
+
+// ── Nav ──────────────────────────────────────────────────────────────────────
+
+function Nav({ user, theme, onThemeToggle, onLogout, onMenuToggle }) {
+ return html`
+ <nav class="nav">
+ <div class="nav-left">
+ ${user && html`
+ <button class="nav-hamburger" onClick=${onMenuToggle}
+ aria-label="${t('nav.toggle_menu')}">≡</button>
+ `}
+ <a class="nav-brand" href="#/">MeshBay</a>
+ </div>
+ <div class="nav-right">
+ <button class="nav-theme" onClick=${onThemeToggle}
+ aria-label="${t('nav.toggle_menu')}" title=${theme === 'dark' ? t('nav.light_mode') : t('nav.dark_mode')}>
+ ${theme === 'dark' ? '☀' : '☾'}
+ </button>
+ ${user ? html`
+ <span class="nav-user">${user.username}</span>
+ <button class="nav-btn" onClick=${onLogout}>${t('nav.logout')}</button>
+ ` : html`
+ <a class="nav-btn" href="#/login">${t('nav.login')}</a>
+ `}
+ </div>
+ </nav>
+ `;
}
-// ── Node API helpers ──────────────────────────────────────────────────────────
+// ── Sidebar ──────────────────────────────────────────────────────────────────
-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();
+function Sidebar({ groups, route, menuOpen }) {
+ return html`
+ <aside class="sidebar ${menuOpen ? 'open' : ''}">
+ <div class="sidebar-section">
+ <div class="sidebar-heading">${t('sidebar.my_groups')}</div>
+ ${groups.length === 0
+ ? html`<div class="sidebar-empty">${t('sidebar.no_groups')}</div>`
+ : groups.map(g => html`
+ <a key=${g.id}
+ class="sidebar-item ${route === '/group/' + g.id ? 'active' : ''}"
+ href="#/group/${g.id}">
+ ${g.name}
+ </a>
+ `)
+ }
+ </div>
+ <div class="sidebar-section">
+ <div class="sidebar-heading">${t('sidebar.discover')}</div>
+ <a class="sidebar-item ${route === '/explore' ? 'active' : ''}"
+ href="#/explore">${t('sidebar.public_groups')}</a>
+ <a class="sidebar-item ${route === '/settings' ? 'active' : ''}"
+ href="#/settings">${t('sidebar.settings')}</a>
+ </div>
+ </aside>
+ `;
}
-// ── Pages ─────────────────────────────────────────────────────────────────────
+// ── Login Page ───────────────────────────────────────────────────────────────
-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('');
+function LoginPage() {
+ const auth = useAuth();
+ const [username, setUsername] = useState('');
+ const [password, setPassword] = useState('');
+ const [error, setError] = useState('');
+ const [loading, setLoading] = useState(false);
+
+ const onSubmit = async (e) => {
+ e.preventDefault();
+ if (!username || !password) return;
+ setError('');
+ setLoading(true);
+ try {
+ await auth.login(username, password);
+ navigate('/');
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setLoading(false);
+ }
+ };
- 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>
- `}`;
+ return html`
+ <div class="page-center">
+ <div class="card login-card">
+ <h2>${t('login.title')}</h2>
+ <form onSubmit=${onSubmit}>
+ <input type="text" placeholder="${t('login.username')}" value=${username}
+ onInput=${e => setUsername(e.target.value)}
+ autocomplete="username" required />
+ <input type="password" placeholder="${t('login.password')}" value=${password}
+ onInput=${e => setPassword(e.target.value)}
+ autocomplete="current-password" required />
+ ${error && html`<div class="error-msg">${error}</div>`}
+ <button type="submit" disabled=${loading}>
+ ${loading ? t('login.loading') : t('login.submit')}
+ </button>
+ </form>
+ <div class="login-footer">
+ ${t('login.no_account')} <a href="#/register">${t('login.register_link')}</a>
+ </div>
+ </div>
+ </div>
+ `;
}
-async function pageGroup(groupId) {
- // TODO: fetch group info + node from hub
- return `<p>Group ${groupId} — coming soon</p><button onclick="render()">Back</button>`;
+// ── Register Page ────────────────────────────────────────────────────────────
+
+function RegisterPage() {
+ const [username, setUsername] = useState('');
+ const [email, setEmail] = useState('');
+ const [password, setPassword] = useState('');
+ const [confirm, setConfirm] = useState('');
+ const [error, setError] = useState('');
+ const [success, setSuccess] = useState(false);
+ const [loading, setLoading] = useState(false);
+
+ const onSubmit = async (e) => {
+ e.preventDefault();
+ if (password !== confirm) { setError(t('register.err_mismatch')); return; }
+ if (password.length < 8) { setError(t('register.err_min_len')); return; }
+ setError('');
+ setLoading(true);
+ try {
+ if (window.MeshBayKeys) {
+ await window.MeshBayKeys.registerUser(username, email, password);
+ } else {
+ await hubFetch('/v1/users/register', {
+ method: 'POST',
+ body: { username, email, password, pk_user_ed25519: '', pk_user_x25519: '' },
+ });
+ }
+ setSuccess(true);
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ if (success) {
+ return html`
+ <div class="page-center">
+ <div class="card login-card">
+ <h2>${t('register.success_title')}</h2>
+ <p style="text-align:center; margin-bottom:16px; color:var(--text-secondary)">
+ ${t('register.success_msg')}
+ </p>
+ <a href="#/login" style="display:block; text-align:center">${t('register.go_login')}</a>
+ </div>
+ </div>
+ `;
+ }
+
+ return html`
+ <div class="page-center">
+ <div class="card login-card">
+ <h2>${t('register.title')}</h2>
+ <form onSubmit=${onSubmit}>
+ <input type="text" placeholder="${t('register.username')}" value=${username}
+ onInput=${e => setUsername(e.target.value)}
+ autocomplete="username" required />
+ <input type="email" placeholder="${t('register.email')}" value=${email}
+ onInput=${e => setEmail(e.target.value)}
+ autocomplete="email" required />
+ <input type="password" placeholder="${t('register.password')}" value=${password}
+ onInput=${e => setPassword(e.target.value)}
+ autocomplete="new-password" required minlength="8" />
+ <input type="password" placeholder="${t('register.confirm')}" value=${confirm}
+ onInput=${e => setConfirm(e.target.value)}
+ autocomplete="new-password" required />
+ ${error && html`<div class="error-msg">${error}</div>`}
+ <button type="submit" disabled=${loading}>
+ ${loading ? t('register.loading') : t('register.submit')}
+ </button>
+ </form>
+ <div class="login-footer">
+ ${t('register.has_account')} <a href="#/login">${t('register.login_link')}</a>
+ </div>
+ </div>
+ </div>
+ `;
}
-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('');
+// ── Home Page ────────────────────────────────────────────────────────────────
+
+function HomePage({ groups }) {
+ if (groups.length === 0) {
+ return html`
+ <div>
+ <h2>${t('home.welcome')}</h2>
+ <p class="page-message">
+ ${t('home.no_groups')}
+ ${' '}${t('home.browse_prefix')}<a href="#/explore">${t('home.browse_link')}</a>${t('home.browse_suffix')}
+ </p>
+ </div>
+ `;
+ }
- 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>`;
+ return html`
+ <div>
+ <h2>${t('home.my_groups')}</h2>
+ <div class="group-grid">
+ ${groups.map(g => html`
+ <a key=${g.id} class="group-card" href="#/group/${g.id}">
+ <h3>${g.name}</h3>
+ <span class="badge">${g.visibility}</span>
+ ${' '}
+ <span class="badge">${g.join_policy}</span>
+ ${g.is_admin && html`${' '}<span class="badge">admin</span>`}
+ </a>
+ `)}
+ </div>
+ </div>
+ `;
}
-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>`;
+// ── Explore Page ─────────────────────────────────────────────────────────────
+
+function ExplorePage({ token }) {
+ const [groups, setGroups] = useState([]);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ hubFetch('/v1/groups', { token })
+ .then(data => setGroups(data.groups || []))
+ .catch(() => {})
+ .finally(() => setLoading(false));
+ }, [token]);
+
+ return html`
+ <div>
+ <h2>${t('explore.title')}</h2>
+ ${loading
+ ? html`<p class="page-message">${t('explore.loading')}</p>`
+ : groups.length === 0
+ ? html`<p class="page-message">${t('explore.empty')}</p>`
+ : html`
+ <div class="group-grid">
+ ${groups.map(g => html`
+ <a key=${g.id} class="group-card" href="#/group/${g.id}">
+ <h3>${g.name}</h3>
+ <span class="badge">${g.join_policy}</span>
+ ${g.source && g.source !== 'local' && html`
+ ${' '}<span class="badge">${g.source}</span>
+ `}
+ </a>
+ `)}
+ </div>
+ `
+ }
+ </div>
+ `;
}
-// ── Actions ───────────────────────────────────────────────────────────────────
+// ── Helpers ──────────────────────────────────────────────────────────────────
+
+const FILE_ICONS = {
+ video: '\u{1F3AC}', audio: '\u{1F3B5}', image: '\u{1F5BC}',
+ document: '\u{1F4C4}', archive: '\u{1F4E6}', other: '\u{1F4CE}',
+};
-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);
+function formatSize(bytes) {
+ if (bytes < 1024) return bytes + ' B';
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
+ if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
+ return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
}
-async function streamVideo(fileId, name) {
- setMain(pageStream(fileId, name));
+function formatDate(ts) {
+ return new Date(ts * 1000).toLocaleDateString(undefined, {
+ year: 'numeric', month: 'short', day: 'numeric',
+ });
}
-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); }
+// ── Group Page ──────────────────────────────────────────────────────────────
+
+const CHUNK_SIZE = 1024 * 1024;
+const PIPELINE_WINDOW = 8;
+
+async function pipelinedDownload(transport, gekKey, fileId, totalChunks, onChunk, writable) {
+ const results = writable ? null : new Array(totalChunks);
+ let nextSend = 0, nextRecv = 0;
+ const inflight = new Array(totalChunks);
+
+ const fire = () => {
+ while (nextSend < totalChunks && nextSend - nextRecv < PIPELINE_WINDOW) {
+ inflight[nextSend] = transport.fetchChunk(fileId, nextSend);
+ nextSend++;
+ }
+ };
+
+ fire();
+ while (nextRecv < totalChunks) {
+ const chunkMsg = await inflight[nextRecv];
+ let plaintext;
+ if (gekKey && chunkMsg.ct) {
+ plaintext = await window.MeshBayCrypto.decryptChunkBin(
+ gekKey, fileId, nextRecv, chunkMsg.nonce, chunkMsg.ct);
+ } else if (gekKey && chunkMsg.ct_b64) {
+ plaintext = await window.MeshBayCrypto.decryptChunk(
+ gekKey, fileId, nextRecv, chunkMsg.nonce_b64, chunkMsg.ct_b64);
+ } else {
+ plaintext = _b64ToU8(chunkMsg.ct_b64 || chunkMsg.data_b64);
+ }
+ if (writable) {
+ await writable.write(plaintext);
+ } else {
+ results[nextRecv] = plaintext;
+ }
+ nextRecv++;
+ fire();
+ if (onChunk) onChunk(plaintext.byteLength, nextRecv, totalChunks);
+ }
+ return results;
+}
+
+function GroupPage({ groupId, group, token, username }) {
+ const [status, setStatus] = useState('idle');
+ const [entries, setEntries] = useState([]);
+ const [error, setError] = useState('');
+ const [sortKey, setSortKey] = useState('name');
+ const [sortAsc, setSortAsc] = useState(true);
+ const [filter, setFilter] = useState('');
+ const [currentPath, setCurrentPath] = useState('');
+ const [dlState, setDlState] = useState(null);
+ const [videoEntry, setVideoEntry] = useState(null);
+ const [tab, setTab] = useState('files');
+ const transportRef = useRef(null);
+ const gekRef = useRef(null);
+
+ useEffect(() => {
+ let cancelled = false;
+ const connect = async () => {
+ setStatus('discovering');
+ setError('');
+ setEntries([]);
+ gekRef.current = null;
+ try {
+ const nodesData = await hubFetch(`/v1/groups/${groupId}/nodes`, { token });
+ if (cancelled) return;
+ if (!nodesData.nodes || nodesData.nodes.length === 0) {
+ setStatus('offline');
+ return;
+ }
+
+ setStatus('connecting');
+ const nodeId = nodesData.nodes[0].node_id;
+ const transport = new window.MeshBayTransport('', token);
+ transportRef.current = transport;
+
+ await transport.connect(nodeId, token, groupId);
+ if (cancelled) return;
+ setStatus('fetching');
+
+ const indexMsg = await transport.fetchIndex();
+ if (cancelled) return;
+ setEntries(indexMsg.entries || []);
+ setStatus('connected');
+ } catch (err) {
+ if (!cancelled) {
+ setError(err.message);
+ setStatus('error');
+ }
+ }
+ };
+
+ if (token && window.MeshBayTransport) {
+ connect();
+ } else if (!window.MeshBayTransport) {
+ setStatus('error');
+ setError(t('group.err_transport'));
+ }
+
+ return () => {
+ cancelled = true;
+ if (transportRef.current) {
+ transportRef.current.close();
+ transportRef.current = null;
+ }
+ };
+ }, [groupId, token]);
+
+ const downloadFile = useCallback(async (entry) => {
+ const transport = transportRef.current;
+ if (!transport || !transport.connected) return;
+
+ setDlState({ fileId: entry.id, name: entry.name, progress: 0, total: entry.size });
+
+ try {
+ if (!gekRef.current && window.MeshBayCrypto) {
+ const gekB64 = await transport.fetchGEK();
+ gekRef.current = await window.MeshBayCrypto.importGEK(gekB64);
+ }
+
+ const totalChunks = Math.ceil(entry.size / CHUNK_SIZE);
+ let downloaded = 0;
+ const onProgress = (bytes) => {
+ downloaded += bytes;
+ setDlState(prev => ({ ...prev, progress: downloaded }));
+ };
+
+ if (window.showSaveFilePicker) {
+ const handle = await window.showSaveFilePicker({ suggestedName: entry.name });
+ const writable = await handle.createWritable();
+ try {
+ await pipelinedDownload(
+ transport, gekRef.current, entry.id, totalChunks, onProgress, writable);
+ await writable.close();
+ } catch (err) {
+ await writable.abort();
+ throw err;
+ }
+ } else {
+ const chunks = await pipelinedDownload(
+ transport, gekRef.current, entry.id, totalChunks, onProgress);
+ const blob = new Blob(chunks);
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = entry.name;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+ }
+ setDlState(null);
+ } catch (err) {
+ setDlState(null);
+ if (err.name === 'AbortError') return;
+ setError(t('group.dl_failed', { err: err.message }));
+ }
+ }, []);
+
+ const toggleSort = useCallback((key) => {
+ setSortAsc(prev => sortKey === key ? !prev : true);
+ setSortKey(key);
+ }, [sortKey]);
+
+ const dirs = new Set();
+ const filteredEntries = entries.filter(e => {
+ const ePath = e.path || '';
+ if (ePath === currentPath) {
+ return !filter || e.name.toLowerCase().includes(filter.toLowerCase());
+ }
+ if (!currentPath && ePath) {
+ dirs.add(ePath.split('/')[0]);
+ } else if (currentPath && ePath.startsWith(currentPath + '/')) {
+ const rest = ePath.slice(currentPath.length + 1);
+ dirs.add(rest.split('/')[0]);
+ }
+ return false;
+ });
+
+ const sorted = [...filteredEntries].sort((a, b) => {
+ let cmp = 0;
+ if (sortKey === 'name') cmp = a.name.localeCompare(b.name);
+ else if (sortKey === 'size') cmp = a.size - b.size;
+ else if (sortKey === 'type') cmp = a.type.localeCompare(b.type);
+ else if (sortKey === 'date') cmp = a.added_at - b.added_at;
+ return sortAsc ? cmp : -cmp;
+ });
+
+ const subdirs = [...dirs].sort();
+
+ const statusLabel = {
+ idle: t('status.idle'),
+ discovering: t('status.discovering'),
+ connecting: t('status.connecting'),
+ fetching: t('status.fetching'),
+ connected: t('status.files', { n: entries.length }),
+ offline: t('status.offline'),
+ error: t('status.error'),
+ }[status] || status;
+
+ const statusClass = status === 'connected' ? 'status-ok'
+ : status === 'error' || status === 'offline' ? 'status-err' : 'status-busy';
+
+ const breadcrumbs = currentPath ? currentPath.split('/') : [];
+
+ return html`
+ <div>
+ <div class="group-header">
+ <h2>${group ? group.name : t('group.default_name')}</h2>
+ <span class="status-badge ${statusClass}">${statusLabel}</span>
+ </div>
+ ${error && html`<div class="error-msg" style="margin-bottom:12px">${error}</div>`}
+ ${dlState && html`
+ <div class="dl-bar">
+ <span class="dl-name">${dlState.name}</span>
+ <div class="dl-progress">
+ <div class="dl-fill" style="width:${Math.round(dlState.progress / dlState.total * 100)}%"></div>
+ </div>
+ <span class="dl-pct">${formatSize(dlState.progress)} / ${formatSize(dlState.total)}</span>
+ </div>
+ `}
+ ${status === 'connected' && html`
+ <div class="group-tabs">
+ <button class="group-tab ${tab === 'files' ? 'active' : ''}"
+ onClick=${() => setTab('files')}>${t('group.tab_files')}</button>
+ <button class="group-tab ${tab === 'chat' ? 'active' : ''}"
+ onClick=${() => setTab('chat')}>${t('group.tab_chat')}</button>
+ </div>
+
+ ${tab === 'files' && html`
+ <div class="file-toolbar">
+ <div class="breadcrumbs">
+ <a class="crumb" onClick=${() => setCurrentPath('')}>/</a>
+ ${breadcrumbs.map((seg, i) => {
+ const path = breadcrumbs.slice(0, i + 1).join('/');
+ return html`
+ <span class="crumb-sep">/</span>
+ <a class="crumb" onClick=${() => setCurrentPath(path)}>${seg}</a>
+ `;
+ })}
+ </div>
+ <input type="text" class="file-search" placeholder="${t('group.filter')}"
+ value=${filter} onInput=${e => setFilter(e.target.value)} />
+ </div>
+ <table class="file-table">
+ <thead>
+ <tr>
+ <th></th>
+ <th class="sortable" onClick=${() => toggleSort('name')}>
+ ${t('group.col_name')} ${sortKey === 'name' ? (sortAsc ? '▲' : '▼') : ''}
+ </th>
+ <th class="sortable" onClick=${() => toggleSort('size')}>
+ ${t('group.col_size')} ${sortKey === 'size' ? (sortAsc ? '▲' : '▼') : ''}
+ </th>
+ <th class="sortable th-type" onClick=${() => toggleSort('type')}>
+ ${t('group.col_type')} ${sortKey === 'type' ? (sortAsc ? '▲' : '▼') : ''}
+ </th>
+ <th class="sortable th-date" onClick=${() => toggleSort('date')}>
+ ${t('group.col_date')} ${sortKey === 'date' ? (sortAsc ? '▲' : '▼') : ''}
+ </th>
+ <th></th>
+ </tr>
+ </thead>
+ <tbody>
+ ${subdirs.map(d => html`
+ <tr class="file-row dir-row" onClick=${() =>
+ setCurrentPath(currentPath ? currentPath + '/' + d : d)}>
+ <td>\u{1F4C1}</td>
+ <td>${d}/</td>
+ <td></td>
+ <td class="td-type"></td>
+ <td class="td-date"></td>
+ <td></td>
+ </tr>
+ `)}
+ ${sorted.map(e => html`
+ <tr class="file-row" key=${e.id}>
+ <td>${FILE_ICONS[e.type] || FILE_ICONS.other}</td>
+ <td class="file-name">${e.name}</td>
+ <td class="file-size">${formatSize(e.size)}</td>
+ <td class="file-type td-type">${e.type}</td>
+ <td class="file-date td-date">${formatDate(e.added_at)}</td>
+ <td>
+ ${e.type === 'video' && html`
+ <button class="play-btn" onClick=${() => setVideoEntry(e)}
+ disabled=${!!dlState || !!videoEntry} title="${t('group.play')}"
+ \u{25B6}
+ </button>
+ `}
+ <button class="dl-btn" onClick=${() => downloadFile(e)}
+ disabled=${!!dlState} title="${t('group.download')}"
+ \u{2B07}
+ </button>
+ </td>
+ </tr>
+ `)}
+ ${sorted.length === 0 && subdirs.length === 0 && html`
+ <tr><td colspan="6" class="file-empty">
+ ${filter ? t('group.empty_filter') : t('group.empty_dir')}
+ </td></tr>
+ `}
+ </tbody>
+ </table>
+ `}
+
+ ${tab === 'chat' && html`
+ <${ChatPanel} transportRef=${transportRef} username=${username} />
+ `}
+ `}
+ ${status === 'offline' && html`
+ <p class="page-message">
+ ${t('group.offline_title')}
+ ${' '}${t('group.offline_hint')}
+ </p>
+ `}
+ ${(status === 'discovering' || status === 'connecting' || status === 'fetching') && html`
+ <p class="page-message">${statusLabel}</p>
+ `}
+ ${videoEntry && html`
+ <${VideoPlayer}
+ entry=${videoEntry}
+ transportRef=${transportRef}
+ gekRef=${gekRef}
+ onClose=${() => setVideoEntry(null)} />
+ `}
+ </div>
+ `;
}
-// ── Router / render ───────────────────────────────────────────────────────────
+function _b64ToU8(b64) {
+ const bin = atob(b64);
+ const arr = new Uint8Array(bin.length);
+ for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
+ return arr;
+}
+
+// ── Chat Panel ──────────────────────────────────────────────────────────
-function setMain(html) {
- document.getElementById('main').innerHTML = html;
+function formatTime(ts) {
+ const d = new Date(ts * 1000);
+ const now = new Date();
+ const time = d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
+ if (d.toDateString() === now.toDateString()) return time;
+ return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + ' ' + time;
}
-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>`);
- }
+function ChatPanel({ transportRef, username }) {
+ const [messages, setMessages] = useState([]);
+ const [input, setInput] = useState('');
+ const [sending, setSending] = useState(false);
+ const listRef = useRef(null);
+ const bottomRef = useRef(null);
+ const loadedRef = useRef(false);
+
+ useEffect(() => {
+ const transport = transportRef.current;
+ if (!transport || !transport.connected) return;
+
+ if (!loadedRef.current) {
+ loadedRef.current = true;
+ transport.fetchChatHistory(0, 200)
+ .then(msgs => setMessages(msgs))
+ .catch(() => {});
+ }
+
+ transport.onChat = (msg) => {
+ setMessages(prev => [...prev, {
+ sender_id: msg.sender_id,
+ payload: msg.payload,
+ timestamp: msg.timestamp || Date.now() / 1000,
+ thread_id: msg.thread_id,
+ }]);
+ };
+
+ return () => { transport.onChat = null; };
+ }, [transportRef.current?.connected]);
+
+ useEffect(() => {
+ if (bottomRef.current) {
+ bottomRef.current.scrollIntoView({ behavior: 'smooth' });
+ }
+ }, [messages.length]);
+
+ const sendMessage = useCallback(async () => {
+ const text = input.trim();
+ if (!text) return;
+ const transport = transportRef.current;
+ if (!transport || !transport.connected) return;
+
+ setSending(true);
+ setInput('');
+ try {
+ await transport.sendChat(text, 0, null);
+ setMessages(prev => [...prev, {
+ sender_id: username,
+ payload: text,
+ timestamp: Date.now() / 1000,
+ thread_id: null,
+ }]);
+ } catch {
+ setInput(text);
+ } finally {
+ setSending(false);
+ }
+ }, [input, username]);
+
+ const onKeyDown = useCallback((e) => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault();
+ sendMessage();
+ }
+ }, [sendMessage]);
+
+ return html`
+ <div class="chat-panel">
+ <div class="chat-messages" ref=${listRef}>
+ ${messages.length === 0 && html`
+ <div class="chat-empty">${t('chat.empty')}</div>
+ `}
+ ${messages.map((m, i) => {
+ const isOwn = m.sender_id === username;
+ const showSender = !isOwn && (i === 0 || messages[i - 1].sender_id !== m.sender_id);
+ return html`
+ <div key=${i} class="chat-msg ${isOwn ? 'chat-msg-own' : ''}">
+ ${showSender && html`
+ <div class="chat-sender">${m.sender_id}</div>
+ `}
+ <div class="chat-bubble ${isOwn ? 'chat-bubble-own' : ''}">
+ <span class="chat-text">${m.payload}</span>
+ <span class="chat-time">${formatTime(m.timestamp)}</span>
+ </div>
+ </div>
+ `;
+ })}
+ <div ref=${bottomRef} />
+ </div>
+ <div class="chat-input-row">
+ <textarea class="chat-input" rows="1"
+ placeholder="${t('chat.placeholder')}"
+ value=${input}
+ onInput=${e => setInput(e.target.value)}
+ onKeyDown=${onKeyDown}
+ disabled=${sending} />
+ <button class="chat-send" onClick=${sendMessage}
+ disabled=${sending || !input.trim()}>
+ ${t('chat.send')}
+ </button>
+ </div>
+ </div>
+ `;
}
-// ── Utils ─────────────────────────────────────────────────────────────────────
+// ── Video Player ────────────────────────────────────────────────────────
+
+const VIDEO_MIMES = {
+ '.mp4': 'video/mp4', '.webm': 'video/webm', '.mkv': 'video/x-matroska',
+ '.avi': 'video/x-msvideo', '.mov': 'video/quicktime', '.m4v': 'video/mp4',
+ '.flv': 'video/x-flv', '.wmv': 'video/x-ms-wmv',
+};
-function esc(s) {
- return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
- .replace(/"/g,'&quot;').replace(/'/g,'&#39;');
+function videoMime(name) {
+ const dot = name.lastIndexOf('.');
+ if (dot < 0) return 'video/mp4';
+ return VIDEO_MIMES[name.slice(dot).toLowerCase()] || 'video/mp4';
}
-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';
+function VideoPlayer({ entry, transportRef, gekRef, onClose }) {
+ const [phase, setPhase] = useState('loading');
+ const [progress, setProgress] = useState(0);
+ const [error, setError] = useState('');
+ const videoRef = useRef(null);
+ const blobUrlRef = useRef(null);
+
+ useEffect(() => {
+ let cancelled = false;
+
+ const load = async () => {
+ const transport = transportRef.current;
+ if (!transport || !transport.connected) {
+ setError(t('video.err_transport'));
+ setPhase('error');
+ return;
+ }
+
+ try {
+ if (!gekRef.current && window.MeshBayCrypto) {
+ const gekB64 = await transport.fetchGEK();
+ gekRef.current = await window.MeshBayCrypto.importGEK(gekB64);
+ }
+
+ const totalChunks = Math.ceil(entry.size / CHUNK_SIZE);
+ let downloaded = 0;
+ const chunks = await pipelinedDownload(
+ transport, gekRef.current, entry.id, totalChunks,
+ (bytes) => { downloaded += bytes; setProgress(downloaded / entry.size); },
+ );
+
+ if (cancelled) return;
+
+ const blob = new Blob(chunks, { type: videoMime(entry.name) });
+ const url = URL.createObjectURL(blob);
+ blobUrlRef.current = url;
+ setPhase('ready');
+ } catch (err) {
+ if (!cancelled) {
+ setError(err.message);
+ setPhase('error');
+ }
+ }
+ };
+
+ load();
+ return () => { cancelled = true; };
+ }, [entry]);
+
+ useEffect(() => {
+ if (phase === 'ready' && videoRef.current && blobUrlRef.current) {
+ videoRef.current.src = blobUrlRef.current;
+ videoRef.current.play().catch(() => {});
+ }
+ }, [phase]);
+
+ useEffect(() => {
+ return () => {
+ if (blobUrlRef.current) {
+ URL.revokeObjectURL(blobUrlRef.current);
+ blobUrlRef.current = null;
+ }
+ };
+ }, []);
+
+ useEffect(() => {
+ const onKey = (e) => { if (e.key === 'Escape') onClose(); };
+ window.addEventListener('keydown', onKey);
+ return () => window.removeEventListener('keydown', onKey);
+ }, [onClose]);
+
+ return html`
+ <div class="video-overlay" onClick=${(e) => {
+ if (e.target.classList.contains('video-overlay')) onClose();
+ }}>
+ <div class="video-top-bar">
+ <span class="video-title">${entry.name}</span>
+ <button class="video-close" onClick=${onClose} title="${t('video.close')}">✕</button>
+ </div>
+
+ ${phase === 'loading' && html`
+ <div class="video-loading">
+ <div class="video-loading-label">${t('video.loading', { name: entry.name })}</div>
+ <div class="video-progress-bar">
+ <div class="video-progress-fill"
+ style="width:${Math.round(progress * 100)}%"></div>
+ </div>
+ <div class="video-progress-text">
+ ${formatSize(Math.round(progress * entry.size))} / ${formatSize(entry.size)}
+ </div>
+ </div>
+ `}
+
+ ${phase === 'ready' && html`
+ <div class="video-container">
+ <video ref=${videoRef} controls autoplay />
+ </div>
+ `}
+
+ ${phase === 'error' && html`
+ <div class="video-error">${error}</div>
+ `}
+ </div>
+ `;
+}
+
+// ── Settings Page ───────────────────────────────────────────────────────────
+
+const THEME_OPTIONS = ['light', 'dark', 'system'];
+
+function SettingsPage({ user, theme, onThemeChange }) {
+ const [locale, setLoc] = useState(getLocale);
+
+ const onLocaleChange = useCallback((e) => {
+ const code = e.target.value;
+ setLocale(code);
+ setLoc(code);
+ window.location.reload();
+ }, []);
+
+ const onThemeSelect = useCallback((e) => {
+ onThemeChange(e.target.value);
+ }, [onThemeChange]);
+
+ return html`
+ <div>
+ <h2>${t('settings.title')}</h2>
+
+ <div class="settings-section">
+ <h3 class="settings-heading">${t('settings.profile')}</h3>
+ <div class="settings-row">
+ <span class="settings-label">${t('settings.username')}</span>
+ <span class="settings-value">${user.username}</span>
+ </div>
+ </div>
+
+ <div class="settings-section">
+ <h3 class="settings-heading">${t('settings.appearance')}</h3>
+ <div class="settings-row">
+ <span class="settings-label">${t('settings.theme')}</span>
+ <select class="settings-select" value=${theme} onChange=${onThemeSelect}>
+ <option value="light">${t('settings.theme_light')}</option>
+ <option value="dark">${t('settings.theme_dark')}</option>
+ <option value="system">${t('settings.theme_system')}</option>
+ </select>
+ </div>
+ <div class="settings-row">
+ <span class="settings-label">${t('settings.language')}</span>
+ <select class="settings-select" value=${locale} onChange=${onLocaleChange}>
+ ${LOCALES.map(l => html`
+ <option key=${l.code} value=${l.code}>${l.name}</option>
+ `)}
+ </select>
+ </div>
+ </div>
+
+ <div class="settings-section">
+ <h3 class="settings-heading">${t('settings.about')}</h3>
+ <div class="settings-row">
+ <span class="settings-label">${t('settings.version')}</span>
+ <span class="settings-value">0.1.0</span>
+ </div>
+ <div class="settings-row">
+ <span class="settings-label">${t('settings.protocol')}</span>
+ <span class="settings-value">MNP 0.1 / MHP 0.1</span>
+ </div>
+ </div>
+ </div>
+ `;
}
-// ── Boot ──────────────────────────────────────────────────────────────────────
-document.addEventListener('DOMContentLoaded', render);
+// ── App ──────────────────────────────────────────────────────────────────────
+
+function App() {
+ const route = useRoute();
+ const [theme, setTheme] = useState(getInitialTheme);
+ const [user, setUser] = useState(loadAuth);
+ const [groups, setGroups] = useState([]);
+ const [menuOpen, setMenuOpen] = useState(false);
+
+ const resolved = resolveTheme(theme);
+
+ useEffect(() => {
+ document.documentElement.className = `theme-${resolved}`;
+ localStorage.setItem(THEME_KEY, theme);
+ }, [theme, resolved]);
+
+ useEffect(() => {
+ if (!user) { setGroups([]); return; }
+ hubFetch('/v1/groups/mine', { token: user.token })
+ .then(data => setGroups(data.groups || []))
+ .catch(() => setGroups([]));
+ }, [user]);
+
+ useEffect(() => { setMenuOpen(false); }, [route]);
+
+ const toggleTheme = useCallback(() => {
+ setTheme(prev => resolveTheme(prev) === 'dark' ? 'light' : 'dark');
+ }, []);
+
+ const authCtx = {
+ user,
+ login: async (username, password) => {
+ if (window.MeshBayKeys) {
+ const data = await window.MeshBayKeys.loginAndRecover(username, password);
+ _sessionKeys = { skXB64: data.skXB64, skEdB64: data.skEdB64 };
+ const u = {
+ username,
+ token: data.accessToken,
+ refreshToken: data.refreshToken,
+ };
+ setUser(u);
+ saveAuth(u);
+ } else {
+ const data = await hubFetch('/v1/users/login', {
+ method: 'POST',
+ body: { username, password },
+ });
+ const u = {
+ username,
+ token: data.access_token,
+ refreshToken: data.refresh_token,
+ };
+ setUser(u);
+ saveAuth(u);
+ }
+ },
+ logout: () => {
+ setUser(null);
+ saveAuth(null);
+ setGroups([]);
+ navigate('/login');
+ },
+ };
+
+ let page;
+ if (route === '/login' || route === '/register') {
+ page = route === '/register'
+ ? html`<${RegisterPage} />`
+ : html`<${LoginPage} />`;
+ } else if (!user) {
+ page = html`<${LoginPage} />`;
+ } else if (route === '/explore') {
+ page = html`<${ExplorePage} token=${user.token} />`;
+ } else if (route.startsWith('/group/')) {
+ const groupId = route.slice(7);
+ const group = groups.find(g => g.id === groupId);
+ page = html`<${GroupPage}
+ groupId=${groupId} group=${group} token=${user.token}
+ username=${user.username} />`;
+ } else if (route === '/settings') {
+ page = html`<${SettingsPage} user=${user} theme=${theme}
+ onThemeChange=${setTheme} />`;
+ } else {
+ page = html`<${HomePage} groups=${groups} />`;
+ }
+
+ return html`
+ <${AuthContext.Provider} value=${authCtx}>
+ <${Nav}
+ user=${user}
+ theme=${resolved}
+ onThemeToggle=${toggleTheme}
+ onLogout=${authCtx.logout}
+ onMenuToggle=${() => setMenuOpen(o => !o)} />
+ <div class="layout">
+ ${user && html`<${Sidebar}
+ groups=${groups}
+ route=${route}
+ menuOpen=${menuOpen} />`}
+ ${menuOpen && html`<div class="overlay visible"
+ onClick=${() => setMenuOpen(false)} />`}
+ <main class="main">
+ ${page}
+ </main>
+ </div>
+ <//>
+ `;
+}
+
+// ── Boot ─────────────────────────────────────────────────────────────────────
+
+render(html`<${App} />`, document.getElementById('app'));