aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/files-app.js
diff options
context:
space:
mode:
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.js151
1 files changed, 87 insertions, 64 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
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`
- <button class="tb-icon-btn ${opts.danger ? 'danger' : ''}"
- title=${label} aria-label=${label}
- disabled=${!!opts.disabled} onClick=${onClick}>
- <${Icon} name=${icon} />
+ const actionItems = actionsFor(selectedFiles, selectedDirs, true).map(a => html`
+ <button class="tb-icon-btn ${a.danger ? 'danger' : ''}" key=${a.key}
+ title=${a.label} aria-label=${a.label}
+ disabled=${!!a.disabled} onClick=${a.onSelect}>
+ <${Icon} name=${a.icon} />
</button>
- `;
+ `);
- 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`
<tr class="file-row dir-row${isEjected ? ' root-ejected' : ''}" key=${full}
- onClick=${() => { if (!isEjected) setCurrentPath(full); }}>
+ onClick=${() => { if (!isEjected) setCurrentPath(full); }}
+ onContextMenu=${(ev) => onRowMenu(ev, dirKey(d))}>
<td class="sel-cell">
<input type="checkbox" checked=${selected.has(dirKey(d))}
onClick=${(ev) => ev.stopPropagation()}
@@ -826,7 +847,8 @@ function FilesPanel({
</tr>
`; })}
${sorted.map(e => html`
- <tr class="file-row" key=${e.id}>
+ <tr class="file-row" key=${e.id}
+ onContextMenu=${(ev) => onRowMenu(ev, e.id)}>
<td class="sel-cell">
<input type="checkbox" checked=${selected.has(e.id)}
onClick=${(ev) => ev.stopPropagation()}
@@ -858,6 +880,7 @@ function FilesPanel({
</tbody>
</table>
`}
+ ${menu && html`<${Menu} ...${menu} onClose=${closeMenu} />`}
${dragging && !readOnly && html`
<div class="drop-overlay ${canDropHere ? '' : 'refused'}">
<div class="drop-overlay-label">