diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-14 20:54:57 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-14 20:54:57 +0200 |
| commit | 54b535d7102e9a68d9b37fb215623fbd4faff95e (patch) | |
| tree | 2df0f8ba7069d74713fd6a7d2241f403a54fa70a /packages/meshbay-hub | |
| parent | e525f7e123b621deec10a9719efa10ccafa93ad0 (diff) | |
| download | meshbay-54b535d7102e9a68d9b37fb215623fbd4faff95e.tar.gz | |
feat(ui): upload progress, PDF preview, invite form above the member list
Three of the ten UI items, the ones that needed no decision.
Upload showed a disabled button and nothing else — on a large file that is
indistinguishable from a hang. It now has the same bar downloads have, filled
from chunks the node has acknowledged rather than bytes read locally, and says
"indexing" for the pause at the end: the node re-indexes on a filesystem event,
so there is a real wait there with nothing to poll.
PDFs open in the overlay through the browser's own viewer. The file is decrypted
in the page as any other preview is, and shown from a blob: URL — nothing leaves
the tab, and no external viewer is involved.
The invite form sits above the member list, where the action is rather than after
the thing it acts on.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/app.js | 80 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/i18n.js | 2 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/style.css | 9 |
3 files changed, 69 insertions, 22 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index 3087e0e..cff001b 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -823,6 +823,7 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth }) { const [previewEntry, setPreviewEntry] = useState(null); const [tab, setTab] = useState('files'); const [uploading, setUploading] = useState(false); + const [ulState, setUlState] = useState(null); const [menuOpen, setMenuOpen] = useState(null); const [isNodeAdmin, setIsNodeAdmin] = useState(false); const [needsCode, setNeedsCode] = useState(false); @@ -1020,13 +1021,20 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth }) { if (!transport || !transport.connected) return; setUploading(true); setError(''); + setUlState({ name: file.name, sent: 0, total: file.size, indexing: false }); try { const totalChunks = Math.ceil(file.size / UPLOAD_CHUNK_SIZE); for (let i = 0; i < totalChunks; i++) { const slice = file.slice(i * UPLOAD_CHUNK_SIZE, (i + 1) * UPLOAD_CHUNK_SIZE); const buf = new Uint8Array(await slice.arrayBuffer()); await transport.uploadChunk(file.name, i, totalChunks, buf); + // Bytes actually acknowledged by the node, not bytes read locally. + setUlState(prev => prev && { ...prev, sent: Math.min(file.size, + (i + 1) * UPLOAD_CHUNK_SIZE) }); } + // The node re-indexes on a filesystem event; there is nothing to poll, so + // say what is happening instead of showing a finished bar and no file. + setUlState(prev => prev && { ...prev, indexing: true }); await new Promise(r => setTimeout(r, 2500)); const indexMsg = await transport.fetchIndex(); if (indexMsg.entries) setEntries(indexMsg.entries); @@ -1034,6 +1042,7 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth }) { setError(err.message); } finally { setUploading(false); + setUlState(null); } }, []); @@ -1155,6 +1164,21 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth }) { </div> </form> `} + ${ulState && html` + <div class="dl-bar"> + <span class="spinner"></span> + <span class="dl-name">${ulState.name}</span> + <div class="dl-progress"> + <div class="dl-fill" style=${`width:${ulState.total + ? Math.round(ulState.sent / ulState.total * 100) : 0}%`}></div> + </div> + <span class="dl-pct"> + ${ulState.indexing + ? t('group.upload_indexing') + : `${ulState.total ? Math.round(ulState.sent / ulState.total * 100) : 0}%`} + </span> + </div> + `} ${dlState && html` <div class="dl-bar"> <span class="dl-name">${dlState.name}</span> @@ -1361,7 +1385,13 @@ function FilePreview({ entry, transportRef, gekRef, onClose }) { ); if (cancelled) return; - if (entry.name.match(IMAGE_EXTS)) { + if (/\.pdf$/i.test(entry.name)) { + // Decrypted here and shown from a blob: URL — the bytes never leave + // the page, and the browser's own viewer renders them. + const blob = new Blob(chunks, { type: 'application/pdf' }); + blobUrlRef.current = URL.createObjectURL(blob); + setContent({ type: 'pdf' }); + } else if (entry.name.match(IMAGE_EXTS)) { const ext = entry.name.split('.').pop().toLowerCase(); const mime = ext === 'svg' ? 'image/svg+xml' : ext === 'png' ? 'image/png' @@ -1416,6 +1446,12 @@ function FilePreview({ entry, transportRef, gekRef, onClose }) { </div> </div> `} + ${phase === 'ready' && content?.type === 'pdf' && html` + <object data=${blobUrlRef.current} type="application/pdf" + class="preview-pdf" aria-label=${entry.name}> + <p class="page-message">${t('preview.pdf_fallback')}</p> + </object> + `} ${phase === 'ready' && content?.type === 'image' && html` <div class="preview-image-wrap"> <img class="preview-image" src=${blobUrlRef.current} alt=${entry.name} /> @@ -1542,27 +1578,6 @@ function MembersPanel({ groupId, group, token, transportRef, gekRef, return html` <div class="members-panel"> - <table class="admin-table"> - <thead> - <tr> - <th>${t('admin.col_username')}</th> - <th>${t('members.group_role')}</th> - </tr> - </thead> - <tbody> - ${members.map(m => html` - <tr key=${m.user_id}> - <td>${m.username}</td> - <td> - ${m.user_id === adminId - ? html`<span class="badge" style="background:var(--accent);color:var(--accent-text)">${t('members.owner')}</span>` - : html`<span class="badge">${t('members.member')}</span>` - } - </td> - </tr> - `)} - </tbody> - </table> ${isAdmin && html` <form class="invite-form" onSubmit=${doInvite}> <h4>${t('members.invite_title')}</h4> @@ -1585,6 +1600,27 @@ function MembersPanel({ groupId, group, token, transportRef, gekRef, </div> </form> `} + <table class="admin-table"> + <thead> + <tr> + <th>${t('admin.col_username')}</th> + <th>${t('members.group_role')}</th> + </tr> + </thead> + <tbody> + ${members.map(m => html` + <tr key=${m.user_id}> + <td>${m.username}</td> + <td> + ${m.user_id === adminId + ? html`<span class="badge" style="background:var(--accent);color:var(--accent-text)">${t('members.owner')}</span>` + : html`<span class="badge">${t('members.member')}</span>` + } + </td> + </tr> + `)} + </tbody> + </table> ${isNodeAdmin && html` <form class="invite-form" onSubmit=${doPair}> <h4>${t('members.pair_title')}</h4> diff --git a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js index f0c5cef..e3e0def 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js @@ -116,6 +116,8 @@ const en = { 'video.loading': 'Loading {name}...', 'video.buffering': 'Buffering...', 'video.close': 'Close (Esc)', + 'preview.pdf_fallback': 'This browser will not display the PDF inline. Download it instead — it was decrypted here either way.', + 'group.upload_indexing': 'indexing…', 'video.err_transport': 'Transport not connected', 'video.err_mse': 'Codec not supported for streaming: {codec}', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css index 81c4130..361ff7e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/style.css +++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css @@ -1411,3 +1411,12 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } -webkit-box-orient: vertical; overflow: hidden; } + +/* PDF preview: fills the overlay, scrolls in its own viewer. */ +.preview-pdf { + width: min(1000px, 92vw); + height: 82vh; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg-surface); +} |