aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/app.js
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/app.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js173
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,