summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-14 20:54:57 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-14 20:54:57 +0200
commit54b535d7102e9a68d9b37fb215623fbd4faff95e (patch)
tree2df0f8ba7069d74713fd6a7d2241f403a54fa70a /packages/meshbay-hub/src
parente525f7e123b621deec10a9719efa10ccafa93ad0 (diff)
downloadmeshbay-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/src')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js80
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/i18n.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css9
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);
+}