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 | 564 |
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([]); |