From 3d8c1acf785ff7389a68cc515a5c9324dee8de41 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Wed, 23 Sep 2026 15:21:30 +0200 Subject: feat(hub): right-click menu in the Files tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The toolbar's actions on the row under the pointer, sharing one action list with the toolbar — which keeps showing what does not apply, disabled, while the menu leaves it out. A count only where more than one item is concerned. Co-Authored-By: Claude Opus 5 --- .../src/meshbay_hub/static/files-app.js | 151 ++++++++++++--------- .../src/meshbay_hub/static/locales/de.js | 1 + .../src/meshbay_hub/static/locales/en.js | 1 + .../src/meshbay_hub/static/locales/es.js | 1 + .../src/meshbay_hub/static/locales/fr.js | 1 + .../src/meshbay_hub/static/locales/it.js | 1 + .../src/meshbay_hub/static/locales/ja.js | 1 + .../src/meshbay_hub/static/locales/nl.js | 1 + .../src/meshbay_hub/static/locales/pl.js | 1 + .../src/meshbay_hub/static/locales/pt-BR.js | 1 + .../src/meshbay_hub/static/locales/zh-CN.js | 1 + 11 files changed, 97 insertions(+), 64 deletions(-) (limited to 'packages/meshbay-hub/src') diff --git a/packages/meshbay-hub/src/meshbay_hub/static/files-app.js b/packages/meshbay-hub/src/meshbay_hub/static/files-app.js index 6aabea3..ac978e2 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/files-app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/files-app.js @@ -11,6 +11,7 @@ import { pipelinedDownload, downloadEntry, downloadDirectory as sharedDownloadDirectory, } from './file-utils.js'; import { useStickyBand } from './sticky.js'; +import { Menu, useMenu } from './menu.js'; // ── Files ──────────────────────────────────────────────────────────────────── // @@ -152,6 +153,7 @@ function FilesPanel({ // a phone and grows a field while a folder is being named — so it is // measured rather than written down (sticky.js). const toolbarBand = useStickyBand('--toolbar-h'); + const { menu, openAt, close: closeMenu } = useMenu(); // Only the cross-group Search page shows this (`showRefresh`): it has no // live node connection pushing index deltas, so its file list really is @@ -572,18 +574,72 @@ function FilesPanel({ } }; - 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) => { - setSelected(new Set()); + const run = (fn, clear = true) => { + if (clear) setSelected(new Set()); Promise.resolve().then(fn).catch(err => { if (err && err.name !== 'AbortError') setError(err.message); }); }; + // 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)); + + // What can be done to these files and folders — one list for the toolbar, + // which acts on the ticked rows and shows the ones that do not apply + // disabled, and for the right-click menu, which acts on the row under the + // pointer and leaves them out. `clear` is false for the menu opened on an + // unticked row: acting on that row must not throw away a selection it was + // never part of. + const actionsFor = (files, dirs, clear) => { + const onlyFile = files.length === 1 && dirs.length === 0 ? files[0] : null; + const deletableFiles = files.filter( + e => isNodeAdmin || (userId && e.uploader_id === userId)); + const deletableCount = deletableFiles.length + (operatorPaired ? dirs.length : 0); + const canPlay = !!(onlyFile && (onlyFile.type === 'video' || onlyFile.type === 'audio')); + const canView = !!(onlyFile && onlyFile.type !== 'video' && onlyFile.type !== 'audio' + && canPreview(onlyFile)); + return [ + { key: 'play', icon: 'play', label: t('group.play'), disabled: !canPlay, + onSelect: () => run(() => onPreview(onlyFile), clear) }, + { key: 'view', icon: 'eye', label: t('group.view'), disabled: !canView, + onSelect: () => run(() => onPreview(onlyFile), clear) }, + { key: 'download', icon: 'download', + label: files.length > 1 ? t('group.download_n', { n: files.length }) : t('group.download'), + disabled: files.length === 0, + onSelect: () => 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 files) await downloadFile(e); + }, clear) }, + !readOnly && { key: 'archive', icon: 'archive', + label: dirs.length === 1 ? t('group.download_zip_one') + : t('group.download_zip_n', { n: dirs.length }), + disabled: dirs.length === 0, + onSelect: () => run(async () => { + for (const d of dirs) await downloadDirectory(d); + }, clear) }, + mayEverDelete && !readOnly && { key: 'delete', icon: 'trash', danger: true, + label: deletableCount > 1 ? t('group.delete_n', { n: deletableCount }) : t('group.delete'), + disabled: status !== 'connected' || deletableCount === 0, + onSelect: async () => { + const names = [...deletableFiles.map(e => e.name), + ...(operatorPaired ? dirs : [])]; + if (!await ask(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 dirs) deleteDirectory(d); + }, clear); + } }, + ].filter(Boolean); + }; + // 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 — @@ -593,63 +649,27 @@ function FilesPanel({ // 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` - - `; + `); - const canPlay = !!(onlyFile && (onlyFile.type === 'video' || onlyFile.type === 'audio')); - const canView = !!(onlyFile && onlyFile.type !== 'video' && onlyFile.type !== 'audio' - && 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 })} - ${!readOnly && 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 && !readOnly && action('trash', - deletableCount ? t('group.delete_n', { n: deletableCount }) : t('group.delete'), - async () => { - const names = [...deletableFiles.map(e => e.name), - ...(operatorPaired ? selectedDirs : [])]; - if (!await ask(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 })} - `; + // Right-click on a row. A ticked row stands for the whole selection, as in + // any file manager; an unticked one for itself alone. A menu has no layout + // to keep still, so what does not apply is left out rather than greyed — + // and with nothing left, the browser's own menu is not taken away. + const onRowMenu = (e, key) => { + const [files, dirs] = selected.has(key) + ? [selectedFiles, selectedDirs] + : typeof key === 'string' && key.startsWith('dir:') + ? [[], [key.slice(4)]] + : [entries.filter(x => x.id === key), []]; + const items = actionsFor(files, dirs, selected.has(key)).filter(a => !a.disabled); + if (items.length) openAt(e, items); + }; return html` ${(status === 'discovering' || status === 'connecting' || status === 'fetching') && html` @@ -782,7 +802,8 @@ function FilesPanel({ const isRemovable = rs && rs.removable; return html` { if (!isEjected) setCurrentPath(full); }}> + onClick=${() => { if (!isEjected) setCurrentPath(full); }} + onContextMenu=${(ev) => onRowMenu(ev, dirKey(d))}> ev.stopPropagation()} @@ -826,7 +847,8 @@ function FilesPanel({ `; })} ${sorted.map(e => html` - + onRowMenu(ev, e.id)}> ev.stopPropagation()} @@ -858,6 +880,7 @@ function FilesPanel({ `} + ${menu && html`<${Menu} ...${menu} onClose=${closeMenu} />`} ${dragging && !readOnly && html`
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js index e742787..730c75b 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -717,6 +717,7 @@ export default { 'group.actions': 'Aktionen ({n})', 'group.download_n': 'Herunterladen ({n})', 'group.download_zip_n': 'Ordner als ZIP herunterladen ({n})', + 'group.download_zip_one': 'Ordner als ZIP herunterladen', 'group.delete_n': 'Löschen ({n})', 'group.delete_n_confirm': { one: '{n} Element löschen? {names}', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js index 87e5a4a..daa378b 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -833,6 +833,7 @@ export default { 'group.actions': 'Actions ({n})', 'group.download_n': 'Download ({n})', 'group.download_zip_n': 'Download folders as zip ({n})', + 'group.download_zip_one': 'Download folder as zip', 'group.delete_n': 'Delete ({n})', 'group.delete_n_confirm': { one: 'Delete {n} item? {names}', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js index 9e4040d..3f8261c 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -712,6 +712,7 @@ export default { 'group.actions': 'Acciones ({n})', 'group.download_n': 'Descargar ({n})', 'group.download_zip_n': 'Descargar las carpetas en zip ({n})', + 'group.download_zip_one': 'Descargar la carpeta en zip', 'group.delete_n': 'Eliminar ({n})', 'group.delete_n_confirm': { one: '¿Eliminar {n} elemento? {names}', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js index aaa3e19..7a0209e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -715,6 +715,7 @@ export default { 'group.actions': 'Actions ({n})', 'group.download_n': 'Télécharger ({n})', 'group.download_zip_n': 'Télécharger les dossiers en zip ({n})', + 'group.download_zip_one': 'Télécharger le dossier en zip', 'group.delete_n': 'Supprimer ({n})', 'group.delete_n_confirm': { one: 'Supprimer {n} élément ? {names}', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js index a976e09..5b99839 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -715,6 +715,7 @@ export default { 'group.actions': 'Azioni ({n})', 'group.download_n': 'Scarica ({n})', 'group.download_zip_n': 'Scarica le cartelle in zip ({n})', + 'group.download_zip_one': 'Scarica la cartella in zip', 'group.delete_n': 'Elimina ({n})', 'group.delete_n_confirm': { one: 'Eliminare {n} elemento? {names}', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js index 2660e7f..b825412 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -707,6 +707,7 @@ export default { 'group.actions': '操作({n})', 'group.download_n': 'ダウンロード({n})', 'group.download_zip_n': 'フォルダーを zip でダウンロード({n})', + 'group.download_zip_one': 'フォルダーを zip でダウンロード', 'group.delete_n': '削除({n})', 'group.delete_n_confirm': { other: '{n} 件を削除しますか?{names}', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js index 81ed678..9b6070b 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -716,6 +716,7 @@ export default { 'group.actions': 'Acties ({n})', 'group.download_n': 'Downloaden ({n})', 'group.download_zip_n': 'Mappen als zip downloaden ({n})', + 'group.download_zip_one': 'Map als zip downloaden', 'group.delete_n': 'Verwijderen ({n})', 'group.delete_n_confirm': { one: '{n} item verwijderen? {names}', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js index bd91a07..20e721f 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -728,6 +728,7 @@ export default { 'group.actions': 'Działania ({n})', 'group.download_n': 'Pobierz ({n})', 'group.download_zip_n': 'Pobierz foldery jako zip ({n})', + 'group.download_zip_one': 'Pobierz folder jako zip', 'group.delete_n': 'Usuń ({n})', 'group.delete_n_confirm': { one: 'Usunąć {n} element? {names}', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js index 490f2f9..5506b60 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js @@ -714,6 +714,7 @@ export default { 'group.actions': 'Ações ({n})', 'group.download_n': 'Baixar ({n})', 'group.download_zip_n': 'Baixar as pastas em zip ({n})', + 'group.download_zip_one': 'Baixar a pasta em zip', 'group.delete_n': 'Excluir ({n})', 'group.delete_n_confirm': { one: 'Excluir {n} item? {names}', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js index 8609297..21ccd04 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js @@ -696,6 +696,7 @@ export default { 'group.actions': '操作({n})', 'group.download_n': '下载({n})', 'group.download_zip_n': '将文件夹打包为 zip 下载({n})', + 'group.download_zip_one': '将文件夹打包为 zip 下载', 'group.delete_n': '删除({n})', 'group.delete_n_confirm': { other: '删除 {n} 个项目?{names}', -- cgit v1.2.3