diff options
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/app.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/app.js | 1210 |
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,'&').replace(/</g,'<').replace(/>/g,'>') - .replace(/"/g,'"').replace(/'/g,'''); +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')); |