From e23e33adeaf8ee7439187d4451c856b37816a51f Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Tue, 11 Aug 2026 04:13:53 +0200 Subject: feat: Phase 9 — Web client SPA with WebRTC P2P transport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete browser-based client: Preact SPA with login, group file browser, encrypted download, video playback, group chat, i18n, and dark/light theme. Browser connects P2P to nodes behind residential NAT via WebRTC DataChannel (aiortc). Hub handles signaling only — all data flows E2E. Performance: pipelined downloads (8-chunk sliding window), binary msgpack wire format (no base64), redundant I/O elimination. Large file downloads stream to disk via File System Access API (showSaveFilePicker). Validated on SFR + Orange residential NATs, Chrome + Firefox, IPv4/IPv6. 132 tests passing. Deployed to meshbay.org + Orange node. Co-Authored-By: Claude Opus 4.6 --- packages/meshbay-hub/src/meshbay_hub/api/groups.py | 51 + .../meshbay-hub/src/meshbay_hub/api/revocation.py | 11 +- packages/meshbay-hub/src/meshbay_hub/api/webapp.py | 73 +- packages/meshbay-hub/src/meshbay_hub/app.py | 5 +- packages/meshbay-hub/src/meshbay_hub/static/app.js | 1238 +++++++++++++++++--- .../meshbay-hub/src/meshbay_hub/static/crypto.js | 11 +- .../meshbay-hub/src/meshbay_hub/static/i18n.js | 172 +++ .../meshbay-hub/src/meshbay_hub/static/style.css | 802 +++++++++++++ .../src/meshbay_hub/static/transport.js | 47 +- .../src/meshbay_hub/static/vendor/htm-preact.js | 1 + .../src/meshbay_hub/static/webrtc-test.html | 19 +- 11 files changed, 2192 insertions(+), 238 deletions(-) create mode 100644 packages/meshbay-hub/src/meshbay_hub/static/i18n.js create mode 100644 packages/meshbay-hub/src/meshbay_hub/static/style.css create mode 100644 packages/meshbay-hub/src/meshbay_hub/static/vendor/htm-preact.js (limited to 'packages/meshbay-hub/src') diff --git a/packages/meshbay-hub/src/meshbay_hub/api/groups.py b/packages/meshbay-hub/src/meshbay_hub/api/groups.py index 5bccf71..6dd4275 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/groups.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/groups.py @@ -15,6 +15,57 @@ from meshbay_hub.db.models import ( router = APIRouter(prefix="/v1/groups", tags=["groups"]) +@router.get("/mine") +async def my_groups( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """List groups the current user belongs to.""" + result = await db.execute( + select(Group) + .join(GroupMember, Group.id == GroupMember.group_id) + .where(GroupMember.user_id == current_user.id, Group.status == "active") + .order_by(Group.name) + ) + groups = result.scalars().all() + return { + "groups": [ + { + "id": g.id, + "name": g.name, + "visibility": g.visibility, + "join_policy": g.join_policy, + "created_at": g.created_at.isoformat(), + "is_admin": g.admin_id == current_user.id, + } + for g in groups + ] + } + + +@router.get("/{group_id}/nodes") +async def group_online_nodes( + group_id: str, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Return online nodes that serve a group (for WebRTC connection).""" + from meshbay_hub.api.revocation import get_online_nodes_for_group + from meshbay_hub.db.models import Node + + group = await db.get(Group, group_id) + if not group: + raise HTTPException(status_code=404, detail="Group not found") + + node_ids = get_online_nodes_for_group(group_id) + nodes = [] + for nid in node_ids: + node = await db.get(Node, nid) + if node: + nodes.append({"node_id": nid, "pk_node": node.pk_node}) + return {"nodes": nodes} + + @router.get("") async def list_public_groups( db: AsyncSession = Depends(get_db), diff --git a/packages/meshbay-hub/src/meshbay_hub/api/revocation.py b/packages/meshbay-hub/src/meshbay_hub/api/revocation.py index 8f30745..8f65f89 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/revocation.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/revocation.py @@ -51,6 +51,7 @@ router = APIRouter(tags=["revocation"]) # ── Connected node registry ─────────────────────────────────────────────────── _connected_nodes: dict[str, WebSocket] = {} # node_id → websocket +_node_groups: dict[str, list[str]] = {} # node_id → [group_id, ...] _punch_events: dict[str, asyncio.Event] = {} # node_id → signaling event @@ -58,6 +59,10 @@ def get_connected_node_count() -> int: return len(_connected_nodes) +def get_online_nodes_for_group(group_id: str) -> list[str]: + return [nid for nid, gids in _node_groups.items() if group_id in gids] + + async def broadcast_revocation(token: str) -> int: """Push a signed revocation token to all connected nodes. Returns count sent.""" payload = json.dumps({"type": "revocation", "token": token}) @@ -121,7 +126,10 @@ async def node_websocket(ws: WebSocket): node_id = msg.get("node_id") or decoded.get("sub", "unknown") _connected_nodes[node_id] = ws - log.info("Node WS connected: %s", node_id[:8]) + group_ids = msg.get("group_ids", []) + if group_ids: + _node_groups[node_id] = group_ids + log.info("Node WS connected: %s (groups=%d)", node_id[:8], len(group_ids)) await ws.send_text(json.dumps({"type": "auth_ok", "node_id": node_id})) # Message loop — handle ping, punch_ready, etc. @@ -145,6 +153,7 @@ async def node_websocket(ws: WebSocket): finally: if node_id: _connected_nodes.pop(node_id, None) + _node_groups.pop(node_id, None) # ── Admin revocation endpoint ───────────────────────────────────────────────── diff --git a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py index 6005927..62917f4 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py @@ -1,47 +1,26 @@ """ -Hub web application — serves the MeshBay web client at /. +Hub web application — serves the MeshBay SPA and static assets. -The web client (HTML/JS) is a single-page app that: - - Logs in via the hub API - - Discovers public groups - - Connects to a node URL entered by the user - - Browses the node's file index - - Downloads or streams files via the node HTTP API +The SPA (Preact + htm) handles: + - Authentication (login, register, token refresh) + - Group discovery and browsing + - WebRTC connection to nodes for P2P file transfer + - Dark/light theme with system preference detection -Static files are served from meshbay_hub/static/. -API routes remain at /v1/*. +Static files are served from meshbay_hub/static/ via Starlette StaticFiles. +The root route (/) returns the SPA HTML shell. """ from pathlib import Path from fastapi import APIRouter -from fastapi.responses import FileResponse, HTMLResponse +from fastapi.responses import HTMLResponse STATIC_DIR = Path(__file__).parent.parent / "static" router = APIRouter(tags=["webapp"]) -@router.get("/app.js") -async def app_js(): - return FileResponse(STATIC_DIR / "app.js", media_type="application/javascript") - - -@router.get("/transport.js") -async def transport_js(): - return FileResponse(STATIC_DIR / "transport.js", media_type="application/javascript") - - -@router.get("/crypto.js") -async def crypto_js(): - return FileResponse(STATIC_DIR / "crypto.js", media_type="application/javascript") - - -@router.get("/webrtc-test.html") -async def webrtc_test(): - return FileResponse(STATIC_DIR / "webrtc-test.html", media_type="text/html") - - @router.get("/", response_class=HTMLResponse) async def index(): return HTMLResponse(_HTML) @@ -54,36 +33,14 @@ _HTML = """\ MeshBay - + - -

Loading…

- +
+ + + + """ diff --git a/packages/meshbay-hub/src/meshbay_hub/app.py b/packages/meshbay-hub/src/meshbay_hub/app.py index 7bd5ee3..1b12a37 100644 --- a/packages/meshbay-hub/src/meshbay_hub/app.py +++ b/packages/meshbay-hub/src/meshbay_hub/app.py @@ -33,7 +33,7 @@ from meshbay_hub.csam import csam_router from meshbay_hub.api.health import router as health_router from meshbay_hub.api.relay import router as relay_router from meshbay_hub.api.signaling import router as signaling_router -from meshbay_hub.api.webapp import router as webapp_router +from meshbay_hub.api.webapp import router as webapp_router, STATIC_DIR from meshbay_hub.api.middleware import limiter @@ -97,4 +97,7 @@ def create_app(cfg: HubConfig | None = None) -> FastAPI: app.include_router(signaling_router) app.include_router(webapp_router) + from starlette.staticfiles import StaticFiles + app.mount("/", StaticFiles(directory=STATIC_DIR), name="static") + return app 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` + + `; } -// ── 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` + + `; } -// ── Pages ───────────────────────────────────────────────────────────────────── - -async function pageHome() { - const groups = await hubGet('/v1/groups'); - const items = groups.groups.map(g => ` -
- ${esc(g.name)} - ${g.join_policy} -
`).join(''); - - return ` -

Public Groups

- ${items || '

No public groups yet.

'} - ${state.nodeUrl ? ` -

My Node (${esc(state.nodeUrl)})

- - ` : ` -

Connect to a Node

- - - `}`; -} - -async function pageGroup(groupId) { - // TODO: fetch group info + node from hub - return `

Group ${groupId} — coming soon

`; -} - -async function pageNodeBrowser() { - const data = await nodeGet('/index'); - const rows = data.entries.map(e => ` - - ${esc(e.name)} - ${e.type} - ${fmtSize(e.size)} - - ${e.type === 'video' ? `` : ''} - ⬇ Download - - `).join(''); - - return ` -

📁 ${esc(data.group_name)}

-

${data.entries.length} files — index v${data.version}

- - - ${rows} -
NameTypeSizeActions
- `; -} - -function pageStream(fileId, name) { - const src = `${state.nodeUrl}/hls/${fileId}/playlist.m3u8${state.token ? '?token=' + state.token : ''}`; - return ` -

▶ ${esc(name)}

- -
`; -} - -// ── 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); } +// ── Login Page ─────────────────────────────────────────────────────────────── + +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 html` +
+ +
+ `; } -// ── Router / render ─────────────────────────────────────────────────────────── +// ── Register Page ──────────────────────────────────────────────────────────── -function setMain(html) { - document.getElementById('main').innerHTML = html; +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` +
+ +
+ `; + } + + return html` +
+ +
+ `; } -async function render() { - const nav = document.getElementById('nav'); - if (state.username) { - nav.innerHTML = `MeshBay | Logged in as ${esc(state.username)} - `; - setMain('

Loading…

'); - setMain(await pageHome()); - } else { - nav.innerHTML = 'MeshBay'; - setMain(` -

Login

- - - -

No account? Register via the API for now.

`); +// ── Home Page ──────────────────────────────────────────────────────────────── + +function HomePage({ groups }) { + if (groups.length === 0) { + return html` +
+

${t('home.welcome')}

+

+ ${t('home.no_groups')} + ${' '}${t('home.browse_prefix')}${t('home.browse_link')}${t('home.browse_suffix')} +

+
+ `; } + + return html` +
+

${t('home.my_groups')}

+ +
+ `; } -// ── Utils ───────────────────────────────────────────────────────────────────── +// ── Explore Page ───────────────────────────────────────────────────────────── -function esc(s) { - return String(s).replace(/&/g,'&').replace(//g,'>') - .replace(/"/g,'"').replace(/'/g,'''); +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` +
+

${t('explore.title')}

+ ${loading + ? html`

${t('explore.loading')}

` + : groups.length === 0 + ? html`

${t('explore.empty')}

` + : html` + + ` + } +
+ `; } -function fmtSize(bytes) { +// ── Helpers ────────────────────────────────────────────────────────────────── + +const FILE_ICONS = { + video: '\u{1F3AC}', audio: '\u{1F3B5}', image: '\u{1F5BC}', + document: '\u{1F4C4}', archive: '\u{1F4E6}', other: '\u{1F4CE}', +}; + +function formatSize(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'; + 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'; +} + +function formatDate(ts) { + return new Date(ts * 1000).toLocaleDateString(undefined, { + year: 'numeric', month: 'short', day: 'numeric', + }); +} + +// ── 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` +
+
+

${group ? group.name : t('group.default_name')}

+ ${statusLabel} +
+ ${error && html`
${error}
`} + ${dlState && html` +
+ ${dlState.name} +
+
+
+ ${formatSize(dlState.progress)} / ${formatSize(dlState.total)} +
+ `} + ${status === 'connected' && html` +
+ + +
+ + ${tab === 'files' && html` +
+ + setFilter(e.target.value)} /> +
+ + + + + + + + + + + + + ${subdirs.map(d => html` + + setCurrentPath(currentPath ? currentPath + '/' + d : d)}> + + + + + + + + `)} + ${sorted.map(e => html` + + + + + + + + + `)} + ${sorted.length === 0 && subdirs.length === 0 && html` + + `} + +
toggleSort('name')}> + ${t('group.col_name')} ${sortKey === 'name' ? (sortAsc ? '▲' : '▼') : ''} + toggleSort('size')}> + ${t('group.col_size')} ${sortKey === 'size' ? (sortAsc ? '▲' : '▼') : ''} + toggleSort('type')}> + ${t('group.col_type')} ${sortKey === 'type' ? (sortAsc ? '▲' : '▼') : ''} + toggleSort('date')}> + ${t('group.col_date')} ${sortKey === 'date' ? (sortAsc ? '▲' : '▼') : ''} +
\u{1F4C1}${d}/
${FILE_ICONS[e.type] || FILE_ICONS.other}${e.name}${formatSize(e.size)}${e.type}${formatDate(e.added_at)} + ${e.type === 'video' && html` + + `} + +
+ ${filter ? t('group.empty_filter') : t('group.empty_dir')} +
+ `} + + ${tab === 'chat' && html` + <${ChatPanel} transportRef=${transportRef} username=${username} /> + `} + `} + ${status === 'offline' && html` +

+ ${t('group.offline_title')} + ${' '}${t('group.offline_hint')} +

+ `} + ${(status === 'discovering' || status === 'connecting' || status === 'fetching') && html` +

${statusLabel}

+ `} + ${videoEntry && html` + <${VideoPlayer} + entry=${videoEntry} + transportRef=${transportRef} + gekRef=${gekRef} + onClose=${() => setVideoEntry(null)} /> + `} +
+ `; +} + +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; } -// ── Boot ────────────────────────────────────────────────────────────────────── -document.addEventListener('DOMContentLoaded', render); +// ── Chat Panel ────────────────────────────────────────────────────────── + +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; +} + +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` +
+
+ ${messages.length === 0 && html` +
${t('chat.empty')}
+ `} + ${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` +
+ ${showSender && html` +
${m.sender_id}
+ `} +
+ ${m.payload} + ${formatTime(m.timestamp)} +
+
+ `; + })} +
+
+
+