import { html, render, useState, useEffect, useCallback, useRef, createContext, useContext, } from './vendor/htm-preact.js'; import { t, getLocale, setLocale, LOCALES } from './i18n.js'; // ── Constants ──────────────────────────────────────────────────────────────── const HUB = ''; const AUTH_KEY = 'mb_auth'; const THEME_KEY = 'mb_theme'; // ── Auth persistence ───────────────────────────────────────────────────────── let _sessionKeys = null; function loadAuth() { try { return JSON.parse(localStorage.getItem(AUTH_KEY)); } catch { return null; } } 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(); } // ── Router ─────────────────────────────────────────────────────────────────── 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; } function navigate(path) { window.location.hash = path; } // ── Context ────────────────────────────────────────────────────────────────── const AuthContext = createContext(null); function useAuth() { return useContext(AuthContext); } // ── Nav ────────────────────────────────────────────────────────────────────── function Nav({ user, theme, onThemeToggle, onLogout, onMenuToggle }) { return html` `; } // ── Sidebar ────────────────────────────────────────────────────────────────── function Sidebar({ groups, route, menuOpen, role }) { const isStaff = role === 'moderator' || role === 'admin'; return html` `; } // ── 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`

${t('login.title')}

setUsername(e.target.value)} autocomplete="username" required /> setPassword(e.target.value)} autocomplete="current-password" required /> ${error && html`
${error}
`}
`; } // ── 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`

${t('register.success_title')}

${t('register.success_msg')}

${t('register.go_login')}
`; } return html`

${t('register.title')}

setUsername(e.target.value)} autocomplete="username" required /> setEmail(e.target.value)} autocomplete="email" required /> setPassword(e.target.value)} autocomplete="new-password" required minlength="8" /> setConfirm(e.target.value)} autocomplete="new-password" required /> ${error && html`
${error}
`}
`; } // ── 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')}

${groups.map(g => html`

${g.name}

${g.visibility} ${' '} ${g.join_policy} ${g.is_admin && html`${' '}admin`}
`)}
`; } // ── 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`

${t('explore.title')}

${loading ? html`

${t('explore.loading')}

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

${t('explore.empty')}

` : html`
${groups.map(g => html`

${g.name}

${g.join_policy} ${g.source && g.source !== 'local' && html` ${' '}${g.source} `}
`)}
` }
`; } // ── 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 * 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; } // ── 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)}
`; })}