aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/copy-link.js50
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/files-app.js18
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-page.js17
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/icon.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/music-app.js10
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/note.js24
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/photos-app.js31
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/search-page.js47
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css12
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/video-player.js7
20 files changed, 217 insertions, 31 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..4cc3f30
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/copy-link.js
@@ -0,0 +1,50 @@
+import { t } from './i18n.js';
+import * as platform from './platform.js';
+import { groupLinkRoute } from './group-link.js';
+import { say } from './note.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;
+}
+
+/** 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/note.js b/packages/meshbay-hub/src/meshbay_hub/static/note.js
new file mode 100644
index 0000000..f55c307
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/note.js
@@ -0,0 +1,24 @@
+/**
+ * A line said once and gone, in the playlists' note (style.css
+ * `.playlist-note`): "Link copied", "2 groups unreachable". Not a dialog —
+ * nothing here is anyone's to dismiss.
+ *
+ * Appended to the body rather than rendered by a view, because the views that
+ * speak include modals and a context menu that has already closed. One note
+ * at a time: a second replaces the first and restarts the clock.
+ */
+
+let _note = null;
+let _timer = null;
+
+export function say(text, ms = 2500) {
+ if (!_note) {
+ _note = document.createElement('div');
+ _note.className = 'playlist-note page-note';
+ _note.setAttribute('role', 'status');
+ }
+ _note.textContent = text;
+ document.body.appendChild(_note);
+ clearTimeout(_timer);
+ _timer = setTimeout(() => { if (_note) _note.remove(); }, ms);
+}
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..ae8eac7 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,8 @@ 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 { say } from './note.js';
import { mergeUnitEntries } from './source-merge.js';
import { useStickyBand } from './sticky.js';
import { ConnectionPool, MAX_IN_FLIGHT, MAX_POOL_SIZE } from './connection-pool.js';
@@ -21,6 +23,8 @@ const DEBOUNCE_MS = 200;
const SEARCH_VIDEO_ROOT = '__search__';
const SEARCH_AUDIO_ROOT = '__search__';
const SEARCH_PHOTO_ROOTS = ['__search_photos__'];
+// How long "N groups unreachable" stays up once a search pass is over.
+const UNREACHABLE_NOTE_MS = 5000;
// -- Index fetching -----------------------------------------------------------
@@ -312,13 +316,20 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs })
setProgress({ done: 0, total: groups.length, unreachable: [] });
if (!poolRef.current) return;
- await fetchAllIndexes(
+ const { unreachable } = await fetchAllIndexes(
poolRef.current, groups, token, username, userId,
(p) => { if (!cancelled) setProgress(p); },
(results) => { if (!cancelled) setIndexedGroups(new Map(results)); },
);
- if (!cancelled) setFetching(false);
+ if (cancelled) return;
+ setFetching(false);
+ // Said once the pass is over, for a few seconds, rather than left above
+ // the results: by then nothing more can be done about it here, and a
+ // line that stays is one more thing between the reader and the results.
+ if (unreachable.length) {
+ say(t('search.unreachable', { n: unreachable.length }), UNREACHABLE_NOTE_MS);
+ }
})();
return () => { cancelled = true; };
@@ -514,6 +525,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 +552,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 +580,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 +654,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").
@@ -698,11 +725,6 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs })
</div>
`}
- ${progress.unreachable.length > 0 && html`
- <p class="search-unreachable">
- ${t('search.unreachable', { n: progress.unreachable.length })}
- </p>
- `}
${viewMode === 'files' && hasResults && html`
<${FilesPanel}
@@ -726,6 +748,7 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs })
readOnly=${true}
getTransport=${getTransport}
showRefresh=${true}
+ linkFor=${linkFor}
onRefreshIndex=${() => setRefreshTick((n) => n + 1)} />
`}
@@ -754,7 +777,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 +790,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 +811,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 +820,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..21d92df 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/style.css
+++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css
@@ -156,10 +156,6 @@ a:hover { text-decoration: underline; }
height: 100%; background: var(--accent);
transition: width 0.3s;
}
-.search-unreachable {
- font-size: 0.8em; color: var(--text-dim); margin-bottom: 8px;
-}
-
.view-toggle {
display: flex; gap: 0; flex-shrink: 0; margin-left: auto;
}
@@ -5613,6 +5609,14 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; }
overflow: hidden;
text-overflow: ellipsis;
}
+/* The same note, said from anywhere (note.js). It can carry a link the
+ clipboard refused, which must be readable whole rather than cut. */
+.page-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=${() => {