diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-23 15:15:35 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-23 15:15:35 +0200 |
| commit | 9f02ee2c09652abf1308bdfa4a3eec4e9ca9ac83 (patch) | |
| tree | b13198a79a0965f254c828adba3eb41dd5e9a5b4 /packages/meshbay-hub/src/meshbay_hub/static/files-app.js | |
| parent | 8dc11dc05a35a5d64ba4d2c892ccc01c7bfae3da (diff) | |
| download | meshbay-9f02ee2c09652abf1308bdfa4a3eec4e9ca9ac83.tar.gz | |
feat(hub): split the group UI into a pluggable "applications" architecture
GroupPage's 6620-line app.js carried Chat and Files wedged in directly, with
no way to add another group-level app without touching the shell itself. It
is now app.js (routing, non-group pages) plus nine focused files — apps.js
(the registry), chat-app.js, files-app.js, video-player.js, group-page.js
(the shell), group-settings.js, hub-client.js, icon.js and file-utils.js —
with docs/apps.md as the checklist for adding one (Videos/Music/Photos are
sketched there, not built).
Node side gained the matching enablement mechanism, mirroring
member_upload exactly: a roster setting, a signed apps_enabled op enforced
by _has_admin_authority, exposed in the handshake ack. Operators toggle
applications per group from Settings, which also gained a small reorder:
Invite, Pairing, Applications, Shared directories, Uploads, danger zone,
Your devices, Members.
Two bugs surfaced during the split, both missing an import across the new
file boundary and invisible to node --check or a module-load probe since
they only throw when the code path actually runs:
- group-page.js called onRefreshAuth on a stale-token handshake rejection,
but app.js never imported refreshAccessToken from hub-client.js — so a
brand new member (including a group's own creator) hit "Not a member of
this group" and the retry silently failed, throwing before it could
refresh the token.
- chat-app.js called getLocale() for message timestamps without importing
it from i18n.js. Opening Chat on a group with real messages threw mid-
render; uncaught, that appears to wedge Preact's render scheduler, so
every button on the page stopped responding until reload.
Caught the second class of bug with a proper no-undef audit across all
split files (a temporarily installed ESLint 9, since the system one is too
old to parse this codebase's syntax) rather than trusting grep. 827 tests
pass; 6 new ones cover the apps_enabled policy.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016SF6RKNBKg9qejmoMJ9ybA
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/files-app.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/files-app.js | 620 |
1 files changed, 620 insertions, 0 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/files-app.js b/packages/meshbay-hub/src/meshbay_hub/static/files-app.js new file mode 100644 index 0000000..561d8a7 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/files-app.js @@ -0,0 +1,620 @@ +import { + html, useState, useEffect, useRef, useCallback, +} from './vendor/htm-preact.js'; +import { t } from './i18n.js'; +import { Icon } from './icon.js'; +import { ZipStream, entriesUnder } from './zipstream.js'; +import { transfers } from './transfers.js'; +import { + FILE_ICONS, formatSize, formatDate, canPreview, CHUNK_SIZE, + _openDownloadTarget, _saveBlob, pipelinedDownload, downloadEntry, +} from './file-utils.js'; + +// ── Files ──────────────────────────────────────────────────────────────────── +// +// The group's file browser: toolbar (upload, mkdir, filter, select), the +// table itself, and every file/directory operation. `entries`/`nodeDirs`/ +// `nodeRoots` are owned by the group shell (group-page.js) — Chat needs the +// same index for its image attachments — and handed down here read-only +// alongside `applyIndex`/the raw setters to write back through, the same +// shape ChatPanel already takes for `onRefreshIndex`. +// +// `onPreview` opens a file in the shell's video/preview modal rather than +// this component owning that state itself, again because more than one tab +// (Chat's attachments) can trigger it. + +function FilesPanel({ + groupId, transportRef, gekRef, status, + entries, nodeDirs, nodeRoots, setEntries, setNodeDirs, setNodeRoots, applyIndex, + isNodeAdmin, operatorPaired, mayUpload, userId, setError, onPreview, +}) { + const [selecting, setSelecting] = useState(false); + const [selected, setSelected] = useState(() => new Set()); + const [sortKey, setSortKey] = useState('name'); + const [sortAsc, setSortAsc] = useState(true); + const [filter, setFilter] = useState(''); + const [currentPath, setCurrentPath] = useState(''); + + // A directory from the group just left rarely exists in the one just + // entered (e.g. "outputs" in one group, absent in another) — Files would + // otherwise show that stale path and list nothing. + useEffect(() => { setCurrentPath(''); setSelected(new Set()); setFilter(''); }, [groupId]); + + const downloadFile = useCallback(async (entry) => { + const transport = transportRef.current; + if (!transport || !transport.connected) return; + // Both of these have to happen inside the click: a browser grants a file + // picker, and re-grants a folder, only from a user gesture — so this stays + // a click handler all the way down into downloadEntry's own picker call. + await downloadEntry(transfers, transport, gekRef.current, entry); + }, []); + + const uploadFile = useCallback((e) => { + const files = [...(e.target.files || [])]; + e.target.value = ''; + const transport = transportRef.current; + if (!files.length || !transport || !transport.connected) return; + setError(''); + + 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()); + }, + }); + } + }, [applyIndex]); + + const makeDirectory = useCallback(async () => { + const transport = transportRef.current; + if (!transport || !transport.connected) return; + const name = prompt(t('group.mkdir_prompt')); + if (!name || !name.trim()) return; + try { + await transport.createDirectory(currentPath, name.trim()); + const indexMsg = await transport.fetchIndex(); + if (indexMsg.entries) setEntries(indexMsg.entries); + if (indexMsg.dirs) setNodeDirs(indexMsg.dirs); + if (indexMsg.roots) setNodeRoots(indexMsg.roots); + } catch (err) { + setError(err.message); + } + }, [currentPath]); + + /** + * Download a directory as a zip, written straight to disk. + * + * An archive of a group directory is routinely tens of gigabytes, so it is + * never held anywhere: each file is fetched chunk by chunk, decrypted, and + * handed to the zip writer, which hands it to the file the browser opened. + * Peak memory is one chunk plus one small record per file. + * + * Without the File System Access API there is nowhere to stream to, and the + * only alternative is to build the whole thing in memory — so that path is + * offered but says what it costs first. + */ + const downloadDirectory = useCallback(async (dir) => { + const transport = transportRef.current; + if (!transport || !transport.connected) return; + + const files = entriesUnder(entries, dir); + if (!files.length) { + setError(t('group.zip_empty')); + return; + } + const totalBytes = files.reduce((n, f) => n + (f.entry.size || 0), 0); + const suggested = (dir.split('/').pop() || 'files') + '.zip'; + + // totalBytes decides how this is delivered, but it is not the archive's + // size — headers and the central directory come on top — so it is not + // announced as a Content-Length that the download would then miss. + const target = await _openDownloadTarget(suggested, totalBytes, { + types: [{ description: 'ZIP archive', + accept: { 'application/zip': ['.zip'] } }], + }, 0); + if (target === false) return; + if (!target && !confirm(t('group.zip_no_stream', { + size: formatSize(totalBytes), name: suggested, + }))) { + return; + } + const gek = gekRef.current; + const zipOpenRef = { url: null }; + + transfers.start({ + kind: 'download', name: (target && target.name) || suggested, + total: totalBytes, transport, + open: target + ? (target.open || null) + : () => { if (zipOpenRef.url) window.open(zipOpenRef.url, '_blank'); }, + run: async ({ signal, onProgress }) => { + const writable = target ? target.writable : 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()); + }); + + 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 { + const blob = new Blob(parts, { type: 'application/zip' }); + _saveBlob(blob, suggested); + zipOpenRef.url = URL.createObjectURL(blob); + } + } catch (err) { + if (writable) await writable.abort().catch(() => {}); + throw err; + } + }, + }); + }, [entries]); + + const deleteDirectory = useCallback(async (dir) => { + const transport = transportRef.current; + if (!transport || !transport.connected) return; + try { + const sk = transport.sessionKeys && transport.sessionKeys.skEdB64; + const signFn = (sk && window.MeshBayKeys) + ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript) + : null; + await transport.deleteDirectory(dir, signFn); + applyIndex(await transport.fetchIndex()); + } catch (err) { + setError(err.message); + } + }, [applyIndex]); + + const deleteFile = useCallback(async (entry) => { + const transport = transportRef.current; + if (!transport || !transport.connected) return; + try { + // Signs an explicit transcript built by transport.js, not opaque bytes from + // the node — see MeshBayCrypto.adminTranscript and finding H5. + // Signed with the identity this node pinned for us — the only one it + // will accept, and the only one we hold here. + const sk = transport.sessionKeys && transport.sessionKeys.skEdB64; + const signFn = (sk && window.MeshBayKeys) + ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript) + : null; + await transport.deleteFile(entry.id, signFn); + applyIndex(await transport.fetchIndex()); + } catch (err) { + setError(err.message); + } + }, [applyIndex]); + + const toggleSort = useCallback((key) => { + setSortAsc(prev => sortKey === key ? !prev : true); + setSortKey(key); + }, [sortKey]); + + const dirs = new Set(); + const filteredEntries = entries.filter(e => { + const ePath = e.path || ''; + if (ePath === currentPath) { + return !filter || e.name.toLowerCase().includes(filter.toLowerCase()); + } + if (!currentPath && ePath) { + dirs.add(ePath.split('/')[0]); + } else if (currentPath && ePath.startsWith(currentPath + '/')) { + const rest = ePath.slice(currentPath.length + 1); + dirs.add(rest.split('/')[0]); + } + return false; + }); + + const sorted = [...filteredEntries].sort((a, b) => { + let cmp = 0; + if (sortKey === 'name') cmp = a.name.localeCompare(b.name); + else if (sortKey === 'size') cmp = a.size - b.size; + else if (sortKey === 'type') cmp = a.type.localeCompare(b.type); + else if (sortKey === 'date') cmp = a.added_at - b.added_at; + return sortAsc ? cmp : -cmp; + }); + + // The node's own listing, so an empty folder is visible, plus anything implied + // by a file path in case the two ever disagree. + for (const d of nodeDirs) { + if (!currentPath && !d.includes('/')) dirs.add(d); + else if (currentPath && d.startsWith(currentPath + '/')) { + const rest = d.slice(currentPath.length + 1); + if (!rest.includes('/')) dirs.add(rest); + } + } + const subdirs = [...dirs].sort(); + + // At the top of a group the folders on screen ARE the roots, so their state + // belongs there. Deeper in, everything shown lives inside one readable root + // and there is nothing to flag. + const rootState = new Map(nodeRoots.map(r => [r.name, r])); + const unavailableHere = currentPath + ? [] + : subdirs.filter(d => rootState.get(d) && rootState.get(d).available === false); + // A member cannot create a folder at the top of a group: that level is the + // set of roots, which is the operator's configuration and not a directory on + // anyone's disk. The node refuses it, so offering it would only produce an + // error nobody can act on. + const canCreateDir = Boolean(currentPath) && isNodeAdmin; + + 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) => { + setSelecting(false); + setSelected(new Set()); + Promise.resolve().then(fn).catch(err => { + if (err && err.name !== 'AbortError') setError(err.message); + }); + }; + + // Icon only, with the name in the tooltip: these sit in a toolbar that is + // already narrow, and every one of them is a verb the icon carries on its + // own. `title` gives the hover text and `aria-label` the accessible name — + // an icon button with neither is unusable with a screen reader. + // + // Every action is rendered as soon as Select is on, and the ones that do not + // apply are disabled rather than absent. Buttons appearing and vanishing as + // the selection changed made the bar jump about and gave no clue that an + // action existed at all before something was ticked. + const action = (icon, label, onClick, opts = {}) => html` + <button class="tb-icon-btn ${opts.danger ? 'danger' : ''}" + title=${label} aria-label=${label} + disabled=${!!opts.disabled} onClick=${onClick}> + <${Icon} name=${icon} /> + </button> + `; + + const canPlay = !!(onlyFile && onlyFile.type === 'video'); + const canView = !!(onlyFile && onlyFile.type !== 'video' && canPreview(onlyFile)); + const deletableCount = deletableFiles.length + + (operatorPaired ? selectedDirs.length : 0); + // The operator can always delete; anyone else only ever sees the button if + // something here is theirs to remove. Hiding it from an uploader would take + // away a right the protocol grants them (draft-v5 §5.1), not just a control. + const mayEverDelete = isNodeAdmin + || (userId && entries.some(e => e.uploader_id === userId)); + + const actionItems = html` + ${action('play', t('group.play'), + () => run(() => onPreview(onlyFile)), { disabled: !canPlay })} + ${action('eye', t('group.view'), + () => run(() => onPreview(onlyFile)), { disabled: !canView })} + ${action('download', + selectedFiles.length + ? t('group.download_n', { n: selectedFiles.length }) + : t('group.download'), + () => run(async () => { + // Awaited one at a time, and each returns as soon as its transfer is + // registered — so the transfers still run together. Firing them without + // awaiting meant every file asked the browser for a save dialog at + // once, and a browser allows one: the rest were rejected and only the + // first file ever downloaded. + for (const e of selectedFiles) await downloadFile(e); + }), { disabled: selectedFiles.length === 0 })} + ${action('archive', + selectedDirs.length + ? t('group.download_zip_n', { n: selectedDirs.length }) + : t('group.download_zip_n', { n: 0 }), + () => run(async () => { + for (const d of selectedDirs) await downloadDirectory(d); + }), { disabled: selectedDirs.length === 0 })} + ${mayEverDelete && action('trash', + deletableCount ? t('group.delete_n', { n: deletableCount }) : t('group.delete'), + () => { + 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); + }); + }, + { danger: true, disabled: status !== 'connected' || deletableCount === 0 })} + `; + + return html` + ${(status === 'discovering' || status === 'connecting' || status === 'fetching') && html` + <p class="page-message"><span class="spinner"></span>${' '}${t('status.connecting_short')}</p> + `} + ${status === 'offline' && html` + <p class="page-message">${t('group.offline_title')} ${t('group.offline_hint')}</p> + `} + + ${status === 'connected' && html` + <div class="file-toolbar"> + <div class="toolbar-group"> + ${mayUpload && html` + <label class="tb-btn primary"> + <${Icon} name="upload" /> ${t('group.upload')} + <input type="file" multiple style="display:none" + onChange=${uploadFile} /> + </label> + `} + ${canCreateDir && html` + <button class="tb-btn" onClick=${makeDirectory}> + <${Icon} name="folder-plus" /> ${t('group.mkdir')} + </button> + `} + </div> + + <div class="breadcrumbs"> + <a class="crumb" onClick=${() => setCurrentPath('')}> + <${Icon} name="home" /> + </a> + ${breadcrumbs.map((seg, i) => { + const path = breadcrumbs.slice(0, i + 1).join('/'); + return html` + <span class="crumb-sep">/</span> + <a class="crumb" onClick=${() => setCurrentPath(path)}>${seg}</a> + `; + })} + </div> + + <div class="toolbar-group right"> + <div class="tb-search"> + <${Icon} name="search" /> + <input type="text" placeholder="${t('group.filter')}" + value=${filter} onInput=${e => setFilter(e.target.value)} /> + </div> + <button class="tb-btn ${selecting ? 'active' : ''}" + onClick=${() => { + setSelecting(v => !v); + setSelected(new Set()); + }}> + <${Icon} name=${selecting ? 'check' : 'checkbox'} /> + ${selecting ? t('group.select_done') : t('group.select')} + </button> + ${selecting && html`<div class="tb-actions">${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 ? '▲' : '▼') : ''} + </th> + <th class="sortable" onClick=${() => toggleSort('size')}> + ${t('group.col_size')} ${sortKey === 'size' ? (sortAsc ? '▲' : '▼') : ''} + </th> + <th class="sortable th-type" onClick=${() => toggleSort('type')}> + ${t('group.col_type')} ${sortKey === 'type' ? (sortAsc ? '▲' : '▼') : ''} + </th> + <th class="sortable th-date" onClick=${() => toggleSort('date')}> + ${t('group.col_date')} ${sortKey === 'date' ? (sortAsc ? '▲' : '▼') : ''} + </th> + </tr> + </thead> + <tbody> + ${subdirs.map(d => { + const full = currentPath ? currentPath + '/' + d : d; + const inside = entriesUnder(entries, full); + const bytes = inside.reduce((n, f) => n + (f.entry.size || 0), 0); + return html` + <tr class="file-row dir-row" key=${full} onClick=${() => + 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>${unavailableHere.includes(d) ? '\u{26A0}' : '\u{1F4C1}'}</td> + <td>${d}${unavailableHere.includes(d) ? html` + <span class="root-offline"> ${t('group.root_unavailable')}</span> + ` : ''}</td> + <td class="file-size">${inside.length ? formatSize(bytes) : ''}</td> + <td class="td-type"></td> + <td class="td-date"></td> + </tr> + `; })} + ${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"> + ${!selecting && canPreview(e) + ? html`<a class="file-link" onClick=${() => onPreview(e)}>${e.name}</a>` + : e.name + } + </td> + <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> + </tr> + `)} + ${sorted.length === 0 && subdirs.length === 0 && html` + <tr><td colspan=${selecting ? 6 : 5} class="file-empty"> + ${filter ? t('group.empty_filter') : t('group.empty_dir')} + </td></tr> + `} + </tbody> + </table> + `} + `; +} + +// ── File Preview (text, images) ───────────────────────────────────────── + +const TEXT_EXTS = /\.(txt|md|json|csv|log|xml|yaml|yml|ini|conf|py|js|html|css|sh|c|h|java|rs|go|rb|toml)$/i; +const IMAGE_EXTS = /\.(jpg|jpeg|png|gif|webp|svg|bmp|ico)$/i; + +function FilePreview({ entry, transportRef, gekRef, onClose, onDownload }) { + const [phase, setPhase] = useState('loading'); + const [progress, setProgress] = useState(0); + const [content, setContent] = useState(null); + const [error, setError] = useState(''); + const [downloading, setDownloading] = useState(false); + const blobUrlRef = useRef(null); + + useEffect(() => { + let cancelled = false; + const load = async () => { + const transport = transportRef.current; + if (!transport || !transport.connected) { + setError(t('video.err_transport')); + setPhase('error'); + return; + } + try { + const totalChunks = Math.ceil(entry.size / CHUNK_SIZE); + let downloaded = 0; + const chunks = await pipelinedDownload( + transport, gekRef.current, entry.id, totalChunks, + (bytes) => { downloaded += bytes; setProgress(downloaded / entry.size); }, + ); + if (cancelled) return; + + 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' + : ext === 'gif' ? 'image/gif' + : ext === 'webp' ? 'image/webp' + : 'image/jpeg'; + const blob = new Blob(chunks, { type: mime }); + blobUrlRef.current = URL.createObjectURL(blob); + setContent({ type: 'image' }); + } else { + const decoder = new TextDecoder('utf-8', { fatal: false }); + const text = chunks.map(c => decoder.decode(c, { stream: true })).join(''); + setContent({ type: 'text', text: text.slice(0, 500000) }); + } + setPhase('ready'); + } catch (err) { + if (!cancelled) { setError(err.message); setPhase('error'); } + } + }; + load(); + return () => { cancelled = true; }; + }, [entry]); + + useEffect(() => { + const onKey = (e) => { if (e.key === 'Escape') onClose(); }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [onClose]); + + useEffect(() => { + return () => { + if (blobUrlRef.current) { + URL.revokeObjectURL(blobUrlRef.current); + blobUrlRef.current = null; + } + }; + }, []); + + return html` + <div class="video-overlay" onClick=${(e) => { + if (e.target.classList.contains('video-overlay')) onClose(); + }}> + <div class="video-top-bar"> + <span class="video-title">${entry.name} (${formatSize(entry.size)})</span> + ${onDownload && html` + <button class="video-close ${downloading ? 'dl-active' : ''}" + onClick=${() => { + if (!downloading) { + setDownloading(true); + onDownload(); + setTimeout(() => setDownloading(false), 1500); + } + }} + title="${t('group.download')}" disabled=${downloading}> + ${downloading + ? html`<span class="spinner"></span>` + : html`<${Icon} name="download" />`}</button> + `} + <button class="video-close" onClick=${onClose} title="${t('video.close')}"> + <${Icon} name="close" /></button> + </div> + ${phase === 'loading' && html` + <div class="video-loading"> + <div class="video-loading-label">${t('video.loading', { name: entry.name })}</div> + <div class="video-progress-bar"> + <div class="video-progress-fill" style="width:${Math.round(progress * 100)}%"></div> + </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} /> + </div> + `} + ${phase === 'ready' && content?.type === 'text' && html` + <div class="preview-text-wrap"> + <pre class="preview-text">${content.text}</pre> + </div> + `} + ${phase === 'error' && html` + <div class="video-error">${error}</div> + `} + </div> + `; +} + +export { FilesPanel, FilePreview }; |