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 | 173 |
1 files changed, 122 insertions, 51 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index 9d64292..ff066cf 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -4,7 +4,7 @@ import { } from './vendor/htm-preact.js'; import { t, getLocale, setLocale, initLocale, LOCALES } from './i18n.js'; import { ZipStream, entriesUnder } from './zipstream.js'; -import { transfers, formatSpeed } from './transfers.js'; +import { transfers, formatSpeed, etaSeconds } from './transfers.js'; import * as platform from './platform.js'; import * as downloads from './downloads.js'; import { Icon } from './icon.js'; @@ -157,69 +157,67 @@ function TransferWidget() { }, [open]); const running = items.filter(i => i.status === 'running'); + const waiting = items.filter(i => i.status === 'queued'); + const finished = items.filter( + i => i.status !== 'running' && i.status !== 'queued'); + const active = running.length + waiting.length; + + // Grouped, and in this order: what is moving, what is waiting, what is over. + // Re-sorting the flat list on every emit made rows jump under the pointer + // each time a neighbour finished — the group is what changes, not the + // position within it, so a row only moves when its own state does. + const groups = [ + ['running', running], + ['waiting', waiting], + ['finished', finished], + ].filter(([, rows]) => rows.length); + if (!items.length) return null; return html` <div class="transfer-wrap" ref=${ref}> <button class="nav-notif transfer-btn ${running.length ? 'active' : ''}" + aria-label=${t('transfers.title')} + aria-expanded=${open ? 'true' : 'false'} title=${t('transfers.title')} onClick=${(e) => { e.stopPropagation(); setOpen(o => !o); }}> <${Icon} name="transfer" /> - ${running.length > 0 && html` - <span class="notif-badge">${running.length}</span> + ${active > 0 && html` + <span class="notif-badge">${active}</span> `} </button> + ${/* One live region for the panel, announcing what changed state rather + than every progress tick — a reader that says "62%… 63%… 64%" for a + four-gigabyte film is a reader nobody leaves on. */''} + <span class="sr-only" aria-live="polite"> + ${t('transfers.summary', { running: running.length, waiting: waiting.length })} + </span> ${open && html` - <div class="transfer-panel"> + <div class="transfer-panel" role="group" + aria-label=${t('transfers.title')}> <div class="transfer-head"> - ${t('transfers.title')} - <button class="btn-secondary" - onClick=${() => transfers.clearFinished()}> - ${t('transfers.clear')} - </button> + <span class="transfer-head-title">${t('transfers.title')}</span> + ${active > 0 && html` + <span class="transfer-head-summary"> + ${t('transfers.summary', { + running: running.length, waiting: waiting.length })} + </span> + `} + ${finished.length > 0 && html` + <button class="btn-secondary" + onClick=${() => transfers.clearFinished()}> + ${t('transfers.clear')} + </button> + `} </div> - ${items.map(it => html` - <div class="transfer-item" key=${it.id}> - <div class="transfer-line"> - <span class="transfer-kind"> - <${Icon} name=${it.kind === 'upload' ? 'upload' : 'download'} /> - </span> - ${it.canOpen - ? html`<a class="transfer-name" href="#" title=${it.name} - onClick=${(e) => { e.preventDefault(); transfers.open(it.id); }}>${it.name}</a>` - : html`<span class="transfer-name" title=${it.name}>${it.name}</span>`} - ${it.status === 'running' && html` - <button class="transfer-cancel" title=${t('transfers.cancel')} - onClick=${() => transfers.cancel(it.id)}> - <${Icon} name="close" /> - </button> - `} - </div> - ${it.status === 'running' - ? html` - <div class="dl-progress"> - <div class="dl-fill" style="width:${it.percent}%"></div> - </div> - <div class="transfer-meta"> - <span>${formatSize(it.done)}${it.total - ? ' / ' + formatSize(it.total) : ''}</span> - <span>${formatSpeed(it.speed)}</span> - </div> - ` - : html` - <div class="transfer-meta"> - <span class=${it.status === 'failed' ? 'transfer-failed' : ''}> - ${it.status === 'done' ? t('transfers.done') - : it.status === 'cancelled' ? t('transfers.cancelled') - : it.error || t('transfers.failed')} - </span> - ${it.canOpen && html` - <button class="link-btn" onClick=${() => transfers.open(it.id)}> - ${t('transfers.open')} - </button> - `} - </div> - `} + ${groups.map(([label, rows]) => html` + <div class="transfer-group" key=${label}> + ${groups.length > 1 && html` + <div class="transfer-group-head"> + ${t('transfers.group_' + label, { n: rows.length })} + </div> + `} + ${rows.map(it => html`<${TransferRow} it=${it} key=${it.id} />`)} </div> `)} </div> @@ -228,6 +226,79 @@ function TransferWidget() { `; } +/** One row. Split out so the panel above reads as a layout and this as a state + * machine — they change for different reasons. */ +function TransferRow({ it }) { + const eta = etaSeconds(it); + return html` + <div class="transfer-item transfer-${it.status}"> + <div class="transfer-line"> + <span class="transfer-kind" aria-hidden="true"> + <${Icon} name=${it.kind === 'upload' ? 'upload' : 'download'} /> + </span> + ${it.canOpen + ? html`<a class="transfer-name" href="#" title=${it.name} + onClick=${(e) => { e.preventDefault(); transfers.open(it.id); }}>${it.name}</a>` + : html`<span class="transfer-name" title=${it.name}>${it.name}</span>`} + ${(it.status === 'running' || it.status === 'queued') && html` + <button class="transfer-cancel" + aria-label=${t('transfers.cancel_one', { name: it.name })} + title=${t('transfers.cancel')} + onClick=${() => transfers.cancel(it.id)}> + <${Icon} name="close" /> + </button> + `} + </div> + ${it.status === 'queued' + ? html` + <div class="dl-progress dl-waiting"></div> + <div class="transfer-meta"> + <span>${it.queuedByOwnLimit + ? t('transfers.waiting_own_slots') + : t('transfers.waiting_node', { n: it.ahead })}</span> + <span>${formatSize(it.total)}</span> + </div> + ` + : it.status === 'running' + ? html` + <div class="dl-progress" role="progressbar" + aria-valuenow=${it.percent} aria-valuemin="0" aria-valuemax="100"> + <div class="dl-fill" style="width:${it.percent}%"></div> + </div> + <div class="transfer-meta"> + <span>${formatSize(it.done)}${it.total + ? ' / ' + formatSize(it.total) : ''}</span> + <span>${[formatSpeed(it.speed), + it.settled && eta !== null ? formatEta(eta) : ''] + .filter(Boolean).join(' · ')}</span> + </div> + ` + : html` + <div class="transfer-meta"> + <span class=${it.status === 'failed' ? 'transfer-failed' : ''}> + ${it.status === 'done' ? t('transfers.done') + : it.status === 'cancelled' ? t('transfers.cancelled') + : it.error || t('transfers.failed')} + </span> + ${it.canOpen && html` + <button class="link-btn" onClick=${() => transfers.open(it.id)}> + ${t('transfers.open')} + </button> + `} + </div> + `} + </div> + `; +} + +/** "4 min left". Coarse on purpose: a per-second countdown on a transfer whose + * speed varies is a number that is wrong most of the time and looks precise. */ +function formatEta(seconds) { + if (seconds < 60) return t('transfers.eta_seconds', { n: Math.ceil(seconds) }); + if (seconds < 3600) return t('transfers.eta_minutes', { n: Math.round(seconds / 60) }); + return t('transfers.eta_hours', { n: Math.round(seconds / 360) / 10 }); +} + // ── Nav ────────────────────────────────────────────────────────────────────── function Nav({ user, theme, onThemeChange, onLogout, onMenuToggle, unreadCount, |