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.js564
1 files changed, 341 insertions, 223 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index 995e7c7..03b76d5 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -4,6 +4,7 @@ import {
} from './vendor/htm-preact.js';
import { t, getLocale, setLocale, LOCALES } from './i18n.js';
import { ZipStream, entriesUnder } from './zipstream.js';
+import { transfers, formatSpeed } from './transfers.js';
// ── Constants ────────────────────────────────────────────────────────────────
@@ -255,6 +256,10 @@ const ICON_PATHS = {
'M3.4 6.6L12 13.4l8.6-6.8'],
door: ['M13.5 3.5H6a1 1 0 0 0-1 1v15a1 1 0 0 0 1 1h7.5',
'M10.5 12H21', 'M17.8 8.8L21 12l-3.2 3.2'],
+ download: ['M12 3.5v12', 'M7.5 11l4.5 4.5 4.5-4.5', 'M4.5 20h15'],
+ upload: ['M12 20.5v-12', 'M7.5 13l4.5-4.5 4.5 4.5', 'M4.5 4h15'],
+ transfer: ['M6.5 3.5v11', 'M3.5 11l3 3.5 3-3.5',
+ 'M17.5 20.5v-11', 'M14.5 13l3-3.5 3 3.5'],
clip: ['M20.5 11.8l-8.4 8.4a5.4 5.4 0 0 1-7.6-7.6l8.8-8.8a3.6 3.6 0 0 1 5.1 5.1l-8.8 8.8a1.8 1.8 0 0 1-2.5-2.5l8.1-8.1'],
pencil: ['M4 20h4l10.5-10.5a2.1 2.1 0 0 0-3-3L5 17v3',
'M14.5 6.5l3 3'],
@@ -341,6 +346,87 @@ function UserMenu({ user, theme, onThemeChange, onLogout }) {
`;
}
+// ── Transfers widget ─────────────────────────────────────────────────────────
+
+function TransferWidget() {
+ const [items, setItems] = useState(() => transfers.list());
+ const [open, setOpen] = useState(false);
+ const ref = useRef(null);
+
+ useEffect(() => transfers.subscribe(setItems), []);
+
+ useEffect(() => {
+ if (!open) return;
+ const close = (e) => {
+ if (ref.current && !ref.current.contains(e.target)) setOpen(false);
+ };
+ document.addEventListener('click', close);
+ return () => document.removeEventListener('click', close);
+ }, [open]);
+
+ const running = items.filter(i => i.status === 'running');
+ if (!items.length) return null;
+
+ return html`
+ <div class="transfer-wrap" ref=${ref}>
+ <button class="nav-notif transfer-btn" 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>
+ `}
+ </button>
+ ${open && html`
+ <div class="transfer-panel">
+ <div class="transfer-head">
+ ${t('transfers.title')}
+ <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>
+ <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>
+ </div>
+ `}
+ </div>
+ `)}
+ </div>
+ `}
+ </div>
+ `;
+}
+
// ── Nav ──────────────────────────────────────────────────────────────────────
function Nav({ user, theme, onThemeChange, onLogout, onMenuToggle, unreadCount }) {
@@ -354,6 +440,7 @@ function Nav({ user, theme, onThemeChange, onLogout, onMenuToggle, unreadCount }
<a class="nav-brand" href="#/">MeshBay</a>
</div>
<div class="nav-right">
+ ${user && html`<${TransferWidget} />`}
${user && html`
<a class="nav-notif" href="#/" title=${t('notif.title')}>
<${Icon} name="bell" />${unreadCount > 0
@@ -832,10 +919,31 @@ function formatDate(ts) {
// ── Group Page ──────────────────────────────────────────────────────────────
+const PREVIEWABLE_TEXT =
+ /\.(txt|md|json|csv|log|xml|yaml|yml|ini|conf|py|js|html|css|sh|c|h|java|rs|go|rb|toml)$/i;
+
+function canPreview(e) {
+ return ['image', 'video', 'document'].includes(e.type)
+ || PREVIEWABLE_TEXT.test(e.name);
+}
+
const CHUNK_SIZE = 1024 * 1024;
const PIPELINE_WINDOW = 8;
-async function pipelinedDownload(transport, gekKey, fileId, totalChunks, onChunk, writable) {
+/** The download of last resort, for browsers with no way to stream to disk. */
+function _saveBlob(blob, filename) {
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = filename;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+}
+
+async function pipelinedDownload(transport, gekKey, fileId, totalChunks, onChunk,
+ writable, signal) {
const results = writable ? null : new Array(totalChunks);
let nextSend = 0, nextRecv = 0;
const inflight = new Array(totalChunks);
@@ -849,6 +957,11 @@ async function pipelinedDownload(transport, gekKey, fileId, totalChunks, onChunk
fire();
while (nextRecv < totalChunks) {
+ if (signal && signal.aborted) {
+ const err = new Error('Cancelled');
+ err.name = 'AbortError';
+ throw err;
+ }
const chunkMsg = await inflight[nextRecv];
let plaintext;
if (gekKey && chunkMsg.ct) {
@@ -878,6 +991,9 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
const [entries, setEntries] = useState([]);
const [error, setError] = useState('');
+ const [selecting, setSelecting] = useState(false);
+ const [selected, setSelected] = useState(() => new Set());
+ const [actionsOpen, setActionsOpen] = useState(false);
const [editingDesc, setEditingDesc] = useState(false);
const [descDraft, setDescDraft] = useState('');
const [savingDesc, setSavingDesc] = useState(false);
@@ -885,16 +1001,13 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
const [sortAsc, setSortAsc] = useState(true);
const [filter, setFilter] = useState('');
const [currentPath, setCurrentPath] = useState('');
- const [dlState, setDlState] = useState(null);
const [videoEntry, setVideoEntry] = useState(null);
const [previewEntry, setPreviewEntry] = useState(null);
const [tab, setTab] = useState('files');
- const [uploading, setUploading] = useState(false);
- const [ulState, setUlState] = useState(null);
// Directories are not index entries, so a new empty one needs a nudge
// to appear in the breadcrumb listing.
const [nodeDirs, setNodeDirs] = useState([]);
- const [menuOpen, setMenuOpen] = useState(null);
+
const [isNodeAdmin, setIsNodeAdmin] = useState(false);
// Paired ≠ operator account. `is_node_admin` says the hub account owning this
// node is the one connecting; this says the node pinned *this browser's* key
@@ -922,11 +1035,11 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
}, [codeInput]);
useEffect(() => {
- if (menuOpen === null) return;
- const close = () => setMenuOpen(null);
+ if (!actionsOpen) return;
+ const close = () => setActionsOpen(false);
document.addEventListener('click', close);
return () => document.removeEventListener('click', close);
- }, [menuOpen]);
+ }, [actionsOpen]);
// One place that takes an index from the node and puts it everywhere it has to
// go. Deleting a file used to refresh the table and leave the cache alone, so
@@ -1042,8 +1155,9 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
return () => {
cancelled = true;
if (transportRef.current) {
- transportRef.current.onIndexSync = null;
- transportRef.current.close();
+ // Handed over rather than closed: a download running when you leave the
+ // group keeps its connection, and the last transfer using it closes it.
+ transfers.releaseWhenIdle(transportRef.current);
transportRef.current = null;
}
};
@@ -1056,77 +1170,71 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
const downloadFile = useCallback(async (entry) => {
const transport = transportRef.current;
if (!transport || !transport.connected) return;
+ const gek = gekRef.current;
- setDlState({ fileId: entry.id, name: entry.name, progress: 0, total: entry.size });
+ // The file picker has to be opened here, in the click, before anything is
+ // handed to the store: a browser only grants one from a user gesture.
+ let handle = null;
+ if (window.showSaveFilePicker) {
+ try {
+ handle = await window.showSaveFilePicker({ suggestedName: entry.name });
+ } catch (err) {
+ if (err.name === 'AbortError') return;
+ throw err;
+ }
+ }
- try {
- const totalChunks = Math.ceil(entry.size / CHUNK_SIZE);
- let downloaded = 0;
- const onProgress = (bytes) => {
- downloaded += bytes;
- setDlState(prev => ({ ...prev, progress: downloaded }));
- };
+ transfers.start({
+ kind: 'download', name: entry.name, total: entry.size, transport,
+ run: async ({ signal, onProgress }) => {
+ const totalChunks = Math.ceil(entry.size / CHUNK_SIZE);
+ let done = 0;
+ const onChunk = (bytes) => { done += bytes; onProgress(done, entry.size); };
- 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;
+ if (handle) {
+ const writable = await handle.createWritable();
+ try {
+ await pipelinedDownload(transport, gek, entry.id, totalChunks,
+ onChunk, writable, signal);
+ await writable.close();
+ } catch (err) {
+ await writable.abort().catch(() => {});
+ throw err;
+ }
+ } else {
+ const chunks = await pipelinedDownload(
+ transport, gek, entry.id, totalChunks, onChunk, null, signal);
+ _saveBlob(new Blob(chunks), entry.name);
}
- } 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 uploadFile = useCallback(async (e) => {
- const file = e.target.files?.[0];
- if (!file) return;
+ const uploadFile = useCallback((e) => {
+ const files = [...(e.target.files || [])];
e.target.value = '';
const transport = transportRef.current;
- if (!transport || !transport.connected) return;
- setUploading(true);
+ if (!files.length || !transport || !transport.connected) return;
setError('');
- setUlState({ name: file.name, sent: 0, total: file.size, indexing: false });
- try {
- await transport.uploadFile(file, {
- // Bytes actually acknowledged by the node, not bytes read locally.
- onProgress: (sent) => setUlState(prev => prev && { ...prev, sent }),
+
+ for (const file of files) {
+ transfers.start({
+ kind: 'upload', name: file.name, total: file.size, transport,
+ run: async ({ signal, onProgress }) => {
+ await transport.uploadFile(file, {
+ // Bytes the node acknowledged, not bytes read locally.
+ onProgress: (sent) => onProgress(sent, file.size),
+ signal,
+ });
+ // The node re-indexes on a filesystem event, so there is nothing to
+ // wait on but the clock. Refreshing here means the file appears in
+ // the list without anyone reloading.
+ await new Promise(r => setTimeout(r, 2500));
+ if (transport.connected) applyIndex(await transport.fetchIndex());
+ },
});
- // 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);
- if (indexMsg.dirs) setNodeDirs(indexMsg.dirs);
- } catch (err) {
- setError(err.message);
- } finally {
- setUploading(false);
- setUlState(null);
}
- }, []);
+ }, [applyIndex]);
const makeDirectory = useCallback(async () => {
const transport = transportRef.current;
@@ -1183,66 +1291,59 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
const totalBytes = files.reduce((n, f) => n + (f.entry.size || 0), 0);
const suggested = (dir.split('/').pop() || 'files') + '.zip';
- let writable = null;
+ let handle = null;
if (window.showSaveFilePicker) {
- const handle = await window.showSaveFilePicker({
- suggestedName: suggested,
- types: [{ description: 'ZIP archive', accept: { 'application/zip': ['.zip'] } }],
- });
- writable = await handle.createWritable();
+ try {
+ handle = await window.showSaveFilePicker({
+ suggestedName: suggested,
+ types: [{ description: 'ZIP archive',
+ accept: { 'application/zip': ['.zip'] } }],
+ });
+ } catch (err) {
+ if (err.name === 'AbortError') return;
+ throw err;
+ }
} else if (!confirm(t('group.zip_no_stream', {
size: formatSize(totalBytes), name: suggested,
}))) {
return;
}
+ const gek = gekRef.current;
- setDlState({ name: suggested, progress: 0, total: totalBytes });
- const parts = writable ? null : [];
- let written = 0;
-
- try {
- const zip = new ZipStream(async (bytes) => {
- if (writable) await writable.write(bytes);
- else parts.push(bytes.slice());
- });
-
- for (const { entry, name } of files) {
- await zip.begin(name, entry.size,
- new Date((entry.added_at || 0) * 1000));
- // A zero-byte file has no chunk to ask for; the header and an empty
- // descriptor are the whole entry.
- const totalChunks = Math.ceil(entry.size / CHUNK_SIZE);
- if (totalChunks > 0) await pipelinedDownload(
- transport, gekRef.current, entry.id, totalChunks,
- (bytes) => {
- written += bytes;
- setDlState(prev => prev && { ...prev, progress: written });
- },
- // pipelinedDownload writes in order, which is what the archive needs.
- { write: (plaintext) => zip.write(plaintext) });
- await zip.end();
- }
- await zip.finish();
+ transfers.start({
+ kind: 'download', name: suggested, total: totalBytes, transport,
+ run: async ({ signal, onProgress }) => {
+ const writable = handle ? await handle.createWritable() : null;
+ const parts = writable ? null : [];
+ let written = 0;
+ try {
+ const zip = new ZipStream(async (bytes) => {
+ if (writable) await writable.write(bytes);
+ else parts.push(bytes.slice());
+ });
- if (writable) {
- await writable.close();
- } else {
- const url = URL.createObjectURL(new Blob(parts, { type: 'application/zip' }));
- const a = document.createElement('a');
- a.href = url;
- a.download = suggested;
- document.body.appendChild(a);
- a.click();
- document.body.removeChild(a);
- URL.revokeObjectURL(url);
- }
- setDlState(null);
- } catch (err) {
- setDlState(null);
- if (writable) await writable.abort().catch(() => {});
- if (err.name === 'AbortError') return;
- setError(t('group.dl_failed', { err: err.message }));
- }
+ for (const { entry, name } of files) {
+ await zip.begin(name, entry.size,
+ new Date((entry.added_at || 0) * 1000));
+ // A zero-byte file has no chunk to ask for; the header and an empty
+ // descriptor are the whole entry.
+ const totalChunks = Math.ceil(entry.size / CHUNK_SIZE);
+ if (totalChunks > 0) await pipelinedDownload(
+ transport, gek, entry.id, totalChunks,
+ (bytes) => { written += bytes; onProgress(written, totalBytes); },
+ // pipelinedDownload writes in order, which the archive needs.
+ { write: (plaintext) => zip.write(plaintext) }, signal);
+ await zip.end();
+ }
+ await zip.finish();
+ if (writable) await writable.close();
+ else _saveBlob(new Blob(parts, { type: 'application/zip' }), suggested);
+ } catch (err) {
+ if (writable) await writable.abort().catch(() => {});
+ throw err;
+ }
+ },
+ });
}, [entries]);
const deleteDirectory = useCallback(async (dir) => {
@@ -1343,6 +1444,74 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
const breadcrumbs = currentPath ? currentPath.split('/') : [];
+ // Selection is keyed globally — file ids, and 'dir:' plus a full path — so
+ // walking into another folder keeps what was already ticked.
+ const dirKey = (name) => 'dir:' + (currentPath ? currentPath + '/' + name : name);
+ const selectedFiles = entries.filter(e => selected.has(e.id));
+ const selectedDirs = [...selected]
+ .filter(k => typeof k === 'string' && k.startsWith('dir:'))
+ .map(k => k.slice(4));
+ const toggle = (key) => setSelected(prev => {
+ const next = new Set(prev);
+ if (next.has(key)) next.delete(key); else next.add(key);
+ return next;
+ });
+
+ const onlyFile = selectedFiles.length === 1 && selectedDirs.length === 0
+ ? selectedFiles[0] : null;
+ const deletableFiles = selectedFiles.filter(
+ e => isNodeAdmin || (userId && e.uploader_id === userId));
+ const run = (fn) => { setActionsOpen(false); setSelecting(false);
+ setSelected(new Set()); fn(); };
+
+ const actionItems = html`
+ ${onlyFile && onlyFile.type === 'video' && html`
+ <button onClick=${() => run(() => setVideoEntry(onlyFile))}>
+ <span class="fmi">${'\u{25B6}'}</span> ${t('group.play')}
+ </button>
+ `}
+ ${onlyFile && onlyFile.type !== 'video' && canPreview(onlyFile) && html`
+ <button onClick=${() => run(() => setPreviewEntry(onlyFile))}>
+ <span class="fmi">${'\u{1F441}'}</span> ${t('group.view')}
+ </button>
+ `}
+ ${selectedFiles.length > 0 && html`
+ <button onClick=${() => run(() => {
+ // Started together and left to run together: each is its own transfer,
+ // and the widget by the bell is where they are watched from now.
+ for (const e of selectedFiles) downloadFile(e);
+ })}>
+ <span class="fmi">${'\u{2B07}'}</span>
+ ${t('group.download_n', { n: selectedFiles.length })}
+ </button>
+ `}
+ ${selectedDirs.length > 0 && html`
+ <button onClick=${() => run(() => {
+ for (const d of selectedDirs) downloadDirectory(d);
+ })}>
+ <span class="fmi">${'\u{2B07}'}</span>
+ ${t('group.download_zip_n', { n: selectedDirs.length })}
+ </button>
+ `}
+ ${status === 'connected' && (deletableFiles.length > 0
+ || (operatorPaired && selectedDirs.length > 0)) && html`
+ <button class="danger" onClick=${() => {
+ const names = [...deletableFiles.map(e => e.name),
+ ...(operatorPaired ? selectedDirs : [])];
+ if (!confirm(t('group.delete_n_confirm', { n: names.length,
+ names: names.join(', ') }))) return;
+ run(() => {
+ for (const e of deletableFiles) deleteFile(e);
+ if (operatorPaired) for (const d of selectedDirs) deleteDirectory(d);
+ });
+ }}>
+ <span class="fmi">${'\u{1F5D1}'}</span>
+ ${t('group.delete_n', { n: deletableFiles.length
+ + (operatorPaired ? selectedDirs.length : 0) })}
+ </button>
+ `}
+ `;
+
return html`
<div>
<div class="group-header">
@@ -1409,30 +1578,6 @@ 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>
- <div class="dl-progress">
- <div class="dl-fill" style="width:${Math.round(dlState.progress / dlState.total * 100)}%"></div>
- </div>
- <span class="dl-pct">${formatSize(dlState.progress)} / ${formatSize(dlState.total)}</span>
- </div>
- `}
${status === 'connected' && html`
<div class="group-tabs">
<button class="group-tab ${tab === 'files' ? 'active' : ''}"
@@ -1446,12 +1591,12 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
${tab === 'files' && html`
<div class="file-toolbar">
<label class="admin-btn upload-btn" style="cursor:pointer;margin-right:8px">
- ${uploading ? t('group.uploading') : t('group.upload')}
- <input type="file" style="display:none" onChange=${uploadFile}
- disabled=${uploading} />
+ ${t('group.upload')}
+ <input type="file" multiple style="display:none"
+ onChange=${uploadFile} />
</label>
- <button class="admin-btn" style="margin-right:8px" onClick=${makeDirectory}
- disabled=${uploading}>${t('group.mkdir')}</button>
+ <button class="admin-btn" style="margin-right:8px"
+ onClick=${makeDirectory}>${t('group.mkdir')}</button>
<div class="breadcrumbs">
<a class="crumb" onClick=${() => setCurrentPath('')}>/</a>
${breadcrumbs.map((seg, i) => {
@@ -1464,10 +1609,31 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
</div>
<input type="text" class="file-search" placeholder="${t('group.filter')}"
value=${filter} onInput=${e => setFilter(e.target.value)} />
+ <button class="admin-btn ${selecting ? 'active' : ''}"
+ style="margin-left:8px"
+ onClick=${() => {
+ setSelecting(v => !v);
+ setSelected(new Set());
+ setActionsOpen(false);
+ }}>
+ ${selecting ? t('group.select_done') : t('group.select')}
+ </button>
+ ${selecting && html`
+ <div class="actions-wrap">
+ <button class="admin-btn" disabled=${selected.size === 0}
+ onClick=${(ev) => { ev.stopPropagation(); setActionsOpen(o => !o); }}>
+ ${'\u{22EE}'} ${t('group.actions', { n: selected.size })}
+ </button>
+ ${actionsOpen && html`
+ <div class="file-menu">${actionItems}</div>
+ `}
+ </div>
+ `}
</div>
<table class="file-table">
<thead>
<tr>
+ ${selecting && html`<th class="sel-cell"></th>`}
<th></th>
<th class="sortable" onClick=${() => toggleSort('name')}>
${t('group.col_name')} ${sortKey === 'name' ? (sortAsc ? '▲' : '▼') : ''}
@@ -1481,7 +1647,6 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
<th class="sortable th-date" onClick=${() => toggleSort('date')}>
${t('group.col_date')} ${sortKey === 'date' ? (sortAsc ? '▲' : '▼') : ''}
</th>
- <th></th>
</tr>
</thead>
<tbody>
@@ -1491,52 +1656,34 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
const bytes = inside.reduce((n, f) => n + (f.entry.size || 0), 0);
return html`
<tr class="file-row dir-row" key=${full} onClick=${() =>
- setCurrentPath(full)}>
+ selecting ? toggle(dirKey(d)) : setCurrentPath(full)}>
+ ${selecting && html`
+ <td class="sel-cell">
+ <input type="checkbox" checked=${selected.has(dirKey(d))}
+ onClick=${(ev) => ev.stopPropagation()}
+ onChange=${() => toggle(dirKey(d))} />
+ </td>
+ `}
<td>\u{1F4C1}</td>
<td>${d}/</td>
<td class="file-size">${inside.length ? formatSize(bytes) : ''}</td>
<td class="td-type"></td>
<td class="td-date"></td>
- <td class="file-actions-cell">
- <button class="file-menu-btn" onClick=${(ev) => {
- ev.stopPropagation();
- setMenuOpen(menuOpen === 'dir:' + full ? null : 'dir:' + full);
- }}>${'\u{22EE}'}</button>
- ${menuOpen === 'dir:' + full && html`
- <div class="file-menu">
- ${inside.length > 0 && html`
- <button onClick=${(ev) => {
- ev.stopPropagation();
- setMenuOpen(null);
- downloadDirectory(full);
- }}>
- <span class="fmi">${'\u{2B07}'}</span>
- ${t('group.download_zip', { n: inside.length })}
- </button>
- `}
- ${operatorPaired && status === 'connected' && html`
- <button class="danger" onClick=${(ev) => {
- ev.stopPropagation();
- setMenuOpen(null);
- if (inside.length) { setError(t('group.rmdir_not_empty')); return; }
- if (confirm(t('group.rmdir_confirm', { name: d }))) {
- deleteDirectory(full);
- }
- }}><span class="fmi">${'\u{1F5D1}'}</span> ${t('group.rmdir')}</button>
- `}
- </div>
- `}
- </td>
</tr>
`; })}
- ${sorted.map(e => {
- const canPreview = ['image', 'video', 'document'].includes(e.type)
- || e.name.match(/\.(txt|md|json|csv|log|xml|yaml|yml|ini|conf|py|js|html|css|sh|c|h|java|rs|go|rb|toml)$/i);
- return html`
- <tr class="file-row" key=${e.id}>
+ ${sorted.map(e => html`
+ <tr class="file-row" key=${e.id}
+ onClick=${() => selecting && toggle(e.id)}>
+ ${selecting && html`
+ <td class="sel-cell">
+ <input type="checkbox" checked=${selected.has(e.id)}
+ onClick=${(ev) => ev.stopPropagation()}
+ onChange=${() => toggle(e.id)} />
+ </td>
+ `}
<td>${FILE_ICONS[e.type] || FILE_ICONS.other}</td>
<td class="file-name">
- ${canPreview
+ ${!selecting && canPreview(e)
? html`<a class="file-link" onClick=${() => {
if (e.type === 'video') setVideoEntry(e);
else setPreviewEntry(e);
@@ -1547,42 +1694,10 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
<td class="file-size">${formatSize(e.size)}</td>
<td class="file-type td-type">${e.type}</td>
<td class="file-date td-date">${formatDate(e.added_at)}</td>
- <td class="file-actions-cell">
- <button class="file-menu-btn" onClick=${(ev) => {
- ev.stopPropagation();
- setMenuOpen(menuOpen === e.id ? null : e.id);
- }}>${'\u{22EE}'}</button>
- ${menuOpen === e.id && html`
- <div class="file-menu">
- ${canPreview && html`
- <button onClick=${() => {
- setMenuOpen(null);
- if (e.type === 'video') setVideoEntry(e);
- else setPreviewEntry(e);
- }}><span class="fmi">${'\u{1F441}'}</span> ${t('group.view')}</button>
- `}
- <button onClick=${() => { setMenuOpen(null); downloadFile(e); }}>
- <span class="fmi">${'\u{2B07}'}</span> ${t('group.download')}
- </button>
- ${e.type === 'video' && html`
- <button onClick=${() => { setMenuOpen(null); setVideoEntry(e); }}>
- <span class="fmi">${'\u{25B6}'}</span> ${t('group.play')}
- </button>
- `}
- ${(isNodeAdmin || (userId && e.uploader_id === userId)) && status === 'connected' && html`
- <button class="danger" onClick=${() => {
- setMenuOpen(null);
- if (confirm(t('group.delete_confirm', { name: e.name }))) deleteFile(e);
- }}><span class="fmi">${'\u{1F5D1}'}</span> ${t('group.delete')}</button>
- `}
- </div>
- `}
- </td>
</tr>
- `;
- })}
+ `)}
${sorted.length === 0 && subdirs.length === 0 && html`
- <tr><td colspan="6" class="file-empty">
+ <tr><td colspan=${selecting ? 6 : 5} class="file-empty">
${filter ? t('group.empty_filter') : t('group.empty_dir')}
</td></tr>
`}
@@ -3142,6 +3257,9 @@ function App() {
saveAuth(u);
},
logout: () => {
+ // Navigating away leaves transfers running; signing out does not. They
+ // are moving data on tokens that are about to stop being ours.
+ transfers.reset();
setUser(null);
saveAuth(null);
setGroups([]);