diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-10-05 12:23:18 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-10-05 12:23:18 +0200 |
| commit | 1d94a92936abb3d37a8f9bfb36c8850246fee15d (patch) | |
| tree | f07aa4f65277d703ac64a84882b0c559bf379f30 /packages/meshbay-hub/src/meshbay_hub/static | |
| parent | 8f25294b0f6bc3f292442edd69a2e149f0717b52 (diff) | |
| download | meshbay-1d94a92936abb3d37a8f9bfb36c8850246fee15d.tar.gz | |
feat: copy a file's or folder's #/name@owner link from Files, Music, Photos and Search
"Copy link" puts the address group-link.js resolves on the clipboard, on the
hub's origin rather than the page's, so a link copied in the desktop
application is not app://meshbay. Files offers it for one row, from the
right-click menu or the toolbar with one row ticked (a phone's way in);
Music on one track's menu, whose dots a phone has; Photos on a right-clicked
tile and in the lightbox's bar. The video player and the file preview carry
a link button next to Download.
Applications get a `linkFor(entry | folderPath)` prop (MESHBAY_DESIGN.md
§9.2) and offer the action only when it names a link. The group page builds
it from the hub's row; Search from each result's own group and its path
before the merged views prefixed it, and names no link for a folder of the
merged tree, which a group name alone does not identify.
harness/copy_link_probe.py mounts the three applications in Chrome and reads
what reached the clipboard.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static')
19 files changed, 198 insertions, 20 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/copy-link.js b/packages/meshbay-hub/src/meshbay_hub/static/copy-link.js new file mode 100644 index 0000000..ad6c3be --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/copy-link.js @@ -0,0 +1,67 @@ +import { t } from './i18n.js'; +import * as platform from './platform.js'; +import { groupLinkRoute } from './group-link.js'; + +/** + * "Copy link": the `#/name@owner/path` address of a file or folder + * (group-link.js), on this hub, put on the clipboard. + * + * The hub's origin, never the page's: in the desktop application the page is + * `app://meshbay`, and a link that only opens inside one person's application + * is not a link. + */ + +/** The full link to `path` inside `group` (`{ name, owner_username }`). */ +export function groupItemLink(group, path) { + return platform.hubOrigin() + '/#' + groupLinkRoute(group, path); +} + +/** An entry's path inside its group: its folder, root first, then its name. */ +export function entryPath(entry, dir = entry.path) { + return [dir, entry.name].filter(Boolean).join('/'); +} + +// The async clipboard first; the old command where it is refused — a page +// without focus, a WebView without the permission. Neither needs to read +// anything back. +async function _write(text) { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { /* fall through */ } + const area = document.createElement('textarea'); + area.value = text; + area.setAttribute('readonly', ''); + area.style.cssText = 'position:fixed;top:0;left:0;opacity:0;pointer-events:none'; + document.body.appendChild(area); + area.select(); + let ok = false; + try { ok = document.execCommand('copy'); } catch { ok = false; } + area.remove(); + return ok; +} + +let _note = null; +let _noteTimer = null; + +// Said once and gone, in the playlists' note (style.css `.playlist-note`). +// Appended to the body rather than rendered by a view, because the views that +// copy include modals and a context menu that has already closed. +function _say(text) { + if (!_note) { + _note = document.createElement('div'); + _note.className = 'playlist-note copy-link-note'; + _note.setAttribute('role', 'status'); + } + _note.textContent = text; + document.body.appendChild(_note); + clearTimeout(_noteTimer); + _noteTimer = setTimeout(() => { if (_note) _note.remove(); }, 2500); +} + +/** Copy `url`, and say whether it worked. The link itself is shown if not. */ +export async function copyLink(url) { + if (!url) return; + const ok = await _write(url); + _say(ok ? t('link.copied') : t('link.copy_failed', { url })); +} 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 978090c..b3672c9 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/files-app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/files-app.js @@ -13,6 +13,7 @@ import { import { useStickyBand } from './sticky.js'; import { Menu, useMenu } from './menu.js'; import { askReport } from './report.js'; +import { copyLink } from './copy-link.js'; // ── Files ──────────────────────────────────────────────────────────────────── // @@ -142,7 +143,7 @@ function FilesPanel({ entries, nodeDirs, nodeRoots, setEntries, setNodeDirs, setNodeRoots, applyIndex, isNodeAdmin, operatorPaired, userId, setError, onPreview, showGroup, readOnly, getTransport, onRefreshIndex, showRefresh, onReport, - openDirectory, + openDirectory, linkFor, }) { const [selected, setSelected] = useState(() => new Set()); const [sortKey, setSortKey] = useState('name'); @@ -623,6 +624,14 @@ function FilesPanel({ onSelect: () => run(async () => { for (const d of dirs) await downloadDirectory(d); }, clear) }, + // One file or one folder, ticked (the toolbar, which is also how a phone + // gets at it) or under the pointer. Absent rather than disabled where + // the view can name no link at all (`linkFor` returns null). + linkFor && { key: 'link', icon: 'link', label: t('link.copy'), + disabled: files.length + dirs.length !== 1 + || !linkFor(files.length ? files[0] : dirs[0]), + onSelect: () => run( + () => copyLink(linkFor(files.length ? files[0] : dirs[0])), 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, @@ -905,7 +914,7 @@ function FilesPanel({ const IMAGE_EXTS = /\.(jpg|jpeg|png|gif|webp|svg|bmp|ico)$/i; -function FilePreview({ entry, transportRef, gekRef, onClose, onDownload }) { +function FilePreview({ entry, transportRef, gekRef, onClose, onDownload, onCopyLink }) { const [phase, setPhase] = useState('loading'); const [progress, setProgress] = useState(0); const [content, setContent] = useState(null); @@ -1000,6 +1009,11 @@ function FilePreview({ entry, transportRef, gekRef, onClose, onDownload }) { }}> <div class="video-top-bar"> <span class="video-title">${entry.name} (${formatSize(entry.size)})</span> + ${onCopyLink && html` + <button class="video-close" onClick=${onCopyLink} + title="${t('link.copy')}" aria-label="${t('link.copy')}"> + <${Icon} name="link" /></button> + `} ${onDownload && html` <button class="video-close ${downloading ? 'dl-active' : ''}" onClick=${() => { diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js index a2d6c18..48b1c80 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js @@ -6,6 +6,7 @@ import { Icon } from './icon.js'; import { transfers } from './transfers.js'; import { downloadEntry } from './file-utils.js'; import { resolveLinkedPath } from './group-link.js'; +import { copyLink, entryPath, groupItemLink } from './copy-link.js'; import { HUB, session, hubFetch, ensureFreshToken, _loadBundleKey, _loadRecoveryKey, _storeBundleKey, @@ -761,6 +762,14 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, if (onLinkOpened) onLinkOpened(); }, [openPath, status, groupId]); + // The link "Copy link" puts on the clipboard, for an entry or a folder path + // (group-link.js). Null until the hub's row for the group is in: the handle + // is its name and its owner's, and the node knows neither. + const linkFor = useCallback((target) => { + if (!group || !group.name || !group.owner_username || !target) return null; + return groupItemLink(group, typeof target === 'string' ? target : entryPath(target)); + }, [group]); + const refreshIndex = useCallback(async () => { const transport = transportRef.current; if (!transport || !transport.connected) return; @@ -891,7 +900,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, entries, availableEntries, nodeDirs, nodeRoots, setEntries, setNodeDirs, setNodeRoots, applyIndex, isNodeAdmin, operatorPaired, attachRoot, attachDir, userId, setError, onPreview, - onRefreshIndex: refreshIndex, onActivity: touchActivity, + onRefreshIndex: refreshIndex, onActivity: touchActivity, linkFor, // Plural everywhere, and built from the registry rather than a list of app // names kept here: Videos and Music read a list, Photos always did, and an // application added to `APPS` gets its own entry without this file @@ -1060,7 +1069,8 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, transportRef=${transportRef} gekRef=${gekRef} onClose=${() => setPreviewEntry(null)} - onDownload=${() => downloadFileForModal(previewEntry)} /> + onDownload=${() => downloadFileForModal(previewEntry)} + onCopyLink=${linkFor(previewEntry) && (() => copyLink(linkFor(previewEntry)))} /> `} ${videoEntry && html` <${VideoPlayer} @@ -1068,7 +1078,8 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, transportRef=${transportRef} gekRef=${gekRef} onClose=${() => setVideoEntry(null)} - onDownload=${() => downloadFileForModal(videoEntry)} /> + onDownload=${() => downloadFileForModal(videoEntry)} + onCopyLink=${linkFor(videoEntry) && (() => copyLink(linkFor(videoEntry)))} /> `} </div> `; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/icon.js b/packages/meshbay-hub/src/meshbay_hub/static/icon.js index 4fa4357..4d549f1 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/icon.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/icon.js @@ -65,6 +65,8 @@ const ICON_PATHS = { plus: ['M12 5v14', 'M5 12h14'], refresh: ['M20 11a8 8 0 0 0-15.3-2', 'M4 5v4h4', 'M4 13a8 8 0 0 0 15.3 2', 'M20 19v-4h-4'], + link: ['M10 13.5a4.5 4.5 0 0 0 6.4.4l3-3a4.5 4.5 0 0 0-6.4-6.4l-1.6 1.6', + 'M14 10.5a4.5 4.5 0 0 0-6.4-.4l-3 3a4.5 4.5 0 0 0 6.4 6.4l1.6-1.6'], 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'], 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 8d14dca..6e42af9 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -212,6 +212,9 @@ export default { 'group.download_offline': 'Keine Verbindung zum Node — der Download kann nicht starten. Die Verbindung wird automatisch wiederhergestellt; versuchen Sie es gleich erneut.', 'group.link_unknown': "Dieser Link verweist auf eine Gruppe, in der Sie nicht Mitglied sind, oder die nicht mehr so heißt.", 'group.link_path_unknown': "Unter {path} gibt es in dieser Gruppe nichts — die Datei wurde vielleicht verschoben, umbenannt oder gelöscht.", + 'link.copy': "Link kopieren", + 'link.copied': "Link kopiert", + 'link.copy_failed': "Kopieren nicht möglich — der Link lautet {url}", 'group.download_write_stalled': 'Die Datei wird nicht mehr auf die Festplatte geschrieben ({seconds} s ohne Fortschritt). Der Download wurde abgebrochen statt hängen gelassen; versuchen Sie es erneut.', 'device.add_title': 'This browser is not linked to this node yet', 'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.', 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 a6cd0b4..f94b438 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -212,6 +212,9 @@ export default { 'group.download_offline': 'Not connected to the node — the download cannot start. It reconnects on its own; try again in a moment.', 'group.link_unknown': "This link names a group you are not a member of, or one that no longer goes by that name.", 'group.link_path_unknown': "Nothing at {path} in this group — the file may have been moved, renamed or deleted.", + 'link.copy': "Copy link", + 'link.copied': "Link copied", + 'link.copy_failed': "Could not copy — the link is {url}", 'group.download_write_stalled': 'The file stopped being written to disk ({seconds}s with no progress). The download was stopped rather than left hanging; try it again.', 'device.add_title': 'This browser is not linked to this node yet', 'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.', 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 e066081..6ff8be2 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -210,6 +210,9 @@ export default { 'group.download_offline': 'Sin conexión con el nodo — la descarga no puede empezar. Se reconecta sola; inténtelo de nuevo en un momento.', 'group.link_unknown': "Este enlace nombra un grupo del que no eres miembro, o que ya no se llama así.", 'group.link_path_unknown': "No hay nada en {path} en este grupo: puede que el archivo se haya movido, renombrado o eliminado.", + 'link.copy': "Copiar enlace", + 'link.copied': "Enlace copiado", + 'link.copy_failed': "No se pudo copiar: el enlace es {url}", 'group.download_write_stalled': 'El archivo dejó de escribirse en el disco ({seconds} s sin avance). La descarga se detuvo en lugar de quedarse colgada; inténtelo de nuevo.', 'device.add_title': 'This browser is not linked to this node yet', 'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.', 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 01ccfcd..9132bd2 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -211,6 +211,9 @@ export default { 'group.download_offline': 'Pas de connexion au node — le téléchargement ne peut pas démarrer. La reconnexion est automatique, réessayez dans un instant.', 'group.link_unknown': "Ce lien désigne un groupe dont vous n'êtes pas membre, ou qui ne porte plus ce nom.", 'group.link_path_unknown': "Rien à l'emplacement {path} dans ce groupe — le fichier a peut-être été déplacé, renommé ou supprimé.", + 'link.copy': "Copier le lien", + 'link.copied': "Lien copié", + 'link.copy_failed': "Copie impossible — le lien est {url}", 'group.download_write_stalled': 'L\'écriture du fichier sur le disque s\'est arrêtée ({seconds} s sans progression). Le téléchargement a été interrompu plutôt que laissé en suspens ; réessayez.', 'device.add_title': 'Ce navigateur n’est pas encore lié à ce nœud', 'device.add_hint': 'Votre compte est connu ici, mais ce navigateur détient une autre clé. Approuvez-le depuis un appareil déjà lié — sans passer par l’opérateur.', 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 71af9f8..f3ff714 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -211,6 +211,9 @@ export default { 'group.download_offline': 'Nessuna connessione al nodo — il download non può iniziare. La riconnessione è automatica, riprovi tra poco.', 'group.link_unknown': "Questo link indica un gruppo di cui non sei membro, o che non ha più questo nome.", 'group.link_path_unknown': "Non c'è nulla in {path} in questo gruppo: il file potrebbe essere stato spostato, rinominato o eliminato.", + 'link.copy': "Copia link", + 'link.copied': "Link copiato", + 'link.copy_failed': "Impossibile copiare — il link è {url}", 'group.download_write_stalled': 'Il file ha smesso di essere scritto su disco ({seconds} s senza progressi). Il download è stato interrotto invece di restare bloccato; riprovi.', 'device.add_title': 'This browser is not linked to this node yet', 'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.', 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 4b357c8..0690034 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -209,6 +209,9 @@ export default { 'group.download_offline': 'ノードに接続していません — ダウンロードを開始できません。再接続は自動で行われます。少し待って再試行してください。', 'group.link_unknown': "このリンクは、あなたがメンバーでないグループ、またはもうその名前ではないグループを指しています。", 'group.link_path_unknown': "このグループの {path} には何もありません。ファイルが移動、名前変更、または削除された可能性があります。", + 'link.copy': "リンクをコピー", + 'link.copied': "リンクをコピーしました", + 'link.copy_failed': "コピーできませんでした。リンク: {url}", 'group.download_write_stalled': 'ファイルのディスクへの書き込みが止まりました({seconds} 秒間進みません)。ぶら下がったままにせず中止しました。もう一度お試しください。', 'device.add_title': 'This browser is not linked to this node yet', 'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.', 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 1f4b12c..ce6312c 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -212,6 +212,9 @@ export default { 'group.download_offline': 'Geen verbinding met de node — de download kan niet starten. Er wordt automatisch opnieuw verbonden; probeer het zo weer.', 'group.link_unknown': "Deze link verwijst naar een groep waarvan je geen lid bent, of die niet meer zo heet.", 'group.link_path_unknown': "Er staat niets op {path} in deze groep — het bestand is misschien verplaatst, hernoemd of verwijderd.", + 'link.copy': "Link kopiëren", + 'link.copied': "Link gekopieerd", + 'link.copy_failed': "Kopiëren mislukt — de link is {url}", 'group.download_write_stalled': 'Het bestand wordt niet meer naar schijf geschreven ({seconds} s zonder voortgang). De download is gestopt in plaats van te blijven hangen; probeer het opnieuw.', 'device.add_title': 'This browser is not linked to this node yet', 'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.', 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 05a3006..2737532 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -215,6 +215,9 @@ export default { 'group.download_offline': 'Brak połączenia z węzłem — pobieranie nie może się rozpocząć. Połączenie wróci samo; proszę spróbować za chwilę.', 'group.link_unknown': "Ten link wskazuje grupę, do której nie należysz, lub która nie nosi już tej nazwy.", 'group.link_path_unknown': "W tej grupie nie ma niczego pod {path} — plik mógł zostać przeniesiony, zmieniony lub usunięty.", + 'link.copy': "Kopiuj link", + 'link.copied': "Link skopiowany", + 'link.copy_failed': "Nie udało się skopiować — link to {url}", 'group.download_write_stalled': 'Plik przestał być zapisywany na dysk ({seconds} s bez postępu). Pobieranie zostało przerwane, zamiast wisieć w nieskończoność; proszę spróbować ponownie.', 'device.add_title': 'This browser is not linked to this node yet', 'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.', 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 824c69a..c7cdf01 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 @@ -212,6 +212,9 @@ export default { 'group.download_offline': 'Sem conexão com o nó — o download não pode começar. Ele reconecta sozinho; tente de novo em instantes.', 'group.link_unknown': "Este link aponta para um grupo do qual você não é membro, ou que não tem mais esse nome.", 'group.link_path_unknown': "Não há nada em {path} neste grupo — o arquivo pode ter sido movido, renomeado ou excluído.", + 'link.copy': "Copiar link", + 'link.copied': "Link copiado", + 'link.copy_failed': "Não foi possível copiar — o link é {url}", 'group.download_write_stalled': 'O arquivo parou de ser gravado no disco ({seconds}s sem progresso). O download foi interrompido em vez de ficar travado; tente de novo.', 'device.add_title': 'This browser is not linked to this node yet', 'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.', 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 d0d3954..06f61ff 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 @@ -208,6 +208,9 @@ export default { 'group.download_offline': '未连接到节点 — 无法开始下载。连接会自动恢复,请稍后重试。', 'group.link_unknown': "此链接指向一个您不是其成员的群组,或该群组已不再使用此名称。", 'group.link_path_unknown': "此群组中 {path} 处没有内容——文件可能已被移动、重命名或删除。", + 'link.copy': "复制链接", + 'link.copied': "链接已复制", + 'link.copy_failed': "无法复制——链接为 {url}", 'group.download_write_stalled': '文件停止写入磁盘({seconds} 秒无进展)。已中止下载而不是让它一直卡住,请重试。', 'device.add_title': 'This browser is not linked to this node yet', 'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/music-app.js b/packages/meshbay-hub/src/meshbay_hub/static/music-app.js index f2c4374..9adb1e1 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/music-app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/music-app.js @@ -11,6 +11,7 @@ import { Menu, MenuDots, useMenu } from './menu.js'; import { PlaylistMenuButton, NameModal, usePlaylists } from './playlist-menu.js'; import * as P from './playlists.js'; import { CastButton } from './cast-session.js'; +import { copyLink } from './copy-link.js'; // -- Music -------------------------------------------------------------------- // @@ -564,7 +565,7 @@ function FlatList({ items, onPlayQueue, onMenu }) { function MusicApp({ groupId, transportRef, gekRef, status, entries, availableEntries, musicDirectories, musicbrainzConfig, onPlayQueue, userId, - hideFilter, userPrefs, pageResetKey, + hideFilter, userPrefs, pageResetKey, linkFor, }) { const [mode, setMode] = useState(loadViewMode); const [filter, setFilter] = useState(''); @@ -618,6 +619,9 @@ function MusicApp({ // argument slot for one. const onMenu = useCallback((e, tracks, startIndex) => { if (!tracks || !tracks.length) return; + // One track's menu — a row's, whose dots are how a phone opens it. An + // album's carries all its tracks and gets no link: Files has the folder's. + const link = tracks.length === 1 && linkFor ? linkFor(tracks[0]) : null; openAt(e, [ { label: t('music.menu_play'), icon: 'play', onSelect: () => onPlayQueue(tracks, startIndex || 0) }, @@ -650,8 +654,10 @@ function MusicApp({ }, ], }, + ...(link ? [{ divider: true }, + { label: t('link.copy'), icon: 'link', onSelect: () => copyLink(link) }] : []), ]); - }, [openAt, onPlayQueue, playlists, addToPlaylist]); + }, [openAt, onPlayQueue, playlists, addToPlaylist, linkFor]); useEffect(() => { setMode(loadViewMode()); }, [groupId]); useEffect(() => { setFilter(''); }, [groupId]); diff --git a/packages/meshbay-hub/src/meshbay_hub/static/photos-app.js b/packages/meshbay-hub/src/meshbay_hub/static/photos-app.js index 00bc288..afc815a 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/photos-app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/photos-app.js @@ -10,6 +10,8 @@ import { transfers } from './transfers.js'; import { MediaThumb, LazyTile } from './media-tiles.js'; import { SourceTag } from './group-name.js'; import { CastButton, castSession, useCastDevice, showPhoto } from './cast-session.js'; +import { Menu, useMenu } from './menu.js'; +import { copyLink } from './copy-link.js'; // ── Photos ─────────────────────────────────────────────────────────────────── // @@ -118,11 +120,11 @@ function AlbumLanding({ albums, transportRef, gekRef, onOpen }) { // ── open album: grid of its own photos ────────────────────────────────────── -function PhotoTile({ entry, transportRef, gekRef, onOpen }) { +function PhotoTile({ entry, transportRef, gekRef, onOpen, onMenu }) { const tRef = entry._tRef || transportRef; const gRef = entry._gRef || gekRef; return html` - <div class="photo-tile" onClick=${onOpen}> + <div class="photo-tile" onClick=${onOpen} onContextMenu=${onMenu}> <${MediaThumb} thumbHash=${entry.thumb_hash} alt=${entry.name} cls="photo-tile-thumb" transportRef=${tRef} gekRef=${gRef} emptyIcon="image" /> @@ -153,7 +155,7 @@ const ZOOM_MAX = 400; // a slow node must not skip a photo nobody saw. const SLIDESHOW_MS = 5000; -function Lightbox({ photos, index, transportRef, gekRef, onClose, onNav }) { +function Lightbox({ photos, index, transportRef, gekRef, onClose, onNav, linkFor }) { const entry = photos[index]; const tRef = entry._tRef || transportRef; const gRef = entry._gRef || gekRef; @@ -284,6 +286,10 @@ function Lightbox({ photos, index, transportRef, gekRef, onClose, onNav }) { aria-pressed=${slideshow ? 'true' : 'false'}> <${Icon} name=${slideshow ? 'pause' : 'play'} /></button> <${CastButton} variant="overlay" cls="video-close" /> + ${linkFor && linkFor(entry) && html` + <button class="video-close" onClick=${() => copyLink(linkFor(entry))} + title=${t('link.copy')} aria-label=${t('link.copy')}> + <${Icon} name="link" /></button>`} <button class="video-close" onClick=${onClose} title=${t('video.close')}> <${Icon} name="close" /></button> </div> @@ -311,8 +317,16 @@ function Lightbox({ photos, index, transportRef, gekRef, onClose, onNav }) { `; } -function AlbumView({ album, entries, transportRef, gekRef, setError, onBack, readOnly }) { +function AlbumView({ album, entries, transportRef, gekRef, setError, onBack, readOnly, linkFor }) { const [lightboxIndex, setLightboxIndex] = useState(null); + // Right-click a photo for its link. A tile has no dots to press, so on a + // touchscreen the link is in the lightbox's bar instead. + const { menu, openAt, close: closeMenu } = useMenu(); + const onTileMenu = (e, entry) => { + const link = linkFor ? linkFor(entry) : null; + if (!link) return; + openAt(e, [{ label: t('link.copy'), icon: 'link', onSelect: () => copyLink(link) }]); + }; const zip = useCallback(async () => { const transport = transportRef.current; @@ -352,15 +366,16 @@ function AlbumView({ album, entries, transportRef, gekRef, setError, onBack, rea ${album.photos.map((e, i) => html` <${LazyTile} key=${e.id} cls="photo-tile-slot"> <${PhotoTile} entry=${e} transportRef=${transportRef} gekRef=${gekRef} - onOpen=${() => setLightboxIndex(i)} /> + onOpen=${() => setLightboxIndex(i)} onMenu=${(ev) => onTileMenu(ev, e)} /> </${LazyTile}> `)} </div> ${lightboxIndex !== null && html` <${Lightbox} photos=${album.photos} index=${lightboxIndex} transportRef=${transportRef} gekRef=${gekRef} - onClose=${() => setLightboxIndex(null)} onNav=${navigate} /> + onClose=${() => setLightboxIndex(null)} onNav=${navigate} linkFor=${linkFor} /> `} + ${menu && html`<${Menu} ...${menu} onClose=${closeMenu} />`} `; } @@ -369,7 +384,7 @@ function AlbumView({ album, entries, transportRef, gekRef, setError, onBack, rea function PhotosApp({ groupId, transportRef, gekRef, status, entries, availableEntries, photoDirectories, setError, - hideFilter, readOnly, + hideFilter, readOnly, linkFor, }) { const [openDir, setOpenDir] = useState(null); const [filter, setFilter] = useState(''); @@ -423,7 +438,7 @@ function PhotosApp({ ${status === 'connected' && openAlbum && html` <${AlbumView} album=${openAlbum} entries=${entries} transportRef=${transportRef} gekRef=${gekRef} setError=${setError} - onBack=${() => setOpenDir(null)} readOnly=${readOnly} /> + onBack=${() => setOpenDir(null)} readOnly=${readOnly} linkFor=${linkFor} /> `} `; } diff --git a/packages/meshbay-hub/src/meshbay_hub/static/search-page.js b/packages/meshbay-hub/src/meshbay_hub/static/search-page.js index 874b7b0..1ede1f6 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/search-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/search-page.js @@ -13,6 +13,7 @@ import { MusicApp, groupMusicEntries, foldKey } from './music-app.js'; import { PhotosApp, groupPhotoAlbums } from './photos-app.js'; import { VideoPlayer } from './video-player.js'; import { transfers } from './transfers.js'; +import { copyLink, entryPath, groupItemLink } from './copy-link.js'; import { mergeUnitEntries } from './source-merge.js'; import { useStickyBand } from './sticky.js'; import { ConnectionPool, MAX_IN_FLIGHT, MAX_POOL_SIZE } from './connection-pool.js'; @@ -514,6 +515,7 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs }) result.push({ ...e, path: SEARCH_VIDEO_ROOT + '/' + e.path, + _origPath: e.path, groupId, groupName: data.groupName, groupOwner: data.groupOwner, @@ -540,6 +542,7 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs }) result.push({ ...e, path: SEARCH_AUDIO_ROOT + '/' + e.path, + _origPath: e.path, groupId, groupName: data.groupName, groupOwner: data.groupOwner, @@ -567,6 +570,7 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs }) result.push({ ...e, path: '__search_photos__/' + e.path, + _origPath: e.path, groupId, groupName: data.groupName, groupOwner: data.groupOwner, @@ -640,6 +644,19 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs }) // No-op setters for FilesPanel const noop = useCallback(() => {}, []); + + // "Copy link" for a result: its own group's handle and its path there, from + // `_origPath` — every view here prefixes `path` to merge groups into one tree. + // A merged entry is the source it was resolved to (source-merge.js), so the + // link names a group that has the file. A folder is a path in that merged + // tree, named by group name alone, which two owners may share: no link. + const linkFor = useCallback((target) => { + if (!target || typeof target === 'string' || !target.groupName || !target.groupOwner) { + return null; + } + return groupItemLink({ name: target.groupName, owner_username: target.groupOwner }, + entryPath(target, target._origPath)); + }, []); // The search field and its view toggle are this page's equivalent of a // group's tab bar: the same band, pinned the same way, publishing the same // property for the toolbar underneath (style.css, "Sticky chrome"). @@ -726,6 +743,7 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs }) readOnly=${true} getTransport=${getTransport} showRefresh=${true} + linkFor=${linkFor} onRefreshIndex=${() => setRefreshTick((n) => n + 1)} /> `} @@ -754,7 +772,7 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs }) entries=${musicEntries} musicDirectories=${[SEARCH_AUDIO_ROOT]} musicbrainzConfig=${{ enabled: true }} - onPlayQueue=${handleMusicPlay} userId=${userId} + onPlayQueue=${handleMusicPlay} userId=${userId} linkFor=${linkFor} userPrefs=${userPrefs} pageResetKey=${q} hideFilter=${true} /> `} @@ -767,7 +785,7 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs }) status="connected" entries=${photoEntries} photoDirectories=${SEARCH_PHOTO_ROOTS} - setError=${noop} + setError=${noop} linkFor=${linkFor} hideFilter=${true} readOnly=${true} /> `} @@ -788,7 +806,8 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs }) transportRef=${modalTransportRef} gekRef=${modalGekRef} onClose=${() => setPreviewEntry(null)} - onDownload=${() => downloadForModal(previewEntry)} /> + onDownload=${() => downloadForModal(previewEntry)} + onCopyLink=${linkFor(previewEntry) && (() => copyLink(linkFor(previewEntry)))} /> `} ${videoEntry && html` <${VideoPlayer} @@ -796,7 +815,8 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs }) transportRef=${modalTransportRef} gekRef=${modalGekRef} onClose=${() => setVideoEntry(null)} - onDownload=${() => downloadForModal(videoEntry)} /> + onDownload=${() => downloadForModal(videoEntry)} + onCopyLink=${linkFor(videoEntry) && (() => copyLink(linkFor(videoEntry)))} /> `} </div> `; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css index 9383043..bde1c61 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/style.css +++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css @@ -5613,6 +5613,14 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; } overflow: hidden; text-overflow: ellipsis; } +/* The same note for "Copy link" (copy-link.js). When the clipboard refused, it + carries the link itself, which must be readable whole rather than cut. */ +.copy-link-note.playlist-note { + white-space: normal; + overflow-wrap: anywhere; + border-radius: 12px; + text-align: center; +} /* A run of single-album artists, pooled into one grid so five of them fill one row instead of spending five (music-app.js `albumSections`). diff --git a/packages/meshbay-hub/src/meshbay_hub/static/video-player.js b/packages/meshbay-hub/src/meshbay_hub/static/video-player.js index 37ea727..21abe8e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/video-player.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/video-player.js @@ -403,7 +403,7 @@ function purgeUnscopedResumePositions() { purgeUnscopedResumePositions(); -function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { +function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload, onCopyLink }) { const [dlBusy, setDlBusy] = useState(false); const [phase, setPhase] = useState('loading'); const [error, setError] = useState(''); @@ -1957,6 +1957,11 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { } </span> `} + ${onCopyLink && html` + <button class="video-close" onClick=${onCopyLink} + title="${t('link.copy')}" aria-label="${t('link.copy')}"> + <${Icon} name="link" /></button> + `} ${onDownload && html` <button class="video-close ${dlBusy ? 'dl-active' : ''}" disabled=${dlBusy} onClick=${() => { |