aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-07 10:35:09 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-07 10:35:09 +0200
commit2c0903c648e24b4e2adf20492398e8b67d033b49 (patch)
tree0435f298010f0f946362f28baebbe88337ca8768 /packages/meshbay-hub
parent0ed078c92cabab1dab0f70f321562032ea549ce6 (diff)
parenteeda274d751c537f4ecef3087994a16a9517478f (diff)
downloadmeshbay-2c0903c648e24b4e2adf20492398e8b67d033b49.tar.gz
Merge branch 'refactor/groups-phase1'
Groups refactor, phases 1-3. The root model replaces the old `upload` flag and group-wide `member_upload` with per-root `writable`/`removable`/`ejected`, carried by a `RootSet` that both front doors — the loopback API and signed MNP — reach through the same `ops` functions. MNP goes to 1.1, additively: the roots table now rides on `index_delta`, so a root added, removed, ejected or plugged reaches every connected client instead of only whoever reloaded. The group UI becomes a plugin architecture: an application is a registry entry in `apps.js` plus its own files, with directories stored generically by `ops.set_app_directories` under whatever the app is called. A reference application, hidden behind `?dev=1`, is what makes that claim testable — adding it is what found the two places still naming apps by hand. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
Diffstat (limited to 'packages/meshbay-hub')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/webapp.py12
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/apps.js95
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/chat-app-settings.js67
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/chat-app.js21
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js110
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/files-app.js134
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/folder-tree.js298
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-page.js199
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-settings.js1183
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/helloworld-app-settings.js50
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/helloworld-app.js62
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js61
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js63
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js61
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js63
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js61
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js61
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js61
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js61
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js61
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js61
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/music-app-settings.js60
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/music-app.js23
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/node-page.js16
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/photos-app-settings.js44
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/photos-app.js11
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/search-page.js62
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/settings-ui.js92
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css231
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js301
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/video-app-settings.js131
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/video-app.js25
-rw-r--r--packages/meshbay-hub/tests/test_app_settings_plugin.py383
-rw-r--r--packages/meshbay-hub/tests/test_css_variables.py78
-rw-r--r--packages/meshbay-hub/tests/test_helloworld_proves_the_plugin_claim.py159
-rw-r--r--packages/meshbay-hub/tests/test_hook_ordering.py7
-rw-r--r--packages/meshbay-hub/tests/test_mnp_1_0_node_compat.py175
-rw-r--r--packages/meshbay-hub/tests/test_no_prompt_in_the_spa.py90
-rw-r--r--packages/meshbay-hub/tests/test_search_media_merge.py12
-rw-r--r--packages/meshbay-hub/tests/test_spa_imports.py87
-rw-r--r--packages/meshbay-hub/tests/test_spa_syntax.py85
-rw-r--r--packages/meshbay-hub/tests/test_transfers.py14
-rw-r--r--packages/meshbay-hub/tests/test_transport_contracts.py10
-rw-r--r--packages/meshbay-hub/tests/test_upload_controls_hidden.py263
44 files changed, 4155 insertions, 1049 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py
index a1807ea..3cfb208 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py
@@ -38,6 +38,18 @@ _ASSETS = ("style.css", "keyderive.js", "crypto.js", "transport.js", "app.js",
"chat-app.js", "files-app.js", "video-player.js", "video-app.js",
"music-app.js", "music-player.js", "photos-app.js",
"group-settings.js", "group-page.js",
+ # The per-app settings architecture (docs/refactor-groups.md §3):
+ # the shared widgets, the folder picker, and one settings pane per
+ # app. Reached through the `apps.js` registry rather than imported
+ # by name anywhere, which is exactly why they have to be listed —
+ # nothing else would notice one of them changing.
+ "settings-ui.js", "folder-tree.js",
+ "chat-app-settings.js", "video-app-settings.js",
+ "music-app-settings.js", "photos-app-settings.js",
+ # The reference app (docs/refactor-groups.md §4.1). Hidden behind
+ # `?dev=1` client-side, but it is still served and still cached, so
+ # it participates in the hash like anything else here.
+ "helloworld-app.js", "helloworld-app-settings.js",
# Pages extracted from app.js — statically imported or lazy-loaded,
# but all must participate in the content hash.
"auth-page.js", "explore-page.js", "create-group-page.js",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/apps.js b/packages/meshbay-hub/src/meshbay_hub/static/apps.js
index 47b5bba..461bd57 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/apps.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/apps.js
@@ -3,32 +3,97 @@ import { FilesPanel } from './files-app.js';
import { VideoApp } from './video-app.js';
import { MusicApp } from './music-app.js';
import { PhotosApp } from './photos-app.js';
+import { ChatSettings } from './chat-app-settings.js';
+import { VideoSettings } from './video-app-settings.js';
+import { MusicSettings } from './music-app-settings.js';
+import { PhotoSettings } from './photos-app-settings.js';
+import { HelloWorldApp } from './helloworld-app.js';
+import { HelloWorldSettings } from './helloworld-app-settings.js';
+
+/**
+ * Whether apps marked `dev` are shown. Opt in once with `?dev=1`, off with
+ * `?dev=0` — persisted, the same shape as transport.js's `?trace=1`.
+ *
+ * HelloWorld exists to prove that adding an application is a registry entry
+ * and two files. Registering it normally would put a toy app in every
+ * operator's group; leaving it unregistered would prove nothing, since the
+ * claim is precisely that registration is enough. So it is registered, and
+ * hidden behind a flag that changes nothing else.
+ */
+const DEV_KEY = 'mb_dev_apps';
+(function _initDevFlag() {
+ try {
+ const params = new URLSearchParams(location.search);
+ if (params.has('dev')) {
+ if (params.get('dev') === '0') localStorage.removeItem(DEV_KEY);
+ else localStorage.setItem(DEV_KEY, '1');
+ }
+ } catch { /* localStorage unavailable — dev apps stay hidden */ }
+})();
+
+function devAppsShown() {
+ try { return localStorage.getItem(DEV_KEY) === '1'; } catch { return false; }
+}
/**
* Every group "application", in tab order.
*
- * Adding one (Videos, Music, Photos — none of them need an MNP change, see
- * the node's indexer classifying video/audio/image already) means a new file
- * exporting a component and one entry here. Nothing in group-page.js changes:
- * every registered component receives the same shared context (see its
- * `commonProps`) and renders itself into the active tab.
+ * Adding one means a new file exporting a component, an optional second file
+ * exporting its settings pane, and one entry here. Nothing in `group-page.js`
+ * or `group-settings.js` changes: every registered component receives the same
+ * shared context (see `commonProps`) and renders itself into the active tab,
+ * and every registered `Settings` gets its own collapsible section with a
+ * toggle, rendered by a loop that names no app.
*
- * `key` doubles as the identifier the node's `apps_enabled` setting uses, so
- * it must match `ALLOWED_APPS` in the node's webrtc_server.py.
+ * `key` doubles as the identifier the node's `apps_enabled` setting and its
+ * `app_directories` op use, so it must match `ALLOWED_APPS` in the node's
+ * webrtc_server.py. It is also the key an app's directories are stored under
+ * (`<key>_directories`) — one identifier per app, everywhere.
+ *
+ * Fields:
+ * key the identifier, shared with the node
+ * icon, labelKey the tab
+ * Component the app itself
+ * Settings its operator settings pane, if it has any (optional)
+ * alwaysEnabled cannot be turned off, and is not offered as a toggle
*/
const APPS = [
- { key: 'chat', icon: 'chat', labelKey: 'group.tab_chat', Component: ChatPanel },
- { key: 'files', icon: 'folder', labelKey: 'group.tab_files', Component: FilesPanel },
- { key: 'video', icon: 'video', labelKey: 'group.tab_video', Component: VideoApp },
- { key: 'music', icon: 'music', labelKey: 'group.tab_music', Component: MusicApp },
- { key: 'photo', icon: 'image', labelKey: 'group.tab_photos', Component: PhotosApp },
+ { key: 'chat', icon: 'chat', labelKey: 'group.tab_chat',
+ Component: ChatPanel, Settings: ChatSettings },
+ // Files has no settings of its own: it works over every shared directory by
+ // definition, which is what the shared-directories table already configures.
+ { key: 'files', icon: 'folder', labelKey: 'group.tab_files',
+ Component: FilesPanel, alwaysEnabled: true },
+ { key: 'video', icon: 'video', labelKey: 'group.tab_video',
+ Component: VideoApp, Settings: VideoSettings },
+ { key: 'music', icon: 'music', labelKey: 'group.tab_music',
+ Component: MusicApp, Settings: MusicSettings },
+ { key: 'photo', icon: 'image', labelKey: 'group.tab_photos',
+ Component: PhotosApp, Settings: PhotoSettings },
+ // The reference implementation (docs/refactor-groups.md §4.1). `dev` keeps
+ // it out of an operator's way; everything else about it is an ordinary
+ // entry, which is the point.
+ { key: 'helloworld', icon: 'chat', labelKey: 'group.tab_helloworld',
+ Component: HelloWorldApp, Settings: HelloWorldSettings, dev: true },
];
+/** Everything a reader of this page is allowed to see, in registry order. */
+function availableApps() {
+ const dev = devAppsShown();
+ return APPS.filter(a => dev || !a.dev);
+}
+
/** The registry filtered to what this group has enabled, in registry order. */
function visibleApps(enabledKeys) {
+ const registered = availableApps();
const enabled = new Set(
- enabledKeys && enabledKeys.length ? enabledKeys : APPS.map(a => a.key));
- return APPS.filter(a => enabled.has(a.key));
+ enabledKeys && enabledKeys.length ? enabledKeys : registered.map(a => a.key));
+ return registered.filter(a => a.alwaysEnabled || enabled.has(a.key));
+}
+
+/** The apps the Settings page offers a section for, in registry order. */
+function configurableApps() {
+ return availableApps().filter(a => !a.alwaysEnabled && a.Settings);
}
-export { APPS, visibleApps };
+export { APPS, availableApps, visibleApps, configurableApps };
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/chat-app-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/chat-app-settings.js
new file mode 100644
index 0000000..96fc52f
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/chat-app-settings.js
@@ -0,0 +1,67 @@
+import { html, useState, useEffect } from './vendor/htm-preact.js';
+import { t } from './i18n.js';
+import { ToggleSwitch, useSaver } from './settings-ui.js';
+import { FolderPickerField } from './folder-tree.js';
+
+/**
+ * Chat's operator settings.
+ *
+ * Every app's settings pane takes the same props (see `apps.js`): the group's
+ * roots and known folders, the node's current answers, a `saveDirectories`
+ * bound to this app, and the transport for anything the app alone needs. It
+ * owns its drafts and its own busy state, and the page renders it without
+ * naming it.
+ *
+ * The directory here is unlike every other app's. Videos, Music and Photos
+ * point at folders they *read*; this is where attachments get *written*, so it
+ * has to be on a read-write root. The picker greys out the rest rather than
+ * letting the node's refusal arrive after the fact.
+ */
+function ChatSettings({ roots, dirs, settings, saveDirectories, transport, signFn }) {
+ const { busy, msg, run } = useSaver();
+ const [directory, setDirectory] = useState(settings.chatDirectory || '');
+ const [linkPreview, setLinkPreview] = useState(settings.chatLinkPreview !== false);
+
+ // Re-seeded from the node's answer: another operator may be editing the
+ // same group, and their change arrives here as a prop.
+ useEffect(() => { setDirectory(settings.chatDirectory || ''); },
+ [settings.chatDirectory]);
+ useEffect(() => { setLinkPreview(settings.chatLinkPreview !== false); },
+ [settings.chatLinkPreview]);
+
+ const noWritable = !(roots || []).some((r) => r.writable);
+ const dirty = directory !== (settings.chatDirectory || '');
+
+ return html`
+ <div class="app-settings">
+ ${noWritable && html`
+ <p class="settings-hint">${t('settings_app.chat_no_writable_root')}</p>`}
+
+ <${FolderPickerField}
+ label=${t('settings_app.chat_directory_label')}
+ hint=${t('settings_app.chat_directory_hint')}
+ roots=${roots} dirs=${dirs}
+ mode="single" requireWritable=${true}
+ value=${directory} disabled=${busy || noWritable}
+ onChange=${setDirectory} />
+
+ <button class="app-save" disabled=${busy || !dirty}
+ onClick=${() => run(() => transport.setChatDirectory(directory, signFn))}>
+ ${busy ? t('settings_app.saving') : t('settings_app.save')}
+ </button>
+
+ <div class="settings-row" style="margin-top:12px">
+ <${ToggleSwitch} checked=${linkPreview} disabled=${busy}
+ onChange=${(v) => {
+ setLinkPreview(v);
+ run(() => transport.setChatLinkPreview(v, signFn));
+ }}
+ label=${t('settings_app.chat_link_preview_label')} />
+ <p class="settings-hint">${t('settings_app.chat_link_preview_hint')}</p>
+ </div>
+ ${msg && html`<p class="settings-hint">${msg}</p>`}
+ </div>
+ `;
+}
+
+export { ChatSettings };
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js b/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js
index 3bc110f..0a7ef26 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js
@@ -198,8 +198,14 @@ function ChatImage({ filename, entries, transportRef, gekRef }) {
return html`<img class="chat-att-thumb" src=${blobUrl} alt=${filename} />`;
}
+// `attachRoot` is the shared directory attachments are written to: the name of
+// the first writable, available root, decided in group-page.js so Files and Chat
+// read one answer. Empty means the group has no writable root right now — every
+// root is read-only, or the one drive that was writable is unplugged — and the
+// paperclip says so rather than producing a refusal from the node.
function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex,
- onPreview, mayUpload = true, onActivity, status }) {
+ onPreview, attachRoot = '', attachDir = '',
+ onActivity, status }) {
const [messages, setMessages] = useState([]);
const [hasMore, setHasMore] = useState(false);
const [loadingOlder, setLoadingOlder] = useState(false);
@@ -479,7 +485,10 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex,
try {
// Two people sending IMG_1234.jpg both succeed; the node picks a free name
// and the message has to point at the one it chose.
- const ack = await transport.uploadFile(file);
+ // `attachDir` is the folder the operator chose in Settings; `attachRoot`
+ // is the fallback for a group where they have not chosen one yet.
+ const ack = await transport.uploadFile(
+ file, { root: attachRoot, dir: attachDir || undefined });
const storedAs = (ack && ack.stored_as) || file.name;
await new Promise(r => setTimeout(r, 2500));
if (onRefreshIndex) await onRefreshIndex();
@@ -501,7 +510,7 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex,
} finally {
setAttaching(false);
}
- }, [username, onRefreshIndex, jumpToBottom]);
+ }, [username, onRefreshIndex, jumpToBottom, attachRoot, attachDir]);
const onKeyDown = useCallback((e) => {
if (e.key === 'Enter' && !e.shiftKey) {
@@ -592,12 +601,16 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex,
</button>
`}
<div class="chat-input-row">
- ${mayUpload && html`
+ ${attachRoot ? html`
<label class="chat-attach" title="${t('chat.attach')}">
${attaching ? html`<span class="spinner"></span>`
: html`<${Icon} name="clip" />`}
<input type="file" style="display:none" onChange=${attachFile} disabled=${attaching} />
</label>
+ ` : html`
+ <span class="chat-attach chat-attach-off" title="${t('chat.attach_read_only')}">
+ <${Icon} name="clip" />
+ </span>
`}
<textarea class="chat-input" rows="1" ref=${inputRef}
placeholder="${t('chat.placeholder')}"
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js
index d86521f..c3b542f 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js
@@ -5,7 +5,7 @@ import { t } from './i18n.js';
import { HUB, hubFetch, session, navigate } from './hub-client.js';
import * as platform from './platform.js';
import { Icon } from './icon.js';
-import { APPS } from './apps.js';
+import { SharedDirectoriesTable } from './group-settings.js';
export function CreateGroupPage(props) {
if (platform.node.available) return html`<${CreateGroupWizard} ...${props} />`;
@@ -109,13 +109,6 @@ function CreateGroupWizard({ token, username, onCreated, allowPublicGroups = tru
const [description, setDescription] = useState('');
const [joinPolicy, setJoinPolicy] = useState('invite');
const [roots, setRoots] = useState([]);
- const [uploadIdx, setUploadIdx] = useState(0);
- const [enabledApps, setEnabledApps] = useState(() => APPS.map(a => a.key));
- const toggleWizardApp = useCallback((key) => {
- setEnabledApps(prev => prev.includes(key)
- ? prev.filter(k => k !== key)
- : [...prev, key]);
- }, []);
const [setupSteps, setSetupSteps] = useState([]);
const [setupError, setSetupError] = useState('');
@@ -166,20 +159,9 @@ function CreateGroupWizard({ token, username, onCreated, allowPublicGroups = tru
useEffect(() => { detectNode(); }, [detectNode]);
- const addRoot = useCallback(async () => {
- const chosen = await platform.rootPicker.choose();
- if (!chosen) return;
- if (roots.some(r => r.path === chosen.path)) return;
- setRoots(prev => [...prev, chosen]);
- }, [roots]);
-
- const removeRoot = useCallback((idx) => {
- setRoots(prev => {
- const next = prev.filter((_, i) => i !== idx);
- if (uploadIdx >= next.length && next.length > 0) setUploadIdx(0);
- return next;
- });
- }, [uploadIdx]);
+ const handleLocalRootsChange = useCallback((newRoots) => {
+ setRoots(newRoots);
+ }, []);
const runSetup = useCallback(async () => {
setStep(2);
@@ -189,7 +171,6 @@ function CreateGroupWizard({ token, username, onCreated, allowPublicGroups = tru
{ label: t('wizard.step_attach'), status: 'pending' },
];
steps.push({ label: t('wizard.step_index'), status: 'pending' });
- steps.push({ label: t('wizard.step_apps'), status: 'pending' });
if (roots.length > 1)
steps.push({ label: t('wizard.step_add_roots'), status: 'pending' });
steps.push({ label: t('wizard.step_gek'), status: 'pending' });
@@ -231,11 +212,16 @@ function CreateGroupWizard({ token, username, onCreated, allowPublicGroups = tru
// 2. Attach to node with first root
update('running');
- const mainRoot = roots[uploadIdx] || roots[0];
- const attachBody = { name: name.trim(), shared_dir: mainRoot.path };
- if (roots.length === 1 || uploadIdx === 0) {
- attachBody.upload_dir = mainRoot.path;
- }
+ // The first root is attached with the group, so its RW switch has to
+ // travel with it — writing it and then correcting it afterwards would
+ // leave a window where a group the operator marked read-only accepts
+ // uploads.
+ const mainRoot = roots[0];
+ const attachBody = {
+ name: name.trim(),
+ shared_dir: mainRoot.path,
+ writable: mainRoot.writable !== false,
+ };
await platform.node.call('POST', '/api/groups/attach', attachBody);
await platform.node.call('POST', '/api/reload');
update('done');
@@ -247,22 +233,13 @@ function CreateGroupWizard({ token, username, onCreated, allowPublicGroups = tru
update('done');
advance();
- // 4. Set enabled apps
- update('running');
- await withRetry(() => platform.node.call(
- 'PUT', `/api/groups/${gid}/apps`, { apps: enabledApps }));
- update('done');
- advance();
-
- // 5. Add extra roots (if >1)
+ // 4. Add extra roots (if >1)
if (roots.length > 1) {
update('running');
- for (let i = 0; i < roots.length; i++) {
- if (i === (uploadIdx < roots.length ? uploadIdx : 0)) continue;
+ for (let i = 1; i < roots.length; i++) {
const r = roots[i];
await withRetry(() => platform.node.call('POST', `/api/groups/${gid}/roots`, {
- path: r.path, name: r.name,
- upload: i === uploadIdx,
+ path: r.path, name: r.name, writable: !!r.writable, removable: !!r.removable,
}));
}
await platform.waitForRootsIndexed(gid, setIndexProgress);
@@ -270,13 +247,13 @@ function CreateGroupWizard({ token, username, onCreated, allowPublicGroups = tru
advance();
}
- // 6. GEK init
+ // 5. GEK init
update('running');
await withRetry(() => platform.node.call('POST', `/api/groups/${gid}/gek`));
update('done');
advance();
- // 7. Generate pairing code
+ // 6. Generate pairing code
update('running');
const pairResult = await platform.node.call('POST', '/api/operator/pair');
if (pairResult && pairResult.code) {
@@ -293,7 +270,7 @@ function CreateGroupWizard({ token, username, onCreated, allowPublicGroups = tru
update('error');
setSetupError(platform.bridgeMessage(err));
}
- }, [name, description, joinPolicy, roots, uploadIdx, enabledApps, token, onCreated]);
+ }, [name, description, joinPolicy, roots, token, onCreated]);
// Step 0: Node detection
if (step === 0) {
@@ -349,7 +326,7 @@ function CreateGroupWizard({ token, username, onCreated, allowPublicGroups = tru
</div>`;
}
if (step === 1) {
- const canProceed = name.trim() && roots.length > 0 && enabledApps.length > 0;
+ const canProceed = name.trim() && roots.length > 0;
return html`<div class="page-content">
<h2>${t('wizard.title')}</h2>
${error && html`<div class="error-msg" style="margin-bottom:16px">${error}</div>`}
@@ -398,52 +375,11 @@ function CreateGroupWizard({ token, username, onCreated, allowPublicGroups = tru
`}
<div class="settings-section">
- <h3 class="settings-heading">${t('members.apps_title')}</h3>
- <p style="font-size:0.85em;color:var(--text-dim);margin-bottom:8px">
- ${t('members.apps_hint')}</p>
- <ul class="apps-toggle-list">
- ${APPS.map(a => html`
- <li key=${a.key} class="settings-row">
- <label class="settings-label">
- <input type="checkbox" checked=${enabledApps.includes(a.key)}
- onChange=${() => toggleWizardApp(a.key)} />
- ${' '}${t(a.labelKey)}
- </label>
- </li>
- `)}
- </ul>
- ${enabledApps.length === 0 && html`
- <p class="error-msg">${t('members.apps_need_one')}</p>`}
- </div>
-
- <div class="settings-section">
<h3 class="settings-heading">${t('wizard.directories')}</h3>
<p style="font-size:0.85em;color:var(--text-dim);margin-bottom:8px">
${t('wizard.directories_hint')}</p>
- ${roots.map((r, i) => html`
- <div class="wizard-root" key=${r.path}>
- <div class="wizard-root-info">
- <${Icon} name="folder" />
- <span class="wizard-root-name">${r.name}</span>
- <span class="wizard-root-path">${r.path}</span>
- ${i === uploadIdx && html`
- <span class="node-root-badge">${t('wizard.upload_target')}</span>`}
- </div>
- <div class="wizard-root-actions">
- ${roots.length > 1 && i !== uploadIdx && html`
- <button class="btn btn-small btn-secondary"
- onClick=${() => setUploadIdx(i)}>
- ${t('wizard.set_upload')}</button>`}
- <button class="btn btn-small btn-danger"
- onClick=${() => removeRoot(i)}>
- ${t('wizard.remove')}</button>
- </div>
- </div>
- `)}
- <button class="btn btn-secondary" style="margin-top:8px"
- onClick=${addRoot}>
- <${Icon} name="folder-plus" /> ${t('wizard.add_directory')}
- </button>
+ <${SharedDirectoriesTable} mode="local"
+ localRoots=${roots} onLocalRootsChange=${handleLocalRootsChange} />
</div>
<div style="display:flex;gap:8px;margin-top:16px">
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 195c385..4947f8c 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/files-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/files-app.js
@@ -26,7 +26,7 @@ import {
function FilesPanel({
groupId, transportRef, gekRef, status,
entries, nodeDirs, nodeRoots, setEntries, setNodeDirs, setNodeRoots, applyIndex,
- isNodeAdmin, operatorPaired, mayUpload, userId, setError, onPreview,
+ isNodeAdmin, operatorPaired, userId, setError, onPreview,
showGroup, readOnly, getTransport, onRefreshIndex, showRefresh,
}) {
const [selected, setSelected] = useState(() => new Set());
@@ -75,6 +75,12 @@ function FilesPanel({
e.target.value = '';
const transport = transportRef.current;
if (!files.length || !transport || !transport.connected) return;
+ // The folder on screen is the destination — not its root, and not a
+ // subdirectory of the node's invention. Somebody dropping a file into the
+ // folder they are looking at expects it to be in that folder.
+ const uploadDir = currentPath;
+ if (!uploadDir) return;
+ const uploadRoot = uploadDir.split('/')[0];
setError('');
for (const file of files) {
@@ -85,6 +91,8 @@ function FilesPanel({
// Bytes the node acknowledged, not bytes read locally.
onProgress: (sent) => onProgress(sent, file.size),
signal,
+ root: uploadRoot,
+ dir: uploadDir,
});
// The node re-indexes on a filesystem event, so there is nothing to
// wait on but the clock. Refreshing here means the file appears in
@@ -94,21 +102,49 @@ function FilesPanel({
},
});
}
- }, [applyIndex]);
+ }, [applyIndex, currentPath]);
+
+ // `null` while not creating one; a draft string while the field is open.
+ const [newDirName, setNewDirName] = useState(null);
+ const [creatingDir, setCreatingDir] = useState(false);
+
+ /**
+ * Create a folder in the directory being browsed.
+ *
+ * The name comes from a field in the toolbar rather than `window.prompt`,
+ * which **throws** in Electron — "prompt() is not supported" — and threw
+ * outside this function's try, so clicking the button did nothing at all:
+ * no folder, no error, nothing in the interface to react to. `confirm()` and
+ * `alert()` do work there and are used elsewhere; `prompt` is the one
+ * Chromium leaves to the embedder and Electron declines to implement.
+ *
+ * An inline field is better anyway — it can show the refusal next to the
+ * input instead of after the dialog has closed.
+ */
+ useEffect(() => { setNewDirName(null); }, [currentPath]);
- const makeDirectory = useCallback(async () => {
+ const makeDirectory = useCallback(async (rawName) => {
const transport = transportRef.current;
- if (!transport || !transport.connected) return;
- const name = prompt(t('group.mkdir_prompt'));
- if (!name || !name.trim()) return;
+ const name = (rawName || '').trim();
+ if (!name) return;
+ if (!transport || !transport.connected) {
+ setError(t('group.mkdir_offline'));
+ return;
+ }
+ setCreatingDir(true);
try {
- await transport.createDirectory(currentPath, name.trim());
+ await transport.createDirectory(currentPath, name);
+ // `list_dirs` walks the filesystem rather than the index, so a folder
+ // with nothing in it is here on this very fetch.
const indexMsg = await transport.fetchIndex();
if (indexMsg.entries) setEntries(indexMsg.entries);
if (indexMsg.dirs) setNodeDirs(indexMsg.dirs);
if (indexMsg.roots) setNodeRoots(indexMsg.roots);
+ setNewDirName(null);
} catch (err) {
setError(err.message);
+ } finally {
+ setCreatingDir(false);
}
}, [currentPath]);
@@ -212,11 +248,27 @@ function FilesPanel({
const unavailableHere = currentPath
? []
: subdirs.filter(d => rootState.get(d) && rootState.get(d).available === false);
+ const currentRootName = currentPath ? currentPath.split('/')[0] : '';
+ const currentRoot = currentRootName ? rootState.get(currentRootName) : null;
+ // `upload` is the same answer under the name a node speaking MNP 1.0 uses;
+ // reading only `writable` there means the Upload button disappears on every
+ // node that has not been upgraded yet, which is most of them on the day the
+ // page ships.
+ const currentRootWritable = currentRoot
+ ? (currentRoot.writable !== undefined ? currentRoot.writable
+ : Boolean(currentRoot.upload))
+ : false;
// A member cannot create a folder at the top of a group: that level is the
// set of roots, which is the operator's configuration and not a directory on
// anyone's disk. The node refuses it, so offering it would only produce an
// error nobody can act on.
- const canCreateDir = Boolean(currentPath) && isNodeAdmin;
+ //
+ // Otherwise the rule is the same as the Upload button's, and for the same
+ // reason the node gives: "making a directory is not a privileged act — a
+ // member who can add a file can organise where it goes". It used to require
+ // `isNodeAdmin`, which contradicted the node and hid the control from
+ // everyone who could actually use it.
+ const canCreateDir = Boolean(currentPath) && currentRootWritable && !readOnly;
const breadcrumbs = currentPath ? currentPath.split('/') : [];
@@ -338,13 +390,47 @@ function FilesPanel({
${status === 'connected' && html`
<div class="file-toolbar">
<div class="toolbar-group">
- ${mayUpload && html`
+ ${currentPath && currentRootWritable && html`
<label class="tb-btn primary">
<${Icon} name="upload" /> ${t('group.upload')}
<input type="file" multiple style="display:none"
onChange=${uploadFile} />
</label>
`}
+ ${/* Icon only: the toolbar already carries a labelled primary
+ action, and a second one beside it competes with it for the
+ width a breadcrumb trail needs. The name lives in the tooltip
+ and in aria-label, so it is not lost to anyone reading with
+ something other than their eyes. */''}
+ ${canCreateDir && newDirName === null && html`
+ <button class="tb-btn tb-btn-icon" onClick=${() => setNewDirName('')}
+ title=${t('group.mkdir')} aria-label=${t('group.mkdir')}>
+ <${Icon} name="folder-plus" />
+ </button>
+ `}
+ ${canCreateDir && newDirName !== null && html`
+ <span class="tb-mkdir">
+ <input type="text" class="tb-mkdir-input" autofocus
+ value=${newDirName} disabled=${creatingDir}
+ placeholder=${t('group.mkdir_prompt')}
+ aria-label=${t('group.mkdir')}
+ onInput=${(e) => setNewDirName(e.target.value)}
+ onKeyDown=${(e) => {
+ if (e.key === 'Enter') makeDirectory(newDirName);
+ if (e.key === 'Escape') setNewDirName(null);
+ }} />
+ <button class="tb-btn tb-btn-icon" title=${t('group.mkdir')}
+ disabled=${creatingDir || !newDirName.trim()}
+ onClick=${() => makeDirectory(newDirName)}>
+ <${Icon} name="check" />
+ </button>
+ <button class="tb-btn tb-btn-icon" title=${t('settings.cancel')}
+ disabled=${creatingDir}
+ onClick=${() => setNewDirName(null)}>
+ <${Icon} name="close" />
+ </button>
+ </span>
+ `}
</div>
<div class="breadcrumbs">
@@ -419,16 +505,40 @@ function FilesPanel({
const full = currentPath ? currentPath + '/' + d : d;
const inside = entriesUnder(entries, full);
const bytes = inside.reduce((n, f) => n + (f.entry.size || 0), 0);
+ const rs = rootState.get(d);
+ const isEjected = rs && rs.ejected;
+ const isUnavail = unavailableHere.includes(d);
+ const isRemovable = rs && rs.removable;
return html`
- <tr class="file-row dir-row" key=${full} onClick=${() => setCurrentPath(full)}>
+ <tr class="file-row dir-row${isEjected ? ' root-ejected' : ''}" key=${full}
+ onClick=${() => { if (!isEjected) setCurrentPath(full); }}>
<td class="sel-cell">
<input type="checkbox" checked=${selected.has(dirKey(d))}
onClick=${(ev) => ev.stopPropagation()}
onChange=${() => toggle(dirKey(d))} />
</td>
- <td>${unavailableHere.includes(d) ? '\u{26A0}' : '\u{1F4C1}'}</td>
- <td>${d}${unavailableHere.includes(d) ? html`
+ <td>${isEjected ? '\u{23CF}' : isUnavail ? '\u{26A0}' : '\u{1F4C1}'}</td>
+ <td>${d}${isEjected ? html`
+ <span class="root-offline"> ${t('group.root_ejected')}</span>
+ ` : isUnavail ? html`
<span class="root-offline"> ${t('group.root_unavailable')}</span>
+ ` : ''}${rs && rs.writable && !isEjected ? html`
+ <span class="root-rw" title="${t('group.root_writable')}" style="margin-left:8px;opacity:0.5;font-size:0.9em">✎</span>
+ ` : ''}${isRemovable && isNodeAdmin && operatorPaired ? html`
+ <button class="btn-small root-eject-btn" title=${isEjected ? t('group.root_plug') : t('group.root_eject')}
+ onClick=${(ev) => {
+ ev.stopPropagation();
+ const transport = transportRef.current;
+ if (!transport) return;
+ const sk = transport.sessionKeys && transport.sessionKeys.skEdB64;
+ const signFn = (sk && window.MeshBayKeys)
+ ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript)
+ : null;
+ const fn = isEjected
+ ? () => transport.plugRoot(groupId, d, signFn)
+ : () => transport.ejectRoot(groupId, d, signFn);
+ fn().catch((err) => setError(err.message));
+ }}>${isEjected ? '\u{1F50C}' : '\u{23CF}'}</button>
` : ''}</td>
<td class="file-size">${inside.length ? formatSize(bytes) : ''}</td>
${showGroup && html`<td></td>`}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/folder-tree.js b/packages/meshbay-hub/src/meshbay_hub/static/folder-tree.js
new file mode 100644
index 0000000..6e6dfad
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/folder-tree.js
@@ -0,0 +1,298 @@
+import {
+ html, useState, useEffect, useMemo, useCallback, useRef,
+} from './vendor/htm-preact.js';
+import { t } from './i18n.js';
+import { Icon } from './icon.js';
+
+/**
+ * A modal folder picker over a group's shared directories.
+ *
+ * It replaces the flat `<select>` of depth-indented paths every app settings
+ * pane used to carry. That control was defensible while an app picked one
+ * folder once; with several apps picking several folders each, a list of a few
+ * hundred `Media/Films/Action/1999` strings is not something anyone reads.
+ *
+ * **There is no folder-browsing protocol, and this does not add one.** The
+ * whole tree is derived from paths the client already holds — every entry's
+ * folder and every directory the index reports — so opening this asks the node
+ * nothing. That also means it shows exactly what the group's index contains:
+ * an empty folder the node never indexed is not in here, because as far as the
+ * group is concerned it does not exist.
+ *
+ * Props:
+ * roots — the group's roots ({ name, writable, removable, ejected,
+ * available }), for the badges and the writable rule
+ * dirs — every known directory path, `Media/Films` style
+ * mode — "single" (default) or "multi"
+ * requireWritable— grey out roots that do not accept writes, for a
+ * destination rather than a view (Chat's attachments)
+ * selected — current selection: a string in single mode, an array in
+ * multi
+ * onConfirm(sel) — called with the same shape on OK
+ * onCancel()
+ */
+function FolderTreePicker({
+ roots, dirs, mode = 'single', requireWritable = false,
+ selected, onConfirm, onCancel,
+}) {
+ const multi = mode === 'multi';
+ const initial = useMemo(() => {
+ if (multi) return new Set(selected || []);
+ return new Set(selected ? [selected] : []);
+ }, []); // eslint-disable-line -- the initial selection only, never a reset
+
+ const [picked, setPicked] = useState(initial);
+ const [expanded, setExpanded] = useState(() => new Set());
+ const panelRef = useRef(null);
+
+ // Escape closes, and the panel takes focus so it does — a modal that only
+ // responds to the mouse is one a keyboard user cannot leave.
+ useEffect(() => {
+ const onKey = (e) => { if (e.key === 'Escape') onCancel(); };
+ window.addEventListener('keydown', onKey);
+ if (panelRef.current) panelRef.current.focus();
+ return () => window.removeEventListener('keydown', onKey);
+ }, [onCancel]);
+
+ // Every ancestor of every known path, so a folder is reachable even when
+ // only something several levels below it was ever indexed.
+ const nodes = useMemo(() => {
+ const all = new Set();
+ for (const d of (dirs || [])) {
+ if (!d) continue;
+ const parts = d.split('/');
+ for (let i = 1; i <= parts.length; i++) all.add(parts.slice(0, i).join('/'));
+ }
+ // A root with nothing under it is still a choice: pointing an app at a
+ // library that has not been scanned yet is exactly what an operator does
+ // right after adding the directory.
+ for (const r of (roots || [])) all.add(r.name);
+ return all;
+ }, [dirs, roots]);
+
+ const childrenOf = useMemo(() => {
+ const map = new Map();
+ for (const path of nodes) {
+ const cut = path.lastIndexOf('/');
+ const parent = cut === -1 ? '' : path.slice(0, cut);
+ if (!map.has(parent)) map.set(parent, []);
+ map.get(parent).push(path);
+ }
+ for (const list of map.values()) {
+ list.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
+ }
+ return map;
+ }, [nodes]);
+
+ const rootByName = useMemo(
+ () => new Map((roots || []).map((r) => [r.name, r])), [roots]);
+
+ // A path's own root decides whether it can be picked: writability is a
+ // property of the root, and everything under it inherits.
+ const rootOf = useCallback(
+ (path) => rootByName.get(path.split('/')[0]) || null, [rootByName]);
+
+ const selectable = useCallback((path) => {
+ if (!requireWritable) return true;
+ const root = rootOf(path);
+ return Boolean(root && root.writable);
+ }, [requireWritable, rootOf]);
+
+ const toggleExpand = useCallback((path) => {
+ setExpanded((prev) => {
+ const next = new Set(prev);
+ if (next.has(path)) next.delete(path); else next.add(path);
+ return next;
+ });
+ }, []);
+
+ const choose = useCallback((path) => {
+ if (!selectable(path)) return;
+ setPicked((prev) => {
+ if (!multi) return new Set(prev.has(path) ? [] : [path]);
+ const next = new Set(prev);
+ if (next.has(path)) next.delete(path); else next.add(path);
+ return next;
+ });
+ }, [multi, selectable]);
+
+ // Everything already chosen is expanded on open, so the selection is
+ // visible rather than folded away inside a collapsed branch.
+ useEffect(() => {
+ const open = new Set();
+ for (const path of initial) {
+ const parts = path.split('/');
+ for (let i = 1; i < parts.length; i++) open.add(parts.slice(0, i).join('/'));
+ }
+ setExpanded(open);
+ }, [initial]);
+
+ const renderNode = (path, depth) => {
+ const kids = childrenOf.get(path) || [];
+ const isOpen = expanded.has(path);
+ const isRoot = depth === 0;
+ const root = isRoot ? rootByName.get(path) : null;
+ const name = isRoot ? path : path.slice(path.lastIndexOf('/') + 1);
+ const can = selectable(path);
+ const chosen = picked.has(path);
+
+ return html`
+ <li key=${path} class="ftp-node">
+ <div class="ftp-row ${chosen ? 'chosen' : ''} ${can ? '' : 'blocked'}"
+ style="padding-left:${depth * 18}px"
+ title=${can ? path : t('folder_tree.read_only_blocked')}>
+ <button class="ftp-twisty" disabled=${!kids.length}
+ aria-label=${isOpen ? t('folder_tree.collapse') : t('folder_tree.expand')}
+ onClick=${() => toggleExpand(path)}>
+ ${kids.length ? (isOpen ? '−' : '+') : ' '}
+ </button>
+ <button class="ftp-label" disabled=${!can} onClick=${() => choose(path)}>
+ <${Icon} name="folder" />
+ <span class="ftp-name">${name}</span>
+ ${isRoot && root && html`
+ <span class="ftp-badge ${root.writable ? 'rw' : 'ro'}">
+ ${root.writable ? t('node.root_rw') : t('node.root_ro')}
+ </span>`}
+ ${isRoot && root && root.ejected && html`
+ <span class="ftp-badge warn">${t('group.root_ejected')}</span>`}
+ ${isRoot && root && !root.ejected && root.available === false && html`
+ <span class="ftp-badge warn">${t('node.unavailable')}</span>`}
+ ${chosen && html`<span class="ftp-check">✓</span>`}
+ </button>
+ </div>
+ ${isOpen && kids.length > 0 && html`
+ <ul class="ftp-children">
+ ${kids.map((child) => renderNode(child, depth + 1))}
+ </ul>
+ `}
+ </li>
+ `;
+ };
+
+ const topLevel = childrenOf.get('') || [];
+ const chosenList = [...picked].sort();
+ const noWritableRoot = requireWritable
+ && !(roots || []).some((r) => r.writable);
+
+ return html`
+ <div class="ftp-backdrop" onClick=${onCancel}>
+ <div class="ftp-panel" tabindex="-1" ref=${panelRef}
+ onClick=${(e) => e.stopPropagation()}>
+ <h3 class="ftp-title">${t(multi ? 'folder_tree.title_multi'
+ : 'folder_tree.title_single')}</h3>
+ ${requireWritable && html`
+ <p class="settings-hint">${
+ noWritableRoot ? t('folder_tree.no_writable_root')
+ : t('folder_tree.writable_only')}</p>`}
+
+ ${topLevel.length === 0 ? html`
+ <p class="settings-hint">${t('folder_tree.empty')}</p>
+ ` : html`
+ <ul class="ftp-tree">${topLevel.map((p) => renderNode(p, 0))}</ul>
+ `}
+
+ <div class="ftp-selection">
+ ${chosenList.length
+ ? chosenList.map((p) => html`<code key=${p} class="ftp-chip">${p}</code>`)
+ : html`<span class="settings-hint">${t('folder_tree.nothing_selected')}</span>`}
+ </div>
+
+ <div class="ftp-actions">
+ <button class="btn btn-small" onClick=${onCancel}>
+ ${t('settings.cancel')}
+ </button>
+ ${/* OK is offered with nothing selected on purpose: clearing an
+ app's directories is a real choice, and the only way to make
+ it. */''}
+ <button class="btn btn-small btn-secondary"
+ onClick=${() => onConfirm(multi ? chosenList : (chosenList[0] || ''))}>
+ ${t('folder_tree.confirm')}
+ </button>
+ </div>
+ </div>
+ </div>
+ `;
+}
+
+/**
+ * The chosen folders, as a table, plus the button that opens the picker.
+ *
+ * Not a `.settings-row`: that class is `display:flex; justify-content:
+ * space-between`, so a label, a hint and a value laid out inside one end up
+ * spread across a single line in whatever order they were written — which is
+ * how the first version of this read as three unrelated fragments per app.
+ *
+ * A table rather than a row of chips because these are lists now. Videos and
+ * Music can hold several folders, Photos routinely does, and a wrapped run of
+ * chips gives no column to scan and nowhere to put a per-row control. One
+ * folder per line, removable where it sits, in the same shape as the shared
+ * directories table above it — the operator is looking at two lists of
+ * directories on one page and they should read alike.
+ */
+function FolderPickerField({
+ label, hint, roots, dirs, mode = 'single', requireWritable = false,
+ value, onChange, disabled,
+}) {
+ const [open, setOpen] = useState(false);
+ const multi = mode === 'multi';
+ const chosen = multi ? (value || []) : (value ? [value] : []);
+
+ const removeAt = (path) => {
+ if (!multi) { onChange(''); return; }
+ onChange(chosen.filter((p) => p !== path));
+ };
+
+ return html`
+ <div class="folder-field">
+ <div class="folder-field-head">
+ <h4 class="folder-field-label">${label}</h4>
+ ${hint && html`<p class="settings-hint">${hint}</p>`}
+ </div>
+
+ ${chosen.length > 0 && html`
+ <table class="shared-dirs-tbl folder-field-tbl">
+ <tbody>
+ ${chosen.map((path) => html`
+ <tr key=${path}>
+ <td class="sdt-col-dir">
+ <span class="sdt-dir-name">
+ <${Icon} name="folder" />
+ ${path}
+ </span>
+ </td>
+ <td class="sdt-col-actions">
+ <button class="sdt-action-btn sdt-action-danger"
+ disabled=${disabled}
+ title=${t('folder_tree.remove')}
+ onClick=${() => removeAt(path)}>\u{2715}</button>
+ </td>
+ </tr>
+ `)}
+ </tbody>
+ </table>
+ `}
+
+ <div class="folder-field-actions">
+ ${chosen.length === 0 && html`
+ <span class="settings-hint">${t('folder_tree.nothing_selected')}</span>`}
+ <button class="btn btn-small btn-secondary" disabled=${disabled}
+ onClick=${() => setOpen(true)}>
+ <${Icon} name="folder" />
+ ${' '}${chosen.length && multi ? t('folder_tree.add')
+ : t('folder_tree.choose')}
+ </button>
+ </div>
+
+ ${open && html`
+ <${FolderTreePicker}
+ roots=${roots} dirs=${dirs} mode=${mode}
+ requireWritable=${requireWritable}
+ selected=${value}
+ onCancel=${() => setOpen(false)}
+ onConfirm=${(sel) => { setOpen(false); onChange(sel); }} />
+ `}
+ </div>
+ `;
+}
+
+export { FolderTreePicker, FolderPickerField };
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 466af53..6f41426 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
@@ -1,5 +1,5 @@
import {
- html, useState, useEffect, useCallback, useRef,
+ html, useState, useEffect, useCallback, useRef, useMemo,
} from './vendor/htm-preact.js';
import { t } from './i18n.js';
import { Icon } from './icon.js';
@@ -9,7 +9,7 @@ import {
HUB, session, cacheGroupIndex, hubFetch, ensureFreshToken,
_loadBundleKey, _loadRecoveryKey, _storeBundleKey,
} from './hub-client.js';
-import { visibleApps } from './apps.js';
+import { APPS, visibleApps } from './apps.js';
import { GroupName } from './group-name.js';
import { FilePreview } from './files-app.js';
import { VideoPlayer } from './video-player.js';
@@ -88,9 +88,10 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
const [nodeRoots, setNodeRoots] = useState([]);
const [isNodeAdmin, setIsNodeAdmin] = useState(false);
- // Whether ordinary members may upload here. The node decides and enforces it;
- // this only says whether to offer the controls. Defaults to true so a node
- // that predates the setting behaves as it always did.
+ // Legacy: the group-wide upload switch a node speaking MNP 1.0 sends on its
+ // handshake ack. Per-root `writable` replaced it, and this is read only when
+ // the roots carry no flags at all — see `attachRoot` below. Defaults to true
+ // so such a node behaves as it always did.
const [memberUpload, setMemberUpload] = useState(true);
// Which applications this group has enabled, from the node. Falls back to
// every registered app when a node predates the setting (or hasn't answered
@@ -127,15 +128,22 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
// TMDB on/off + whether a custom token is set, node-wide (not per-group) —
// docs/mediacenter.md §5.5. Null until the handshake ack arrives.
const [tmdbConfig, setTmdbConfig] = useState(null);
- // Which folder is the Videos app's entry point for this group — ''
- // (the default) means the whole group index. Set from Files, per-group.
- const [videoRoot, setVideoRoot] = useState('');
- // Same shape — the Music app's own entry point.
- const [audioRoot, setAudioRoot] = useState('');
- // The Photos app's entry points — a *list*, unlike videoRoot/audioRoot
- // above (docs/photos.md §2.1: a photo library is routinely scattered
- // across several folders). Empty means nothing configured yet.
- const [photoRoots, setPhotoRoots] = useState([]);
+ // Which folders each app works over. One shape for all of them — a list,
+ // always, even where an app only wants one (docs/refactor-groups.md §1.6):
+ // Videos and Music were single values, which meant a library spread over two
+ // drives could not be described at all. Empty means nothing configured yet,
+ // which every app reads as "show nothing", never "the whole group index".
+ const [appDirectories, setAppDirectories] = useState({});
+ const appDirs = useCallback(
+ (key) => appDirectories[key] || [], [appDirectories]);
+ // Where chat attachments are written — one directory, because Chat has one
+ // destination rather than a set of folders it reads.
+ const [chatDirectory, setChatDirectory] = useState('');
+ const [chatLinkPreview, setChatLinkPreview] = useState(true);
+ // Whether this node speaks the operations MNP 1.1 added. False for one that
+ // predates them, and the Settings page then offers what that node can
+ // actually do rather than controls whose messages it drops unanswered.
+ const [nodeSupportsAppOps, setNodeSupportsAppOps] = useState(false);
// MusicBrainz on/off (per-group) — docs/musicbay.md §3.2.
const [musicbrainzConfig, setMusicbrainzConfig] = useState(null);
const onPlayQueue = useCallback((tracks, startIndex) => {
@@ -225,8 +233,9 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
if (indexMsg.roots) setNodeRoots(indexMsg.roots);
cacheGroupIndex(groupId, group ? group.name : groupId,
group ? group.owner_username : null, fresh,
- { videoRoot, audioRoot, photoRoots });
- }, [groupId, group, videoRoot, audioRoot, photoRoots]);
+ { video: appDirs('video'), music: appDirs('music'),
+ photo: appDirs('photo') });
+ }, [groupId, group, appDirs]);
// additions/deletions/updates (daemon.py _broadcast_index_change, once
// there is a previous snapshot to diff against) — applied on top of
@@ -235,6 +244,13 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
// enrichment (duration/thumb_hash/display_title/...) arriving for a file
// already in the table — same id, new fields (see group_index.py diff()).
const applyIndexDelta = useCallback((deltaMsg) => {
+ // The roots table rides on the delta as of MNP 1.1. Before that it
+ // travelled only on a full index_sync, which is sent on request — so a
+ // root added, removed, ejected or plugged by anyone left every other
+ // client's directory table stale until they reloaded the page.
+ if (Array.isArray(deltaMsg.roots) && deltaMsg.roots.length) {
+ setNodeRoots(deltaMsg.roots);
+ }
setEntries((prev) => {
const deletions = new Set(deltaMsg.deletions || []);
const kept = prev.filter((e) => !deletions.has(e.id));
@@ -248,10 +264,11 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
const fresh = updated.concat(additions);
cacheGroupIndex(groupId, group ? group.name : groupId,
group ? group.owner_username : null, fresh,
- { videoRoot, audioRoot, photoRoots });
+ { video: appDirs('video'), music: appDirs('music'),
+ photo: appDirs('photo') });
return fresh;
});
- }, [groupId, group, videoRoot, audioRoot, photoRoots]);
+ }, [groupId, group, appDirs]);
useEffect(() => {
let cancelled = false;
@@ -325,9 +342,20 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
tokenCustomized: !!ack.tmdb_token_customized,
language: ack.tmdb_language || '',
});
- setVideoRoot(ack.video_root || '');
- setAudioRoot(ack.audio_root || '');
- setPhotoRoots(ack.photo_roots || []);
+ // The plural form when the node speaks it, the old scalars when it
+ // does not — an MNP 1.0 node sends only the latter, and reading its
+ // missing `video_directories` as "nothing configured" would empty a
+ // working Videos tab.
+ setAppDirectories({
+ video: ack.video_directories
+ || (ack.video_root ? [ack.video_root] : []),
+ music: ack.music_directories
+ || (ack.audio_root ? [ack.audio_root] : []),
+ photo: ack.photo_directories || ack.photo_roots || [],
+ });
+ setNodeSupportsAppOps(transport.supportsAppOps);
+ setChatDirectory(ack.chat_directory || '');
+ setChatLinkPreview(ack.chat_link_preview !== false);
setMusicbrainzConfig({
enabled: ack.musicbrainz_enabled !== false,
});
@@ -343,11 +371,29 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
// recent value.
transport.onTmdbConfig = (cfg) => setTmdbConfig((prev) => ({ ...(prev || {}), ...cfg }));
transport.onTmdbEnabled = (enabled) => setTmdbConfig((prev) => ({ ...(prev || {}), enabled }));
- transport.onVideoRoot = (path) => setVideoRoot(path);
- transport.onAudioRoot = (path) => setAudioRoot(path);
- transport.onPhotoRoots = (roots) => setPhotoRoots(roots);
+ // One handler for every app's directories, plus the three older
+ // per-app messages a node that predates the generic op still sends.
+ transport.onAppDirectories = (app, dirs) =>
+ setAppDirectories((prev) => ({ ...prev, [app]: dirs }));
+ transport.onVideoRoot = (path) => setAppDirectories(
+ (prev) => ({ ...prev, video: path ? [path] : [] }));
+ transport.onAudioRoot = (path) => setAppDirectories(
+ (prev) => ({ ...prev, music: path ? [path] : [] }));
+ transport.onPhotoRoots = (roots) => setAppDirectories(
+ (prev) => ({ ...prev, photo: roots || [] }));
+ transport.onChatDirectory = (path) => setChatDirectory(path);
+ transport.onChatLinkPreview = (on) => setChatLinkPreview(on);
transport.onMusicbrainzEnabled = (enabled) =>
setMusicbrainzConfig((prev) => ({ ...(prev || {}), enabled }));
+ transport.onRootsChanged = (msg) => {
+ // `msg.roots &&` would accept `[]`, and an empty array is truthy —
+ // so a node that could not describe its roots would blank the
+ // operator's table on an op that actually succeeded. A group always
+ // has at least one root, so nothing legitimate is dropped here.
+ if (Array.isArray(msg.roots) && msg.roots.length) {
+ setNodeRoots(msg.roots);
+ }
+ };
// The node's own scan (a root added while we were already connected,
// or reconcile catching one back up) — never the entries, just
// enough to animate the sidebar dot. Guaranteed a final push at the
@@ -535,9 +581,35 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
}
}, [groupId, token, descDraft, onGroupUpdated]);
- // Asked in two places — the Files toolbar and the chat composer — so it is
- // answered once. The operator is never locked out of their own node.
- const mayUpload = memberUpload || isNodeAdmin;
+ // Where an attachment goes, answered once for the whole page.
+ //
+ // Files does not use this — it uploads into the root being browsed, which is
+ // the only unambiguous answer once a group can have several writable roots.
+ // Chat has no folder to browse, so it needs one picked for it, and this is
+ // the same rule the node applies when a client names no root at all. It
+ // becomes an operator-chosen directory in phase 2 (refactor-groups.md §1.7).
+ //
+ // `memberUpload` is the fallback for a node still speaking MNP 1.0, whose
+ // roots carry no `writable` at all: there, the single upload root is the one
+ // the node marked, and the ack's computed flag is all we get.
+ const writableRoots = useMemo(
+ () => nodeRoots.filter((r) => r.writable && r.available !== false),
+ [nodeRoots]);
+ const legacyNode = nodeRoots.length > 0
+ && nodeRoots.every((r) => r.writable === undefined);
+ // The operator's chosen attachment folder wins where there is one — that is
+ // what the Chat settings pane is for. Its root has to be writable and
+ // present, or the choice is stale (they made it read-only, or ejected the
+ // drive) and the fallback is better than a refusal at send time.
+ const chatDirRoot = chatDirectory ? chatDirectory.split('/')[0] : '';
+ const chatDirUsable = Boolean(
+ chatDirRoot && writableRoots.some((r) => r.name === chatDirRoot));
+ const attachDir = chatDirUsable ? chatDirectory : '';
+ const attachRoot = chatDirUsable ? chatDirRoot
+ : writableRoots.length ? writableRoots[0].name
+ : (legacyNode && memberUpload
+ ? (nodeRoots.find((r) => r.upload) || nodeRoots[0]).name
+ : '');
// A single dispatcher so any app can open the right modal without owning
// video/preview state itself — Files' table and Chat's attachments both
@@ -566,14 +638,55 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
setPreviewEntry(entry);
}, [entries, onPlayQueue, onStopMusic]);
+ const unavailRoots = useMemo(() => {
+ const s = new Set();
+ for (const r of nodeRoots) if (!r.available) s.add(r.name);
+ return s;
+ }, [nodeRoots]);
+ const availableEntries = useMemo(
+ () => entries.filter((e) => {
+ const root = (e.path || '').split('/')[0];
+ return !root || !unavailRoots.has(root);
+ }), [entries, unavailRoots]);
+
+ // Everything the operator settings panes read, in one object. Built here
+ // because this is where the state already lives, and passed through
+ // `group-settings.js` untouched — that page renders the panes without
+ // knowing what any of them is for, which is what makes adding an app a
+ // registry entry rather than an edit to the page.
+ // `<key>Directories` for every registered app, derived from the registry
+ // rather than written out. Naming them here would mean adding an app
+ // required editing this file, which is the one thing the plugin
+ // architecture is supposed to have removed — and the reference app
+ // (docs/refactor-groups.md §4.1) is what made the difference visible.
+ const perAppDirectories = useMemo(() => {
+ const out = {};
+ for (const app of APPS) out[`${app.key}Directories`] = appDirs(app.key);
+ return out;
+ }, [appDirs]);
+
+ const appSettings = useMemo(() => ({
+ ...perAppDirectories,
+ chatDirectory,
+ chatLinkPreview,
+ tmdbEnabled: tmdbConfig ? tmdbConfig.enabled !== false : true,
+ tmdbLanguage: (tmdbConfig && tmdbConfig.language) || '',
+ tmdbTokenCustomized: Boolean(tmdbConfig && tmdbConfig.tokenCustomized),
+ musicbrainzEnabled: musicbrainzConfig
+ ? musicbrainzConfig.enabled !== false : true,
+ }), [perAppDirectories, chatDirectory, chatLinkPreview, tmdbConfig,
+ musicbrainzConfig]);
+
const commonProps = {
groupId, transportRef, gekRef, status, username,
- entries, nodeDirs, nodeRoots, setEntries, setNodeDirs, setNodeRoots, applyIndex,
- isNodeAdmin, operatorPaired, mayUpload, userId, setError, onPreview,
+ entries, availableEntries, nodeDirs, nodeRoots,
+ setEntries, setNodeDirs, setNodeRoots, applyIndex,
+ isNodeAdmin, operatorPaired, attachRoot, attachDir, userId, setError, onPreview,
onRefreshIndex: refreshIndex, onActivity: touchActivity,
- videoRoot, onVideoRoot: (path) => setVideoRoot(path),
- audioRoot, onAudioRoot: (path) => setAudioRoot(path),
- photoRoots, onPhotoRoots: (roots) => setPhotoRoots(roots),
+ // Plural everywhere: Videos and Music read a list now, and Photos always
+ // did. The scalar `videoRoot`/`audioRoot` shapes survive only on the wire,
+ // for a node that speaks MNP 1.0 — nothing in the client carries them.
+ ...perAppDirectories,
tmdbConfig,
musicbrainzConfig, onPlayQueue,
};
@@ -699,24 +812,20 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
transportRef=${transportRef} gekRef=${gekRef}
isNodeAdmin=${isNodeAdmin} userId=${userId}
operatorPaired=${operatorPaired} connected=${status === 'connected'}
- memberUpload=${memberUpload}
- onMemberUpload=${(allowed) => setMemberUpload(allowed)}
+ mnpRoots=${nodeRoots}
enabledApps=${enabledApps}
onEnabledApps=${(keys) => setEnabledApps(keys)}
scanSettings=${scanSettings}
onScanSettings=${(s) => setScanSettings(s)}
- tmdbConfig=${tmdbConfig}
- onTmdbConfig=${(cfg) => setTmdbConfig((prev) => ({ ...(prev || {}), ...cfg }))}
- onTmdbEnabled=${(enabled) => setTmdbConfig((prev) => ({ ...(prev || {}), enabled }))}
- musicbrainzConfig=${musicbrainzConfig}
- onMusicbrainzEnabled=${(enabled) => setMusicbrainzConfig((prev) => ({ ...(prev || {}), enabled }))}
entries=${entries} nodeDirs=${nodeDirs}
- videoRoot=${videoRoot}
- onVideoRoot=${(path) => setVideoRoot(path)}
- audioRoot=${audioRoot}
- onAudioRoot=${(path) => setAudioRoot(path)}
- photoRoots=${photoRoots}
- onPhotoRoots=${(roots) => setPhotoRoots(roots)}
+ appSettings=${appSettings}
+ nodeSupportsAppOps=${nodeSupportsAppOps}
+ ${/* The saving pane already knows what it asked for; this is so
+ the page's own copy moves at the same time, rather than
+ waiting for the ack it will not be handed (transport.js
+ resolves an admin ack against the pending request). */''}
+ onAppDirectories=${(app, dirs) =>
+ setAppDirectories((prev) => ({ ...prev, [app]: dirs }))}
onRefreshIndex=${refreshIndex}
onLeft=${onLeft}
onPaired=${() => setOperatorPaired(true)} />
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
index 1c6ca71..c80cff5 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
@@ -1,179 +1,381 @@
import {
html, useState, useEffect, useCallback, useMemo, useRef,
} from './vendor/htm-preact.js';
-import { t, getLocale, LOCALES } from './i18n.js';
+import { t } from './i18n.js';
import { Icon } from './icon.js';
+import { CollapsibleSection, ToggleSwitch } from './settings-ui.js';
import { hubFetch, navigate } from './hub-client.js';
-import { APPS } from './apps.js';
+import { availableApps, configurableApps } from './apps.js';
import * as platform from './platform.js';
-// MeshBay's own locale codes (i18n.js LOCALES) to the language tag TMDB
-// expects — the two don't share a format (MeshBay's "en" vs TMDB's
-// required region, "en-US"). Used only to pre-fill the TMDB language field
-// with the operator's own current UI language, a reasonable default they
-// can still change; the node never guesses this on its own.
-const TMDB_LANGUAGE_BY_LOCALE = {
- en: 'en-US', fr: 'fr-FR', es: 'es-ES', 'pt-BR': 'pt-BR', 'zh-CN': 'zh-CN',
- ja: 'ja-JP', de: 'de-DE', it: 'it-IT', nl: 'nl-NL', pl: 'pl-PL',
-};
-/**
- * A settings-section that folds — every section but the ones that are
- * really just a form to fill in (invite, pair-operator, approve-device):
- * hiding an input the operator is mid-typing-into behind a click they'd
- * have to undo is friction with nothing to show for it, but a section that
- * is only ever glanced at once it's configured (TMDB, scan tuning, the
- * danger zone) benefits from staying out of the way otherwise. `title` (an
- * already-built string/vnode) wins over `titleKey` when both are given —
- * the members-table heading needs a live count baked in, not just a
- * lookup.
- */
-function CollapsibleSection({ titleKey, title, defaultOpen = true, children }) {
- const [open, setOpen] = useState(defaultOpen);
- return html`
- <div class="settings-section">
- <button type="button" class="settings-collapsible-header"
- onClick=${() => setOpen((v) => !v)} aria-expanded=${open}>
- <h3 class="settings-heading">${title != null ? title : t(titleKey)}</h3>
- <${Icon} name="chevron" cls=${open ? 'video-flat-chevron open' : 'video-flat-chevron'} />
- </button>
- ${open && html`<div class="settings-collapsible-body">${children}</div>`}
- </div>
- `;
-}
+// ── Shared Directories Table ────────────────────────────────────────────
/**
- * A modern on/off switch — replaces a plain checkbox or a "Turn on/off"
- * button wherever the setting itself is a straight binary (uploads
- * allowed, TMDB/MusicBrainz enabled). Still a real <input type="checkbox">
- * under the hood (keyboard/screen-reader behaviour for free), just
- * restyled — see .toggle-switch in style.css.
+ * A group's root directories, and the operator's controls over them.
+ *
+ * One component, two modes, because the Create Group wizard and the Settings
+ * page were drifting apart while showing the same thing:
+ *
+ * mode="live" — a hosted group. Every change is a signed operator op sent
+ * over MNP, or the loopback API when the node is on this
+ * machine and there is no live connection.
+ * mode="local" — the wizard, before the group exists. Changes are held in
+ * an array the caller owns; nothing is persisted until the
+ * group is attached.
+ *
+ * **Both paths matter and neither is optional.** The operator of a node is not
+ * necessarily sitting at it: they may be signing in from any browser, and the
+ * only thing that reaches their node from there is MNP. An earlier version of
+ * this read its roots exclusively from the loopback API, which resolves to
+ * "not available" in a browser — so the section rendered for nobody on the
+ * web, while the controls it replaced had worked there. `mnpRoots` is the
+ * source whenever a connection exists; the loopback list is the fallback for
+ * a local node that is not currently connected (a group still scanning, say).
+ *
+ * Props:
+ * roots — the node's current roots: { name, path, writable,
+ * removable, ejected, available, kind }
+ * groupId — the group id
+ * transport — MeshBayTransport instance, or null when not connected
+ * signFn — signing function for admin ops
+ * nodeDetected — whether the loopback node API answers
+ * readOnly — suppress every edit control
+ * onRootsChange — called after a change, to re-read the loopback list
+ * onRefreshIndex — full index refresh. Not called after a root change: see
+ * `run()` for why the node's own push is what settles it
+ * mode — "live" (default) or "local"
+ * localRoots / onLocalRootsChange — the array, in "local" mode
*/
-function ToggleSwitch({ checked, onChange, disabled, label }) {
- return html`
- <label class="toggle-switch ${disabled ? 'toggle-switch-disabled' : ''}">
- <input type="checkbox" checked=${checked} disabled=${disabled}
- onChange=${(e) => onChange(e.target.checked)} />
- <span class="toggle-switch-track"><span class="toggle-switch-thumb"></span></span>
- ${label != null && html`<span class="toggle-switch-label">${label}</span>`}
- </label>
- `;
-}
+function SharedDirectoriesTable({ roots, groupId, transport, signFn,
+ nodeDetected: nodeAvail, readOnly,
+ onRootsChange, onRefreshIndex,
+ mode = 'live',
+ localRoots, onLocalRootsChange }) {
+ const isLocal = mode === 'local';
+ const serverRoots = isLocal ? (localRoots || []) : (roots || []);
+ const [busy, setBusy] = useState(false);
+ // `{ text, error }` — a refusal has to look like one. Every message here was
+ // a `settings-hint`, which is dim grey body text, so "two roots would both
+ // be called uploads" read as a footnote to the section rather than as the
+ // reason nothing happened.
+ const [msg, setMsg] = useState(null);
+ const [indexProgress, setIndexProgress] = useState(null);
+ const say = useCallback((text) => setMsg(text ? { text, error: false } : null), []);
+ const refuse = useCallback((text) => setMsg({ text, error: true }), []);
-/**
- * Which folder is an app's entry point for this group — the shared shape
- * behind both the Videos and Music root pickers (docs/musicbay.md's
- * amended §2.1): a depth-indented <select> over every folder the group's
- * index already knows about, a Save button that only enables once the
- * draft actually differs, and a confirm prompt only when replacing an
- * *already-set* root (setting one for the first time has nothing to lose).
- */
-function RootFolderRow({
- icon, titleKey, hintKey, folders, value, draft, onDraftChange,
- busy, msg, onSave, noneKey, saveKey,
-}) {
- return html`
- <div class="settings-root-row">
- <div class="settings-root-row-title">
- <${Icon} name=${icon} />
- <h4>${t(titleKey)}</h4>
- </div>
- <p class="settings-hint">${t(hintKey)}</p>
- <div class="settings-row">
- <label class="settings-label">
- <select value=${draft} disabled=${busy} onChange=${e => onDraftChange(e.target.value)}>
- <option value="">${t(noneKey)}</option>
- ${folders.map(p => html`
- <option key=${p} value=${p}>
- ${' '.repeat(p.split('/').length - 1)}${p.split('/').pop()}
- </option>
- `)}
- </select>
- </label>
+ // Rendered at the foot of the section, under the Add button — the last thing
+ // below the control that caused it, rather than above a table the eye has
+ // already moved past.
+ const message = !msg ? '' : html`
+ <p class=${msg.error ? 'error-msg' : 'settings-hint'}
+ role=${msg.error ? 'alert' : 'status'}
+ style="margin-top:10px">${msg.text}</p>`;
+ const [pathDraft, setPathDraft] = useState('');
+ const [addingByPath, setAddingByPath] = useState(false);
+
+ // A toggle has to move under the finger, and the answer only comes back
+ // when the node has signed, written node.toml and pushed the new table.
+ // The patch is therefore held until the incoming `roots` actually agrees
+ // with it — clearing it when the request resolves (which is what this did)
+ // drops it in the frame *before* the new table arrives, so the switch
+ // visibly snaps back and then forward again.
+ const [optimistic, setOptimistic] = useState({});
+ useEffect(() => {
+ setOptimistic((prev) => {
+ const keys = Object.keys(prev);
+ if (!keys.length) return prev;
+ const next = {};
+ let changed = false;
+ for (const name of keys) {
+ const server = serverRoots.find(r => r.name === name);
+ const patch = prev[name];
+ // Gone from the table, or the server now says what we asked for:
+ // either way this patch has nothing left to hide.
+ const settled = !server
+ || Object.keys(patch).every(k => server[k] === patch[k]);
+ if (settled) changed = true; else next[name] = patch;
+ }
+ return changed ? next : prev;
+ });
+ }, [serverRoots]);
+
+ const displayRoots = serverRoots.map(r =>
+ optimistic[r.name] ? { ...r, ...optimistic[r.name] } : r);
+
+ // Paths are the operator's own view and do not cross MNP: the roots table
+ // rides in the index payload, which every member receives, and it tells them
+ // what exists and whether it is readable — never where on the operator's
+ // disk it lives. So the column appears when the answer is actually
+ // available (the loopback API, which is already this machine only) and is
+ // left out otherwise, rather than printing a row of blanks.
+ const hasPaths = displayRoots.some((r) => r.path);
+
+ // Which door a change goes through. MNP first: it is the only one that
+ // exists for an operator on the web, and it is signed, which the loopback
+ // API is not (it is authorized by being on localhost with the run token).
+ const overMnp = !isLocal && transport && transport.connected;
+ const overLoopback = !isLocal && !overMnp && nodeAvail;
+ const canEdit = !readOnly && (isLocal || overMnp || overLoopback);
+
+ const rootUrl = (name, suffix = '') =>
+ '/api/groups/' + groupId + '/roots/' + encodeURIComponent(name) + suffix;
+
+ // Deliberately no index refresh after a root change.
+ //
+ // Adding a root makes the node reload, which rescans — minutes on a real
+ // library — and the reload is fire-and-forget for that reason. Fetching the
+ // index in the moment after therefore returns the set from *before* it, and
+ // `applyIndex` writes that over the roots the ack had just delivered: the
+ // new directory appeared for one paint and vanished, which is what "it only
+ // shows up after a refresh" was.
+ //
+ // Nothing is lost by waiting. The ack carries the new table immediately, and
+ // the delta the node pushes when the scan finishes carries it again along
+ // with the files.
+ const run = useCallback(async (work) => {
+ setBusy(true); setMsg(null);
+ try {
+ await work();
+ if (onRootsChange) await onRootsChange();
+ return true;
+ } catch (err) {
+ refuse(platform.bridgeMessage(err));
+ return false;
+ } finally { setBusy(false); }
+ }, [onRootsChange, refuse]);
+
+ const doUpdateRoot = useCallback(async (rootName, updates) => {
+ if (isLocal) {
+ if (onLocalRootsChange) {
+ onLocalRootsChange((localRoots || []).map(r =>
+ r.name === rootName ? { ...r, ...updates } : r));
+ }
+ return;
+ }
+ setOptimistic(prev => ({
+ ...prev, [rootName]: { ...(prev[rootName] || {}), ...updates },
+ }));
+ const ok = await run(async () => {
+ if (overMnp) await transport.updateRoot(groupId, rootName, updates, signFn);
+ else if (overLoopback) await platform.node.call('PATCH', rootUrl(rootName), updates);
+ else throw new Error(t('node.root_no_route'));
+ });
+ // Only a failure clears the patch here; a success waits for the node's
+ // own table, so the switch never travels backwards on its way forwards.
+ if (!ok) {
+ setOptimistic(prev => {
+ const next = { ...prev }; delete next[rootName]; return next;
+ });
+ }
+ }, [isLocal, localRoots, onLocalRootsChange, overMnp, overLoopback,
+ transport, groupId, signFn, run]);
+
+ const doEjectRoot = useCallback((rootName) => run(async () => {
+ if (overMnp) await transport.ejectRoot(groupId, rootName, signFn);
+ else if (overLoopback) await platform.node.call('PUT', rootUrl(rootName, '/eject'));
+ else throw new Error(t('node.root_no_route'));
+ }), [overMnp, overLoopback, transport, groupId, signFn, run]);
+
+ const doPlugRoot = useCallback((rootName) => run(async () => {
+ if (overMnp) await transport.plugRoot(groupId, rootName, signFn);
+ else if (overLoopback) await platform.node.call('PUT', rootUrl(rootName, '/plug'));
+ else throw new Error(t('node.root_no_route'));
+ }), [overMnp, overLoopback, transport, groupId, signFn, run]);
+
+ const doRemoveRoot = useCallback(async (rootName) => {
+ if (isLocal) {
+ if (onLocalRootsChange) {
+ onLocalRootsChange((localRoots || []).filter(r => r.name !== rootName));
+ }
+ return;
+ }
+ if (!confirm(t('node.root_remove_confirm', { name: rootName }))) return;
+ const ok = await run(async () => {
+ if (overMnp) await transport.removeRoot(groupId, rootName, signFn);
+ else if (overLoopback) {
+ await platform.node.call('DELETE', rootUrl(rootName));
+ await platform.node.call('POST', '/api/reload');
+ } else throw new Error(t('node.root_no_route'));
+ });
+ if (ok) say(t('node.root_removed'));
+ }, [isLocal, localRoots, onLocalRootsChange, overMnp, overLoopback,
+ transport, groupId, signFn, run]);
+
+ // Adding a root needs a directory that exists on the *node's* filesystem.
+ // With the node on this machine that is a native folder picker; from any
+ // other browser the operator has to type the path, because nothing in a web
+ // page can browse a remote disk. Both end at the same signed op.
+ const addRootAtPath = useCallback(async (path, name) => {
+ if (isLocal) {
+ if ((localRoots || []).some(r => r.path === path)) return true;
+ const isFirst = (localRoots || []).length === 0;
+ // The first directory is writable so a new group can receive an upload
+ // without the operator having to find this switch first. Every later
+ // one is read-only until they say otherwise.
+ if (onLocalRootsChange) {
+ onLocalRootsChange([...(localRoots || []),
+ { name, path, writable: isFirst, removable: false }]);
+ }
+ return true;
+ }
+ setIndexProgress(null);
+ return run(async () => {
+ if (overMnp) {
+ await transport.addRoot(groupId, path, { name }, signFn);
+ } else if (overLoopback) {
+ await platform.node.call('POST', '/api/groups/' + groupId + '/roots',
+ { path, name });
+ await platform.node.call('POST', '/api/reload');
+ await platform.watchIndexProgress(groupId, setIndexProgress);
+ } else throw new Error(t('node.root_no_route'));
+ });
+ }, [isLocal, localRoots, onLocalRootsChange, overMnp, overLoopback,
+ transport, groupId, signFn, run]);
+
+ const doPickRoot = useCallback(async () => {
+ const chosen = await platform.rootPicker.choose();
+ if (!chosen) return;
+ const ok = await addRootAtPath(chosen.path, chosen.name);
+ if (ok && !isLocal) say(t('node.root_added'));
+ }, [addRootAtPath, isLocal]);
+
+ const doAddByPath = useCallback(async () => {
+ const path = pathDraft.trim();
+ if (!path) return;
+ // The name is the node's business — it derives the basename and refuses a
+ // duplicate. Sending one guessed from a string typed here would be a
+ // second opinion about something already decided in one place.
+ const ok = await addRootAtPath(path, '');
+ if (ok) { setPathDraft(''); setAddingByPath(false); if (!isLocal) say(t('node.root_added')); }
+ }, [pathDraft, addRootAtPath, isLocal]);
+
+ const addControls = !canEdit ? '' : html`
+ ${platform.rootPicker.available ? html`
+ <button class="btn btn-small btn-secondary" style="margin-top:8px"
+ disabled=${busy} onClick=${doPickRoot}>
+ <${Icon} name="folder-plus" /> ${t('node.add_root')}
+ </button>
+ ` : addingByPath ? html`
+ <div class="sdt-add-row">
+ <input class="sdt-add-input" type="text" value=${pathDraft}
+ placeholder=${t('node.root_path_placeholder')}
+ disabled=${busy}
+ onInput=${(e) => setPathDraft(e.target.value)}
+ onKeyDown=${(e) => { if (e.key === 'Enter') doAddByPath(); }} />
+ <button class="btn btn-small btn-secondary" disabled=${busy || !pathDraft.trim()}
+ onClick=${doAddByPath}>${t('node.add_root')}</button>
+ <button class="btn btn-small" disabled=${busy}
+ onClick=${() => { setAddingByPath(false); setPathDraft(''); }}>
+ ${t('settings.cancel')}</button>
</div>
+ <p class="settings-hint">${t('node.root_path_hint')}</p>
+ ` : html`
<button class="btn btn-small btn-secondary" style="margin-top:8px"
- disabled=${busy || draft === (value || '')} onClick=${onSave}>
- ${busy ? t('settings_node.scan_saving') : t(saveKey)}
+ disabled=${busy} onClick=${() => setAddingByPath(true)}>
+ <${Icon} name="folder-plus" /> ${t('node.add_root')}
</button>
- ${msg && html`<p class=${msg.ok ? 'success-msg' : 'error-msg'} style="margin-top:8px">
- ${msg.text}</p>`}
- </div>
+ `}
`;
-}
-
-/**
- * Which folder(s) are the Photos app's entry points for this group — a
- * *set*, unlike RootFolderRow's single value above (docs/photos.md §2.1: a
- * photo library is routinely scattered across several folders). An
- * add/remove list rather than a `<select>`: pick a folder to add from the
- * same `rootFolderOptions` the Videos/Music pickers use, list what is
- * already configured with a remove button each, and one Save signs the
- * whole resulting set in one op (same shape as the app-enable checkboxes
- * below — several changes staged, one signature).
- */
-function PhotoRootsRow({ folders, value, busy, msg, onSave }) {
- const [draft, setDraft] = useState(value || []);
- useEffect(() => { setDraft(value || []); }, [value]);
- const [addSelection, setAddSelection] = useState('');
-
- const available = folders.filter((p) => !draft.includes(p));
- const addRoot = () => {
- if (!addSelection || draft.includes(addSelection)) return;
- setDraft((prev) => [...prev, addSelection].sort());
- setAddSelection('');
- };
- const removeRoot = (path) => setDraft((prev) => prev.filter((p) => p !== path));
- const unchanged = draft.length === (value || []).length
- && draft.every((p) => (value || []).includes(p));
+ if (!displayRoots.length) {
+ return html`
+ <div class="shared-directories-table">
+ <p class="settings-hint">${t('settings_node.shared_directories_hint')}</p>
+ ${addControls}
+ ${message}
+ </div>
+ `;
+ }
return html`
- <div class="settings-root-row">
- <div class="settings-root-row-title">
- <${Icon} name="image" />
- <h4>${t('settings_node.photo_roots_title')}</h4>
- </div>
- <p class="settings-hint">${t('settings_node.photo_roots_hint')}</p>
- ${draft.length === 0 && html`
- <p class="settings-hint">${t('settings_node.photo_roots_none')}</p>
- `}
- ${draft.length > 0 && html`
- <ul class="settings-root-list">
- ${draft.map((p) => html`
- <li key=${p} class="settings-root-list-item">
- <span>${' '.repeat(p.split('/').length - 1)}${p.split('/').pop()}</span>
- <button class="link-btn" disabled=${busy} onClick=${() => removeRoot(p)}
- title=${t('settings_node.photo_roots_remove')}>
- <${Icon} name="close" /></button>
- </li>
- `)}
- </ul>
+ <div class="shared-directories-table">
+ <table class="shared-dirs-tbl">
+ <thead>
+ <tr>
+ <th class="sdt-col-dir">${t('node.directory')}</th>
+ ${hasPaths && html`<th class="sdt-col-path">${t('node.root_path')}</th>`}
+ ${canEdit && html`<th class="sdt-col-toggle">${t('node.root_rw')}</th>`}
+ ${canEdit && !isLocal && html`<th class="sdt-col-toggle">${t('node.removable')}</th>`}
+ <th class="sdt-col-actions"></th>
+ </tr>
+ </thead>
+ <tbody>
+ ${displayRoots.map(r => {
+ const rowClass = r.ejected ? 'sdt-row-ejected'
+ : (!isLocal && r.available === false) ? 'sdt-row-unavail' : '';
+ return html`
+ <tr class=${rowClass} key=${r.name}>
+ <td class="sdt-col-dir">
+ <span class="sdt-dir-name">
+ <${Icon} name="folder" />
+ ${r.name}
+ </span>
+ ${r.ejected && html`
+ <span class="node-root-badge node-root-badge-warn">${t('group.root_ejected')}</span>`}
+ ${!isLocal && r.available === false && !r.ejected && html`
+ <span class="node-root-badge node-root-badge-warn">${t('node.unavailable')}</span>`}
+ </td>
+ ${hasPaths && html`
+ <td class="sdt-col-path" title=${r.path || ''}>${r.path || ''}</td>`}
+ ${canEdit && html`
+ <td class="sdt-col-toggle">
+ <${ToggleSwitch} checked=${!!r.writable} disabled=${busy || !!r.ejected}
+ onChange=${(v) => doUpdateRoot(r.name, { writable: v })} />
+ </td>
+ `}
+ ${canEdit && !isLocal && html`
+ <td class="sdt-col-toggle">
+ <${ToggleSwitch} checked=${!!r.removable} disabled=${busy}
+ onChange=${(v) => doUpdateRoot(r.name, { removable: v })} />
+ </td>
+ `}
+ <td class="sdt-col-actions">
+ ${canEdit && !isLocal && html`
+ <button class="sdt-action-btn" disabled=${busy || !r.removable}
+ title=${r.ejected ? t('group.root_plug') : t('group.root_eject')}
+ onClick=${() => r.ejected ? doPlugRoot(r.name) : doEjectRoot(r.name)}>
+ ${r.ejected ? '\u{1F50C}' : '\u{23CF}'}
+ </button>
+ `}
+ ${canEdit && html`
+ <button class="sdt-action-btn sdt-action-danger"
+ disabled=${busy || displayRoots.length < 2}
+ title=${displayRoots.length < 2
+ ? t('node.root_remove_last') : t('node.remove_root')}
+ onClick=${() => doRemoveRoot(r.name)}>
+ \u{2715}
+ </button>
+ `}
+ </td>
+ </tr>
+ `; })}
+ </tbody>
+ </table>
+ ${addControls}
+ ${message}
+ ${indexProgress && indexProgress.scanning && html`
+ <div class="index-progress" style="margin-top:8px">
+ <div class="index-progress-bar">
+ <div class="index-progress-fill" style="width:${
+ indexProgress.total_bytes
+ ? Math.min(100, Math.round(
+ 100 * indexProgress.scanned_bytes / indexProgress.total_bytes))
+ : 0}%"></div>
+ </div>
+ <div class="index-progress-label">${t('wizard.indexing_progress', {
+ pct: indexProgress.total_bytes
+ ? Math.min(100, Math.round(
+ 100 * indexProgress.scanned_bytes / indexProgress.total_bytes))
+ : 0,
+ })}</div>
+ </div>
`}
- <div class="settings-row">
- <label class="settings-label">
- <select value=${addSelection} disabled=${busy || available.length === 0}
- onChange=${(e) => setAddSelection(e.target.value)}>
- <option value="">${t('settings_node.photo_roots_add_placeholder')}</option>
- ${available.map((p) => html`
- <option key=${p} value=${p}>
- ${' '.repeat(p.split('/').length - 1)}${p.split('/').pop()}
- </option>
- `)}
- </select>
- </label>
- <button class="btn btn-small btn-secondary" disabled=${busy || !addSelection}
- onClick=${addRoot}>${t('settings_node.photo_roots_add')}</button>
- </div>
- <button class="btn btn-small btn-secondary" style="margin-top:8px"
- disabled=${busy || unchanged} onClick=${() => onSave(draft)}>
- ${busy ? t('settings_node.scan_saving') : t('settings_node.photo_roots_save')}
- </button>
- ${msg && html`<p class=${msg.ok ? 'success-msg' : 'error-msg'} style="margin-top:8px">
- ${msg.text}</p>`}
</div>
`;
}
+
// ── Members Panel ────────────────────────────────────────────────────────
/**
@@ -187,14 +389,12 @@ function PhotoRootsRow({ folders, value, busy, msg, onSave }) {
*/
function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
isNodeAdmin, userId, operatorPaired, connected,
- memberUpload, onMemberUpload,
+ mnpRoots,
enabledApps, onEnabledApps,
scanSettings, onScanSettings,
- tmdbConfig, onTmdbConfig, onTmdbEnabled,
- musicbrainzConfig, onMusicbrainzEnabled,
- entries, nodeDirs, videoRoot, onVideoRoot,
- audioRoot, onAudioRoot,
- photoRoots, onPhotoRoots, onRefreshIndex,
+ entries, nodeDirs,
+ appSettings, nodeSupportsAppOps,
+ onAppDirectories, onRefreshIndex,
onPaired, onLeft }) {
const [members, setMembers] = useState([]);
const [adminId, setAdminId] = useState('');
@@ -214,6 +414,29 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
// platform.watchIndexProgress.
const [nodeIndexProgress, setNodeIndexProgress] = useState(null);
+ // The roots to show, from whichever source can actually answer.
+ //
+ // `mnpRoots` comes from the index payload the node pushes over the live
+ // connection, and is the only source an operator signing in from an
+ // ordinary browser has. `nodeRoots` comes from the loopback API and exists
+ // only on the machine running the node. Preferring MNP when connected also
+ // keeps this table on the same data Files and the apps read, so an eject
+ // shows in one place at the same instant it shows in the other.
+ const effectiveRoots = (connected && mnpRoots && mnpRoots.length)
+ ? mnpRoots : nodeRoots;
+
+ // Declared here rather than inline at the call site: a function rebuilt on
+ // every render is a new prop identity every render, and the callbacks that
+ // close over it in the table below are memoised on it.
+ const adminSignFn = useCallback((transcript) => {
+ const sk = transportRef.current && transportRef.current.sessionKeys
+ && transportRef.current.sessionKeys.skEdB64;
+ if (!sk || !window.MeshBayKeys) {
+ throw new Error(t('node.root_no_signing_key'));
+ }
+ return window.MeshBayKeys.signBytes(sk, transcript);
+ }, [transportRef]);
+
const loadNodeInfo = useCallback(async () => {
if (!platform.node.available) return;
try {
@@ -332,40 +555,16 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
}
}, [pairCode, transportRef, userId]);
- const [uploadBusy, setUploadBusy] = useState(false);
- const [uploadMsg, setUploadMsg] = useState('');
-
- /**
- * Close or open uploading for everyone who is not the operator.
- *
- * Signed, like removing a member: the node refuses an unsigned instruction,
- * so this is a request to the node rather than a decision taken here. The
- * button does not move until the node has said it did it.
- */
- const setUploads = useCallback(async (allowed) => {
- const transport = transportRef && transportRef.current;
- setUploadMsg('');
- setUploadBusy(true);
- try {
- if (!transport || !transport.connected) {
- throw new Error('Not connected to the node');
- }
- const sk = transport.sessionKeys && transport.sessionKeys.skEdB64;
- const signFn = (sk && window.MeshBayKeys)
- ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript)
- : null;
- await transport.setMemberUpload(allowed, signFn);
- if (onMemberUpload) onMemberUpload(allowed);
- } catch (err) {
- setUploadMsg(err.message);
- } finally {
- setUploadBusy(false);
- }
- }, [transportRef, onMemberUpload]);
+ // DEPRECATED: upload toggle removed — per-root writable flag replaces it.
const [appsBusy, setAppsBusy] = useState(false);
const [appsMsg, setAppsMsg] = useState('');
- const activeApps = enabledApps && enabledApps.length ? enabledApps : APPS.map(a => a.key);
+ // `availableApps()` rather than the raw registry: an app the reader is not
+ // shown must not be turned on for the whole group by falling back to "all of
+ // them". Found by adding one that is hidden by default — an ordinary app
+ // would never have exposed the difference.
+ const activeApps = enabledApps && enabledApps.length
+ ? enabledApps : availableApps().map(a => a.key);
/**
* Toggle one app in or out of the group's enabled set. Same shape as
@@ -446,146 +645,21 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
}
}, [transportRef, onScanSettings, reconcileMinutes, debounceSeconds]);
- const [tmdbBusy, setTmdbBusy] = useState(false);
- const [tmdbMsg, setTmdbMsg] = useState('');
- const [tmdbTokenDraft, setTmdbTokenDraft] = useState('');
- const [tmdbEnabledBusy, setTmdbEnabledBusy] = useState(false);
- const tmdbEnabled = tmdbConfig ? tmdbConfig.enabled : true;
- // Pre-filled from the operator's own current UI language the first time
- // this renders with nothing configured yet — a sensible default, not a
- // claim about what the node is actually using until they hit Save.
- const [tmdbLanguage, setTmdbLanguage] = useState(
- () => (tmdbConfig && tmdbConfig.language)
- || TMDB_LANGUAGE_BY_LOCALE[getLocale()] || 'en-US');
- useEffect(() => {
- if (tmdbConfig && tmdbConfig.language) setTmdbLanguage(tmdbConfig.language);
- }, [tmdbConfig && tmdbConfig.language]);
-
- /**
- * Whether TMDB is used at all — per-group (2026-08-24, used to be bundled
- * into the same signed op as the token/language below): a real
- * media-library group and a test/demo group on the same node need not
- * share this decision. Saves immediately on toggle, same as an ordinary
- * checkbox-style setting elsewhere — there is nothing else on the form to
- * batch it with any more.
- */
- const saveTmdbEnabled = useCallback(async (nextEnabled) => {
- const transport = transportRef && transportRef.current;
- setTmdbMsg('');
- setTmdbEnabledBusy(true);
- try {
- if (!transport || !transport.connected) {
- throw new Error('Not connected to the node');
- }
- const sk = transport.sessionKeys && transport.sessionKeys.skEdB64;
- const signFn = (sk && window.MeshBayKeys)
- ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript)
- : null;
- await transport.setTmdbEnabled(nextEnabled, signFn);
- if (onTmdbEnabled) onTmdbEnabled(nextEnabled);
- } catch (err) {
- setTmdbMsg(err.message);
- } finally {
- setTmdbEnabledBusy(false);
- }
- }, [transportRef, onTmdbEnabled]);
-
- /**
- * An optional custom API token, and the language TMDB is queried in —
- * node-wide, not per-group (docs/mediacenter.md §5.5): one shared
- * credential and cache. Same shape as saveScanSettings: signed, and the
- * button does not claim success until the node confirms it. The token
- * field is cleared after a save either way: it is never echoed back by
- * the node (tmdb_config_ack carries only whether one is set, never the
- * value), so there is nothing to keep showing.
- */
- const saveTmdbConfig = useCallback(async () => {
- const transport = transportRef && transportRef.current;
- setTmdbMsg('');
- setTmdbBusy(true);
- try {
- if (!transport || !transport.connected) {
- throw new Error('Not connected to the node');
- }
- const sk = transport.sessionKeys && transport.sessionKeys.skEdB64;
- const signFn = (sk && window.MeshBayKeys)
- ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript)
- : null;
- const token = tmdbTokenDraft.trim();
- await transport.setTmdbConfig(token || undefined, tmdbLanguage, signFn);
- setTmdbTokenDraft('');
- if (onTmdbConfig) {
- onTmdbConfig({
- tokenCustomized: token
- ? true
- : (tmdbConfig ? tmdbConfig.tokenCustomized : false),
- language: tmdbLanguage,
- });
- }
- setTmdbMsg(t('settings_node.scan_saved'));
- } catch (err) {
- setTmdbMsg(err.message);
- } finally {
- setTmdbBusy(false);
- }
- }, [transportRef, onTmdbConfig, tmdbTokenDraft, tmdbConfig, tmdbLanguage]);
-
- // A node that has never had a language explicitly set would otherwise
- // query TMDB with none at all — which TMDB itself resolves to English,
- // regardless of who the operator is — even though this form already
- // *suggests* their own UI language as the value. Applied once,
- // automatically, the first time the operator (the only one who can sign
- // this) is actually connected to see it: a real default tied to whoever
- // runs this particular node, never a single hardcoded language for every
- // node. `tmdbConfig.language` being set at all — from this or from an
- // explicit save — is what stops it from ever firing again, so "unless
- // manually changed" holds regardless of which of the two set it first.
- const autoLanguageSetRef = useRef(false);
- useEffect(() => {
- if (!isNodeAdmin || !connected || !tmdbConfig || tmdbConfig.language) return;
- if (autoLanguageSetRef.current) return;
- autoLanguageSetRef.current = true;
- saveTmdbConfig();
- }, [isNodeAdmin, connected, tmdbConfig, saveTmdbConfig]);
+ // ── Per-app settings ────────────────────────────────────────────────
+ //
+ // What every app's settings pane is given, and the one operation the page
+ // performs on their behalf. TMDB, MusicBrainz and each app's folder pickers
+ // used to be hand-written sections here, ~470 lines of them, each with its
+ // own draft state and save handler saying the same thing about a different
+ // key. They live in `<app>-app-settings.js` now; this is the whole of what
+ // the page still knows about any of it.
- const [mbMsg, setMbMsg] = useState('');
- const [mbEnabledBusy, setMbEnabledBusy] = useState(false);
- const mbEnabled = musicbrainzConfig ? musicbrainzConfig.enabled : true;
-
- /**
- * Whether MusicBrainz is used at all — per-group from the start
- * (docs/musicbay.md §3.2/§6). Same shape as saveTmdbEnabled.
- */
- const saveMusicbrainzEnabled = useCallback(async (nextEnabled) => {
- const transport = transportRef && transportRef.current;
- setMbMsg('');
- setMbEnabledBusy(true);
- try {
- if (!transport || !transport.connected) {
- throw new Error('Not connected to the node');
- }
- const sk = transport.sessionKeys && transport.sessionKeys.skEdB64;
- const signFn = (sk && window.MeshBayKeys)
- ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript)
- : null;
- await transport.setMusicbrainzEnabled(nextEnabled, signFn);
- if (onMusicbrainzEnabled) onMusicbrainzEnabled(nextEnabled);
- } catch (err) {
- setMbMsg(err.message);
- } finally {
- setMbEnabledBusy(false);
- }
- }, [transportRef, onMusicbrainzEnabled]);
-
- // Every folder anywhere in the group's shared index, deepest included —
- // `entries[].path` is each file's containing directory (files-app.js's own
- // convention), so every ancestor prefix of it is a real folder, and
- // `nodeDirs` covers ones with nothing in them yet. A flat, depth-indented
- // <select> rather than a live folder browser: choosing an app's root is a
- // rare, one-off decision, not something worth a whole navigable tree for.
- // Shared between the Videos and Music root pickers below — same folder
- // set either way.
- const rootFolderOptions = useMemo(() => {
+ // Every folder anywhere in the group's shared index. `entries[].path` is a
+ // file's containing directory (files-app.js's convention), so every ancestor
+ // prefix of it is a real folder; `nodeDirs` covers the ones with nothing in
+ // them yet. Derived here rather than in the picker so all of them agree, and
+ // so it is computed once per change instead of once per open.
+ const folderOptions = useMemo(() => {
const set = new Set();
const addAncestors = (path) => {
if (!path) return;
@@ -597,109 +671,30 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
return [...set].sort();
}, [entries, nodeDirs]);
- const [videoRootDraft, setVideoRootDraft] = useState(videoRoot || '');
- useEffect(() => { setVideoRootDraft(videoRoot || ''); }, [videoRoot]);
- const [videoRootBusy, setVideoRootBusy] = useState(false);
- const [videoRootMsg, setVideoRootMsg] = useState(null);
-
/**
- * Which folder is the Videos app's entry point for this group — same
- * shape as toggleApp/saveScanSettings: signed, and the picker does not
- * claim success until the node confirms it.
+ * Point one app at folders — the only app-specific operation this page
+ * performs, and it is generic.
*
- * Changing an *already-set* root is destructive to every member's Videos
- * tab (a different set of files, possibly none in common) — the operator
- * confirms that explicitly. Setting it for the first time is not: there is
- * nothing yet to lose.
+ * Everything else a pane needs it does itself with the transport it is
+ * given. That is the line: what every app has (directories) is here, what
+ * one app alone has (a TMDB key, a link-preview switch) is in its own file,
+ * and adding an app that only needs directories touches neither.
*/
- const saveVideoRoot = useCallback(async () => {
- const next = videoRootDraft;
- const current = videoRoot || '';
- if (next === current) return;
- if (current && !confirm(t('settings_node.video_root_change_confirm'))) return;
- const transport = transportRef && transportRef.current;
- setVideoRootMsg(null);
- setVideoRootBusy(true);
- try {
- if (!transport || !transport.connected) {
- throw new Error('Not connected to the node');
- }
- const sk = transport.sessionKeys && transport.sessionKeys.skEdB64;
- const signFn = (sk && window.MeshBayKeys)
- ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript)
- : null;
- await transport.setVideoRoot(next, signFn);
- if (onVideoRoot) onVideoRoot(next);
- setVideoRootMsg({ text: t('settings_node.scan_saved'), ok: true });
- } catch (err) {
- setVideoRootMsg({ text: err.message, ok: false });
- } finally {
- setVideoRootBusy(false);
- }
- }, [transportRef, onVideoRoot, videoRootDraft, videoRoot]);
-
- // Same shape as the Videos root above — the Music app's own entry point
- // (docs/musicbay.md's amended §2.1).
- const [audioRootDraft, setAudioRootDraft] = useState(audioRoot || '');
- useEffect(() => { setAudioRootDraft(audioRoot || ''); }, [audioRoot]);
- const [audioRootBusy, setAudioRootBusy] = useState(false);
- const [audioRootMsg, setAudioRootMsg] = useState(null);
-
- const saveAudioRoot = useCallback(async () => {
- const next = audioRootDraft;
- const current = audioRoot || '';
- if (next === current) return;
- if (current && !confirm(t('settings_node.audio_root_change_confirm'))) return;
+ const saveAppDirectories = useCallback(async (appKey, paths) => {
const transport = transportRef && transportRef.current;
- setAudioRootMsg(null);
- setAudioRootBusy(true);
- try {
- if (!transport || !transport.connected) {
- throw new Error('Not connected to the node');
- }
- const sk = transport.sessionKeys && transport.sessionKeys.skEdB64;
- const signFn = (sk && window.MeshBayKeys)
- ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript)
- : null;
- await transport.setAudioRoot(next, signFn);
- if (onAudioRoot) onAudioRoot(next);
- setAudioRootMsg({ text: t('settings_node.scan_saved'), ok: true });
- } catch (err) {
- setAudioRootMsg({ text: err.message, ok: false });
- } finally {
- setAudioRootBusy(false);
+ if (!transport || !transport.connected) {
+ throw new Error(t('node.root_no_route'));
}
- }, [transportRef, onAudioRoot, audioRootDraft, audioRoot]);
-
- // Photos app's own entry points — a set (docs/photos.md §2.1), unlike
- // videoRoot/audioRoot above. No "removing a root is destructive" confirm
- // dialog: removing one root only drops that root's albums from view, it
- // does not replace the whole tab's content the way changing video_root
- // does.
- const [photoRootsBusy, setPhotoRootsBusy] = useState(false);
- const [photoRootsMsg, setPhotoRootsMsg] = useState(null);
-
- const savePhotoRoots = useCallback(async (nextRoots) => {
- const transport = transportRef && transportRef.current;
- setPhotoRootsMsg(null);
- setPhotoRootsBusy(true);
- try {
- if (!transport || !transport.connected) {
- throw new Error('Not connected to the node');
- }
- const sk = transport.sessionKeys && transport.sessionKeys.skEdB64;
- const signFn = (sk && window.MeshBayKeys)
- ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript)
- : null;
- await transport.setPhotoRoots(nextRoots, signFn);
- if (onPhotoRoots) onPhotoRoots(nextRoots);
- setPhotoRootsMsg({ text: t('settings_node.scan_saved'), ok: true });
- } catch (err) {
- setPhotoRootsMsg({ text: err.message, ok: false });
- } finally {
- setPhotoRootsBusy(false);
+ // A node too old for the generic op still answers the three per-app
+ // messages that came before it, so an operator on one keeps the ability
+ // they had rather than being handed a control that times out.
+ if (transport.supportsAppOps) {
+ await transport.setAppDirectories(appKey, paths, adminSignFn);
+ } else {
+ await transport.setAppDirectoriesLegacy(appKey, paths, adminSignFn);
}
- }, [transportRef, onPhotoRoots]);
+ if (onAppDirectories) onAppDirectories(appKey, paths);
+ }, [transportRef, adminSignFn, onAppDirectories]);
const [removing, setRemoving] = useState('');
@@ -884,28 +879,70 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
</div>
`}
- ${/* Which group "applications" members see. New ones (Videos, Music,
- Photos) show up here automatically as they register in apps.js —
- nothing about this section changes to add one. */
- isNodeAdmin && connected && html`
- <${CollapsibleSection} titleKey="members.apps_title">
- <p class="settings-hint">${t('members.apps_hint')}</p>
- <ul class="apps-toggle-list">
- ${APPS.map(a => html`
- <li key=${a.key} class="settings-row">
- <label class="settings-label">
- <input type="checkbox" checked=${activeApps.includes(a.key)}
- disabled=${appsBusy}
- onChange=${() => toggleApp(a.key)} />
- ${' '}${t(a.labelKey)}
- </label>
- </li>
- `)}
- </ul>
- ${appsMsg && html`<p class="error-msg">${appsMsg}</p>`}
+ ${/* Shared directories — the group's root folders, and the structural
+ control everything else in this page sits on top of, so it comes
+ first. Rendered whenever the operator has a route to their node:
+ a live MNP connection (any browser, anywhere) or the loopback API
+ (the node on this machine). It used to require the second, which
+ meant it rendered for nobody on the web. */
+ isNodeAdmin && (connected || nodeDetected) && html`
+ <${CollapsibleSection} titleKey="settings_node.shared_directories_title">
+ <p class="settings-hint">${t('settings_node.shared_directories_hint')}</p>
+ ${!connected && nodeDetected && html`
+ <p class="settings-hint">${t('settings_node.roots_offline_hint')}</p>`}
+ ${/* Read-only against a node that predates the root operations:
+ writable, removable, eject and plug have no older equivalent
+ to fall back to, and an unknown message type is dropped
+ unanswered — a thirty-second wait ending in a timeout, with
+ nothing on screen to say the node simply cannot do it. */''}
+ ${connected && !nodeSupportsAppOps && !nodeDetected && html`
+ <p class="settings-hint">${t('settings_node.roots_node_too_old')}</p>`}
+ <${SharedDirectoriesTable}
+ roots=${effectiveRoots}
+ groupId=${groupId}
+ transport=${transportRef.current}
+ signFn=${adminSignFn}
+ readOnly=${connected && !nodeSupportsAppOps && !nodeDetected}
+ nodeDetected=${nodeDetected}
+ onRootsChange=${loadNodeInfo}
+ onRefreshIndex=${onRefreshIndex} />
</${CollapsibleSection}>
`}
+ ${/* One collapsible section per application, from the registry.
+ Adding an app adds an entry to `apps.js` and a settings file; this
+ loop names none of them. The toggle in the header *is* the
+ enablement control — a separate checkbox list somewhere else meant
+ the operator turned an app on in one place and configured it in
+ another, with the two able to disagree.
+
+ Collapsed by default, and the settings inside are not rendered at
+ all while the app is off: a form for something that is not running
+ is a form whose Save button does nothing anyone can see. */
+ isNodeAdmin && connected && configurableApps().map((app) => html`
+ <${CollapsibleSection} key=${app.key} defaultOpen=${false} title=${html`
+ <span class="settings-meta-title">
+ <${Icon} name=${app.icon} />${' '}${t(app.labelKey)}
+ </span>
+ `} action=${html`
+ <${ToggleSwitch} checked=${activeApps.includes(app.key)}
+ disabled=${appsBusy}
+ onChange=${() => toggleApp(app.key)} />
+ `}>
+ ${!nodeSupportsAppOps && app.key === 'chat'
+ ? html`<p class="settings-hint">${t('settings_node.app_node_too_old')}</p>`
+ : activeApps.includes(app.key)
+ ? html`<${app.Settings}
+ roots=${effectiveRoots} dirs=${folderOptions}
+ settings=${appSettings}
+ saveDirectories=${(paths) => saveAppDirectories(app.key, paths)}
+ transport=${transportRef.current} signFn=${adminSignFn} />`
+ : html`<p class="settings-hint">${t('settings_app.disabled_hint')}</p>`}
+ </${CollapsibleSection}>
+ `)}
+
+ ${appsMsg && html`<p class="error-msg">${appsMsg}</p>`}
+
${/* How hard the node works watching its own disk — indexer.py
DirectoryIndexer. A performance knob, not a permission: it
changes nothing about who can see or do what. */
@@ -936,262 +973,6 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
</${CollapsibleSection}>
`}
- ${/* The on/off switch is per-group (2026-08-24); the custom token and
- query language stay node-wide, one shared credential/cache
- (docs/mediacenter.md §5.5). Both are new outbound third-party
- traffic the node did not have before the Videos app, so both are
- signed operator settings, not display preferences — but two
- independent ones now, saved separately. */
- isNodeAdmin && connected && html`
- <${CollapsibleSection} defaultOpen=${false} title=${html`
- <span class="settings-meta-title">
- <${Icon} name="server" />${' '}${t('settings_node.tmdb_title')}
- <span class="settings-meta-badge ${tmdbEnabled ? 'on' : ''}">
- ${tmdbEnabled ? t('settings_node.tmdb_enabled') : t('settings_node.tmdb_disabled')}
- </span>
- </span>
- `}>
- <p class="settings-hint">${t('settings_node.tmdb_hint')}</p>
- <div class="settings-row">
- <${ToggleSwitch} checked=${tmdbEnabled} disabled=${tmdbEnabledBusy}
- onChange=${(v) => saveTmdbEnabled(v)}
- label=${tmdbEnabled ? t('settings_node.tmdb_enabled') : t('settings_node.tmdb_disabled')} />
- </div>
- <div class="settings-row">
- <label class="settings-label">
- ${t('settings_node.tmdb_token_label')}
- <input type="password" placeholder=${t('settings_node.tmdb_token_placeholder')}
- value=${tmdbTokenDraft} disabled=${tmdbBusy}
- onInput=${e => setTmdbTokenDraft(e.target.value)} />
- </label>
- <p class="settings-hint">
- ${tmdbConfig && tmdbConfig.tokenCustomized
- ? t('settings_node.tmdb_token_customized')
- : t('settings_node.tmdb_token_default')}
- </p>
- </div>
- <div class="settings-row">
- <label class="settings-label">
- ${t('settings_node.tmdb_language_label')}
- <select value=${tmdbLanguage} disabled=${tmdbBusy}
- onChange=${e => setTmdbLanguage(e.target.value)}>
- ${LOCALES.map(l => html`
- <option key=${l.code} value=${TMDB_LANGUAGE_BY_LOCALE[l.code]}>
- ${l.name}
- </option>
- `)}
- </select>
- </label>
- <p class="settings-hint">${t('settings_node.tmdb_language_hint')}</p>
- </div>
- <button class="btn btn-small btn-secondary" style="margin-top:8px"
- disabled=${tmdbBusy} onClick=${() => saveTmdbConfig()}>
- ${tmdbBusy ? t('settings_node.scan_saving') : t('settings_node.tmdb_save')}
- </button>
- ${tmdbMsg && html`<p class="settings-hint">${tmdbMsg}</p>`}
- </${CollapsibleSection}>
- `}
-
- ${/* Same two-part shape as TMDB above: the on/off switch is per-group,
- the contact string stays node-wide (docs/musicbay.md §3.2) —
- one operator identity, not a per-group concern. Unlike TMDB
- there is no token field: MusicBrainz's read endpoints need no
- credential, just a descriptive User-Agent contact. */
- isNodeAdmin && connected && html`
- <${CollapsibleSection} defaultOpen=${false} title=${html`
- <span class="settings-meta-title">
- <${Icon} name="music" />${' '}${t('settings_node.musicbrainz_title')}
- <span class="settings-meta-badge ${mbEnabled ? 'on' : ''}">
- ${mbEnabled ? t('settings_node.musicbrainz_enabled') : t('settings_node.musicbrainz_disabled')}
- </span>
- </span>
- `}>
- <p class="settings-hint">${t('settings_node.musicbrainz_hint')}</p>
- <div class="settings-row">
- <${ToggleSwitch} checked=${mbEnabled} disabled=${mbEnabledBusy}
- onChange=${(v) => saveMusicbrainzEnabled(v)}
- label=${mbEnabled ? t('settings_node.musicbrainz_enabled') : t('settings_node.musicbrainz_disabled')} />
- </div>
- ${mbMsg && html`<p class="settings-hint">${mbMsg}</p>`}
- </${CollapsibleSection}>
- `}
-
- ${/* Which folder is the Videos app's entry point for this group —
- per-group like uploads, not node-wide like TMDB (mediacenter.md
- §5.6). Until one is chosen, the Videos tab says so instead of
- listing anything, and the node runs no TMDB/thumbnail work for
- this group at all (daemon.py's _enrich_new_video_entries). */
- isNodeAdmin && connected
- && ((nodeDetected && nodeRoots.length > 0)
- || activeApps.includes('video') || activeApps.includes('music')
- || activeApps.includes('photo')) && html`
- <${CollapsibleSection} titleKey="settings_node.directories_title">
- <p class="settings-hint">${t('settings_node.directories_hint')}</p>
-
- ${activeApps.includes('video') && html`
- <${RootFolderRow} icon="video"
- titleKey="settings_node.video_root_title" hintKey="settings_node.video_root_hint"
- folders=${rootFolderOptions} value=${videoRoot}
- draft=${videoRootDraft} onDraftChange=${setVideoRootDraft}
- busy=${videoRootBusy} msg=${videoRootMsg} onSave=${saveVideoRoot}
- noneKey="settings_node.video_root_none" saveKey="settings_node.video_root_save" />
- `}
- ${activeApps.includes('music') && html`
- <${RootFolderRow} icon="music"
- titleKey="settings_node.audio_root_title" hintKey="settings_node.audio_root_hint"
- folders=${rootFolderOptions} value=${audioRoot}
- draft=${audioRootDraft} onDraftChange=${setAudioRootDraft}
- busy=${audioRootBusy} msg=${audioRootMsg} onSave=${saveAudioRoot}
- noneKey="settings_node.audio_root_none" saveKey="settings_node.audio_root_save" />
- `}
- ${activeApps.includes('photo') && html`
- <${PhotoRootsRow}
- folders=${rootFolderOptions} value=${photoRoots}
- busy=${photoRootsBusy} msg=${photoRootsMsg} onSave=${savePhotoRoots} />
- `}
- ${/* Roots management (Electron-only, when node is local) — folded into
- the same Directories section as the two root pickers above. */
- nodeDetected && nodeRoots.length > 0 && html`
- <div class="settings-root-row">
- <div class="settings-root-row-title">
- <${Icon} name="server" />
- <h4>${t('settings_node.roots')}</h4>
- </div>
- ${nodeMsg && html`<p class="settings-hint">${nodeMsg}</p>`}
- <div class="node-roots">
- ${nodeRoots.map(r => html`
- <div class="node-root ${!r.available ? 'node-root-unavailable' : ''}"
- key=${r.name}>
- <div class="node-root-info">
- <span class="node-root-name">
- <${Icon} name="folder" />
- ${r.name}
- </span>
- ${r.upload && html`
- <span class="node-root-badge">${t('node.upload_root')}</span>`}
- ${!r.available && html`
- <span class="node-root-badge node-root-badge-warn">
- ${t('node.unavailable')}</span>`}
- </div>
- ${nodeRoots.length > 1 && !r.upload && html`
- <button class="btn btn-small btn-danger"
- disabled=${nodeBusy}
- onClick=${async () => {
- if (!confirm(t('node.root_remove_confirm', { name: r.name }))) return;
- const countBefore = nodeRoots.length;
- setNodeBusy(true); setNodeMsg('');
- try {
- await platform.node.call('DELETE',
- '/api/groups/' + groupId + '/roots/' + encodeURIComponent(r.name));
- await platform.node.call('POST', '/api/reload');
- setNodeMsg(t('node.root_removed'));
- await waitForRootCount(countBefore - 1);
- // Folders (unlike files) only ever arrive via a full
- // index_sync, never index_delta (daemon.py's ongoing
- // push has no `dirs` field) — without this, the
- // Videos/Music root pickers kept offering a folder
- // that no longer existed until the page was reloaded.
- if (onRefreshIndex) await onRefreshIndex();
- } catch (err) { setNodeMsg(platform.bridgeMessage(err)); }
- finally { setNodeBusy(false); }
- }}>
- ${t('node.remove_root')}</button>`}
- </div>
- `)}
- <button class="btn btn-small btn-secondary" style="margin-top:8px"
- disabled=${nodeBusy}
- onClick=${async () => {
- const chosen = await platform.rootPicker.choose();
- if (!chosen) return;
- const countBefore = nodeRoots.length;
- setNodeBusy(true); setNodeMsg(''); setNodeIndexProgress(null);
- try {
- await platform.node.call('POST',
- '/api/groups/' + groupId + '/roots',
- { path: chosen.path, name: chosen.name });
- await platform.node.call('POST', '/api/reload');
- // The root is already scanning in the background on the
- // node regardless of whether anyone watches this — see
- // the "closing the client" test in test_hot_reload_*.py.
- // This is only about not leaving the operator staring at
- // an unchanged screen while it happens.
- await platform.watchIndexProgress(groupId, setNodeIndexProgress);
- setNodeMsg(t('node.root_added'));
- await waitForRootCount(countBefore + 1);
- // See the matching comment on root removal above — a new
- // folder needs a full index_sync to show up anywhere that
- // reads `nodeDirs` (the Videos/Music root pickers), not
- // just in this section's own node-roots list.
- if (onRefreshIndex) await onRefreshIndex();
- } catch (err) { setNodeMsg(platform.bridgeMessage(err)); }
- finally { setNodeBusy(false); }
- }}>
- <${Icon} name="folder-plus" /> ${t('node.add_root')}
- </button>
- ${nodeIndexProgress && nodeIndexProgress.scanning && html`
- <div class="index-progress" style="margin-top:8px">
- <div class="index-progress-bar">
- <div class="index-progress-fill" style="width:${
- nodeIndexProgress.total_bytes
- ? Math.min(100, Math.round(
- 100 * nodeIndexProgress.scanned_bytes / nodeIndexProgress.total_bytes))
- : 0}%"></div>
- </div>
- <div class="index-progress-label">${t('wizard.indexing_progress', {
- pct: nodeIndexProgress.total_bytes
- ? Math.min(100, Math.round(
- 100 * nodeIndexProgress.scanned_bytes / nodeIndexProgress.total_bytes))
- : 0,
- })}</div>
- ${nodeIndexProgress.current_dir && html`
- <div class="index-progress-dir">
- ${t('wizard.indexing_current_dir', { dir: nodeIndexProgress.current_dir })}
- </div>
- `}
- </div>
- `}
- </div>
- </div>
- `}
- </${CollapsibleSection}>
- `}
-
- ${/* Operator only, and only with a live connection: the node is what
- holds and enforces this, so there is nothing to show or change
- without one. */ isNodeAdmin && connected && html`
- <${CollapsibleSection} titleKey="members.uploads_title">
- <div class="settings-row">
- <${ToggleSwitch} checked=${memberUpload} disabled=${uploadBusy}
- onChange=${() => setUploads(!memberUpload)}
- label=${memberUpload ? t('members.uploads_on') : t('members.uploads_off')} />
- </div>
- <p class="settings-hint">${t('members.uploads_hint')}</p>
- ${uploadMsg && html`<p class="error-msg">${uploadMsg}</p>`}
- </${CollapsibleSection}>
- `}
-
- ${/* Upload toggle via loopback when MNP not connected */
- nodeDetected && !connected && html`
- <${CollapsibleSection} titleKey="members.uploads_title">
- <div class="settings-row">
- <${ToggleSwitch} checked=${memberUpload} disabled=${nodeBusy}
- onChange=${async () => {
- setNodeBusy(true); setNodeMsg('');
- try {
- const newVal = !memberUpload;
- await platform.node.call('PUT',
- '/api/groups/' + groupId + '/member-upload',
- { allowed: newVal });
- if (onMemberUpload) onMemberUpload(newVal);
- } catch (err) { setNodeMsg(platform.bridgeMessage(err)); }
- finally { setNodeBusy(false); }
- }}
- label=${memberUpload ? t('members.uploads_on') : t('members.uploads_off')} />
- </div>
- <p class="settings-hint">${t('members.uploads_hint')}</p>
- </${CollapsibleSection}>
- `}
-
${/* Delete/leave — node detach first (reversible), then hub delete
(irreversible). Closed by default: a danger-zone action is one
click away either way, but not the first thing seen on open. */
@@ -1334,4 +1115,4 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
const URL_RE = /\bhttps?:\/\/[^\s<>"']+/gi;
-export { GroupSettingsPanel };
+export { GroupSettingsPanel, SharedDirectoriesTable };
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/helloworld-app-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/helloworld-app-settings.js
new file mode 100644
index 0000000..9058199
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/helloworld-app-settings.js
@@ -0,0 +1,50 @@
+import { html, useState, useEffect } from './vendor/htm-preact.js';
+import { t } from './i18n.js';
+import { useSaver } from './settings-ui.js';
+import { FolderPickerField } from './folder-tree.js';
+
+/**
+ * HelloWorld's settings: one folder, and nothing else.
+ *
+ * This is the whole server-side surface an application needs — no op, no MNP
+ * message, no route, no roster accessor. `saveDirectories` is bound to this
+ * app's registry key by the page, and `ops.set_app_directories` stores the row
+ * under that name without knowing what it is.
+ *
+ * It takes the shared props and no others (`docs/apps.md` §3b), which is what
+ * `test_app_settings_plugin.py` checks of every pane — including this one, so
+ * the reference implementation is held to the contract it demonstrates.
+ */
+function HelloWorldSettings({ roots, dirs, settings, saveDirectories }) {
+ const { busy, msg, run } = useSaver();
+ const [directories, setDirectories] = useState(
+ settings.helloworldDirectories || []);
+
+ useEffect(() => {
+ setDirectories(settings.helloworldDirectories || []);
+ }, [settings.helloworldDirectories]);
+
+ const current = settings.helloworldDirectories || [];
+ const dirty = directories.length !== current.length
+ || directories.some((d, i) => d !== current[i]);
+
+ return html`
+ <div class="app-settings">
+ <${FolderPickerField}
+ label=${t('settings_app.helloworld_directory_label')}
+ hint=${t('settings_app.helloworld_directory_hint')}
+ roots=${roots} dirs=${dirs} mode="single"
+ value=${directories[0] || ''} disabled=${busy}
+ onChange=${(path) => setDirectories(path ? [path] : [])} />
+
+ <button class="btn btn-small btn-secondary" style="margin-top:4px"
+ disabled=${busy || !dirty}
+ onClick=${() => run(() => saveDirectories(directories))}>
+ ${busy ? t('settings_app.saving') : t('settings_app.save')}
+ </button>
+ ${msg && html`<p class="settings-hint">${msg}</p>`}
+ </div>
+ `;
+}
+
+export { HelloWorldSettings };
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/helloworld-app.js b/packages/meshbay-hub/src/meshbay_hub/static/helloworld-app.js
new file mode 100644
index 0000000..01b4ed4
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/helloworld-app.js
@@ -0,0 +1,62 @@
+import { html, useMemo } from './vendor/htm-preact.js';
+import { t } from './i18n.js';
+import { Icon } from './icon.js';
+import { formatSize } from './file-utils.js';
+
+/**
+ * The smallest application this platform can host, and the proof that adding
+ * one costs nothing outside its own two files.
+ *
+ * Everything else in `docs/refactor-groups.md` §3 is asserted by tests that
+ * read source. This is the other kind of evidence: an app nobody wrote a line
+ * of plumbing for, that stores directories, appears as a tab, and lists files —
+ * because the registry entry beside it is genuinely all there is.
+ *
+ * **Not shipped to operators.** It is registered with `dev: true`, which keeps
+ * it out of the tab bar and the settings page unless the reader opts in with
+ * `?dev=1` (same shape as transport.js's `?trace=1`). Deleting the flag would
+ * put a toy app in everybody's group; deleting the app would leave the claim
+ * resting entirely on tests that read text.
+ *
+ * It takes the standard props — see `docs/apps.md` §2 — and reads
+ * `helloworldDirectories`, which nothing on the node knows about by name:
+ * `ops.set_app_directories` keys the row by whatever the app is called.
+ */
+function HelloWorldApp({ entries, availableEntries, helloworldDirectories, status }) {
+ const dirs = helloworldDirectories || [];
+ const pool = availableEntries || entries || [];
+
+ const files = useMemo(() => pool.filter((e) => {
+ const p = e.path || '';
+ return dirs.some((d) => p === d || p.startsWith(d + '/'));
+ }).slice(0, 200), [pool, dirs]);
+
+ if (status !== 'connected') {
+ return html`<p class="page-message">${t('status.connecting_short')}</p>`;
+ }
+
+ return html`
+ <div class="page-content">
+ <h2>${t('helloworld.greeting')}</h2>
+ ${dirs.length === 0 ? html`
+ <p class="page-message">${t('helloworld.no_directory')}</p>
+ ` : html`
+ <p class="settings-hint">
+ ${t('helloworld.counted', { n: files.length })}
+ ${' — '}${dirs.join(', ')}
+ </p>
+ <ul class="hw-list">
+ ${files.map((e) => html`
+ <li key=${e.id} class="hw-item">
+ <${Icon} name="folder" />
+ <span class="hw-name">${e.name}</span>
+ <span class="hw-size">${formatSize(e.size || 0)}</span>
+ </li>
+ `)}
+ </ul>
+ `}
+ </div>
+ `;
+}
+
+export { HelloWorldApp };
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 46168cf..d05d3a3 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -126,6 +126,12 @@ export default {
'group.tab_video': 'Videos',
'group.tab_music': 'Musik',
'group.tab_photos': 'Fotos',
+ 'group.tab_helloworld': 'HelloWorld',
+ 'helloworld.greeting': 'Hallo, Welt!',
+ 'helloworld.no_directory': 'Noch kein Ordner gewählt. Wählen Sie einen in den Einstellungen.',
+ 'helloworld.counted': '{n} Datei(en)',
+ 'settings_app.helloworld_directory_label': 'Ordner',
+ 'settings_app.helloworld_directory_hint': 'Der Ordner, den diese Referenz-App auflistet. Sie zeigt, dass eine neue Anwendung nichts außer ihren beiden Dateien braucht.',
'group.tab_members': 'Mitglieder',
'group.tab_settings': "Einstellungen",
'members.danger_leave_hint': "Sie verlieren den Zugriff auf die Dateien und den Chat dieser Gruppe.",
@@ -154,7 +160,8 @@ export default {
+ 'diese Gruppe hostet.',
'group.upload': 'Hochladen',
'group.mkdir': 'Neuer Ordner',
- 'group.mkdir_prompt': 'Name des neuen Ordners:',
+ 'group.mkdir_prompt': 'Name des neuen Ordners',
+ 'group.mkdir_offline': 'Nicht mit dem Node verbunden.',
'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.',
'device.add_btn': 'Get a linking code',
@@ -169,6 +176,10 @@ export default {
'device.approve_btn': 'Approve',
'device.approved': 'Device linked.',
'group.root_unavailable': '(nicht verfügbar — Laufwerk getrennt)',
+ 'group.root_plug': 'Einstecken',
+ 'group.root_eject': 'Auswerfen',
+ 'group.root_writable': 'Lesen/Schreiben',
+ 'group.root_ejected': '(ausgeworfen)',
'group.view': 'Ansehen',
'group.delete': 'Löschen',
'group.delete_confirm': '{name} löschen?',
@@ -195,6 +206,7 @@ export default {
'chat.placeholder': 'Nachricht schreiben …',
'chat.send': 'Senden',
'chat.attach': 'Datei anhängen',
+ 'chat.attach_read_only': 'Kein beschreibbares freigegebenes Verzeichnis — Anhänge sind aus',
// Video player
'video.loading': '{name} wird geladen …',
@@ -722,10 +734,16 @@ export default {
'node.roots': 'Verzeichnisse',
'node.add_root': 'Verzeichnis hinzufügen',
'node.remove_root': 'Entfernen',
+ 'node.root_no_signing_key': 'Kein Signaturschlüssel verfügbar — koppeln Sie dieses Gerät zuerst mit dem Node',
+ 'node.root_no_route': 'Keine Verbindung zum Node — verbinden Sie sich damit oder verwenden Sie die App auf dem Rechner, der ihn hostet',
+ 'node.root_remove_last': 'Eine Gruppe braucht mindestens ein Verzeichnis',
+ 'node.root_path_hint': 'Der Pfad, wie der Node ihn sieht, auf dem Rechner, der diese Gruppe hostet.',
+ 'node.root_path_placeholder': '/home/user/Media',
+ 'node.root_path': 'Pfad',
+ 'node.directory': 'Verzeichnis',
'node.root_added': 'Verzeichnis hinzugefügt.',
'node.root_remove_confirm': '„{name}" aus dieser Gruppe entfernen?',
'node.root_removed': 'Verzeichnis entfernt. Neustart empfohlen, um den Index zu aktualisieren.',
- 'node.upload_root': 'Uploads',
'node.attach_group': 'Gruppe hinzufügen',
'node.attach_pick': 'Zu hostende Gruppe',
'node.attach_dir': 'Freigegebenes Verzeichnis',
@@ -805,7 +823,41 @@ export default {
'settings_node.photo_roots_add': 'Hinzufügen',
'settings_node.photo_roots_remove': 'Entfernen',
'settings_node.photo_roots_save': 'Speichern',
- 'settings_node.directories_title': 'Verzeichnisse',
+ 'settings_node.shared_directories_title': 'Freigegebene Verzeichnisse',
+ 'settings_node.shared_directories_hint': 'Ordner, die mit dieser Gruppe geteilt werden. Lesen/Schreiben umschalten, um Uploads zu erlauben. Als wechselbar markieren für externe Laufwerke.',
+ 'folder_tree.title_single': 'Ordner auswählen',
+ 'folder_tree.title_multi': 'Ordner auswählen',
+ 'folder_tree.choose': 'Auswählen…',
+ 'folder_tree.remove': 'Entfernen',
+ 'folder_tree.add': 'Ordner hinzufügen…',
+ 'folder_tree.confirm': 'Übernehmen',
+ 'folder_tree.expand': 'Aufklappen',
+ 'folder_tree.collapse': 'Zuklappen',
+ 'folder_tree.nothing_selected': 'Nichts ausgewählt',
+ 'folder_tree.empty': 'Diese Gruppe hat noch keine freigegebenen Verzeichnisse.',
+ 'folder_tree.writable_only': 'Hier sind nur les- und beschreibbare Verzeichnisse wählbar — es wird hineingeschrieben.',
+ 'folder_tree.no_writable_root': 'Diese Gruppe hat kein beschreibbares Verzeichnis. Schalten Sie zuerst eines unter Freigegebene Verzeichnisse frei.',
+ 'folder_tree.read_only_blocked': 'Nur lesen — kein Schreiben möglich',
+ 'settings_app.save': 'Speichern',
+ 'settings_app.saving': 'Wird gespeichert…',
+ 'settings_app.disabled_hint': 'Schalten Sie diese App ein, um sie zu konfigurieren.',
+ 'settings_app.video_directories_label': 'Video-Ordner',
+ 'settings_app.video_directories_hint': 'Wo die Filme und Serien dieser Gruppe liegen. Nichts außerhalb erscheint im Videos-Tab.',
+ 'settings_app.music_directories_label': 'Musik-Ordner',
+ 'settings_app.music_directories_hint': 'Wo die Alben dieser Gruppe liegen. Nichts außerhalb erscheint im Musik-Tab.',
+ 'settings_app.photo_directories_label': 'Foto-Ordner',
+ 'settings_app.photo_directories_hint': 'Wo die Alben dieser Gruppe liegen. Nichts außerhalb erscheint im Fotos-Tab.',
+ 'settings_app.chat_directory_label': 'Ordner für Anhänge',
+ 'settings_app.chat_directory_hint': 'Wohin im Chat gesendete Dateien geschrieben werden. Muss ein beschreibbares Verzeichnis sein.',
+ 'settings_app.chat_no_writable_root': 'Diese Gruppe hat kein beschreibbares Verzeichnis, daher sind Anhänge aus.',
+ 'settings_app.chat_link_preview_label': 'Link-Vorschauen',
+ 'settings_app.chat_link_preview_hint': 'Postet ein Mitglied einen Link, holt der Node Titel und Bild der Seite. Das ist eine Anfrage von Ihrem Rechner an eine Website, die jemand anderes gewählt hat.',
+ 'settings_app.tmdb_token_prompt': 'Registrieren Sie sich bei TMDB, um einen eigenen API-Schlüssel zu erzeugen.',
+ 'settings_app.tmdb_token_link': 'Schlüssel holen',
+ 'settings_node.roots_offline_hint': 'Nicht mit dem Node verbunden — Änderungen laufen über den lokalen Node und greifen beim nächsten Neuladen.',
+ 'settings_node.roots_node_too_old': 'Dieser Node ist älter als diese Seite: Er kann seine Verzeichnisse anzeigen, aber hier nicht ändern. Aktualisieren Sie ihn oder nutzen Sie die meshbay-node root-Befehle.',
+ 'settings_node.app_node_too_old': 'Dieser Node ist älter als diese Seite und hat für diese App noch keine Einstellung. Aktualisieren Sie ihn, um sie hier zu konfigurieren.',
+ 'settings_node.directories_title': 'App-Verzeichnisse',
'settings_node.directories_hint': 'Freigegebene Ordner und welchen davon die Videos-, Musik- und Fotos-Apps als eigene(n) Einstiegspunkt(e) nutzen.',
// Create-group wizard
@@ -909,4 +961,7 @@ export default {
'node.tab_peers': 'Peers',
'node.tab_audit': 'Audit',
'node.tab_settings': 'Settings',
+ 'node.root_rw': 'Lesen/Schreiben',
+ 'node.root_ro': 'Nur lesen',
+ 'node.removable': 'wechselbar',
};
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 2d38e4b..6dbb70f 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -127,6 +127,12 @@ export default {
'group.tab_video': 'Videos',
'group.tab_music': 'Music',
'group.tab_photos': 'Photos',
+ 'group.tab_helloworld': 'HelloWorld',
+ 'helloworld.greeting': 'Hello, World!',
+ 'helloworld.no_directory': 'No folder chosen yet. Pick one in Settings.',
+ 'helloworld.counted': '{n} file(s)',
+ 'settings_app.helloworld_directory_label': 'Folder',
+ 'settings_app.helloworld_directory_hint': 'The folder this reference app lists. It exists to show that adding an application needs nothing beyond its own two files.',
'group.tab_members': 'Members',
'group.tab_settings': "Settings",
'members.danger_leave_hint': "You will lose access to this group's files and chat.",
@@ -154,7 +160,8 @@ export default {
'group.offline_hint': 'Files will appear when a node hosting this group connects.',
'group.upload': 'Upload',
'group.mkdir': 'New folder',
- 'group.mkdir_prompt': 'Name of the new folder:',
+ 'group.mkdir_prompt': 'New folder name',
+ 'group.mkdir_offline': 'Not connected to the node.',
'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.',
'device.add_btn': 'Get a linking code',
@@ -169,6 +176,10 @@ export default {
'device.approve_btn': 'Approve',
'device.approved': 'Device linked.',
'group.root_unavailable': '(unavailable — the drive is disconnected)',
+ 'group.root_ejected': '(ejected)',
+ 'group.root_writable': 'Read/Write',
+ 'group.root_eject': 'Eject',
+ 'group.root_plug': 'Plug in',
'group.view': 'View',
'group.delete': 'Delete',
'group.delete_confirm': 'Delete {name}?',
@@ -194,6 +205,7 @@ export default {
'chat.placeholder': 'Type a message...',
'chat.send': 'Send',
'chat.attach': 'Attach file',
+ 'chat.attach_read_only': 'No writable shared directory — attachments are off',
// Video player
'video.loading': 'Loading {name}...',
@@ -599,8 +611,42 @@ export default {
'settings_node.photo_roots_add': 'Add',
'settings_node.photo_roots_remove': 'Remove',
'settings_node.photo_roots_save': 'Save',
- 'settings_node.directories_title': 'Directories',
- 'settings_node.directories_hint': 'Shared folders, and which of them the Videos, Music and Photos apps use as their own entry point(s).',
+ 'settings_node.shared_directories_title': 'Shared directories',
+ 'settings_node.shared_directories_hint': 'Folders shared with this group. Toggle read-write to allow uploads, mark as removable for external drives.',
+ 'folder_tree.title_single': 'Choose a folder',
+ 'folder_tree.title_multi': 'Choose folders',
+ 'folder_tree.choose': 'Choose…',
+ 'folder_tree.remove': 'Remove',
+ 'folder_tree.add': 'Add a folder…',
+ 'folder_tree.confirm': 'Use these',
+ 'folder_tree.expand': 'Expand',
+ 'folder_tree.collapse': 'Collapse',
+ 'folder_tree.nothing_selected': 'Nothing selected',
+ 'folder_tree.empty': 'This group has no shared directories yet.',
+ 'folder_tree.writable_only': 'Only read-write directories can be chosen here — files are written to this one.',
+ 'folder_tree.no_writable_root': 'This group has no read-write directory. Make one read-write in Shared directories first.',
+ 'folder_tree.read_only_blocked': 'Read-only — cannot be written to',
+ 'settings_app.save': 'Save',
+ 'settings_app.saving': 'Saving…',
+ 'settings_app.disabled_hint': 'Turn this app on to configure it.',
+ 'settings_app.video_directories_label': 'Video folders',
+ 'settings_app.video_directories_hint': 'Where this group\'s films and shows live. Nothing outside them appears in the Videos tab.',
+ 'settings_app.music_directories_label': 'Music folders',
+ 'settings_app.music_directories_hint': 'Where this group\'s albums live. Nothing outside them appears in the Music tab.',
+ 'settings_app.photo_directories_label': 'Photo folders',
+ 'settings_app.photo_directories_hint': 'Where this group\'s albums live. Nothing outside them appears in the Photos tab.',
+ 'settings_app.chat_directory_label': 'Attachment folder',
+ 'settings_app.chat_directory_hint': 'Where files sent in chat are written. Must be a read-write directory.',
+ 'settings_app.chat_no_writable_root': 'This group has no read-write directory, so attachments are off.',
+ 'settings_app.chat_link_preview_label': 'Link previews',
+ 'settings_app.chat_link_preview_hint': 'When a member posts a link, the node fetches the page\'s title and image. That is a request from your machine to a site somebody else chose.',
+ 'settings_app.tmdb_token_prompt': 'Sign up on TMDB to generate your own API key.',
+ 'settings_app.tmdb_token_link': 'Get a key',
+ 'settings_node.roots_offline_hint': 'Not connected to the node — changes go through the local node instead, and take effect on its next reload.',
+ 'settings_node.roots_node_too_old': 'This node is older than this page: it can show its directories but not change them here. Update it, or use the meshbay-node root commands.',
+ 'settings_node.app_node_too_old': 'This node is older than this page and has no setting for this app yet. Update it to configure this here.',
+ 'settings_node.directories_title': 'App directories',
+ 'settings_node.directories_hint': 'Which shared folders the Videos, Music and Photos apps use as their entry point(s).',
// Members
'members.col_role': 'Role',
@@ -753,10 +799,19 @@ export default {
'node.peers': { one: '1 peer', other: '{n} peers' },
'node.no_gek': 'No group key',
'node.roots': 'Directories',
- 'node.upload_root': 'uploads',
+ 'node.directory': 'Directory',
+ 'node.root_rw': 'Writable',
+ 'node.root_ro': 'read-only',
+ 'node.removable': 'Removable',
'node.unavailable': 'unavailable',
'node.add_root': 'Add directory',
'node.remove_root': 'Remove',
+ 'node.root_no_signing_key': 'No signing key available — pair this device with the node first',
+ 'node.root_no_route': 'No route to the node — connect to it, or use the app on the machine hosting it',
+ 'node.root_remove_last': 'A group needs at least one directory',
+ 'node.root_path_hint': 'The path as the node sees it, on the machine hosting this group.',
+ 'node.root_path_placeholder': '/home/user/Media',
+ 'node.root_path': 'Path',
'node.root_added': 'Directory added.',
'node.root_removed': 'Directory removed. Restart recommended to update the index.',
'node.root_remove_confirm': 'Remove "{name}" from this group?',
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 471ad82..aa950cc 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -124,6 +124,12 @@ export default {
'group.tab_video': 'Vídeos',
'group.tab_music': 'Música',
'group.tab_photos': 'Fotos',
+ 'group.tab_helloworld': 'HelloWorld',
+ 'helloworld.greeting': '¡Hola, mundo!',
+ 'helloworld.no_directory': 'Aún no hay carpeta elegida. Elige una en los ajustes.',
+ 'helloworld.counted': '{n} archivo(s)',
+ 'settings_app.helloworld_directory_label': 'Carpeta',
+ 'settings_app.helloworld_directory_hint': 'La carpeta que lista esta aplicación de referencia. Existe para mostrar que añadir una aplicación no requiere más que sus dos archivos.',
'group.tab_members': 'Miembros',
'group.tab_settings': "Ajustes",
'members.danger_leave_hint': "Perderá el acceso a los archivos y al chat de este grupo.",
@@ -152,7 +158,8 @@ export default {
+ 'este grupo.',
'group.upload': 'Subir',
'group.mkdir': 'Nueva carpeta',
- 'group.mkdir_prompt': 'Nombre de la nueva carpeta:',
+ 'group.mkdir_prompt': 'Nombre de la nueva carpeta',
+ 'group.mkdir_offline': 'Sin conexión con el nodo.',
'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.',
'device.add_btn': 'Get a linking code',
@@ -167,6 +174,10 @@ export default {
'device.approve_btn': 'Approve',
'device.approved': 'Device linked.',
'group.root_unavailable': '(no disponible — la unidad está desconectada)',
+ 'group.root_plug': 'Conectar',
+ 'group.root_eject': 'Expulsar',
+ 'group.root_writable': 'Lectura/Escritura',
+ 'group.root_ejected': '(expulsado)',
'group.view': 'Ver',
'group.delete': 'Eliminar',
'group.delete_confirm': '¿Eliminar {name}?',
@@ -193,6 +204,7 @@ export default {
'chat.placeholder': 'Escriba un mensaje...',
'chat.send': 'Enviar',
'chat.attach': 'Adjuntar archivo',
+ 'chat.attach_read_only': 'Ningún directorio compartido con escritura: los adjuntos están desactivados',
// Video player
'video.loading': 'Cargando {name}...',
@@ -718,10 +730,16 @@ export default {
'node.roots': 'Directorios',
'node.add_root': 'Añadir directorio',
'node.remove_root': 'Eliminar',
+ 'node.root_no_signing_key': 'No hay clave de firma disponible: empareja primero este dispositivo con el nodo',
+ 'node.root_no_route': 'No hay ruta al nodo: conéctate a él o usa la aplicación en la máquina que lo aloja',
+ 'node.root_remove_last': 'Un grupo necesita al menos un directorio',
+ 'node.root_path_hint': 'La ruta tal como la ve el nodo, en la máquina que aloja este grupo.',
+ 'node.root_path_placeholder': '/home/user/Media',
+ 'node.root_path': 'Ruta',
+ 'node.directory': 'Directorio',
'node.root_added': 'Directorio añadido.',
'node.root_remove_confirm': '¿Eliminar «{name}» de este grupo?',
'node.root_removed': 'Directorio eliminado. Se recomienda reiniciar para actualizar el índice.',
- 'node.upload_root': 'subidas',
'node.attach_group': 'Añadir grupo',
'node.attach_pick': 'Grupo a alojar',
'node.attach_dir': 'Directorio compartido',
@@ -801,7 +819,41 @@ export default {
'settings_node.photo_roots_add': 'Añadir',
'settings_node.photo_roots_remove': 'Quitar',
'settings_node.photo_roots_save': 'Guardar',
- 'settings_node.directories_title': 'Directorios',
+ 'settings_node.shared_directories_title': 'Directorios compartidos',
+ 'settings_node.shared_directories_hint': 'Carpetas compartidas con este grupo. Active lectura-escritura para permitir subidas, marque como extraíble para unidades externas.',
+ 'folder_tree.title_single': 'Elegir una carpeta',
+ 'folder_tree.title_multi': 'Elegir carpetas',
+ 'folder_tree.choose': 'Elegir…',
+ 'folder_tree.remove': 'Quitar',
+ 'folder_tree.add': 'Añadir una carpeta…',
+ 'folder_tree.confirm': 'Usar',
+ 'folder_tree.expand': 'Expandir',
+ 'folder_tree.collapse': 'Contraer',
+ 'folder_tree.nothing_selected': 'Nada seleccionado',
+ 'folder_tree.empty': 'Este grupo aún no tiene directorios compartidos.',
+ 'folder_tree.writable_only': 'Aquí solo se pueden elegir directorios de lectura-escritura: se escriben archivos en él.',
+ 'folder_tree.no_writable_root': 'Este grupo no tiene ningún directorio de escritura. Activa primero lectura-escritura en Directorios compartidos.',
+ 'folder_tree.read_only_blocked': 'Solo lectura: no se puede escribir',
+ 'settings_app.save': 'Guardar',
+ 'settings_app.saving': 'Guardando…',
+ 'settings_app.disabled_hint': 'Activa esta aplicación para configurarla.',
+ 'settings_app.video_directories_label': 'Carpetas de vídeo',
+ 'settings_app.video_directories_hint': 'Dónde están las películas y series de este grupo. Nada fuera de ellas aparece en la pestaña Vídeos.',
+ 'settings_app.music_directories_label': 'Carpetas de música',
+ 'settings_app.music_directories_hint': 'Dónde están los álbumes de este grupo. Nada fuera de ellos aparece en la pestaña Música.',
+ 'settings_app.photo_directories_label': 'Carpetas de fotos',
+ 'settings_app.photo_directories_hint': 'Dónde están los álbumes de este grupo. Nada fuera de ellos aparece en la pestaña Fotos.',
+ 'settings_app.chat_directory_label': 'Carpeta de adjuntos',
+ 'settings_app.chat_directory_hint': 'Dónde se escriben los archivos enviados en el chat. Debe ser un directorio de lectura-escritura.',
+ 'settings_app.chat_no_writable_root': 'Este grupo no tiene directorio de escritura, así que los adjuntos están desactivados.',
+ 'settings_app.chat_link_preview_label': 'Vistas previas de enlaces',
+ 'settings_app.chat_link_preview_hint': 'Cuando un miembro publica un enlace, el nodo obtiene el título y la imagen de la página. Es una petición desde tu máquina a un sitio que eligió otra persona.',
+ 'settings_app.tmdb_token_prompt': 'Regístrate en TMDB para generar tu propia clave de API.',
+ 'settings_app.tmdb_token_link': 'Obtener una clave',
+ 'settings_node.roots_offline_hint': 'Sin conexión con el nodo: los cambios pasan por el nodo local y se aplican en su próxima recarga.',
+ 'settings_node.roots_node_too_old': 'Este nodo es más antiguo que esta página: puede mostrar sus directorios pero no cambiarlos aquí. Actualízalo o usa los comandos meshbay-node root.',
+ 'settings_node.app_node_too_old': 'Este nodo es más antiguo que esta página y aún no tiene ajustes para esta aplicación. Actualízalo para configurarla aquí.',
+ 'settings_node.directories_title': 'Directorios de apps',
'settings_node.directories_hint': 'Carpetas compartidas, y cuál de ellas usan las apps de Vídeos, Música y Fotos como su(s) propio(s) punto(s) de entrada.',
// Create group wizard
@@ -905,4 +957,7 @@ export default {
'node.tab_peers': 'Peers',
'node.tab_audit': 'Audit',
'node.tab_settings': 'Settings',
+ 'node.root_rw': 'lectura-escritura',
+ 'node.root_ro': 'solo lectura',
+ 'node.removable': 'extraíble',
};
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 48f26a2..daa9f94 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -125,6 +125,12 @@ export default {
'group.tab_video': 'Vidéos',
'group.tab_music': 'Musique',
'group.tab_photos': 'Photos',
+ 'group.tab_helloworld': 'HelloWorld',
+ 'helloworld.greeting': 'Bonjour, monde !',
+ 'helloworld.no_directory': 'Aucun dossier choisi. Choisissez-en un dans les réglages.',
+ 'helloworld.counted': '{n} fichier(s)',
+ 'settings_app.helloworld_directory_label': 'Dossier',
+ 'settings_app.helloworld_directory_hint': 'Le dossier que cette application de référence liste. Elle existe pour montrer qu\'ajouter une application ne demande rien de plus que ses deux fichiers.',
'group.tab_members': 'Membres',
'group.tab_settings': "Paramètres",
'members.danger_leave_hint': "Vous perdrez l’accès aux fichiers et à la discussion de ce groupe.",
@@ -153,7 +159,8 @@ export default {
+ 'groupe se connectera.',
'group.upload': 'Envoyer',
'group.mkdir': 'Nouveau dossier',
- 'group.mkdir_prompt': 'Nom du nouveau dossier :',
+ 'group.mkdir_prompt': 'Nom du nouveau dossier',
+ 'group.mkdir_offline': 'Non connecté au nœud.',
'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.',
'device.add_btn': 'Obtenir un code de liaison',
@@ -168,6 +175,10 @@ export default {
'device.approve_btn': 'Approuver',
'device.approved': 'Appareil lié.',
'group.root_unavailable': '(indisponible — le disque est déconnecté)',
+ 'group.root_ejected': '(éjecté)',
+ 'group.root_writable': 'Lecture/Écriture',
+ 'group.root_eject': 'Éjecter',
+ 'group.root_plug': 'Reconnecter',
'group.view': 'Afficher',
'group.delete': 'Supprimer',
'group.delete_confirm': 'Supprimer {name} ?',
@@ -194,6 +205,7 @@ export default {
'chat.placeholder': 'Écrivez un message...',
'chat.send': 'Envoyer',
'chat.attach': 'Joindre un fichier',
+ 'chat.attach_read_only': 'Aucun répertoire partagé en écriture — pièces jointes désactivées',
// Video player
'video.loading': 'Chargement de {name}...',
@@ -727,10 +739,19 @@ export default {
'node.roots': 'Répertoires',
'node.add_root': 'Ajouter un répertoire',
'node.remove_root': 'Retirer',
+ 'node.root_no_signing_key': 'Aucune clé de signature disponible — appairez d\'abord cet appareil avec le nœud',
+ 'node.root_no_route': 'Aucune route vers le nœud — connectez-vous à lui, ou utilisez l\'application sur la machine qui l\'héberge',
+ 'node.root_remove_last': 'Un groupe a besoin d\'au moins un répertoire',
+ 'node.root_path_hint': 'Le chemin tel que le nœud le voit, sur la machine qui héberge ce groupe.',
+ 'node.root_path_placeholder': '/home/user/Media',
+ 'node.root_path': 'Chemin',
'node.root_added': 'Répertoire ajouté.',
'node.root_remove_confirm': 'Retirer « {name} » de ce groupe ?',
'node.root_removed': 'Répertoire retiré. Un redémarrage est recommandé pour mettre à jour l\'index.',
- 'node.upload_root': 'uploads',
+ 'node.directory': 'Répertoire',
+ 'node.root_rw': 'Écriture',
+ 'node.root_ro': 'lecture seule',
+ 'node.removable': 'Amovible',
'node.attach_group': 'Ajouter un groupe',
'node.attach_pick': 'Groupe à héberger',
'node.attach_dir': 'Répertoire partagé',
@@ -816,8 +837,42 @@ export default {
'settings_node.photo_roots_add': 'Ajouter',
'settings_node.photo_roots_remove': 'Retirer',
'settings_node.photo_roots_save': 'Enregistrer',
- 'settings_node.directories_title': 'Répertoires',
- 'settings_node.directories_hint': 'Dossiers partagés, et lequel d\'entre eux les applications Vidéos, Musique et Photos utilisent comme leur(s) propre(s) point(s) d\'entrée.',
+ 'settings_node.shared_directories_title': 'Répertoires partagés',
+ 'settings_node.shared_directories_hint': 'Dossiers partagés avec ce groupe. Activez lecture-écriture pour autoriser les envois, marquez comme amovible pour les disques externes.',
+ 'folder_tree.title_single': 'Choisir un dossier',
+ 'folder_tree.title_multi': 'Choisir des dossiers',
+ 'folder_tree.choose': 'Choisir…',
+ 'folder_tree.remove': 'Retirer',
+ 'folder_tree.add': 'Ajouter un dossier…',
+ 'folder_tree.confirm': 'Utiliser',
+ 'folder_tree.expand': 'Déplier',
+ 'folder_tree.collapse': 'Replier',
+ 'folder_tree.nothing_selected': 'Rien de sélectionné',
+ 'folder_tree.empty': 'Ce groupe n\'a pas encore de répertoire partagé.',
+ 'folder_tree.writable_only': 'Seuls les répertoires en lecture-écriture sont proposés ici — des fichiers y sont écrits.',
+ 'folder_tree.no_writable_root': 'Ce groupe n\'a aucun répertoire en écriture. Activez d\'abord lecture-écriture dans Répertoires partagés.',
+ 'folder_tree.read_only_blocked': 'Lecture seule — écriture impossible',
+ 'settings_app.save': 'Enregistrer',
+ 'settings_app.saving': 'Enregistrement…',
+ 'settings_app.disabled_hint': 'Activez cette application pour la configurer.',
+ 'settings_app.video_directories_label': 'Dossiers vidéo',
+ 'settings_app.video_directories_hint': 'Où vivent les films et séries de ce groupe. Rien en dehors n\'apparaît dans l\'onglet Vidéos.',
+ 'settings_app.music_directories_label': 'Dossiers musique',
+ 'settings_app.music_directories_hint': 'Où vivent les albums de ce groupe. Rien en dehors n\'apparaît dans l\'onglet Musique.',
+ 'settings_app.photo_directories_label': 'Dossiers photo',
+ 'settings_app.photo_directories_hint': 'Où vivent les albums de ce groupe. Rien en dehors n\'apparaît dans l\'onglet Photos.',
+ 'settings_app.chat_directory_label': 'Dossier des pièces jointes',
+ 'settings_app.chat_directory_hint': 'Où sont écrits les fichiers envoyés dans le chat. Doit être un répertoire en lecture-écriture.',
+ 'settings_app.chat_no_writable_root': 'Ce groupe n\'a aucun répertoire en écriture : les pièces jointes sont désactivées.',
+ 'settings_app.chat_link_preview_label': 'Aperçus des liens',
+ 'settings_app.chat_link_preview_hint': 'Quand un membre poste un lien, le nœud récupère le titre et l\'image de la page. C\'est une requête depuis votre machine vers un site choisi par quelqu\'un d\'autre.',
+ 'settings_app.tmdb_token_prompt': 'Créez un compte TMDB pour générer votre propre clé d\'API.',
+ 'settings_app.tmdb_token_link': 'Obtenir une clé',
+ 'settings_node.roots_offline_hint': 'Non connecté au nœud — les changements passent par le nœud local et prennent effet à son prochain rechargement.',
+ 'settings_node.roots_node_too_old': 'Ce nœud est plus ancien que cette page : il peut afficher ses répertoires mais pas les modifier ici. Mettez-le à jour, ou utilisez les commandes meshbay-node root.',
+ 'settings_node.app_node_too_old': 'Ce nœud est plus ancien que cette page et n\'a pas encore de réglage pour cette application. Mettez-le à jour pour la configurer ici.',
+ 'settings_node.directories_title': 'Répertoires des applications',
+ 'settings_node.directories_hint': 'Quel(s) dossier(s) partagés les applications Vidéos, Musique et Photos utilisent comme leur(s) propre(s) point(s) d\'entrée.',
// Create group wizard
'wizard.title': 'Créer un groupe',
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 564f34b..364c26c 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -125,6 +125,12 @@ export default {
'group.tab_video': 'Video',
'group.tab_music': 'Musica',
'group.tab_photos': 'Foto',
+ 'group.tab_helloworld': 'HelloWorld',
+ 'helloworld.greeting': 'Ciao, mondo!',
+ 'helloworld.no_directory': 'Nessuna cartella scelta. Scegline una nelle impostazioni.',
+ 'helloworld.counted': '{n} file',
+ 'settings_app.helloworld_directory_label': 'Cartella',
+ 'settings_app.helloworld_directory_hint': 'La cartella elencata da questa applicazione di riferimento. Serve a mostrare che aggiungerne una non richiede altro che i suoi due file.',
'group.tab_members': 'Membri',
'group.tab_settings': "Impostazioni",
'members.danger_leave_hint': "Perderai l’accesso ai file e alla chat di questo gruppo.",
@@ -153,7 +159,8 @@ export default {
+ 'questo gruppo.',
'group.upload': 'Carica',
'group.mkdir': 'Nuova cartella',
- 'group.mkdir_prompt': 'Nome della nuova cartella:',
+ 'group.mkdir_prompt': 'Nome della nuova cartella',
+ 'group.mkdir_offline': 'Non connesso al nodo.',
'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.',
'device.add_btn': 'Get a linking code',
@@ -168,6 +175,10 @@ export default {
'device.approve_btn': 'Approve',
'device.approved': 'Device linked.',
'group.root_unavailable': '(non disponibile — l’unità è scollegata)',
+ 'group.root_plug': 'Ricollega',
+ 'group.root_eject': 'Espelli',
+ 'group.root_writable': 'Lettura/Scrittura',
+ 'group.root_ejected': '(espulso)',
'group.view': 'Visualizza',
'group.delete': 'Elimina',
'group.delete_confirm': 'Eliminare {name}?',
@@ -194,6 +205,7 @@ export default {
'chat.placeholder': 'Scriva un messaggio...',
'chat.send': 'Invia',
'chat.attach': 'Allega un file',
+ 'chat.attach_read_only': 'Nessuna directory condivisa scrivibile: gli allegati sono disattivati',
// Video player
'video.loading': 'Caricamento di {name}...',
@@ -726,10 +738,16 @@ export default {
'node.roots': 'Directory',
'node.add_root': 'Aggiungi directory',
'node.remove_root': 'Rimuovi',
+ 'node.root_no_signing_key': 'Nessuna chiave di firma disponibile: associa prima questo dispositivo al nodo',
+ 'node.root_no_route': 'Nessuna via verso il nodo: connettiti a esso oppure usa l\'applicazione sulla macchina che lo ospita',
+ 'node.root_remove_last': 'Un gruppo ha bisogno di almeno una directory',
+ 'node.root_path_hint': 'Il percorso come lo vede il nodo, sulla macchina che ospita questo gruppo.',
+ 'node.root_path_placeholder': '/home/user/Media',
+ 'node.root_path': 'Percorso',
+ 'node.directory': 'Directory',
'node.root_added': 'Directory aggiunta.',
'node.root_remove_confirm': 'Rimuovere «{name}» da questo gruppo?',
'node.root_removed': "Directory rimossa. Si consiglia un riavvio per aggiornare l'indice.",
- 'node.upload_root': 'caricamenti',
'node.attach_group': 'Aggiungi gruppo',
'node.attach_pick': 'Gruppo da ospitare',
'node.attach_dir': 'Directory condivisa',
@@ -815,7 +833,41 @@ export default {
'settings_node.photo_roots_add': 'Aggiungi',
'settings_node.photo_roots_remove': 'Rimuovi',
'settings_node.photo_roots_save': 'Salva',
- 'settings_node.directories_title': 'Directory',
+ 'settings_node.shared_directories_title': 'Directory condivise',
+ 'settings_node.shared_directories_hint': 'Cartelle condivise con questo gruppo. Attiva lettura-scrittura per consentire il caricamento, segna come rimovibile per unità esterne.',
+ 'folder_tree.title_single': 'Scegli una cartella',
+ 'folder_tree.title_multi': 'Scegli le cartelle',
+ 'folder_tree.choose': 'Scegli…',
+ 'folder_tree.remove': 'Rimuovi',
+ 'folder_tree.add': 'Aggiungi una cartella…',
+ 'folder_tree.confirm': 'Usa',
+ 'folder_tree.expand': 'Espandi',
+ 'folder_tree.collapse': 'Comprimi',
+ 'folder_tree.nothing_selected': 'Niente selezionato',
+ 'folder_tree.empty': 'Questo gruppo non ha ancora directory condivise.',
+ 'folder_tree.writable_only': 'Qui si possono scegliere solo directory in lettura-scrittura: ci vengono scritti dei file.',
+ 'folder_tree.no_writable_root': 'Questo gruppo non ha directory scrivibili. Attiva prima lettura-scrittura in Directory condivise.',
+ 'folder_tree.read_only_blocked': 'Sola lettura: non vi si può scrivere',
+ 'settings_app.save': 'Salva',
+ 'settings_app.saving': 'Salvataggio…',
+ 'settings_app.disabled_hint': 'Attiva questa applicazione per configurarla.',
+ 'settings_app.video_directories_label': 'Cartelle video',
+ 'settings_app.video_directories_hint': 'Dove si trovano film e serie di questo gruppo. Nulla al di fuori compare nella scheda Video.',
+ 'settings_app.music_directories_label': 'Cartelle musica',
+ 'settings_app.music_directories_hint': 'Dove si trovano gli album di questo gruppo. Nulla al di fuori compare nella scheda Musica.',
+ 'settings_app.photo_directories_label': 'Cartelle foto',
+ 'settings_app.photo_directories_hint': 'Dove si trovano gli album di questo gruppo. Nulla al di fuori compare nella scheda Foto.',
+ 'settings_app.chat_directory_label': 'Cartella degli allegati',
+ 'settings_app.chat_directory_hint': 'Dove vengono scritti i file inviati in chat. Deve essere una directory in lettura-scrittura.',
+ 'settings_app.chat_no_writable_root': 'Questo gruppo non ha directory scrivibili, quindi gli allegati sono disattivati.',
+ 'settings_app.chat_link_preview_label': 'Anteprime dei link',
+ 'settings_app.chat_link_preview_hint': 'Quando un membro pubblica un link, il nodo recupera titolo e immagine della pagina. È una richiesta dalla tua macchina a un sito scelto da qualcun altro.',
+ 'settings_app.tmdb_token_prompt': 'Registrati su TMDB per generare la tua chiave API.',
+ 'settings_app.tmdb_token_link': 'Ottieni una chiave',
+ 'settings_node.roots_offline_hint': 'Non connesso al nodo: le modifiche passano dal nodo locale e hanno effetto al successivo ricaricamento.',
+ 'settings_node.roots_node_too_old': 'Questo nodo è più vecchio di questa pagina: può mostrare le sue directory ma non modificarle qui. Aggiornalo, oppure usa i comandi meshbay-node root.',
+ 'settings_node.app_node_too_old': 'Questo nodo è più vecchio di questa pagina e non ha ancora impostazioni per questa applicazione. Aggiornalo per configurarla qui.',
+ 'settings_node.directories_title': 'Directory delle app',
'settings_node.directories_hint': 'Cartelle condivise, e quale di esse le app Video, Musica e Foto usano come proprio/i punto/i di ingresso.',
// Create-group wizard
@@ -919,4 +971,7 @@ export default {
'node.tab_peers': 'Peers',
'node.tab_audit': 'Audit',
'node.tab_settings': 'Settings',
+ 'node.root_rw': 'lettura-scrittura',
+ 'node.root_ro': 'sola lettura',
+ 'node.removable': 'rimovibile',
};
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 3eed305..f778fc4 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -123,6 +123,12 @@ export default {
'group.tab_video': '動画',
'group.tab_music': '音楽',
'group.tab_photos': '写真',
+ 'group.tab_helloworld': 'HelloWorld',
+ 'helloworld.greeting': 'ハロー、ワールド!',
+ 'helloworld.no_directory': 'フォルダーが未選択です。設定で選んでください。',
+ 'helloworld.counted': '{n} 件のファイル',
+ 'settings_app.helloworld_directory_label': 'フォルダー',
+ 'settings_app.helloworld_directory_hint': 'この参照アプリが一覧表示するフォルダーです。アプリの追加に必要なのは自身の 2 つのファイルだけであることを示すために存在します。',
'group.tab_members': 'メンバー',
'group.tab_settings': "設定",
'members.danger_leave_hint': "このグループのファイルとチャットにアクセスできなくなります。",
@@ -151,7 +157,8 @@ export default {
+ 'ファイルが表示されます。',
'group.upload': 'アップロード',
'group.mkdir': '新しいフォルダー',
- 'group.mkdir_prompt': '新しいフォルダーの名前:',
+ 'group.mkdir_prompt': '新しいフォルダー名',
+ 'group.mkdir_offline': 'ノードに接続していません。',
'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.',
'device.add_btn': 'Get a linking code',
@@ -166,6 +173,10 @@ export default {
'device.approve_btn': 'Approve',
'device.approved': 'Device linked.',
'group.root_unavailable': '(利用不可 — ドライブが切断されています)',
+ 'group.root_plug': '接続する',
+ 'group.root_eject': '取り外す',
+ 'group.root_writable': '読み書き可',
+ 'group.root_ejected': '(取り外し済み)',
'group.view': '表示',
'group.delete': '削除',
'group.delete_confirm': '{name} を削除しますか?',
@@ -191,6 +202,7 @@ export default {
'chat.placeholder': 'メッセージを入力…',
'chat.send': '送信',
'chat.attach': 'ファイルを添付',
+ 'chat.attach_read_only': '書き込み可能な共有ディレクトリがありません — 添付は無効です',
// Video player
'video.loading': '{name} を読み込んでいます…',
@@ -712,10 +724,16 @@ export default {
'node.roots': 'ディレクトリ',
'node.add_root': 'ディレクトリを追加',
'node.remove_root': '削除',
+ 'node.root_no_signing_key': '署名鍵がありません — 先にこの端末をノードとペアリングしてください',
+ 'node.root_no_route': 'ノードへの経路がありません — 接続するか、ノードを動かしているマシンでアプリを使ってください',
+ 'node.root_remove_last': 'グループには少なくとも 1 つのディレクトリが必要です',
+ 'node.root_path_hint': 'このグループをホストしているマシン上で、ノードから見たパスです。',
+ 'node.root_path_placeholder': '/home/user/Media',
+ 'node.root_path': 'パス',
+ 'node.directory': 'ディレクトリ',
'node.root_added': 'ディレクトリを追加しました。',
'node.root_remove_confirm': '「{name}」をこのグループから削除しますか?',
'node.root_removed': 'ディレクトリを削除しました。インデックスを更新するために再起動を推奨します。',
- 'node.upload_root': 'アップロード',
'node.attach_group': 'グループを追加',
'node.attach_pick': 'ホストするグループ',
'node.attach_dir': '共有ディレクトリ',
@@ -799,7 +817,41 @@ export default {
'settings_node.photo_roots_add': '追加',
'settings_node.photo_roots_remove': '削除',
'settings_node.photo_roots_save': '保存',
- 'settings_node.directories_title': 'ディレクトリ',
+ 'settings_node.shared_directories_title': '共有ディレクトリ',
+ 'settings_node.shared_directories_hint': 'このグループと共有されているフォルダー。読み書きを切り替えてアップロードを許可し、外付けドライブにはリムーバブルを設定します。',
+ 'folder_tree.title_single': 'フォルダーを選択',
+ 'folder_tree.title_multi': 'フォルダーを選択',
+ 'folder_tree.choose': '選択…',
+ 'folder_tree.remove': '外す',
+ 'folder_tree.add': 'フォルダーを追加…',
+ 'folder_tree.confirm': '決定',
+ 'folder_tree.expand': '展開',
+ 'folder_tree.collapse': '折りたたむ',
+ 'folder_tree.nothing_selected': '未選択',
+ 'folder_tree.empty': 'このグループにはまだ共有ディレクトリがありません。',
+ 'folder_tree.writable_only': 'ここには読み書き可能なディレクトリしか選べません — ファイルが書き込まれます。',
+ 'folder_tree.no_writable_root': 'このグループには書き込み可能なディレクトリがありません。まず「共有ディレクトリ」で読み書きを有効にしてください。',
+ 'folder_tree.read_only_blocked': '読み取り専用 — 書き込みできません',
+ 'settings_app.save': '保存',
+ 'settings_app.saving': '保存中…',
+ 'settings_app.disabled_hint': 'このアプリを有効にすると設定できます。',
+ 'settings_app.video_directories_label': '動画フォルダー',
+ 'settings_app.video_directories_hint': 'このグループの映画や番組がある場所です。それ以外は動画タブに表示されません。',
+ 'settings_app.music_directories_label': '音楽フォルダー',
+ 'settings_app.music_directories_hint': 'このグループのアルバムがある場所です。それ以外は音楽タブに表示されません。',
+ 'settings_app.photo_directories_label': '写真フォルダー',
+ 'settings_app.photo_directories_hint': 'このグループのアルバムがある場所です。それ以外は写真タブに表示されません。',
+ 'settings_app.chat_directory_label': '添付ファイルのフォルダー',
+ 'settings_app.chat_directory_hint': 'チャットで送られたファイルの書き込み先です。読み書き可能なディレクトリである必要があります。',
+ 'settings_app.chat_no_writable_root': 'このグループには書き込み可能なディレクトリがないため、添付は無効です。',
+ 'settings_app.chat_link_preview_label': 'リンクのプレビュー',
+ 'settings_app.chat_link_preview_hint': 'メンバーがリンクを投稿すると、ノードがページのタイトルと画像を取得します。これは他人が選んだサイトへの、あなたのマシンからのリクエストです。',
+ 'settings_app.tmdb_token_prompt': 'TMDB に登録して、自分の API キーを発行してください。',
+ 'settings_app.tmdb_token_link': 'キーを取得',
+ 'settings_node.roots_offline_hint': 'ノードに接続していません — 変更はローカルノード経由で行われ、次回の再読み込みで反映されます。',
+ 'settings_node.roots_node_too_old': 'このノードはこのページより古く、ディレクトリの表示はできますがここでの変更はできません。更新するか、meshbay-node root コマンドを使ってください。',
+ 'settings_node.app_node_too_old': 'このノードはこのページより古く、このアプリの設定をまだ持っていません。ここで設定するには更新してください。',
+ 'settings_node.directories_title': 'アプリのディレクトリ',
'settings_node.directories_hint': '共有フォルダと、動画・音楽・写真の各アプリがそれぞれの起点として使用するフォルダです。',
// Wizard
@@ -903,4 +955,7 @@ export default {
'node.tab_peers': 'Peers',
'node.tab_audit': 'Audit',
'node.tab_settings': 'Settings',
+ 'node.root_rw': '読み書き',
+ 'node.root_ro': '読み取り専用',
+ 'node.removable': 'リムーバブル',
};
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 711a22f..6e6842e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -126,6 +126,12 @@ export default {
'group.tab_video': "Video's",
'group.tab_music': 'Muziek',
'group.tab_photos': "Foto's",
+ 'group.tab_helloworld': 'HelloWorld',
+ 'helloworld.greeting': 'Hallo, wereld!',
+ 'helloworld.no_directory': 'Nog geen map gekozen. Kies er een bij Instellingen.',
+ 'helloworld.counted': '{n} bestand(en)',
+ 'settings_app.helloworld_directory_label': 'Map',
+ 'settings_app.helloworld_directory_hint': 'De map die deze referentie-app toont. Hij bestaat om te laten zien dat een app toevoegen niets meer vergt dan zijn eigen twee bestanden.',
'group.tab_members': 'Leden',
'group.tab_settings': "Instellingen",
'members.danger_leave_hint': "U verliest de toegang tot de bestanden en de chat van deze groep.",
@@ -154,7 +160,8 @@ export default {
+ 'die deze groep host.',
'group.upload': 'Uploaden',
'group.mkdir': 'Nieuwe map',
- 'group.mkdir_prompt': 'Naam van de nieuwe map:',
+ 'group.mkdir_prompt': 'Naam van de nieuwe map',
+ 'group.mkdir_offline': 'Niet verbonden met de node.',
'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.',
'device.add_btn': 'Get a linking code',
@@ -169,6 +176,10 @@ export default {
'device.approve_btn': 'Approve',
'device.approved': 'Device linked.',
'group.root_unavailable': '(niet beschikbaar — de schijf is losgekoppeld)',
+ 'group.root_plug': 'Aansluiten',
+ 'group.root_eject': 'Uitwerpen',
+ 'group.root_writable': 'Lezen/schrijven',
+ 'group.root_ejected': '(uitgeworpen)',
'group.view': 'Bekijken',
'group.delete': 'Verwijderen',
'group.delete_confirm': '{name} verwijderen?',
@@ -195,6 +206,7 @@ export default {
'chat.placeholder': 'Typ een bericht...',
'chat.send': 'Versturen',
'chat.attach': 'Bestand bijvoegen',
+ 'chat.attach_read_only': 'Geen beschrijfbare gedeelde map — bijlagen staan uit',
// Video player
'video.loading': '{name} wordt geladen...',
@@ -728,10 +740,16 @@ export default {
'node.roots': 'Mappen',
'node.add_root': 'Map toevoegen',
'node.remove_root': 'Verwijderen',
+ 'node.root_no_signing_key': 'Geen ondertekeningssleutel beschikbaar — koppel dit apparaat eerst aan de node',
+ 'node.root_no_route': 'Geen route naar de node — maak verbinding, of gebruik de app op de machine die hem host',
+ 'node.root_remove_last': 'Een groep heeft minstens één map nodig',
+ 'node.root_path_hint': 'Het pad zoals de node het ziet, op de machine die deze groep host.',
+ 'node.root_path_placeholder': '/home/user/Media',
+ 'node.root_path': 'Pad',
+ 'node.directory': 'Map',
'node.root_added': 'Map toegevoegd.',
'node.root_remove_confirm': '„{name}" uit deze groep verwijderen?',
'node.root_removed': 'Map verwijderd. Herstart aanbevolen om de index bij te werken.',
- 'node.upload_root': 'uploads',
'node.attach_group': 'Groep toevoegen',
'node.attach_pick': 'Groep om te hosten',
'node.attach_dir': 'Gedeelde map',
@@ -817,7 +835,41 @@ export default {
'settings_node.photo_roots_add': 'Toevoegen',
'settings_node.photo_roots_remove': 'Verwijderen',
'settings_node.photo_roots_save': 'Opslaan',
- 'settings_node.directories_title': 'Mappen',
+ 'settings_node.shared_directories_title': 'Gedeelde mappen',
+ 'settings_node.shared_directories_hint': 'Mappen gedeeld met deze groep. Schakel lezen-schrijven in om uploads toe te staan, markeer als verwijderbaar voor externe schijven.',
+ 'folder_tree.title_single': 'Kies een map',
+ 'folder_tree.title_multi': 'Kies mappen',
+ 'folder_tree.choose': 'Kiezen…',
+ 'folder_tree.remove': 'Verwijderen',
+ 'folder_tree.add': 'Map toevoegen…',
+ 'folder_tree.confirm': 'Gebruiken',
+ 'folder_tree.expand': 'Uitklappen',
+ 'folder_tree.collapse': 'Inklappen',
+ 'folder_tree.nothing_selected': 'Niets geselecteerd',
+ 'folder_tree.empty': 'Deze groep heeft nog geen gedeelde mappen.',
+ 'folder_tree.writable_only': 'Hier zijn alleen lees-schrijfmappen te kiezen — er wordt in geschreven.',
+ 'folder_tree.no_writable_root': 'Deze groep heeft geen beschrijfbare map. Zet er eerst één op lezen-schrijven bij Gedeelde mappen.',
+ 'folder_tree.read_only_blocked': 'Alleen-lezen — kan niet worden beschreven',
+ 'settings_app.save': 'Opslaan',
+ 'settings_app.saving': 'Opslaan…',
+ 'settings_app.disabled_hint': 'Zet deze app aan om hem in te stellen.',
+ 'settings_app.video_directories_label': 'Videomappen',
+ 'settings_app.video_directories_hint': 'Waar de films en series van deze groep staan. Niets daarbuiten verschijnt op het tabblad Video\'s.',
+ 'settings_app.music_directories_label': 'Muziekmappen',
+ 'settings_app.music_directories_hint': 'Waar de albums van deze groep staan. Niets daarbuiten verschijnt op het tabblad Muziek.',
+ 'settings_app.photo_directories_label': 'Fotomappen',
+ 'settings_app.photo_directories_hint': 'Waar de albums van deze groep staan. Niets daarbuiten verschijnt op het tabblad Foto\'s.',
+ 'settings_app.chat_directory_label': 'Map voor bijlagen',
+ 'settings_app.chat_directory_hint': 'Waar in de chat verstuurde bestanden worden geschreven. Moet een lees-schrijfmap zijn.',
+ 'settings_app.chat_no_writable_root': 'Deze groep heeft geen beschrijfbare map, dus bijlagen staan uit.',
+ 'settings_app.chat_link_preview_label': 'Linkvoorbeelden',
+ 'settings_app.chat_link_preview_hint': 'Als een lid een link plaatst, haalt de node de titel en afbeelding van de pagina op. Dat is een verzoek vanaf uw machine naar een site die iemand anders koos.',
+ 'settings_app.tmdb_token_prompt': 'Meld u aan bij TMDB om uw eigen API-sleutel te maken.',
+ 'settings_app.tmdb_token_link': 'Sleutel ophalen',
+ 'settings_node.roots_offline_hint': 'Niet verbonden met de node — wijzigingen gaan via de lokale node en worden bij de volgende herlaadbeurt actief.',
+ 'settings_node.roots_node_too_old': 'Deze node is ouder dan deze pagina: hij kan zijn mappen tonen maar hier niet wijzigen. Werk hem bij, of gebruik de meshbay-node root-opdrachten.',
+ 'settings_node.app_node_too_old': 'Deze node is ouder dan deze pagina en heeft nog geen instelling voor deze app. Werk hem bij om die hier in te stellen.',
+ 'settings_node.directories_title': 'App-mappen',
'settings_node.directories_hint': 'Gedeelde mappen, en welke daarvan de Video\'s-, Muziek- en Foto\'s-apps als eigen startpunt(en) gebruiken.',
// Create group wizard
@@ -921,4 +973,7 @@ export default {
'node.tab_peers': 'Peers',
'node.tab_audit': 'Audit',
'node.tab_settings': 'Settings',
+ 'node.root_rw': 'lezen-schrijven',
+ 'node.root_ro': 'alleen-lezen',
+ 'node.removable': 'verwijderbaar',
};
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 67615a2..c8cbd16 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -130,6 +130,12 @@ export default {
'group.tab_video': 'Wideo',
'group.tab_music': 'Muzyka',
'group.tab_photos': 'Zdjęcia',
+ 'group.tab_helloworld': 'HelloWorld',
+ 'helloworld.greeting': 'Witaj, świecie!',
+ 'helloworld.no_directory': 'Nie wybrano folderu. Wybierz go w ustawieniach.',
+ 'helloworld.counted': 'Plików: {n}',
+ 'settings_app.helloworld_directory_label': 'Folder',
+ 'settings_app.helloworld_directory_hint': 'Folder wypisywany przez tę aplikację referencyjną. Istnieje, aby pokazać, że dodanie aplikacji nie wymaga niczego poza jej dwoma plikami.',
'group.tab_members': 'Członkowie',
'group.tab_settings': "Ustawienia",
'members.danger_leave_hint': "Utracisz dostęp do plików i czatu tej grupy.",
@@ -157,7 +163,8 @@ export default {
'group.offline_hint': 'Pliki pojawią się, gdy połączy się node hostujący tę grupę.',
'group.upload': 'Wyślij',
'group.mkdir': 'Nowy folder',
- 'group.mkdir_prompt': 'Nazwa nowego folderu:',
+ 'group.mkdir_prompt': 'Nazwa nowego folderu',
+ 'group.mkdir_offline': 'Brak połączenia z węzłem.',
'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.',
'device.add_btn': 'Get a linking code',
@@ -172,6 +179,10 @@ export default {
'device.approve_btn': 'Approve',
'device.approved': 'Device linked.',
'group.root_unavailable': '(niedostępne — dysk jest odłączony)',
+ 'group.root_plug': 'Podłącz',
+ 'group.root_eject': 'Odłącz',
+ 'group.root_writable': 'Odczyt/zapis',
+ 'group.root_ejected': '(odłączony)',
'group.view': 'Podgląd',
'group.delete': 'Usuń',
'group.delete_confirm': 'Usunąć {name}?',
@@ -200,6 +211,7 @@ export default {
'chat.placeholder': 'Napisz wiadomość...',
'chat.send': 'Wyślij',
'chat.attach': 'Załącz plik',
+ 'chat.attach_read_only': 'Brak zapisywalnego katalogu współdzielonego — załączniki wyłączone',
// Video player
'video.loading': 'Wczytywanie {name}...',
@@ -750,10 +762,16 @@ export default {
'node.roots': 'Katalogi',
'node.add_root': 'Dodaj katalog',
'node.remove_root': 'Usuń',
+ 'node.root_no_signing_key': 'Brak klucza podpisu — najpierw sparuj to urządzenie z węzłem',
+ 'node.root_no_route': 'Brak połączenia z węzłem — połącz się z nim albo użyj aplikacji na komputerze, który go hostuje',
+ 'node.root_remove_last': 'Grupa wymaga co najmniej jednego katalogu',
+ 'node.root_path_hint': 'Ścieżka widziana przez węzeł, na komputerze hostującym tę grupę.',
+ 'node.root_path_placeholder': '/home/user/Media',
+ 'node.root_path': 'Ścieżka',
+ 'node.directory': 'Katalog',
'node.root_added': 'Katalog dodany.',
'node.root_remove_confirm': 'Usunąć „{name}" z tej grupy?',
'node.root_removed': 'Katalog usunięty. Zalecany restart w celu odświeżenia indeksu.',
- 'node.upload_root': 'przesyłanie',
'node.attach_group': 'Dodaj grupę',
'node.attach_pick': 'Grupa do hostowania',
'node.attach_dir': 'Katalog współdzielony',
@@ -843,7 +861,41 @@ export default {
'settings_node.photo_roots_add': 'Dodaj',
'settings_node.photo_roots_remove': 'Usuń',
'settings_node.photo_roots_save': 'Zapisz',
- 'settings_node.directories_title': 'Katalogi',
+ 'settings_node.shared_directories_title': 'Katalogi udostępnione',
+ 'settings_node.shared_directories_hint': 'Foldery udostępnione tej grupie. Przełącz odczyt-zapis, aby zezwolić na przesyłanie, oznacz jako wymienny dla dysków zewnętrznych.',
+ 'folder_tree.title_single': 'Wybierz folder',
+ 'folder_tree.title_multi': 'Wybierz foldery',
+ 'folder_tree.choose': 'Wybierz…',
+ 'folder_tree.remove': 'Usuń',
+ 'folder_tree.add': 'Dodaj folder…',
+ 'folder_tree.confirm': 'Użyj',
+ 'folder_tree.expand': 'Rozwiń',
+ 'folder_tree.collapse': 'Zwiń',
+ 'folder_tree.nothing_selected': 'Nic nie wybrano',
+ 'folder_tree.empty': 'Ta grupa nie ma jeszcze katalogów współdzielonych.',
+ 'folder_tree.writable_only': 'Tutaj można wybrać tylko katalogi do odczytu i zapisu — zapisywane są w nim pliki.',
+ 'folder_tree.no_writable_root': 'Ta grupa nie ma katalogu do zapisu. Najpierw włącz odczyt i zapis w Katalogach współdzielonych.',
+ 'folder_tree.read_only_blocked': 'Tylko do odczytu — nie można zapisywać',
+ 'settings_app.save': 'Zapisz',
+ 'settings_app.saving': 'Zapisywanie…',
+ 'settings_app.disabled_hint': 'Włącz tę aplikację, aby ją skonfigurować.',
+ 'settings_app.video_directories_label': 'Foldery wideo',
+ 'settings_app.video_directories_hint': 'Gdzie znajdują się filmy i seriale tej grupy. Nic poza nimi nie pojawi się w zakładce Wideo.',
+ 'settings_app.music_directories_label': 'Foldery muzyki',
+ 'settings_app.music_directories_hint': 'Gdzie znajdują się albumy tej grupy. Nic poza nimi nie pojawi się w zakładce Muzyka.',
+ 'settings_app.photo_directories_label': 'Foldery zdjęć',
+ 'settings_app.photo_directories_hint': 'Gdzie znajdują się albumy tej grupy. Nic poza nimi nie pojawi się w zakładce Zdjęcia.',
+ 'settings_app.chat_directory_label': 'Folder załączników',
+ 'settings_app.chat_directory_hint': 'Gdzie zapisywane są pliki wysłane na czacie. Musi to być katalog do odczytu i zapisu.',
+ 'settings_app.chat_no_writable_root': 'Ta grupa nie ma katalogu do zapisu, więc załączniki są wyłączone.',
+ 'settings_app.chat_link_preview_label': 'Podglądy linków',
+ 'settings_app.chat_link_preview_hint': 'Gdy członek wysyła link, węzeł pobiera tytuł i obraz strony. To żądanie z Twojego komputera do witryny wybranej przez kogoś innego.',
+ 'settings_app.tmdb_token_prompt': 'Zarejestruj się w TMDB, aby wygenerować własny klucz API.',
+ 'settings_app.tmdb_token_link': 'Pobierz klucz',
+ 'settings_node.roots_offline_hint': 'Brak połączenia z węzłem — zmiany przechodzą przez węzeł lokalny i zaczną działać po jego następnym przeładowaniu.',
+ 'settings_node.roots_node_too_old': 'Ten węzeł jest starszy niż ta strona: może pokazać swoje katalogi, ale nie zmieni ich tutaj. Zaktualizuj go albo użyj poleceń meshbay-node root.',
+ 'settings_node.app_node_too_old': 'Ten węzeł jest starszy niż ta strona i nie ma jeszcze ustawień tej aplikacji. Zaktualizuj go, aby skonfigurować ją tutaj.',
+ 'settings_node.directories_title': 'Katalogi aplikacji',
'settings_node.directories_hint': 'Katalogi udostępnione oraz to, który z nich aplikacje Wideo, Muzyka i Zdjęcia traktują jako własny punkt (punkty) wejścia.',
// Create-group wizard
@@ -947,4 +999,7 @@ export default {
'node.tab_peers': 'Peers',
'node.tab_audit': 'Audit',
'node.tab_settings': 'Settings',
+ 'node.root_rw': 'odczyt-zapis',
+ 'node.root_ro': 'tylko odczyt',
+ 'node.removable': 'wymienny',
};
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 2d07994..6eb2242 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
@@ -126,6 +126,12 @@ export default {
'group.tab_video': 'Vídeos',
'group.tab_music': 'Música',
'group.tab_photos': 'Fotos',
+ 'group.tab_helloworld': 'HelloWorld',
+ 'helloworld.greeting': 'Olá, mundo!',
+ 'helloworld.no_directory': 'Nenhuma pasta escolhida. Escolha uma nas configurações.',
+ 'helloworld.counted': '{n} arquivo(s)',
+ 'settings_app.helloworld_directory_label': 'Pasta',
+ 'settings_app.helloworld_directory_hint': 'A pasta que este aplicativo de referência lista. Existe para mostrar que adicionar um aplicativo não exige nada além de seus dois arquivos.',
'group.tab_members': 'Membros',
'group.tab_settings': "Configurações",
'members.danger_leave_hint': "Você perderá o acesso aos arquivos e ao chat deste grupo.",
@@ -154,7 +160,8 @@ export default {
+ 'se conectar.',
'group.upload': 'Enviar',
'group.mkdir': 'Nova pasta',
- 'group.mkdir_prompt': 'Nome da nova pasta:',
+ 'group.mkdir_prompt': 'Nome da nova pasta',
+ 'group.mkdir_offline': 'Sem conexão com o nó.',
'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.',
'device.add_btn': 'Get a linking code',
@@ -169,6 +176,10 @@ export default {
'device.approve_btn': 'Approve',
'device.approved': 'Device linked.',
'group.root_unavailable': '(indisponível — a unidade está desconectada)',
+ 'group.root_plug': 'Conectar',
+ 'group.root_eject': 'Ejetar',
+ 'group.root_writable': 'Leitura/Escrita',
+ 'group.root_ejected': '(ejetado)',
'group.view': 'Visualizar',
'group.delete': 'Excluir',
'group.delete_confirm': 'Excluir {name}?',
@@ -195,6 +206,7 @@ export default {
'chat.placeholder': 'Escreva uma mensagem...',
'chat.send': 'Enviar',
'chat.attach': 'Anexar arquivo',
+ 'chat.attach_read_only': 'Nenhum diretório compartilhado gravável — anexos desativados',
// Video player
'video.loading': 'Carregando {name}...',
@@ -719,10 +731,16 @@ export default {
'node.roots': 'Diretórios',
'node.add_root': 'Adicionar diretório',
'node.remove_root': 'Remover',
+ 'node.root_no_signing_key': 'Nenhuma chave de assinatura disponível — pareie este dispositivo com o nó primeiro',
+ 'node.root_no_route': 'Sem rota até o nó — conecte-se a ele ou use o aplicativo na máquina que o hospeda',
+ 'node.root_remove_last': 'Um grupo precisa de pelo menos um diretório',
+ 'node.root_path_hint': 'O caminho como o nó o vê, na máquina que hospeda este grupo.',
+ 'node.root_path_placeholder': '/home/user/Media',
+ 'node.root_path': 'Caminho',
+ 'node.directory': 'Diretório',
'node.root_added': 'Diretório adicionado.',
'node.root_remove_confirm': 'Remover "{name}" deste grupo?',
'node.root_removed': 'Diretório removido. Reinicialização recomendada para atualizar o índice.',
- 'node.upload_root': 'uploads',
'node.attach_group': 'Adicionar grupo',
'node.attach_pick': 'Grupo a hospedar',
'node.attach_dir': 'Diretório compartilhado',
@@ -802,7 +820,41 @@ export default {
'settings_node.photo_roots_add': 'Adicionar',
'settings_node.photo_roots_remove': 'Remover',
'settings_node.photo_roots_save': 'Salvar',
- 'settings_node.directories_title': 'Diretórios',
+ 'settings_node.shared_directories_title': 'Diretórios compartilhados',
+ 'settings_node.shared_directories_hint': 'Pastas compartilhadas com este grupo. Alterne leitura-escrita para permitir uploads, marque como removível para unidades externas.',
+ 'folder_tree.title_single': 'Escolher uma pasta',
+ 'folder_tree.title_multi': 'Escolher pastas',
+ 'folder_tree.choose': 'Escolher…',
+ 'folder_tree.remove': 'Remover',
+ 'folder_tree.add': 'Adicionar uma pasta…',
+ 'folder_tree.confirm': 'Usar',
+ 'folder_tree.expand': 'Expandir',
+ 'folder_tree.collapse': 'Recolher',
+ 'folder_tree.nothing_selected': 'Nada selecionado',
+ 'folder_tree.empty': 'Este grupo ainda não tem diretórios compartilhados.',
+ 'folder_tree.writable_only': 'Aqui só é possível escolher diretórios de leitura e escrita — arquivos são gravados nele.',
+ 'folder_tree.no_writable_root': 'Este grupo não tem diretório gravável. Ative leitura e escrita em Diretórios compartilhados primeiro.',
+ 'folder_tree.read_only_blocked': 'Somente leitura — não é possível gravar',
+ 'settings_app.save': 'Salvar',
+ 'settings_app.saving': 'Salvando…',
+ 'settings_app.disabled_hint': 'Ative este aplicativo para configurá-lo.',
+ 'settings_app.video_directories_label': 'Pastas de vídeo',
+ 'settings_app.video_directories_hint': 'Onde ficam os filmes e séries deste grupo. Nada fora delas aparece na aba Vídeos.',
+ 'settings_app.music_directories_label': 'Pastas de música',
+ 'settings_app.music_directories_hint': 'Onde ficam os álbuns deste grupo. Nada fora delas aparece na aba Música.',
+ 'settings_app.photo_directories_label': 'Pastas de fotos',
+ 'settings_app.photo_directories_hint': 'Onde ficam os álbuns deste grupo. Nada fora delas aparece na aba Fotos.',
+ 'settings_app.chat_directory_label': 'Pasta de anexos',
+ 'settings_app.chat_directory_hint': 'Onde os arquivos enviados no chat são gravados. Precisa ser um diretório de leitura e escrita.',
+ 'settings_app.chat_no_writable_root': 'Este grupo não tem diretório gravável, então os anexos estão desativados.',
+ 'settings_app.chat_link_preview_label': 'Prévias de links',
+ 'settings_app.chat_link_preview_hint': 'Quando alguém publica um link, o nó busca o título e a imagem da página. É uma requisição da sua máquina para um site escolhido por outra pessoa.',
+ 'settings_app.tmdb_token_prompt': 'Cadastre-se no TMDB para gerar sua própria chave de API.',
+ 'settings_app.tmdb_token_link': 'Obter uma chave',
+ 'settings_node.roots_offline_hint': 'Sem conexão com o nó — as alterações passam pelo nó local e entram em vigor no próximo recarregamento.',
+ 'settings_node.roots_node_too_old': 'Este nó é mais antigo que esta página: ele pode mostrar seus diretórios, mas não alterá-los aqui. Atualize-o ou use os comandos meshbay-node root.',
+ 'settings_node.app_node_too_old': 'Este nó é mais antigo que esta página e ainda não tem configuração para este aplicativo. Atualize-o para configurá-lo aqui.',
+ 'settings_node.directories_title': 'Diretórios de apps',
'settings_node.directories_hint': 'Pastas compartilhadas, e qual delas os apps Vídeos, Música e Fotos tratam como seu(s) próprio(s) ponto(s) de entrada.',
// Create group wizard
@@ -906,4 +958,7 @@ export default {
'node.tab_peers': 'Peers',
'node.tab_audit': 'Audit',
'node.tab_settings': 'Settings',
+ 'node.root_rw': 'leitura-escrita',
+ 'node.root_ro': 'somente leitura',
+ 'node.removable': 'removível',
};
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 fd0c7f9..044611e 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
@@ -123,6 +123,12 @@ export default {
'group.tab_video': '视频',
'group.tab_music': '音乐',
'group.tab_photos': '照片',
+ 'group.tab_helloworld': 'HelloWorld',
+ 'helloworld.greeting': '你好,世界!',
+ 'helloworld.no_directory': '尚未选择文件夹。请在设置中选择。',
+ 'helloworld.counted': '{n} 个文件',
+ 'settings_app.helloworld_directory_label': '文件夹',
+ 'settings_app.helloworld_directory_hint': '该参考应用所列出的文件夹。它的存在是为了说明:新增一个应用只需要它自己的两个文件。',
'group.tab_members': '成员',
'group.tab_settings': "设置",
'members.danger_leave_hint': "您将无法再访问该群组的文件和聊天。",
@@ -150,7 +156,8 @@ export default {
'group.offline_hint': '当托管此群组的 node 连接后,文件就会出现。',
'group.upload': '上传',
'group.mkdir': '新建文件夹',
- 'group.mkdir_prompt': '新文件夹的名称:',
+ 'group.mkdir_prompt': '新文件夹名称',
+ 'group.mkdir_offline': '未连接到节点。',
'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.',
'device.add_btn': 'Get a linking code',
@@ -165,6 +172,10 @@ export default {
'device.approve_btn': 'Approve',
'device.approved': 'Device linked.',
'group.root_unavailable': '(不可用 — 驱动器已断开连接)',
+ 'group.root_plug': '重新接入',
+ 'group.root_eject': '弹出',
+ 'group.root_writable': '读写',
+ 'group.root_ejected': '(已弹出)',
'group.view': '查看',
'group.delete': '删除',
'group.delete_confirm': '删除 {name}?',
@@ -189,6 +200,7 @@ export default {
'chat.placeholder': '输入消息…',
'chat.send': '发送',
'chat.attach': '添加附件',
+ 'chat.attach_read_only': '没有可写的共享目录 — 附件已停用',
// Video player
'video.loading': '正在加载 {name}…',
@@ -699,10 +711,16 @@ export default {
'node.roots': '目录',
'node.add_root': '添加目录',
'node.remove_root': '移除',
+ 'node.root_no_signing_key': '没有可用的签名密钥 — 请先将本设备与节点配对',
+ 'node.root_no_route': '无法连接到节点 — 请先连接,或在运行该节点的机器上使用应用',
+ 'node.root_remove_last': '每个群组至少需要一个目录',
+ 'node.root_path_hint': '托管该群组的机器上,节点所看到的路径。',
+ 'node.root_path_placeholder': '/home/user/Media',
+ 'node.root_path': '路径',
+ 'node.directory': '目录',
'node.root_added': '目录已添加。',
'node.root_remove_confirm': '从此群组中移除"{name}"?',
'node.root_removed': '目录已移除。建议重启以更新索引。',
- 'node.upload_root': '上传目录',
'node.attach_group': '添加群组',
'node.attach_pick': '要托管的群组',
'node.attach_dir': '共享目录',
@@ -786,7 +804,41 @@ export default {
'settings_node.photo_roots_add': '添加',
'settings_node.photo_roots_remove': '移除',
'settings_node.photo_roots_save': '保存',
- 'settings_node.directories_title': '目录',
+ 'settings_node.shared_directories_title': '共享目录',
+ 'settings_node.shared_directories_hint': '与此群组共享的文件夹。切换读写以允许上传,标记为可移除用于外置驱动器。',
+ 'folder_tree.title_single': '选择文件夹',
+ 'folder_tree.title_multi': '选择文件夹',
+ 'folder_tree.choose': '选择…',
+ 'folder_tree.remove': '移除',
+ 'folder_tree.add': '添加文件夹…',
+ 'folder_tree.confirm': '使用',
+ 'folder_tree.expand': '展开',
+ 'folder_tree.collapse': '折叠',
+ 'folder_tree.nothing_selected': '未选择',
+ 'folder_tree.empty': '该群组还没有共享目录。',
+ 'folder_tree.writable_only': '此处只能选择可读写的目录 — 文件会写入其中。',
+ 'folder_tree.no_writable_root': '该群组没有可写目录。请先在「共享目录」中将某个目录设为读写。',
+ 'folder_tree.read_only_blocked': '只读 — 无法写入',
+ 'settings_app.save': '保存',
+ 'settings_app.saving': '正在保存…',
+ 'settings_app.disabled_hint': '启用该应用后即可配置。',
+ 'settings_app.video_directories_label': '视频文件夹',
+ 'settings_app.video_directories_hint': '该群组的影片和剧集所在位置。其外的内容不会出现在「视频」标签页。',
+ 'settings_app.music_directories_label': '音乐文件夹',
+ 'settings_app.music_directories_hint': '该群组的专辑所在位置。其外的内容不会出现在「音乐」标签页。',
+ 'settings_app.photo_directories_label': '照片文件夹',
+ 'settings_app.photo_directories_hint': '该群组的相册所在位置。其外的内容不会出现在「照片」标签页。',
+ 'settings_app.chat_directory_label': '附件文件夹',
+ 'settings_app.chat_directory_hint': '聊天中发送的文件写入位置。必须是可读写的目录。',
+ 'settings_app.chat_no_writable_root': '该群组没有可写目录,因此附件已停用。',
+ 'settings_app.chat_link_preview_label': '链接预览',
+ 'settings_app.chat_link_preview_hint': '当成员发布链接时,节点会抓取该页面的标题和图片。这是从你的机器发往他人所选站点的请求。',
+ 'settings_app.tmdb_token_prompt': '在 TMDB 注册以生成你自己的 API 密钥。',
+ 'settings_app.tmdb_token_link': '获取密钥',
+ 'settings_node.roots_offline_hint': '未连接到节点 — 变更将通过本地节点进行,并在其下次重新加载时生效。',
+ 'settings_node.roots_node_too_old': '该节点比本页面旧:它能显示自己的目录,但无法在此更改。请更新节点,或使用 meshbay-node root 命令。',
+ 'settings_node.app_node_too_old': '该节点比本页面旧,尚不支持此应用的设置。请更新节点后在此配置。',
+ 'settings_node.directories_title': '应用目录',
'settings_node.directories_hint': '共享文件夹,以及“视频”“音乐”和“照片”应用各自使用哪个(些)作为入口。',
// Create group wizard
@@ -891,4 +943,7 @@ export default {
'node.tab_peers': 'Peers',
'node.tab_audit': 'Audit',
'node.tab_settings': 'Settings',
+ 'node.root_rw': '读写',
+ 'node.root_ro': '只读',
+ 'node.removable': '可移除',
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/music-app-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/music-app-settings.js
new file mode 100644
index 0000000..8ce2ab6
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/music-app-settings.js
@@ -0,0 +1,60 @@
+import { html, useState, useEffect } from './vendor/htm-preact.js';
+import { t } from './i18n.js';
+import { ToggleSwitch, useSaver } from './settings-ui.js';
+import { FolderPickerField } from './folder-tree.js';
+
+/**
+ * The Music app's operator settings.
+ *
+ * Same shape as Videos, minus a credential: MusicBrainz's read endpoints need
+ * no API key, only a descriptive User-Agent, and that is one operator identity
+ * held node-wide rather than a per-group setting (docs/musicbay.md §3.2).
+ *
+ * Several folders, for the same reason Videos has several: a music library
+ * that lives on two drives had no way to say so.
+ */
+function MusicSettings({ roots, dirs, settings, saveDirectories, transport, signFn }) {
+ const { busy, msg, run } = useSaver();
+ const [directories, setDirectories] = useState(settings.musicDirectories || []);
+ const [mbEnabled, setMbEnabled] = useState(settings.musicbrainzEnabled !== false);
+
+ useEffect(() => {
+ setDirectories(settings.musicDirectories || []);
+ }, [settings.musicDirectories]);
+ useEffect(() => {
+ setMbEnabled(settings.musicbrainzEnabled !== false);
+ }, [settings.musicbrainzEnabled]);
+
+ const current = settings.musicDirectories || [];
+ const dirsDirty = directories.length !== current.length
+ || directories.some((d, i) => d !== current[i]);
+
+ return html`
+ <div class="app-settings">
+ <${FolderPickerField}
+ label=${t('settings_app.music_directories_label')}
+ hint=${t('settings_app.music_directories_hint')}
+ roots=${roots} dirs=${dirs} mode="multi"
+ value=${directories} disabled=${busy}
+ onChange=${setDirectories} />
+
+ <button class="app-save" disabled=${busy || !dirsDirty}
+ onClick=${() => run(() => saveDirectories(directories))}>
+ ${busy ? t('settings_app.saving') : t('settings_app.save')}
+ </button>
+
+ <h4 class="app-settings-sub">${t('settings_node.musicbrainz_title')}</h4>
+ <p class="settings-hint">${t('settings_node.musicbrainz_hint')}</p>
+ <div class="settings-row">
+ <${ToggleSwitch} checked=${mbEnabled} disabled=${busy}
+ onChange=${(v) => { setMbEnabled(v);
+ run(() => transport.setMusicbrainzEnabled(v, signFn)); }}
+ label=${mbEnabled ? t('settings_node.musicbrainz_enabled')
+ : t('settings_node.musicbrainz_disabled')} />
+ </div>
+ ${msg && html`<p class="settings-hint">${msg}</p>`}
+ </div>
+ `;
+}
+
+export { MusicSettings };
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 616f169..f622b5d 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/music-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/music-app.js
@@ -52,19 +52,20 @@ function foldKey(s) {
// tag/cover enrichment for this group before a root is chosen either,
// daemon.py's _enrich_new_audio_entries), not "the whole shared tree" —
// falling back to that would just show files nothing has enriched.
-function underAudioRoot(entry, audioRoot) {
- if (!audioRoot) return false;
+function underAudioRoot(entry, directories) {
+ const dirs = directories || [];
+ if (!dirs.length) return false;
const p = entry.path || '';
- return p === audioRoot || p.startsWith(audioRoot + '/');
+ return dirs.some((d) => p === d || p.startsWith(d + '/'));
}
-function groupMusicEntries(entries, audioRoot) {
+function groupMusicEntries(entries, musicDirectories) {
const tracks = []; // no artist at all, even after the folder fallback -- rare, but real
const byArtistKey = new Map(); // foldKey(artist) -> { artist, albumsByKey: Map, loose: [] }
for (const e of entries) {
if (e.type !== 'audio') continue;
- if (!underAudioRoot(e, audioRoot)) continue;
+ if (!underAudioRoot(e, musicDirectories)) continue;
const artistRaw = (e.artist || '').trim();
if (!artistRaw) { tracks.push(e); continue; }
const artistKey = foldKey(artistRaw);
@@ -453,7 +454,8 @@ function FlatList({ tracks, artists, onPlayQueue }) {
// -- shell --------------------------------------------------------------------
function MusicApp({
- groupId, transportRef, gekRef, status, entries, audioRoot, musicbrainzConfig, onPlayQueue,
+ groupId, transportRef, gekRef, status, entries, availableEntries,
+ musicDirectories, musicbrainzConfig, onPlayQueue,
hideFilter,
}) {
const [mode, setMode] = useState(loadViewMode);
@@ -465,8 +467,11 @@ function MusicApp({
const setModeAndSave = (m) => { setMode(m); saveViewMode(m); };
+ const musicEntries = availableEntries || entries;
+ const configured = (musicDirectories || []).length > 0;
const { tracks, artists, albums } = useMemo(
- () => groupMusicEntries(entries, audioRoot), [entries, audioRoot]);
+ () => groupMusicEntries(musicEntries, musicDirectories),
+ [musicEntries, musicDirectories]);
const needle = filter.trim().toLowerCase();
const filteredArtists = useMemo(() => {
@@ -492,10 +497,10 @@ function MusicApp({
${status === 'offline' && html`
<p class="page-message">${t('group.offline_title')} ${t('group.offline_hint')}</p>
`}
- ${status === 'connected' && !audioRoot && html`
+ ${status === 'connected' && !configured && html`
<p class="page-message">${t('music.no_root_configured')}</p>
`}
- ${status === 'connected' && audioRoot && html`
+ ${status === 'connected' && configured && html`
<div class="video-toolbar">
<button class="tb-btn ${mode === 'grid' ? 'active' : ''}"
onClick=${() => setModeAndSave('grid')}>
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/node-page.js b/packages/meshbay-hub/src/meshbay_hub/static/node-page.js
index ec73128..2e216a9 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/node-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/node-page.js
@@ -750,13 +750,21 @@ export function NodePage({ groups }) {
<${Icon} name="folder" />
${r.name}
</span>
- ${r.upload && html`
- <span class="node-root-badge">${t('node.upload_root')}</span>`}
- ${!r.available && html`
+ ${r.writable && html`
+ <span class="node-root-badge">${t('node.root_rw')}</span>`}
+ ${r.removable && html`
+ <span class="node-root-badge">${t('node.removable')}</span>`}
+ ${r.ejected ? html`
+ <span class="node-root-badge node-root-badge-warn">
+ ${t('group.root_ejected')}</span>`
+ : !r.available && html`
<span class="node-root-badge node-root-badge-warn">
${t('node.unavailable')}</span>`}
</div>
- ${(g.roots || []).length > 1 && !r.upload && html`
+ ${/* Removing a writable root is allowed now — several can be
+ writable, and a group with none is a valid read-only
+ group. The last root is still the one that cannot go. */''}
+ ${(g.roots || []).length > 1 && html`
<button class="btn btn-small btn-danger"
disabled=${busy} onClick=${() => removeRoot(g.id, r.name)}>
${t('node.remove_root')}</button>`}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/photos-app-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/photos-app-settings.js
new file mode 100644
index 0000000..8656fa5
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/photos-app-settings.js
@@ -0,0 +1,44 @@
+import { html, useState, useEffect } from './vendor/htm-preact.js';
+import { t } from './i18n.js';
+import { useSaver } from './settings-ui.js';
+import { FolderPickerField } from './folder-tree.js';
+
+/**
+ * The Photos app's operator settings: folders, and nothing else.
+ *
+ * Photos was always several folders (docs/photos.md §2.1) — a photo library
+ * is routinely scattered, with no single natural root — so this pane is what
+ * the other two grew into rather than the exception it used to be. No
+ * third-party service: EXIF is read locally on the node, and nothing about a
+ * photo leaves the machine to be identified.
+ */
+function PhotoSettings({ roots, dirs, settings, saveDirectories }) {
+ const { busy, msg, run } = useSaver();
+ const [directories, setDirectories] = useState(settings.photoDirectories || []);
+ useEffect(() => {
+ setDirectories(settings.photoDirectories || []);
+ }, [settings.photoDirectories]);
+
+ const current = settings.photoDirectories || [];
+ const dirty = directories.length !== current.length
+ || directories.some((d, i) => d !== current[i]);
+
+ return html`
+ <div class="app-settings">
+ <${FolderPickerField}
+ label=${t('settings_app.photo_directories_label')}
+ hint=${t('settings_app.photo_directories_hint')}
+ roots=${roots} dirs=${dirs} mode="multi"
+ value=${directories} disabled=${busy}
+ onChange=${setDirectories} />
+
+ <button class="app-save" disabled=${busy || !dirty}
+ onClick=${() => run(() => saveDirectories(directories))}>
+ ${busy ? t('settings_app.saving') : t('settings_app.save')}
+ </button>
+ ${msg && html`<p class="settings-hint">${msg}</p>`}
+ </div>
+ `;
+}
+
+export { PhotoSettings };
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 e6f3482..a58af8c 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/photos-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/photos-app.js
@@ -322,7 +322,8 @@ function AlbumView({ album, entries, transportRef, gekRef, setError, onBack, rea
// ── shell ────────────────────────────────────────────────────────────────────
function PhotosApp({
- groupId, transportRef, gekRef, status, entries, photoRoots, setError,
+ groupId, transportRef, gekRef, status, entries, availableEntries,
+ photoDirectories, setError,
hideFilter, readOnly,
}) {
const [openDir, setOpenDir] = useState(null);
@@ -330,8 +331,10 @@ function PhotosApp({
useEffect(() => { setOpenDir(null); setFilter(''); }, [groupId]);
+ const photoEntries = availableEntries || entries;
const albums = useMemo(
- () => groupPhotoAlbums(entries, photoRoots), [entries, photoRoots]);
+ () => groupPhotoAlbums(photoEntries, photoDirectories),
+ [photoEntries, photoDirectories]);
const needle = filter.trim().toLowerCase();
const filteredAlbums = useMemo(() => (!needle ? albums : albums.filter(
@@ -346,10 +349,10 @@ function PhotosApp({
${status === 'offline' && html`
<p class="page-message">${t('group.offline_title')} ${t('group.offline_hint')}</p>
`}
- ${status === 'connected' && (!photoRoots || photoRoots.length === 0) && html`
+ ${status === 'connected' && (photoDirectories || []).length === 0 && html`
<p class="page-message">${t('photo.no_roots_configured')}</p>
`}
- ${status === 'connected' && photoRoots && photoRoots.length > 0 && !openAlbum && html`
+ ${status === 'connected' && (photoDirectories || []).length > 0 && !openAlbum && html`
<div class="photo-toolbar">
${!hideFilter && html`<div class="tb-search">
<${Icon} name="search" />
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 608bb81..4ed3df1 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/search-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/search-page.js
@@ -147,10 +147,12 @@ async function fetchGroupIndex(groupId, token, bundleKey, username, userId) {
clearTimeout(timer);
const indexMsg = await transport.fetchIndex();
+ // Plural, with the old scalars as the fallback for a node still speaking
+ // MNP 1.0 — the same reading group-page.js does on its own handshake.
const roots = {
- videoRoot: ack.video_root || '',
- audioRoot: ack.audio_root || '',
- photoRoots: ack.photo_roots || [],
+ video: ack.video_directories || (ack.video_root ? [ack.video_root] : []),
+ music: ack.music_directories || (ack.audio_root ? [ack.audio_root] : []),
+ photo: ack.photo_directories || ack.photo_roots || [],
};
// Which of the reader's groups sit on their own node — the tie-breaker
// when the same file is announced by several of them
@@ -201,10 +203,29 @@ async function fetchAllIndexes(groups, token, username, userId, onProgress, onBa
// -- SearchPage ---------------------------------------------------------------
-function underRoot(entry, root) {
- if (!root) return false;
+function underRoot(entry, directories) {
+ const dirs = directories || [];
+ if (!dirs.length) return false;
const p = entry.path || '';
- return p === root || p.startsWith(root + '/');
+ return dirs.some((d) => p === d || p.startsWith(d + '/'));
+}
+
+/**
+ * An app's directories out of a cached group, in either shape.
+ *
+ * The cache lives in IndexedDB and outlives a deploy, so a reader opening
+ * Search after this ships still has entries written by the previous version —
+ * `{videoRoot: 'X'}` where this now writes `{video: ['X']}`. Reading only the
+ * new shape would empty their Videos results with no explanation and no way
+ * to tell it from "nothing matched".
+ */
+function cachedDirs(roots, appKey, legacyKey) {
+ if (!roots) return [];
+ const fresh = roots[appKey];
+ if (Array.isArray(fresh)) return fresh;
+ const legacy = roots[legacyKey];
+ if (Array.isArray(legacy)) return legacy;
+ return legacy ? [legacy] : [];
}
// -- Merging the same file announced by several groups ------------------------
@@ -226,7 +247,7 @@ function underRoot(entry, root) {
// `mergeUnitEntries` folds lists that share a key, so the two copies become
// one unit without this having to group them first.
function videoUnits(entries) {
- const { movies, shows } = groupVideoEntries(entries, SEARCH_VIDEO_ROOT);
+ const { movies, shows } = groupVideoEntries(entries, [SEARCH_VIDEO_ROOT]);
return [
...movies.map((e) => ({ key: `movie:${e.id}`, entries: [e] })),
...shows.map((s) => ({ key: `show:${s.title}`, entries: s.episodes })),
@@ -238,7 +259,7 @@ function videoUnits(entries) {
// object, so the key only has to name it stably — hence `foldKey` over the
// display strings, which are whichever spelling arrived first.
function musicUnits(entries) {
- const { tracks, albums } = groupMusicEntries(entries, SEARCH_AUDIO_ROOT);
+ const { tracks, albums } = groupMusicEntries(entries, [SEARCH_AUDIO_ROOT]);
return [
...albums.map((a) => ({
key: `album:${foldKey(a.artist)}/${foldKey(a.album)}`,
@@ -497,12 +518,12 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs })
const videoEntries = useMemo(() => {
const result = [];
for (const [groupId, data] of indexedGroups) {
- const root = data.roots.videoRoot;
- if (!root) continue;
+ const dirs = cachedDirs(data.roots, 'video', 'videoRoot');
+ if (!dirs.length) continue;
const conn = groupConns.current.get(groupId);
for (const e of data.entries) {
if (e.type !== 'video') continue;
- if (!underRoot(e, root)) continue;
+ if (!underRoot(e, dirs)) continue;
if (q && !matchesQuery(e)) continue;
result.push({
...e,
@@ -523,12 +544,12 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs })
const musicEntries = useMemo(() => {
const result = [];
for (const [groupId, data] of indexedGroups) {
- const root = data.roots.audioRoot;
- if (!root) continue;
+ const dirs = cachedDirs(data.roots, 'music', 'audioRoot');
+ if (!dirs.length) continue;
const conn = groupConns.current.get(groupId);
for (const e of data.entries) {
if (e.type !== 'audio') continue;
- if (!underRoot(e, root)) continue;
+ if (!underRoot(e, dirs)) continue;
if (q && !matchesQuery(e)) continue;
result.push({
...e,
@@ -549,13 +570,13 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs })
const photoEntries = useMemo(() => {
const result = [];
for (const [groupId, data] of indexedGroups) {
- const roots = data.roots.photoRoots;
- if (!roots || !roots.length) continue;
+ const dirs = cachedDirs(data.roots, 'photo', 'photoRoots');
+ if (!dirs.length) continue;
const conn = groupConns.current.get(groupId);
for (const e of data.entries) {
if (e.type !== 'image') continue;
const p = e.path || '';
- if (!roots.some((r) => p === r || p.startsWith(r + '/'))) continue;
+ if (!dirs.some((d) => p === d || p.startsWith(d + '/'))) continue;
if (q && !matchesQuery(e)) continue;
result.push({
...e,
@@ -707,7 +728,6 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs })
applyIndex=${noop}
isNodeAdmin=${false}
operatorPaired=${false}
- mayUpload=${false}
userId=${userId}
setError=${noop}
onPreview=${onPreview}
@@ -726,7 +746,7 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs })
status="connected"
entries=${videoEntries}
onPreview=${onPreview}
- videoRoot=${SEARCH_VIDEO_ROOT}
+ videoDirectories=${[SEARCH_VIDEO_ROOT]}
tmdbConfig=${{ enabled: true }}
isNodeAdmin=${false}
onNeedConn=${connectGroup}
@@ -740,7 +760,7 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs })
gekRef=${defaultGRef}
status="connected"
entries=${musicEntries}
- audioRoot=${SEARCH_AUDIO_ROOT}
+ musicDirectories=${[SEARCH_AUDIO_ROOT]}
musicbrainzConfig=${{ enabled: true }}
onPlayQueue=${handleMusicPlay}
hideFilter=${true} />
@@ -753,7 +773,7 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs })
gekRef=${defaultGRef}
status="connected"
entries=${photoEntries}
- photoRoots=${SEARCH_PHOTO_ROOTS}
+ photoDirectories=${SEARCH_PHOTO_ROOTS}
setError=${noop}
hideFilter=${true}
readOnly=${true} />
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/settings-ui.js b/packages/meshbay-hub/src/meshbay_hub/static/settings-ui.js
new file mode 100644
index 0000000..9b7a286
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/settings-ui.js
@@ -0,0 +1,92 @@
+import { html, useState, useCallback } from './vendor/htm-preact.js';
+import { t } from './i18n.js';
+import { Icon } from './icon.js';
+
+/**
+ * The two controls every settings pane is built from.
+ *
+ * Their own module rather than `group-settings.js`, because each app's
+ * settings component now lives in its own file and is reached *through* the
+ * app registry — so importing them from the page that renders them would make
+ * a cycle (`group-settings` → `apps` → `<app>-app-settings` → `group-settings`).
+ * ES modules tolerate that and then hand you a temporal-dead-zone
+ * `ReferenceError` at the first render, which is the same class of defect as
+ * the hook-ordering one already recorded in CLAUDE.md.
+ */
+
+/**
+ * A settings-section that folds — every section but the ones that are
+ * really just a form to fill in (invite, pair-operator, approve-device):
+ * hiding an input the operator is mid-typing-into behind a click they'd
+ * have to undo is friction with nothing to show for it, but a section that
+ * is only ever glanced at once it's configured (TMDB, scan tuning, the
+ * danger zone) benefits from staying out of the way otherwise. `title` (an
+ * already-built string/vnode) wins over `titleKey` when both are given —
+ * the members-table heading needs a live count baked in, not just a
+ * lookup.
+ */
+function CollapsibleSection({ titleKey, title, action, defaultOpen = true, children }) {
+ const [open, setOpen] = useState(defaultOpen);
+ return html`
+ <div class="settings-section">
+ ${/* `action` sits *beside* the header button, never inside it: a
+ <label><input> nested in a <button> is invalid HTML, and the click
+ would reach both — flipping the toggle and collapsing the section
+ it belongs to in the same gesture. */''}
+ <div class="settings-collapsible-bar">
+ <button type="button" class="settings-collapsible-header"
+ onClick=${() => setOpen((v) => !v)} aria-expanded=${open}>
+ <h3 class="settings-heading">${title != null ? title : t(titleKey)}</h3>
+ <${Icon} name="chevron" cls=${open ? 'video-flat-chevron open' : 'video-flat-chevron'} />
+ </button>
+ ${action != null && html`
+ <div class="settings-collapsible-action">${action}</div>`}
+ </div>
+ ${open && html`<div class="settings-collapsible-body">${children}</div>`}
+ </div>
+ `;
+}
+
+/**
+ * A modern on/off switch — replaces a plain checkbox or a "Turn on/off"
+ * button wherever the setting itself is a straight binary (uploads
+ * allowed, TMDB/MusicBrainz enabled). Still a real <input type="checkbox">
+ * under the hood (keyboard/screen-reader behaviour for free), just
+ * restyled — see .toggle-switch in style.css.
+ */
+function ToggleSwitch({ checked, onChange, disabled, label }) {
+ return html`
+ <label class="toggle-switch ${disabled ? 'toggle-switch-disabled' : ''}">
+ <input type="checkbox" checked=${checked} disabled=${disabled}
+ onChange=${(e) => onChange(e.target.checked)} />
+ <span class="toggle-switch-track"><span class="toggle-switch-thumb"></span></span>
+ ${label != null && html`<span class="toggle-switch-label">${label}</span>`}
+ </label>
+ `;
+}
+
+/**
+ * The busy/message bookkeeping every settings pane does around one call.
+ *
+ * Each pane owns its own, rather than sharing the page's: two sections saving
+ * at once is ordinary (an operator ticks a toggle in Videos while Music's
+ * folder save is still in flight), and one shared flag would disable both and
+ * then attribute one section's error to the other.
+ */
+function useSaver() {
+ const [busy, setBusy] = useState(false);
+ const [msg, setMsg] = useState('');
+ const run = useCallback(async (work) => {
+ setBusy(true); setMsg('');
+ try {
+ await work();
+ return true;
+ } catch (err) {
+ setMsg(err && err.message ? err.message : String(err));
+ return false;
+ } finally { setBusy(false); }
+ }, []);
+ return { busy, msg, run, setMsg };
+}
+
+export { CollapsibleSection, ToggleSwitch, useSaver };
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css
index c183d7e..d1d5732 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/style.css
+++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css
@@ -24,6 +24,11 @@
--nav-brand: #23b1f0;
--sidebar-bg: #f1f5f9;
--sidebar-hover: #e2e8f0;
+ /* Used with a literal fallback in a handful of rules before they were
+ defined here — same values, so nothing moves; now themeable, and the
+ names in those rules are real. */
+ --warn: #d97706;
+ --accent-bg: rgba(59, 130, 246, 0.1);
--shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
--shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.1);
}
@@ -36,6 +41,8 @@
--text-secondary: #94a3b8;
--text-dim: #64748b;
--border: #334155;
+ --warn: #f59e0b;
+ --accent-bg: rgba(59, 130, 246, 0.18);
--error-bg: #450a0a;
--nav-bg: #020617;
--sidebar-bg: #1e293b;
@@ -826,6 +833,22 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
.tb-btn:disabled { opacity: 0.45; cursor: not-allowed; }
.tb-btn:disabled:hover { border-color: var(--border); color: var(--text); }
.tb-btn .icon { width: 15px; height: 15px; }
+/* Square, for a toolbar button whose name is in its tooltip rather than
+ beside it. The horizontal padding goes; the height does not, so it lines up
+ with the labelled buttons next to it. */
+.tb-btn-icon { padding: 0; width: 32px; justify-content: center; gap: 0; }
+
+/* Naming a new folder, in the toolbar. Not `window.prompt`, which throws in
+ Electron — and an inline field can put the node's refusal next to the input
+ rather than after a dialog has closed. */
+.tb-mkdir { display: inline-flex; align-items: center; gap: 6px; }
+.tb-mkdir-input {
+ height: 32px; width: 170px; padding: 0 9px;
+ border: 1px solid var(--border-focus); border-radius: 6px;
+ background: var(--bg-surface); color: var(--text);
+ font-family: inherit; font-size: 0.83em;
+}
+.tb-mkdir-input:focus { outline: none; }
.tb-btn.primary {
background: var(--accent);
border-color: var(--accent);
@@ -1240,6 +1263,46 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
}
.settings-collapsible-header .settings-heading { margin-bottom: 0; }
.settings-collapsible-body { margin-top: 12px; }
+/* The header button stretches; the action (an app's on/off switch) keeps its
+ own width at the end, and its own click. */
+.settings-collapsible-bar { display: flex; align-items: center; gap: 12px; }
+.settings-collapsible-bar .settings-collapsible-header { flex: 1 1 auto; min-width: 0; }
+.settings-collapsible-action { flex: 0 0 auto; }
+
+/* One app's settings pane, inside its section. */
+.app-settings > .settings-row:first-child { margin-top: 0; }
+/* The Save button of an app's settings pane.
+ *
+ * It was `btn btn-small btn-secondary`, and there is no `.btn` rule at all —
+ * so it inherited `.btn-secondary`: no background, a transparent border, dim
+ * grey text. Enabled, it already looked like a disabled control; disabled, it
+ * was the same thing at 40% opacity. "You cannot always click Save, you do not
+ * notice, and it does not work" was one sentence describing all of that.
+ *
+ * It looks like a button now, and a disabled one looks disabled. */
+.app-save {
+ display: inline-flex; align-items: center; gap: 6px;
+ height: 30px; padding: 0 14px; margin-top: 4px;
+ border: 1px solid var(--accent); border-radius: 6px;
+ background: var(--accent); color: var(--accent-text);
+ font-family: inherit; font-size: 0.83em; cursor: pointer;
+}
+.app-save:hover:not(:disabled) { background: var(--accent-hover); }
+/* No transition on the enabled/disabled swap. It animates a colour change that
+ coincides with a text-colour change, which reads as a flicker — and it made
+ the state genuinely hard to observe: a measurement taken during it reports a
+ transparent background on a button that is not transparent. */
+.app-save:disabled {
+ background: none; color: var(--text-dim);
+ border-color: var(--border); opacity: 1; cursor: default;
+}
+/* Why it is inert, beside it — a disabled control that explains itself is the
+ difference between "nothing changed" and "this is broken". */
+
+.app-settings-sub {
+ margin: 18px 0 4px; font-size: 0.9em; font-weight: 600;
+ padding-top: 12px; border-top: 1px solid var(--border);
+}
.settings-meta-title {
display: flex;
@@ -1267,6 +1330,7 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
/* A modern on/off switch — replaces a checkbox or a "Turn on/off" button
wherever the setting is a straight binary. */
.toggle-switch {
+ position: relative;
display: inline-flex;
align-items: center;
gap: 10px;
@@ -1897,7 +1961,8 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
white-space: nowrap;
}
a.transfer-name {
- color: var(--link);
+ /* `--link` is not defined here; the palette's link colour is `--accent`. */
+ color: var(--accent);
text-decoration: underline;
cursor: pointer;
}
@@ -2125,7 +2190,11 @@ a.transfer-name {
position: absolute;
top: -6px;
right: -8px;
- background: var(--danger);
+ /* `--danger` is not one of this stylesheet's variables — the palette calls
+ it `--error`. The declaration was invalid, so the unread-count badge had
+ white text on no background at all. Pre-existing; found while fixing four
+ more of the same in the folder picker. */
+ background: var(--error);
color: #fff;
font-size: 0.65rem;
padding: 1px 5px;
@@ -2147,7 +2216,8 @@ a.transfer-name {
align-items: center;
gap: 8px;
padding: 8px 12px;
- border-radius: var(--radius);
+ /* `--radius` was never defined — 8px is what the other cards use. */
+ border-radius: 8px;
background: var(--bg-surface);
margin-bottom: 4px;
cursor: pointer;
@@ -2157,7 +2227,8 @@ a.transfer-name {
background: var(--bg-raised);
}
.notif-unread {
- border-left: 3px solid var(--primary);
+ /* `--primary` is not defined here either; it is `--accent`. */
+ border-left: 3px solid var(--accent);
font-weight: 600;
}
.notif-kind {
@@ -2542,6 +2613,66 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; }
.node-roots-header { margin-bottom: 8px; }
+/* Shared directories table */
+.shared-dirs-tbl { width: 100%; border-collapse: collapse; border: none; }
+.shared-dirs-tbl th {
+ text-align: left; font-size: 0.78em; font-weight: 500;
+ color: var(--text-dim); padding: 0 12px 6px 0;
+ text-transform: uppercase; letter-spacing: 0.04em;
+ border: none;
+}
+.shared-dirs-tbl td { padding: 7px 12px 7px 0; border: none; vertical-align: middle; }
+.shared-dirs-tbl tbody tr + tr td { border-top: 1px solid var(--border); }
+.sdt-col-dir { min-width: 140px; }
+/* The path is the only thing separating two libraries whose folders happen to
+ share a basename, so it is shown — truncated, because it is usually long and
+ rarely the thing being read. */
+.sdt-col-path {
+ color: var(--text-dim); font-size: 0.85em;
+ max-width: 260px; overflow: hidden; text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.sdt-add-row { display: flex; gap: 6px; align-items: center; margin-top: 8px; }
+.sdt-add-input {
+ flex: 1 1 auto; min-width: 0; padding: 5px 8px;
+ border: 1px solid var(--border); border-radius: 4px;
+ background: var(--bg-surface); color: var(--text);
+ font-family: inherit; font-size: 0.9em;
+}
+.sdt-col-toggle { width: 90px; text-align: center; }
+.sdt-col-toggle th { text-align: center; }
+.sdt-col-toggle .toggle-switch { justify-content: center; }
+.sdt-col-actions { white-space: nowrap; text-align: right; }
+.sdt-action-btn {
+ background: none; border: 1px solid var(--border); border-radius: 4px;
+ padding: 4px 7px; cursor: pointer; font-size: 0.85em; color: var(--text-dim);
+ line-height: 1; vertical-align: middle;
+}
+.sdt-action-btn + .sdt-action-btn { margin-left: 6px; }
+.sdt-action-btn:hover:not(:disabled) { background: var(--bg-raised); color: var(--text); }
+.sdt-action-btn:disabled { opacity: 0.3; cursor: default; }
+.sdt-action-danger:hover:not(:disabled) { color: var(--error); border-color: var(--error); }
+.sdt-dir-name { display: inline-flex; align-items: center; gap: 6px; font-weight: 500; }
+.sdt-dir-name .icon { width: 16px; height: 16px; flex-shrink: 0; }
+.sdt-row-ejected { opacity: 0.5; }
+.sdt-row-unavail { opacity: 0.6; }
+.root-eject-btn {
+ background: none; border: 1px solid var(--border); border-radius: 4px;
+ padding: 2px 6px; cursor: pointer; font-size: 0.85em; color: var(--text-dim);
+ line-height: 1;
+}
+.root-eject-btn:hover { background: var(--bg-raised); }
+.root-eject-btn { margin-left: 8px; }
+/* An ejected root in the Files table: still listed, deliberately — its files
+ are frozen, not gone — but not somewhere you can walk into. */
+.file-row.root-ejected { opacity: 0.5; }
+.file-row.root-ejected td:not(.sel-cell) { cursor: default; }
+
+/* The paperclip with no writable directory to write to. Shown rather than
+ hidden, so the reason is discoverable instead of the control just being
+ absent. */
+.chat-attach-off { opacity: 0.35; cursor: not-allowed; }
+
.node-root {
display: flex;
align-items: center;
@@ -3993,3 +4124,95 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; }
background: linear-gradient(to top, rgba(0, 0, 0, 0.55), transparent);
}
.photo-lightbox-count { color: #94a3b8; }
+
+/* ── Folder tree picker (folder-tree.js) ─────────────────────────────────── */
+
+.ftp-backdrop {
+ position: fixed; inset: 0; z-index: 1200;
+ background: rgba(0, 0, 0, 0.55);
+ display: flex; align-items: center; justify-content: center;
+ padding: 16px;
+}
+.ftp-panel {
+ background: var(--bg-surface); color: var(--text);
+ border: 1px solid var(--border); border-radius: 8px;
+ width: min(520px, 100%); max-height: min(70vh, 640px);
+ display: flex; flex-direction: column; padding: 16px;
+ box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4);
+}
+.ftp-panel:focus { outline: none; }
+.ftp-title { margin: 0 0 8px; font-size: 1em; }
+/* The tree is the only part that scrolls: the title, the selection and the
+ buttons stay put, so OK never travels off-screen in a deep library. */
+.ftp-tree, .ftp-children { list-style: none; margin: 0; padding: 0; }
+.ftp-tree {
+ flex: 1 1 auto; overflow-y: auto; overflow-x: hidden;
+ border: 1px solid var(--border); border-radius: 4px;
+ padding: 4px 0; margin: 8px 0;
+}
+.ftp-row { display: flex; align-items: center; gap: 2px; }
+.ftp-row.chosen { background: var(--bg-raised); }
+/* 0.4 was low enough that a greyed-out root read as an artefact rather than
+ a disabled control — the names were barely legible in the screenshot that
+ prompted this. */
+.ftp-row.blocked { opacity: 0.55; }
+.ftp-twisty {
+ flex: 0 0 auto; width: 20px; height: 20px; padding: 0;
+ background: none; border: none; color: var(--text-dim);
+ font-family: inherit; font-size: 0.95em; line-height: 1; cursor: pointer;
+}
+.ftp-twisty:disabled { cursor: default; opacity: 0; }
+.ftp-label {
+ flex: 1 1 auto; min-width: 0;
+ display: flex; align-items: center; gap: 6px;
+ background: none; border: none; color: inherit;
+ font: inherit; text-align: left; padding: 3px 6px; cursor: pointer;
+}
+.ftp-label:disabled { cursor: not-allowed; }
+.ftp-label .icon { width: 15px; height: 15px; flex-shrink: 0; }
+.ftp-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.ftp-badge {
+ flex: 0 0 auto; margin-left: 8px;
+ font-size: 0.72em; padding: 1px 5px; border-radius: 3px;
+ border: 1px solid var(--border); background: var(--bg-raised);
+ color: var(--text-dim); white-space: nowrap;
+ text-transform: uppercase; letter-spacing: 0.03em;
+}
+.ftp-badge.rw { color: var(--accent); border-color: currentColor; }
+.ftp-badge.warn { color: var(--error); border-color: currentColor; }
+.ftp-check { margin-left: auto; flex: 0 0 auto; }
+.ftp-selection {
+ display: flex; flex-wrap: wrap; gap: 4px;
+ max-height: 84px; overflow-y: auto; margin-bottom: 10px;
+}
+.ftp-chip {
+ font-size: 0.82em; padding: 2px 6px; border-radius: 3px;
+ background: var(--bg-raised); border: 1px solid var(--border);
+}
+.ftp-actions { display: flex; justify-content: flex-end; gap: 8px; }
+/* An app's chosen folders. Deliberately not a .settings-row: that is a
+ space-between flex row, and a label, a hint and a list laid out inside one
+ end up spread across a single line in source order. */
+.folder-field { padding: 8px 0; }
+.folder-field-head { margin-bottom: 6px; }
+.folder-field-label {
+ margin: 0 0 2px; font-size: 0.9em; font-weight: 600; color: var(--text);
+}
+.folder-field-head .settings-hint { margin-bottom: 0; }
+/* Borrows .shared-dirs-tbl so the two lists of directories on this page read
+ as the same kind of thing. */
+.folder-field-tbl { margin-bottom: 8px; }
+.folder-field-tbl .sdt-col-dir { font-size: 0.9em; }
+.folder-field-actions { display: flex; align-items: center; gap: 10px; }
+
+/* HelloWorld (docs/refactor-groups.md §4.1) — the reference app, hidden
+ behind ?dev=1. Deliberately plain: it exists to prove the plumbing, and
+ anything decorative here would be a second thing to keep working. */
+.hw-list { list-style: none; margin: 12px 0 0; padding: 0; }
+.hw-item {
+ display: flex; align-items: center; gap: 8px;
+ padding: 6px 0; border-bottom: 1px solid var(--border);
+}
+.hw-item .icon { width: 15px; height: 15px; flex-shrink: 0; }
+.hw-name { flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.hw-size { flex: 0 0 auto; color: var(--text-dim); font-size: 0.85em; }
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
index ea1e70a..179292e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
@@ -72,12 +72,43 @@ function _aborted() {
// change anything — `op` is already on every admin_challenge, and this
// list is what lets a response two steps later be tied back to the right
// one.
+// Acks the node *broadcasts* to everyone in the group, which the requester
+// therefore also has to be handed.
+//
+// `_dispatch` resolves an admin ack against the pending request and returns,
+// which is right for an op whose caller already knows the value it chose. It is
+// wrong for these: every *other* connected client learns the change from the
+// broadcast, and the one that asked for it is the only one that does not,
+// because its own request swallowed its copy. Found twice — first on the root
+// table, then on Chat's directory, where it meant the pane went on showing an
+// unsaved-looking draft after a save that had worked.
+const BROADCAST_ACK_TYPES = new Set([
+ 'root_update_ack', 'root_eject_ack', 'root_plug_ack',
+ 'root_add_ack', 'root_remove_ack',
+ 'app_directories_ack', 'chat_directory_ack', 'chat_link_preview_ack',
+]);
+
+/** Hand a broadcast ack to the callback that would have had it from a peer. */
+function _replayBroadcast(transport, msg) {
+ if (msg.type === 'app_directories_ack' && transport._onAppDirectories) {
+ transport._onAppDirectories(msg.app, msg.directories || []);
+ } else if (msg.type === 'chat_directory_ack' && transport._onChatDirectory) {
+ transport._onChatDirectory(msg.path || '');
+ } else if (msg.type === 'chat_link_preview_ack' && transport._onChatLinkPreview) {
+ transport._onChatLinkPreview(Boolean(msg.enabled));
+ } else if (transport._onRootsChanged) {
+ transport._onRootsChanged(msg);
+ }
+}
+
const ADMIN_OP_TYPES = new Set([
'tmdb_override', 'tmdb_rematch', 'tmdb_config', 'tmdb_enabled', 'video_root', 'audio_root',
'photo_roots',
'musicbrainz_enabled', 'file_delete', 'dir_delete',
- 'member_upload', 'apps_enabled', 'set_scan_settings', 'member_revoke',
- 'root_add', 'root_remove', 'member_unpin', 'gek_rotate', 'group_attach',
+ 'apps_enabled', 'set_scan_settings', 'member_revoke',
+ 'root_add', 'root_remove', 'root_update', 'root_eject', 'root_plug',
+ 'app_directories', 'chat_directory', 'chat_link_preview',
+ 'member_unpin', 'gek_rotate', 'group_attach',
'group_detach', 'invite_create',
]);
@@ -217,7 +248,7 @@ window.addEventListener('hashchange', () => {
// The `v: '0.1'` on every other message in this file is the historical value
// and is read by nothing; it is left alone deliberately. The range is
// negotiated once, at the start, not restated per message.
-const MNP_V = '1.0';
+const MNP_V = '1.1';
const MNP_V_MIN = '1.0';
// Codes a NODE sends us, in its own vocabulary (meshbay_common/handshake.py's
@@ -325,7 +356,30 @@ class MeshBayTransport {
set onIndexSync(fn) { this._onIndexSync = fn; }
set onIndexDelta(fn) { this._onIndexDelta = fn; }
set onUploadPolicy(fn) { this._onUploadPolicy = fn; }
+ set onRootsChanged(fn) { this._onRootsChanged = fn; }
+
+ /** The MNP version the connected node declared, or '' before a handshake. */
+ get nodeVersion() { return this._nodeVersion || ''; }
+
+ /**
+ * Whether the node speaks the per-root and per-app operations MNP 1.1 added:
+ * `root_update`/`root_eject`/`root_plug`, `app_directories`,
+ * `chat_directory`, `chat_link_preview`.
+ *
+ * An older node has no equivalent for the root ones at all, and answers the
+ * app ones through their three predecessors (`video_root`, `audio_root`,
+ * `photo_roots`). The caller chooses which; what it must not do is send a
+ * 1.1 message and wait, because an unknown type is logged and dropped.
+ */
+ get supportsAppOps() {
+ const m = /^(\d+)\.(\d+)$/.exec(this._nodeVersion || '');
+ if (!m) return false;
+ return (Number(m[1]) > 1) || (Number(m[1]) === 1 && Number(m[2]) >= 1);
+ }
set onAppsEnabled(fn) { this._onAppsEnabled = fn; }
+ set onAppDirectories(fn) { this._onAppDirectories = fn; }
+ set onChatDirectory(fn) { this._onChatDirectory = fn; }
+ set onChatLinkPreview(fn) { this._onChatLinkPreview = fn; }
set onTmdbConfig(fn) { this._onTmdbConfig = fn; }
set onTmdbEnabled(fn) { this._onTmdbEnabled = fn; }
set onVideoRoot(fn) { this._onVideoRoot = fn; }
@@ -587,6 +641,12 @@ class MeshBayTransport {
// block, because everything below — the join, the proof, the sealed ack
// — assumes both sides mean the same thing by each message.
_checkNodeVersion(reply);
+ // Kept, not just checked. Several controls exist only on a node new
+ // enough to have them, and the alternative to asking is offering a
+ // button whose message an older node logs as unknown and never answers
+ // — a 30-second wait ending in a timeout, with nothing on screen to say
+ // the node simply cannot do this.
+ this._nodeVersion = String(reply.v || '');
if (!window.MeshBayCrypto) {
throw new Error('Node requires GEK proof but no crypto available');
}
@@ -1070,7 +1130,7 @@ class MeshBayTransport {
* queried in (e.g. "fr-FR") — one for the whole node, since both are one
* operator's shared credential/cache, not a per-group concern (see
* setTmdbEnabled below for the per-group on/off switch). Signed like
- * setAppsEnabled/setMemberUpload — an unsigned change would let any
+ * setAppsEnabled/updateRoot — an unsigned change would let any
* member alter outbound third-party network traffic the operator never
* agreed to (docs/mediacenter.md §5.5, §8). `token: ''` explicitly clears
* a previously-set custom token; omit it (undefined/null), like
@@ -1169,6 +1229,96 @@ class MeshBayTransport {
}
/**
+ * Point an application at folder(s) inside the group's shared directories.
+ *
+ * One method for every app, keyed by the app's registry name — the same
+ * generic op the node grew for the same reason (docs/refactor-groups.md
+ * §1.6). `setVideoRoot`, `setAudioRoot` and `setPhotoRoots` are still here
+ * and still work; nothing new should call them.
+ *
+ * The subject names the app as well as the paths, because an operator shown
+ * "Media/Films" alone cannot tell which application is about to be pointed
+ * at it, and two apps' challenges would otherwise be indistinguishable.
+ * Cleaned and sorted the same way the node does, so both sides build the
+ * same bytes to sign.
+ */
+ /**
+ * The same instruction a node too old for `app_directories` understands.
+ *
+ * Videos, Music and Photos each had their own message before this, and they
+ * still work — so an operator on an un-upgraded node keeps the ability they
+ * had, rather than being handed a control that silently times out. Chat has
+ * no predecessor, which is why its settings are hidden rather than routed.
+ */
+ async setAppDirectoriesLegacy(appKey, directories, signFn) {
+ const clean = [...new Set(
+ (directories || []).map((d) => (d || '').replace(/^\/+|\/+$/g, '')).filter(Boolean),
+ )].sort();
+ if (appKey === 'photo') return this.setPhotoRoots(clean, signFn);
+ // One folder was all these two could carry. Sending several would store
+ // the first and silently drop the rest, so it is refused instead.
+ if (clean.length > 1) {
+ throw new Error(
+ 'This node is older than this page and can hold one folder per app. '
+ + 'Update it, or choose a single folder.');
+ }
+ const one = clean[0] || '';
+ if (appKey === 'video') return this.setVideoRoot(one, signFn);
+ if (appKey === 'music') return this.setAudioRoot(one, signFn);
+ throw new Error(
+ 'This node is older than this page and cannot store this app\'s '
+ + 'folders. Its operator has to update it.');
+ }
+
+ async setAppDirectories(appKey, directories, signFn) {
+ const clean = [...new Set(
+ (directories || []).map((d) => (d || '').replace(/^\/+|\/+$/g, '')).filter(Boolean),
+ )].sort();
+ const msg = await this._sendAndWait({
+ type: 'app_directories', v: '1.1', app: appKey, directories: clean,
+ });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ if (msg.type === 'admin_challenge') {
+ return this._authorizeAdminOp(
+ msg, 'app_directories', `${appKey}:${clean.join(',')}`, signFn);
+ }
+ return msg;
+ }
+
+ /**
+ * Where chat attachments are written.
+ *
+ * Its own message rather than `setAppDirectories('chat', ...)`: this one is
+ * a destination, and the node refuses a read-only root for it. A caller
+ * reaching for the generic form would get a refusal it has no reason to
+ * expect, so the difference is in the name.
+ */
+ async setChatDirectory(path, signFn) {
+ const clean = (path || '').replace(/^\/+|\/+$/g, '');
+ const msg = await this._sendAndWait({
+ type: 'chat_directory', v: '1.1', path: clean,
+ });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ if (msg.type === 'admin_challenge') {
+ return this._authorizeAdminOp(msg, 'chat_directory', clean, signFn);
+ }
+ return msg;
+ }
+
+ /** Whether the node unfurls links members post in this group's chat. */
+ async setChatLinkPreview(enabled, signFn) {
+ const msg = await this._sendAndWait({
+ type: 'chat_link_preview', v: '1.1', enabled: Boolean(enabled),
+ });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ if (msg.type === 'admin_challenge') {
+ return this._authorizeAdminOp(
+ msg, 'chat_link_preview', enabled ? 'on' : 'off', signFn);
+ }
+ return msg;
+ }
+
+ /**
* MusicBrainz metadata for one track (Music app, docs/musicbay.md §4.3)
* — same shape as fetchMediaMeta, minus a season/episode concept:
* album-level (release), resolved from the track's own artist/album
@@ -1365,24 +1515,6 @@ class MeshBayTransport {
* on the hub is the other half, and neither implies the other.
*/
/**
- * Turn uploading by ordinary members on or off.
- *
- * Signed by the operator like any other privileged operation — the node
- * refuses an unsigned one, which is what stops a member turning it back on.
- */
- async setMemberUpload(allowed, signFn) {
- const msg = await this._sendAndWait({
- type: 'member_upload', v: '0.1', allowed: Boolean(allowed),
- });
- if (msg.type === 'error') throw new Error(msg.detail);
- if (msg.type === 'admin_challenge') {
- return this._authorizeAdminOp(
- msg, 'member_upload', allowed ? 'on' : 'off', signFn);
- }
- return msg;
- }
-
- /**
* Turn a group "application" (Chat, Files, ...) on or off for everyone.
*
* Takes the whole set in one signed message rather than one op per app, so
@@ -1391,13 +1523,25 @@ class MeshBayTransport {
* `_authorizeAdminOp` below checks the two match.
*/
async setAppsEnabled(apps, signFn) {
+ // Files cannot be turned off — MNP permits root exploration regardless of
+ // this list, so hiding the tab only ever misled — and the node adds it if
+ // it is missing. That normalisation has to happen *here too*: the subject
+ // below is rebuilt from what this client sent, and compared byte for byte
+ // against what the node put in the challenge. A list arriving here without
+ // `files` would produce two different strings and `_authorizeAdminOp`
+ // would refuse to sign an op the operator did ask for. It is reachable
+ // only from a caller that builds the list from something other than the
+ // node's own answer, which is exactly the kind of caller a later phase
+ // adds. (`apps.js` marks it `alwaysEnabled`; this file is a classic
+ // script and cannot import it.)
+ const full = apps.includes('files') ? [...apps] : ['files', ...apps];
const msg = await this._sendAndWait({
- type: 'apps_enabled', v: '0.1', apps,
+ type: 'apps_enabled', v: '0.1', apps: full,
});
if (msg.type === 'error') throw new Error(msg.detail);
if (msg.type === 'admin_challenge') {
return this._authorizeAdminOp(
- msg, 'apps_enabled', [...apps].sort().join(','), signFn);
+ msg, 'apps_enabled', [...full].sort().join(','), signFn);
}
return msg;
}
@@ -1452,11 +1596,12 @@ class MeshBayTransport {
return msg;
}
- async addRoot(groupId, path, { name, kind, upload } = {}, signFn) {
+ async addRoot(groupId, path, { name, kind, writable, removable } = {}, signFn) {
const msg = await this._sendAndWait({
- type: 'root_add', v: '0.1',
+ type: 'root_add', v: '1.1',
group_id: groupId, path,
- name: name || '', kind: kind || 'generic', upload: !!upload,
+ name: name || '', kind: kind || 'generic',
+ writable: !!writable, removable: !!removable,
});
if (msg.type === 'error') throw new Error(msg.detail);
if (msg.type === 'admin_challenge') {
@@ -1477,6 +1622,48 @@ class MeshBayTransport {
return msg;
}
+ async updateRoot(groupId, rootName, { writable, removable } = {}, signFn) {
+ const updates = [];
+ if (writable !== undefined) updates.push(`rw=${writable ? 'on' : 'off'}`);
+ if (removable !== undefined) updates.push(`rem=${removable ? 'on' : 'off'}`);
+ const subject = updates.length ? `${rootName}:${updates.join(',')}` : rootName;
+ const msg = await this._sendAndWait({
+ type: 'root_update', v: '1.1',
+ group_id: groupId, root_name: rootName,
+ ...(writable !== undefined && { writable }),
+ ...(removable !== undefined && { removable }),
+ });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ if (msg.type === 'admin_challenge') {
+ return this._authorizeAdminOp(msg, 'root_update', subject, signFn);
+ }
+ return msg;
+ }
+
+ async ejectRoot(groupId, rootName, signFn) {
+ const msg = await this._sendAndWait({
+ type: 'root_eject', v: '1.1',
+ group_id: groupId, root_name: rootName,
+ });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ if (msg.type === 'admin_challenge') {
+ return this._authorizeAdminOp(msg, 'root_eject', rootName, signFn);
+ }
+ return msg;
+ }
+
+ async plugRoot(groupId, rootName, signFn) {
+ const msg = await this._sendAndWait({
+ type: 'root_plug', v: '1.1',
+ group_id: groupId, root_name: rootName,
+ });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ if (msg.type === 'admin_challenge') {
+ return this._authorizeAdminOp(msg, 'root_plug', rootName, signFn);
+ }
+ return msg;
+ }
+
async unpinMember(userId, signFn) {
const msg = await this._sendAndWait({
type: 'member_unpin', v: '0.1', user_id: userId,
@@ -1612,8 +1799,19 @@ class MeshBayTransport {
* The node decides where this lands (uploads/) and under what name — it finds a
* free one rather than replacing anything. The ack says which, and that is what
* this returns.
+ *
+ * `dir` names the folder to upload into, as a virtual path
+ * (`Media/Films/1999`) — where the sender is actually looking. The node
+ * resolves it against the group's own roots, which refuses `..`, absolute
+ * segments and anything escaping its root; it is a place among the group's
+ * folders, never a path on the operator's filesystem.
+ *
+ * `root` is the older, coarser form: the root's name and nothing below it.
+ * Kept because a node that predates `dir` reads it, and because Chat has no
+ * folder on screen to name. Omitting both leaves the node to pick, which it
+ * only does for a client old enough to have had one destination.
*/
- async uploadFile(file, { chunkSize, onProgress, signal } = {}) {
+ async uploadFile(file, { chunkSize, onProgress, signal, root, dir } = {}) {
// The same file twice at once would confuse the node, which keys its own
// upload state by name — and would race for the same destination.
if (this._uploaders.has(file.name)) {
@@ -1664,6 +1862,8 @@ class MeshBayTransport {
chunk_index: i,
total_chunks: total,
data: buf,
+ ...(root ? { root } : {}),
+ ...(dir ? { dir } : {}),
});
}
while (acked < total) {
@@ -2161,7 +2361,19 @@ class MeshBayTransport {
} else if (typeof msg.type === 'string' && msg.type.endsWith('_ack')) {
const key = `admin:${msg.type.slice(0, -4)}`;
for (const [, handler] of this._pending) {
- if (handler._key === key) { handler.resolve(msg); return; }
+ if (handler._key === key) {
+ handler.resolve(msg);
+ // The comment above ("its own caller already updates local state
+ // from what it sent") is true of every op whose caller passes the
+ // value it just chose to an onX(next). The root ops are not like
+ // that: what changes is the whole roots table, which only the node
+ // can compute — availability, the eject that the plug refused, the
+ // name it settled on. Returning here left the operator who clicked
+ // Eject as the one client that never saw it happen, while every
+ // other peer got the broadcast. So this one type is handed on.
+ if (BROADCAST_ACK_TYPES.has(msg.type)) _replayBroadcast(this, msg);
+ return;
+ }
}
}
@@ -2208,10 +2420,12 @@ class MeshBayTransport {
return;
}
- // The operator changed who may upload. Unsolicited: it arrives at everyone
- // connected, not only at whoever asked. It still has to reach a pending
- // caller — the operator's own request resolves on this reply — so it falls
- // through to the matching below rather than returning here.
+ // Legacy. An MNP 1.0 node still broadcasts this when its operator changes
+ // the group-wide upload switch, and its roots carry no `writable` for us
+ // to read instead — so this is the only answer available from such a node
+ // and it is still honoured. Nothing here *sends* the message any more:
+ // per-root RO/RW replaced it, and a current node answers it with a
+ // deprecation notice and no action.
if (msg.type === 'member_upload_ack' && this._onUploadPolicy) {
this._onUploadPolicy(Boolean(msg.allowed));
}
@@ -2222,6 +2436,18 @@ class MeshBayTransport {
this._onAppsEnabled(msg.apps || []);
}
+ // An application was pointed at different folders. One handler for every
+ // app — the callback is given the app's name and decides.
+ if (msg.type === 'app_directories_ack' && this._onAppDirectories) {
+ this._onAppDirectories(msg.app, msg.directories || []);
+ }
+ if (msg.type === 'chat_directory_ack' && this._onChatDirectory) {
+ this._onChatDirectory(msg.path || '');
+ }
+ if (msg.type === 'chat_link_preview_ack' && this._onChatLinkPreview) {
+ this._onChatLinkPreview(Boolean(msg.enabled));
+ }
+
// Node-wide (not per-group) — the operator supplied/cleared a custom
// token, or changed the query language. `token_customized` only says
// whether one is set, never the token itself.
@@ -2277,6 +2503,15 @@ class MeshBayTransport {
this._onMusicbrainzEnabled(Boolean(msg.enabled));
}
+ // A root's flags changed, or one was ejected, plugged, added or removed.
+ // Broadcast by the node to every peer, so everyone's table updates without
+ // waiting for the next index_sync.
+ if (msg.type === 'root_update_ack' || msg.type === 'root_eject_ack'
+ || msg.type === 'root_plug_ack' || msg.type === 'root_add_ack'
+ || msg.type === 'root_remove_ack') {
+ if (this._onRootsChanged) this._onRootsChanged(msg);
+ }
+
// The operator's node is scanning — never the entries themselves, just
// enough to animate a presence dot. Pushed periodically while it runs,
// plus once more on the transition back to idle (daemon.py
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/video-app-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/video-app-settings.js
new file mode 100644
index 0000000..5c79bc5
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/video-app-settings.js
@@ -0,0 +1,131 @@
+import { html, useState, useEffect } from './vendor/htm-preact.js';
+import { t, getLocale, LOCALES } from './i18n.js';
+import { ToggleSwitch, useSaver } from './settings-ui.js';
+import { FolderPickerField } from './folder-tree.js';
+
+// MeshBay's own locale codes (i18n.js LOCALES) to the language tag TMDB
+// expects. Duplicated from nothing: this is the only place it lives now that
+// the TMDB fields moved out of the monolithic settings page.
+const TMDB_LANGUAGE_BY_LOCALE = {
+ en: 'en-US', fr: 'fr-FR', es: 'es-ES', 'pt-BR': 'pt-BR', 'zh-CN': 'zh-CN',
+ ja: 'ja-JP', de: 'de-DE', it: 'it-IT', nl: 'nl-NL', pl: 'pl-PL',
+};
+
+/**
+ * The Videos app's operator settings: which folders it works over, and the
+ * TMDB lookups it makes.
+ *
+ * Several folders now, not one. A film library is as likely to be two drives
+ * as one, and the single-root model made the second one invisible — the
+ * operator's only recourse was to point Videos at a parent containing both,
+ * which pulls in everything else under it too.
+ *
+ * The TMDB parts are two independent settings that happen to sit together:
+ * the on/off switch is per group, while the API key and the query language
+ * are node-wide, because they are one operator's credential and one shared
+ * cache (docs/mediacenter.md §5.5). They save separately for that reason.
+ */
+function VideoSettings({ roots, dirs, settings, saveDirectories, transport, signFn }) {
+ const { busy, msg, run } = useSaver();
+ const [directories, setDirectories] = useState(settings.videoDirectories || []);
+ const [tmdbEnabled, setTmdbEnabled] = useState(settings.tmdbEnabled !== false);
+ const [token, setToken] = useState('');
+ const [language, setLanguage] = useState(
+ settings.tmdbLanguage || TMDB_LANGUAGE_BY_LOCALE[getLocale()] || 'en-US');
+
+ useEffect(() => {
+ setDirectories(settings.videoDirectories || []);
+ }, [settings.videoDirectories]);
+ useEffect(() => {
+ setTmdbEnabled(settings.tmdbEnabled !== false);
+ }, [settings.tmdbEnabled]);
+ useEffect(() => {
+ if (settings.tmdbLanguage) setLanguage(settings.tmdbLanguage);
+ }, [settings.tmdbLanguage]);
+
+ const current = settings.videoDirectories || [];
+ const dirsDirty = directories.length !== current.length
+ || directories.some((d, i) => d !== current[i]);
+
+ return html`
+ <div class="app-settings">
+ <${FolderPickerField}
+ label=${t('settings_app.video_directories_label')}
+ hint=${t('settings_app.video_directories_hint')}
+ roots=${roots} dirs=${dirs} mode="multi"
+ value=${directories} disabled=${busy}
+ onChange=${setDirectories} />
+
+ <button class="app-save" disabled=${busy || !dirsDirty}
+ onClick=${() => run(() => saveDirectories(directories))}>
+ ${busy ? t('settings_app.saving') : t('settings_app.save')}
+ </button>
+
+ <h4 class="app-settings-sub">${t('settings_node.tmdb_title')}</h4>
+ <p class="settings-hint">${t('settings_node.tmdb_hint')}</p>
+
+ <div class="settings-row">
+ <${ToggleSwitch} checked=${tmdbEnabled} disabled=${busy}
+ onChange=${(v) => { setTmdbEnabled(v);
+ run(() => transport.setTmdbEnabled(v, signFn)); }}
+ label=${tmdbEnabled ? t('settings_node.tmdb_enabled')
+ : t('settings_node.tmdb_disabled')} />
+ </div>
+
+ <div class="settings-row">
+ <label class="settings-label">
+ ${t('settings_node.tmdb_token_label')}
+ <input type="password" value=${token} disabled=${busy}
+ placeholder=${t('settings_node.tmdb_token_placeholder')}
+ onInput=${(e) => setToken(e.target.value)} />
+ </label>
+ ${/* No "optional", and no mention of a shipped default. A key that
+ works without one is a key somebody else is paying the rate
+ limit for, and the operator should know they are meant to have
+ their own. */''}
+ <p class="settings-hint">
+ ${settings.tmdbTokenCustomized
+ ? t('settings_node.tmdb_token_customized')
+ : t('settings_app.tmdb_token_prompt')}
+ ${' '}
+ <a href="https://www.themoviedb.org/settings/api" target="_blank"
+ rel="noopener noreferrer">${t('settings_app.tmdb_token_link')}</a>
+ </p>
+ </div>
+
+ <div class="settings-row">
+ <label class="settings-label">
+ ${t('settings_node.tmdb_language_label')}
+ <select value=${language} disabled=${busy}
+ onChange=${(e) => setLanguage(e.target.value)}>
+ ${LOCALES.map((l) => html`
+ <option key=${l.code} value=${TMDB_LANGUAGE_BY_LOCALE[l.code]}>
+ ${l.name}
+ </option>`)}
+ </select>
+ </label>
+ <p class="settings-hint">${t('settings_node.tmdb_language_hint')}</p>
+ </div>
+
+ ${/* The same control as the one above it. Two Save buttons in one pane
+ that do not look alike is worse than either looking wrong. This one
+ is never inert: the language always has a value to send, and an
+ empty token means "leave the stored one alone", not "no change". */''}
+ <button class="app-save" disabled=${busy}
+ onClick=${() => run(async () => {
+ // `undefined` for the token means "leave the stored one alone",
+ // which is not the same as `''` — that clears it. The input starts
+ // empty on every render because a secret is not read back, so
+ // sending it as a value would wipe the key every time the language
+ // was changed.
+ await transport.setTmdbConfig(token || undefined, language, signFn);
+ setToken('');
+ })}>
+ ${busy ? t('settings_app.saving') : t('settings_node.tmdb_save')}
+ </button>
+ ${msg && html`<p class="settings-hint">${msg}</p>`}
+ </div>
+ `;
+}
+
+export { VideoSettings, TMDB_LANGUAGE_BY_LOCALE };
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/video-app.js b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js
index 4f52b16..57da9ff 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/video-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js
@@ -57,10 +57,11 @@ function yearOf(dateStr) {
// work for this group before that either (daemon.py's
// _enrich_new_video_entries), so falling back to "the whole index" here
// would just show files nothing has enriched.
-function underVideoRoot(entry, videoRoot) {
- if (!videoRoot) return false;
+function underVideoRoot(entry, directories) {
+ const dirs = directories || [];
+ if (!dirs.length) return false;
const p = entry.path || '';
- return p === videoRoot || p.startsWith(videoRoot + '/');
+ return dirs.some((d) => p === d || p.startsWith(d + '/'));
}
function buildSeasons(episodes) {
@@ -96,12 +97,12 @@ function defaultSeason(show) {
return Math.min(...(real.length ? real : numbers));
}
-function groupVideoEntries(entries, videoRoot) {
+function groupVideoEntries(entries, videoDirectories) {
const movies = [];
const showsByTitle = new Map();
for (const e of entries) {
if (e.type !== 'video') continue;
- if (!underVideoRoot(e, videoRoot)) continue;
+ if (!underVideoRoot(e, videoDirectories)) continue;
if (e.season != null && e.episode != null) {
const title = e.display_title || e.name;
if (!showsByTitle.has(title)) showsByTitle.set(title, { title, episodes: [] });
@@ -1040,7 +1041,8 @@ function FlatList({ movies, shows, transportRef, gekRef, onPreview, onNeedConn }
// ── shell ────────────────────────────────────────────────────────────────────
function VideoApp({
- groupId, transportRef, gekRef, status, entries, onPreview, videoRoot, tmdbConfig, isNodeAdmin,
+ groupId, transportRef, gekRef, status, entries, availableEntries, onPreview,
+ videoDirectories, tmdbConfig, isNodeAdmin,
hideFilter, onNeedConn,
}) {
const [mode, setMode] = useState(loadViewMode);
@@ -1056,8 +1058,13 @@ function VideoApp({
const setModeAndSave = (m) => { setMode(m); saveViewMode(m); };
+ const videoEntries = availableEntries || entries;
+ // One or several folders now, so the "is anything configured" question
+ // is asked once rather than by every branch testing a string.
+ const configured = (videoDirectories || []).length > 0;
const { movies, shows } = useMemo(
- () => groupVideoEntries(entries, videoRoot), [entries, videoRoot]);
+ () => groupVideoEntries(videoEntries, videoDirectories),
+ [videoEntries, videoDirectories]);
const needle = filter.trim().toLowerCase();
const filteredMovies = useMemo(() => (typeFilter === 'series' ? [] : !needle ? movies : movies.filter(
@@ -1072,10 +1079,10 @@ function VideoApp({
${status === 'offline' && html`
<p class="page-message">${t('group.offline_title')} ${t('group.offline_hint')}</p>
`}
- ${status === 'connected' && !videoRoot && html`
+ ${status === 'connected' && !configured && html`
<p class="page-message">${t('video.no_root_configured')}</p>
`}
- ${status === 'connected' && videoRoot && html`
+ ${status === 'connected' && configured && html`
<div class="video-toolbar">
<button class="tb-btn ${mode === 'poster' ? 'active' : ''}"
onClick=${() => setModeAndSave('poster')}>
diff --git a/packages/meshbay-hub/tests/test_app_settings_plugin.py b/packages/meshbay-hub/tests/test_app_settings_plugin.py
new file mode 100644
index 0000000..b8afc23
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_app_settings_plugin.py
@@ -0,0 +1,383 @@
+"""
+Adding an application must not mean editing the pages that render it.
+
+That is the whole claim of the plugin architecture, and it is the kind of claim
+that decays silently: the first special case for one app reads as harmless, and
+by the third the loop is a lookup table with a default branch. These tests are
+what makes the claim checkable.
+
+They are source-reading, which is weak evidence and the only kind available for
+the SPA. Where a stronger check exists it is used instead — `test_spa_syntax`
+parses every module, and `test_hook_ordering` catches the temporal-dead-zone
+fault this refactor's new import graph could otherwise reintroduce.
+"""
+
+import re
+from pathlib import Path
+
+import pytest
+
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+APPS = STATIC / "apps.js"
+GROUP_SETTINGS = STATIC / "group-settings.js"
+GROUP_PAGE = STATIC / "group-page.js"
+SETTINGS_UI = STATIC / "settings-ui.js"
+FOLDER_TREE = STATIC / "folder-tree.js"
+TRANSPORT = STATIC / "transport.js"
+# parents[2] is `packages/` — the tests live at
+# packages/meshbay-hub/tests/, so [0] is tests, [1] the package, [2] packages.
+# Got this wrong once and the two cross-package checks below skipped silently,
+# which is worse than not having them: a green run that measured nothing.
+NODE_SERVER = (Path(__file__).resolve().parents[2] / "meshbay-node" / "src"
+ / "meshbay_node" / "transport" / "webrtc_server.py")
+
+PANES = ["chat-app-settings.js", "video-app-settings.js",
+ "music-app-settings.js", "photos-app-settings.js"]
+
+pytestmark = pytest.mark.skipif(not APPS.exists(),
+ reason="SPA sources unavailable")
+
+
+def _component(source: str, name: str) -> str:
+ start = source.index(f"\nfunction {name}(")
+ end = source.find("\nfunction ", start + 1)
+ return source[start:end if end != -1 else len(source)]
+
+
+def _code_only(source: str) -> str:
+ """
+ The same source with comments removed.
+
+ A prose explanation of what moved out of a file is not the file naming an
+ app — and the note recording *why* TMDB is no longer here is exactly the
+ kind of comment this codebase wants kept. Crude on purpose: it does not
+ understand strings containing `//`, which for these files is fine and for
+ a parser would be a second implementation of one.
+ """
+ source = re.sub(r"/\*.*?\*/", "", source, flags=re.S)
+ return re.sub(r"^\s*//.*$", "", source, flags=re.M)
+
+
+# ── The registry is the only place an app is named ──────────────────────────
+
+def test_the_settings_page_names_no_application():
+ """
+ The apps loop renders whatever the registry holds. A branch on `'video'`
+ here is the first step back to the 1338-line page this replaced, where
+ every app's settings were inlined and the file grew with each one.
+ """
+ panel = _code_only(_component(GROUP_SETTINGS.read_text(encoding="utf-8"),
+ "GroupSettingsPanel"))
+ for app in ("'video'", "'music'", "'photo'",
+ "tmdb", "musicbrainz", "TMDB"):
+ assert app not in panel, (
+ f"group-settings.js still names {app} — an app's own settings "
+ f"belong in its settings file")
+
+
+def test_the_settings_page_renders_the_registry():
+ panel = _component(GROUP_SETTINGS.read_text(encoding="utf-8"),
+ "GroupSettingsPanel")
+ assert "configurableApps()" in panel
+ assert "app.Settings" in panel, "the registry's component is not rendered"
+
+
+def test_every_registered_app_has_a_key_the_node_would_accept():
+ """
+ The registry key is the identifier everywhere: the tab, `apps_enabled`,
+ the `app_directories` op, and the roster row the directories live in. A
+ key here that `ALLOWED_APPS` does not have is an app whose settings are
+ refused by the node with no clue why.
+ """
+ node = NODE_SERVER
+ if not node.exists():
+ pytest.skip("the node package is not in this checkout")
+ m = re.search(r"ALLOWED_APPS = frozenset\(\{([^}]*)\}\)",
+ node.read_text(encoding="utf-8"))
+ assert m, "ALLOWED_APPS moved"
+ allowed = set(re.findall(r"'([^']+)'|\"([^\"]+)\"", m.group(1)))
+ allowed = {a or b for a, b in allowed}
+
+ keys = set(re.findall(r"\{ key: '([^']+)'", APPS.read_text(encoding="utf-8")))
+ assert keys, "no app keys found — did the registry's shape change?"
+ assert keys <= allowed, (
+ f"registered apps the node would refuse: {sorted(keys - allowed)}")
+
+
+# ── One contract, every pane ────────────────────────────────────────────────
+
+@pytest.mark.parametrize("pane", PANES)
+def test_every_pane_takes_the_same_props(pane):
+ """
+ A pane that reached for something else would make the loop that renders
+ them conditional, which is the same thing as the page naming apps again.
+ """
+ source = (STATIC / pane).read_text(encoding="utf-8")
+ m = re.search(r"function \w+Settings\(\{([^}]*)\}\)", source)
+ assert m, f"{pane}: no settings component with a destructured props object"
+ props = {p.strip() for p in m.group(1).split(",") if p.strip()}
+ allowed = {"roots", "dirs", "settings", "saveDirectories",
+ "transport", "signFn"}
+ assert props <= allowed, (
+ f"{pane} takes props outside the shared contract: "
+ f"{sorted(props - allowed)}")
+
+
+@pytest.mark.parametrize("pane", PANES)
+def test_no_pane_imports_the_page_that_renders_it(pane):
+ """
+ `group-settings` → `apps` → a pane → `group-settings` is a cycle, and ES
+ modules answer it with a temporal-dead-zone ReferenceError at first render
+ rather than an import error — the component simply does not appear. That
+ is why the shared widgets live in `settings-ui.js`.
+ """
+ source = (STATIC / pane).read_text(encoding="utf-8")
+ assert "group-settings.js" not in source, (
+ f"{pane} imports the page that renders it — that is an import cycle")
+
+
+@pytest.mark.parametrize("pane", PANES)
+def test_every_pane_owns_its_own_busy_state(pane):
+ """
+ One shared flag would disable every section while any one of them saves,
+ and attribute one section's error message to another.
+ """
+ source = (STATIC / pane).read_text(encoding="utf-8")
+ assert "useSaver()" in source
+
+
+# ── The folder picker ───────────────────────────────────────────────────────
+
+def test_the_picker_asks_the_node_for_nothing():
+ """
+ The tree is built from paths the client already holds. A fetch here would
+ be a folder-browsing protocol, which this deliberately is not: what it
+ shows is what the group's index contains, and a folder the node never
+ indexed does not exist as far as the group is concerned.
+ """
+ source = FOLDER_TREE.read_text(encoding="utf-8")
+ for forbidden in ("hubFetch", "fetch(", "platform.node", "transport."):
+ assert forbidden not in source, (
+ f"folder-tree.js reaches for {forbidden} — it is meant to be "
+ f"derived from the index the client already has")
+
+
+def test_a_read_only_root_cannot_be_chosen_as_a_destination():
+ """
+ Chat's attachment folder is the one directory that gets written to, and
+ the node refuses a read-only root for it. Letting the picker offer one
+ would move that refusal to the moment somebody sends a file.
+ """
+ source = FOLDER_TREE.read_text(encoding="utf-8")
+ picker = _component(source, "FolderTreePicker")
+ assert "requireWritable" in picker
+ assert "root.writable" in picker, (
+ "writability is not consulted when deciding what is selectable")
+
+ chat = (STATIC / "chat-app-settings.js").read_text(encoding="utf-8")
+ assert "requireWritable=${true}" in chat, (
+ "Chat's directory picker does not require a writable root")
+
+
+def test_the_picker_can_clear_a_selection():
+ """
+ Confirming with nothing chosen is how an app's directories are unset, and
+ an OK disabled on an empty selection would make that impossible without
+ another control.
+ """
+ picker = _component(FOLDER_TREE.read_text(encoding="utf-8"),
+ "FolderTreePicker")
+ ok = picker[picker.index("folder_tree.confirm") - 400:
+ picker.index("folder_tree.confirm")]
+ assert "disabled" not in ok
+
+
+# ── The generic op ──────────────────────────────────────────────────────────
+
+def test_the_directory_op_is_signed_and_names_its_app():
+ """
+ An operator shown "Media/Films" alone cannot tell which application is
+ about to be pointed at it, and two apps' challenges would be
+ indistinguishable — so the app is in the signed subject, and both sides
+ build it the same way.
+ """
+ transport = TRANSPORT.read_text(encoding="utf-8")
+ body = transport[transport.index("async setAppDirectories("):]
+ body = body[:body.index("\n async ", 1)]
+ assert "admin_challenge" in body and "_authorizeAdminOp" in body
+ assert "${appKey}:${clean.join(',')}" in body
+
+ node = NODE_SERVER
+ if node.exists():
+ assert 'f"{app}:{\',\'.join(clean)}"' in node.read_text(encoding="utf-8"), (
+ "the node builds a different subject than the client signs")
+
+
+def test_the_page_performs_exactly_one_app_specific_operation():
+ """
+ Pointing an app at folders is what every app has, so the page does it.
+ Anything one app alone needs — a TMDB key, a link-preview switch — the
+ pane does with the transport it is given. An app that only wants
+ directories therefore touches neither file.
+ """
+ panel = _component(GROUP_SETTINGS.read_text(encoding="utf-8"),
+ "GroupSettingsPanel")
+ calls = set(re.findall(r"transport\.(set\w+)\(", panel))
+ # The page's own settings, which belong to no app: which apps are enabled
+ # at all, and how hard the node works watching its disk.
+ page_level = {"setAppsEnabled", "setScanSettings"}
+ # Both are the same generic operation; the second is what a node too old
+ # for it understands, chosen by version rather than by app.
+ generic = {"setAppDirectories", "setAppDirectoriesLegacy"}
+ assert calls - page_level == generic, (
+ f"the settings page performs app-specific operations: "
+ f"{sorted(calls - page_level - generic)}")
+
+
+# ── The apps read a list ────────────────────────────────────────────────────
+
+@pytest.mark.parametrize("app,prop", [
+ ("video-app.js", "videoDirectories"),
+ ("music-app.js", "musicDirectories"),
+ ("photos-app.js", "photoDirectories"),
+])
+def test_each_app_takes_a_list_of_directories(app, prop):
+ """
+ Videos and Music took a single folder, so a library spread over two drives
+ could not be described at all — the operator's only recourse was to point
+ the app at a parent containing both, which pulls in everything else too.
+ """
+ source = (STATIC / app).read_text(encoding="utf-8")
+ assert prop in source
+ for singular in ("videoRoot", "audioRoot"):
+ assert singular not in source, (
+ f"{app} still reads {singular} — one shape per idea")
+
+
+def test_an_older_node_still_fills_the_lists():
+ """
+ A node speaking MNP 1.0 sends `video_root`, not `video_directories`.
+ Reading the missing plural as "nothing configured" would empty a working
+ Videos tab on every group hosted by a node that has not been upgraded.
+ """
+ page = GROUP_PAGE.read_text(encoding="utf-8")
+ block = page[page.index("setAppDirectories({"):]
+ block = block[:block.index("setChatDirectory")]
+ assert "ack.video_root" in block and "ack.audio_root" in block
+ assert "ack.photo_roots" in block
+
+
+def test_the_search_cache_reads_both_shapes():
+ """
+ The cross-group index cache lives in IndexedDB and outlives a deploy, so a
+ reader opening Search after this ships still has entries written by the
+ previous version. Reading only the new shape empties their results with
+ nothing to distinguish it from "nothing matched".
+ """
+ source = (STATIC / "search-page.js").read_text(encoding="utf-8")
+ fn = _component(source, "cachedDirs")
+ assert "legacyKey" in fn
+ assert "videoRoot" in source and "audioRoot" in source and "photoRoots" in source
+
+
+# ── Where a result is reported ──────────────────────────────────────────────
+
+def test_the_directory_result_is_reported_below_the_controls():
+ """
+ It was rendered first, between the section's intro and the table header —
+ above everything the eye had already moved past by the time it appeared.
+ It belongs under the button that caused it, which is the last thing in the
+ section.
+ """
+ table = _component(GROUP_SETTINGS.read_text(encoding="utf-8"),
+ "SharedDirectoriesTable")
+ # Two of them: the early return for a group with no directories yet, and
+ # the real one. Both report, and both must report last — keyed on the
+ # wrapper rather than on `return`, of which there are more.
+ blocks = table.split('<div class="shared-directories-table">')[1:]
+ assert len(blocks) == 2, "the section's shape changed; this test is stale"
+
+ for block in blocks:
+ assert block.index("${addControls}") < block.index("${message}"), (
+ "the result is rendered above the Add button rather than below it")
+
+ with_table = next(b for b in blocks if "<table" in b)
+ assert with_table.index("<table") < with_table.index("${message}"), (
+ "the result is rendered above the table")
+
+
+def test_a_refusal_does_not_look_like_a_footnote():
+ """
+ Every message here was a `settings-hint` — dim grey body text — so "two
+ roots would both be called uploads" read as an aside about the section
+ rather than as the reason nothing happened.
+ """
+ table = _component(GROUP_SETTINGS.read_text(encoding="utf-8"),
+ "SharedDirectoriesTable")
+ block = table[table.index("const message ="):]
+ block = block[:block.index("`;") + 2]
+ assert "error-msg" in block
+ assert "role=" in block and "alert" in block, (
+ "a refusal that appears after a click has to be announced, not just "
+ "drawn")
+
+
+# ── Saving, and being able to tell ──────────────────────────────────────────
+
+PANE_FLAGS = {"chat-app-settings.js": "dirty",
+ "video-app-settings.js": "dirsDirty",
+ "music-app-settings.js": "dirsDirty",
+ "photos-app-settings.js": "dirty"}
+
+
+@pytest.mark.parametrize("pane", sorted(PANE_FLAGS))
+def test_save_is_a_button_and_not_dim_text(pane):
+ """
+ It was `btn btn-small btn-secondary`, and there is no `.btn` rule in the
+ stylesheet at all — so it took `.btn-secondary`: no background, a
+ transparent border, dim grey text. Enabled it already looked like a
+ disabled control, and disabled it was that at 40% opacity.
+
+ "You cannot always click Save, you do not notice, and it does not work" is
+ one sentence describing all of that.
+ """
+ source = (STATIC / pane).read_text(encoding="utf-8")
+ assert 'class="app-save"' in source, f"{pane}'s Save is not the shared control"
+ assert "btn-secondary" not in source, (
+ f"{pane}'s Save is still styled as dim text")
+
+
+def test_the_disabled_state_is_visually_distinct():
+ css = (STATIC / "style.css").read_text(encoding="utf-8")
+ rule = css[css.index(".app-save {"):]
+ rule = rule[:rule.index("}", rule.index(".app-save:disabled")) + 1]
+ assert "var(--accent)" in rule, "an enabled Save has no fill"
+ disabled = rule[rule.index(".app-save:disabled"):]
+ assert "background: none" in disabled and "--text-dim" in disabled, (
+ "disabled differs from enabled by opacity alone, which is what made "
+ "it unreadable")
+
+
+def test_a_saved_setting_reaches_the_page_that_renders_the_pane():
+ """
+ Why Chat was the systematic case.
+
+ `_dispatch` resolves an admin ack against the pending request and returns —
+ right for an op whose caller already knows the value it chose. Chat's pane
+ calls `transport.setChatDirectory` itself, so nothing told `group-page`
+ anything: the node saved it, every *other* connected client learned it from
+ the broadcast, and the one that asked went on showing an unsaved-looking
+ draft. Clicking Save again just re-sent it.
+ """
+ transport = TRANSPORT.read_text(encoding="utf-8")
+ block = transport[transport.index("const BROADCAST_ACK_TYPES"):]
+ block = block[:block.index("]);") + 3]
+ for ack in ("chat_directory_ack", "chat_link_preview_ack",
+ "app_directories_ack"):
+ assert ack in block, f"{ack} is swallowed by its own request"
+
+ assert "_replayBroadcast" in transport
+ replay = transport[transport.index("function _replayBroadcast"):]
+ replay = replay[:replay.index("\n}") + 2]
+ for cb in ("_onChatDirectory", "_onChatLinkPreview", "_onAppDirectories"):
+ assert cb in replay, f"{cb} is never called for the requester"
diff --git a/packages/meshbay-hub/tests/test_css_variables.py b/packages/meshbay-hub/tests/test_css_variables.py
new file mode 100644
index 0000000..ec576d6
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_css_variables.py
@@ -0,0 +1,78 @@
+"""
+Every `var(--x)` names a variable this stylesheet defines.
+
+CSS fails silently and generously: an unknown custom property makes the whole
+declaration invalid, and the rule around it still applies. So a panel written
+`background: var(--bg-panel)` when the palette calls it `--bg-surface` does not
+error, does not warn, and does not look obviously wrong in a diff — it just has
+no background, and the page shows straight through the modal.
+
+That is not hypothetical. It shipped in the folder picker, and the same file
+already carried one from before: `.notif-badge` asked for `--danger` where the
+palette says `--error`, so the unread count was white text on nothing. Found by
+a person looking at a screenshot, which is the only thing that was going to
+find it.
+
+A fallback (`var(--x, #ef4444)`) is a lesser version of the same mistake: the
+declaration is valid and renders, but the name is still fiction, and the next
+reader is told a variable exists that does not. Those are reported separately.
+
+There was a third check here, comparing the dark palette against the light one
+for anything a theme must not inherit. It fired on `--border-focus`, which is
+a focus ring deliberately shared by both themes — correct code. A heuristic
+that has to be explained away on its first run is worse than no test, so it is
+gone rather than exempted.
+"""
+
+import re
+from pathlib import Path
+
+import pytest
+
+STYLE = (Path(__file__).resolve().parents[1] / "src" / "meshbay_hub"
+ / "static" / "style.css")
+
+pytestmark = pytest.mark.skipif(not STYLE.exists(),
+ reason="the stylesheet is not in this checkout")
+
+# Where a custom property is defined. Two forms, and both had to be learned
+# the hard way while writing this: a scoped one written inline
+# (`.video-overview-wrap { --ov-lh: 1.5em; --ov-lines: 3; }`), which an
+# anchored pattern misses, and one preceded by an explanatory comment, which a
+# `[{;]`-prefixed pattern misses because the character before it is `/`. Either
+# mistake reports correct code as broken, which is the fastest way to have a
+# test like this ignored.
+DEFINE = re.compile(r"(?:^|[{;])\s*(--[A-Za-z0-9_-]+)\s*:", re.M)
+# `var(--name` and `var(--name, fallback`.
+USE = re.compile(r"var\(\s*(--[A-Za-z0-9_-]+)\s*(,)?")
+
+
+def _text() -> str:
+ return STYLE.read_text(encoding="utf-8")
+
+
+def test_every_variable_used_without_a_fallback_is_defined():
+ source = _text()
+ defined = set(DEFINE.findall(source))
+ assert defined, "no custom properties found — did the palette move?"
+
+ missing = sorted({name for name, fallback in USE.findall(source)
+ if not fallback and name not in defined})
+ assert not missing, (
+ "used but never defined, so every declaration naming one of these is "
+ "invalid and silently does nothing:\n " + "\n ".join(missing))
+
+
+def test_a_fallback_does_not_excuse_an_unknown_name():
+ """
+ `var(--danger, #ef4444)` renders, so it is not the same bug — but it is the
+ same mistake, and it will read as intentional to the next person. Reported
+ so the name gets corrected rather than the fallback relied on.
+ """
+ source = _text()
+ defined = set(DEFINE.findall(source))
+ guessed = sorted({name for name, fallback in USE.findall(source)
+ if fallback and name not in defined})
+ assert not guessed, (
+ "used with a fallback but not defined anywhere — rename to the real "
+ "variable:\n " + "\n ".join(guessed))
diff --git a/packages/meshbay-hub/tests/test_helloworld_proves_the_plugin_claim.py b/packages/meshbay-hub/tests/test_helloworld_proves_the_plugin_claim.py
new file mode 100644
index 0000000..a1baf92
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_helloworld_proves_the_plugin_claim.py
@@ -0,0 +1,159 @@
+"""
+The reference application, and what it is for.
+
+`docs/refactor-groups.md` claims that adding an application costs a registry
+entry and the app's own files — no op, no MNP message, no route, no edit to the
+pages that render it. Every other test of that claim reads source for the
+*absence* of app names, which proves nobody wrote a special case for Videos. It
+cannot prove that a genuinely new app works, because there was no new app.
+
+HelloWorld is one. It stores directories, appears as a tab, has a settings pane
+and lists files, and the node has never heard its name outside one allow-list
+entry. The assertions below are the claim, stated as things that must stay true
+of a file that was not written for it.
+
+It ships hidden behind `?dev=1` (apps.js's `dev: true`). Registering it
+normally would put a toy app in every operator's group; not registering it
+would prove nothing, since registration is exactly the thing being claimed as
+sufficient.
+
+**Two honest exceptions**, both found *by* adding it and both fixed by making
+the code less app-specific rather than more:
+
+* `group-settings.js` fell back to the whole registry when a group had no
+ `enabled_apps` yet, which would have turned a hidden app on for everyone. It
+ asks `availableApps()` now.
+* `group-page.js` wrote out `videoDirectories` / `musicDirectories` /
+ `photoDirectories` by hand. It derives `<key>Directories` from the registry
+ now, which is what made the claim true rather than nearly true.
+"""
+
+import re
+from pathlib import Path
+
+import pytest
+
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+NODE_SRC = (Path(__file__).resolve().parents[2] / "meshbay-node" / "src"
+ / "meshbay_node")
+
+APP = STATIC / "helloworld-app.js"
+SETTINGS = STATIC / "helloworld-app-settings.js"
+
+pytestmark = pytest.mark.skipif(not APP.exists(),
+ reason="the reference app is not in this checkout")
+
+
+def _code_only(source: str) -> str:
+ source = re.sub(r"/\*.*?\*/", "", source, flags=re.S)
+ return re.sub(r"^\s*//.*$", "", source, flags=re.M)
+
+
+# ── The claim ────────────────────────────────────────────────────────────────
+
+@pytest.mark.parametrize("name", [
+ "group-page.js", "group-settings.js", "files-app.js", "transport.js",
+ "hub-client.js", "settings-ui.js", "folder-tree.js",
+])
+def test_no_shared_client_file_mentions_it(name):
+ """
+ The registry is where an app is named, and nowhere else. A branch on
+ `'helloworld'` in any of these would mean the architecture works for four
+ apps somebody wrote plumbing for.
+ """
+ source = _code_only((STATIC / name).read_text(encoding="utf-8"))
+ assert "helloworld" not in source.lower(), (
+ f"{name} names the reference app — adding an application is supposed "
+ f"to touch nothing here")
+
+
+@pytest.mark.parametrize("name", [
+ "ops.py", "roster.py", "config.py", "roots.py",
+])
+def test_no_shared_node_module_mentions_it(name):
+ """
+ Its directories are stored by `ops.set_app_directories`, which keys the row
+ by whatever the app is called. Nothing on the node knows what it is.
+ """
+ source = (NODE_SRC / name).read_text(encoding="utf-8")
+ assert "helloworld" not in source.lower(), (
+ f"{name} names the reference app; the generic path was supposed to "
+ f"cover it")
+
+
+def test_the_node_names_it_once_and_only_in_the_allow_list():
+ """
+ `ALLOWED_APPS` is server-side enforcement — a client naming an app this
+ node does not know is refused — so an app absent from it could not
+ demonstrate anything. That entry plus the client's registry line is the
+ whole cost.
+ """
+ source = (NODE_SRC / "transport" / "webrtc_server.py").read_text(
+ encoding="utf-8")
+ code = re.sub(r"^\s*#.*$", "", source, flags=re.M)
+ hits = [ln for ln in code.splitlines() if "helloworld" in ln.lower()]
+ assert len(hits) == 1, f"expected one mention, got: {hits}"
+ assert "ALLOWED_APPS" in hits[0] or "helloworld" in hits[0]
+ assert "ALLOWED_APPS" in code[:code.index("helloworld") + 200]
+
+
+def test_the_registry_entry_is_ordinary():
+ source = (STATIC / "apps.js").read_text(encoding="utf-8")
+ entry = source[source.index("key: 'helloworld'"):]
+ entry = entry[:entry.index("},") + 2]
+ for field in ("icon:", "labelKey:", "Component:", "Settings:"):
+ assert field in entry, f"the entry has no {field}"
+ assert "dev: true" in entry, "it would ship to every operator"
+
+
+# ── And it is held to the same contract as the rest ──────────────────────────
+
+def test_its_settings_pane_takes_the_shared_props_and_no_others():
+ m = re.search(r"function \w+Settings\(\{([^}]*)\}\)",
+ SETTINGS.read_text(encoding="utf-8"))
+ assert m
+ props = {p.strip() for p in m.group(1).split(",") if p.strip()}
+ assert props <= {"roots", "dirs", "settings", "saveDirectories",
+ "transport", "signFn"}
+
+
+def test_it_reads_its_directories_under_its_own_key():
+ """
+ `<key>Directories` — the shape `group-page.js` derives for every registered
+ app. An app reading a name spelled anywhere else would need that place
+ edited too.
+ """
+ for path in (APP, SETTINGS):
+ assert "helloworldDirectories" in path.read_text(encoding="utf-8")
+
+
+def test_it_does_not_reach_for_the_transport():
+ """
+ It has no third-party service and no setting of its own, so it needs
+ neither — which is the case an app author most often starts from, and the
+ one the architecture has to make free.
+ """
+ source = _code_only(SETTINGS.read_text(encoding="utf-8"))
+ assert "transport." not in source
+ assert "saveDirectories" in source
+
+
+# ── Hidden, but genuinely registered ────────────────────────────────────────
+
+def test_a_dev_app_is_filtered_out_by_default():
+ source = (STATIC / "apps.js").read_text(encoding="utf-8")
+ assert "function availableApps()" in source
+ body = source[source.index("function availableApps()"):]
+ body = body[:body.index("\n}") + 2]
+ assert "devAppsShown()" in body and "a.dev" in body
+
+
+def test_nothing_falls_back_to_the_unfiltered_registry():
+ """
+ A fallback of "every app in the registry" would enable a hidden one for the
+ whole group. This is the exception the reference app found.
+ """
+ source = _code_only((STATIC / "group-settings.js").read_text(encoding="utf-8"))
+ assert "APPS.map(" not in source and "APPS.filter(" not in source, (
+ "group-settings.js reads the raw registry; it should ask "
+ "availableApps()")
diff --git a/packages/meshbay-hub/tests/test_hook_ordering.py b/packages/meshbay-hub/tests/test_hook_ordering.py
index f292b3e..01516bd 100644
--- a/packages/meshbay-hub/tests/test_hook_ordering.py
+++ b/packages/meshbay-hub/tests/test_hook_ordering.py
@@ -37,6 +37,13 @@ STATIC_FILES = [
"video-player.js", "video-app.js", "music-app.js", "music-player.js",
"photos-app.js",
"group-settings.js",
+ # The per-app settings architecture (docs/refactor-groups.md §3). Reached
+ # through the apps.js registry rather than imported by name, so a file
+ # left out of this list is one nothing checks — the failure is silent.
+ "settings-ui.js", "folder-tree.js",
+ "chat-app-settings.js", "video-app-settings.js",
+ "music-app-settings.js", "photos-app-settings.js",
+ "helloworld-app.js", "helloworld-app-settings.js",
"auth-page.js", "explore-page.js", "create-group-page.js",
]
diff --git a/packages/meshbay-hub/tests/test_mnp_1_0_node_compat.py b/packages/meshbay-hub/tests/test_mnp_1_0_node_compat.py
new file mode 100644
index 0000000..63390af
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_mnp_1_0_node_compat.py
@@ -0,0 +1,175 @@
+"""
+The page ships before the nodes do.
+
+The SPA is served by the hub, so deploying the hub puts this version of the
+client in front of *every* node, including the ones still running MNP 1.0. That
+window is not a corner case — it is the normal state for as long as it takes an
+operator to update, and for a node someone else runs it may be indefinite.
+
+The failure mode is specific and quiet: a node logs an unknown message type and
+sends **nothing back**, so a control that speaks MNP 1.1 to it produces a
+thirty-second wait ending in a timeout, with nothing on screen to say the node
+simply cannot do this. Three of them were like that before these tests:
+
+* Files' Upload button read `root.writable`, which a 1.0 node does not send —
+ it says `upload`. The button disappeared on every un-upgraded node.
+* The shared-directories toggles, eject and plug have no older equivalent at
+ all.
+* The per-app folder pickers spoke `app_directories`, where a 1.0 node
+ understands `video_root` / `audio_root` / `photo_roots`.
+
+Source-reading, like the other SPA guards. What it cannot check is that the
+degraded path is pleasant; what it does check is that each of the three exists.
+"""
+
+import re
+from pathlib import Path
+
+import pytest
+
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+TRANSPORT = STATIC / "transport.js"
+FILES_APP = STATIC / "files-app.js"
+GROUP_PAGE = STATIC / "group-page.js"
+GROUP_SETTINGS = STATIC / "group-settings.js"
+
+pytestmark = pytest.mark.skipif(not TRANSPORT.exists(),
+ reason="SPA sources unavailable")
+
+
+def _component(source: str, name: str) -> str:
+ start = source.index(f"\nfunction {name}(")
+ end = source.find("\nfunction ", start + 1)
+ return source[start:end if end != -1 else len(source)]
+
+
+# ── Knowing which node you are talking to ───────────────────────────────────
+
+def test_the_client_keeps_the_version_it_checked():
+ """
+ `_checkNodeVersion` parsed the node's version and threw it away, so nothing
+ downstream could ask. Refusing to connect is not the only thing a version
+ is good for.
+ """
+ source = TRANSPORT.read_text(encoding="utf-8")
+ assert "this._nodeVersion = String(reply.v" in source
+ assert "get supportsAppOps()" in source
+
+
+def test_the_capability_reads_the_version_rather_than_guessing():
+ """
+ Inferring it from whether some field happens to be present is how two
+ unrelated things end up coupled — the flag would flip because a payload
+ changed shape for another reason entirely.
+ """
+ source = TRANSPORT.read_text(encoding="utf-8")
+ getter = source[source.index("get supportsAppOps()"):]
+ getter = getter[:getter.index("\n }") + 4]
+ assert "_nodeVersion" in getter
+ assert "1" in getter, "no version comparison in the capability check"
+
+
+# ── The three degraded paths ────────────────────────────────────────────────
+
+def test_the_upload_button_reads_the_older_flag_too():
+ """
+ A 1.0 node's roots carry `upload`; `writable` is the same answer renamed.
+ Reading only the new name hides the Upload button on every node that has
+ not been updated, which on the day the page ships is all of them.
+ """
+ page = _component(FILES_APP.read_text(encoding="utf-8"), "FilesPanel")
+ decl = page[page.index("const currentRootWritable"):]
+ decl = decl[:decl.index(";") + 1]
+ assert "currentRoot.upload" in decl, (
+ "the Upload button ignores the flag an older node actually sends")
+ assert "writable !== undefined" in decl, (
+ "a root that is explicitly writable=false must stay read-only — "
+ "falling through to `upload` there would reopen it")
+
+
+def test_app_directories_fall_back_to_the_three_older_messages():
+ """
+ Videos, Music and Photos each had their own message before the generic op,
+ and those still work — so an operator on an un-upgraded node keeps the
+ ability they had rather than being handed a control that times out.
+ """
+ source = TRANSPORT.read_text(encoding="utf-8")
+ legacy = source[source.index("async setAppDirectoriesLegacy("):]
+ legacy = legacy[:legacy.index("\n async ", 1)]
+ for call in ("setPhotoRoots", "setVideoRoot", "setAudioRoot"):
+ assert call in legacy, f"{call} is not reachable on the older path"
+
+ panel = _component(GROUP_SETTINGS.read_text(encoding="utf-8"),
+ "GroupSettingsPanel")
+ assert "transport.supportsAppOps" in panel, (
+ "the settings page sends the 1.1 message unconditionally")
+
+
+def test_the_older_path_refuses_what_it_cannot_carry():
+ """
+ `video_root` and `audio_root` hold one folder. Sending several would store
+ the first and drop the rest silently, which is worse than refusing — the
+ operator would see a saved setting that is not what they chose.
+ """
+ source = TRANSPORT.read_text(encoding="utf-8")
+ legacy = source[source.index("async setAppDirectoriesLegacy("):]
+ legacy = legacy[:legacy.index("\n async ", 1)]
+ assert "clean.length > 1" in legacy
+ assert "throw new Error" in legacy
+
+
+def test_root_management_is_read_only_against_an_older_node():
+ """
+ Unlike the app directories, `writable`, `removable`, eject and plug have no
+ older equivalent to route to. The controls are shown without being
+ offered, with the reason, rather than accepting a click that goes nowhere.
+ """
+ panel = _component(GROUP_SETTINGS.read_text(encoding="utf-8"),
+ "GroupSettingsPanel")
+ table_call = panel[panel.index("<${SharedDirectoriesTable}"):]
+ table_call = table_call[:table_call.index("/>")]
+ assert "readOnly=" in table_call
+ assert "nodeSupportsAppOps" in table_call
+ assert "settings_node.roots_node_too_old" in panel, (
+ "nothing says why the controls are inert")
+
+
+def test_chat_settings_are_hidden_rather_than_routed():
+ """
+ Chat's directory and link-preview switch are new in 1.1 with nothing
+ before them, so there is no older message to fall back to.
+ """
+ panel = _component(GROUP_SETTINGS.read_text(encoding="utf-8"),
+ "GroupSettingsPanel")
+ assert "settings_node.app_node_too_old" in panel
+
+
+# ── Reading an older node's handshake ───────────────────────────────────────
+
+def test_the_ack_is_read_in_both_shapes(app=None):
+ """
+ A 1.0 ack has `video_root` and no `video_directories`, and no
+ `chat_link_preview` at all. Reading a missing plural as "nothing
+ configured" empties a working Videos tab; reading a missing switch as off
+ silently changes what a group's chat does.
+ """
+ page = GROUP_PAGE.read_text(encoding="utf-8")
+ block = page[page.index("setAppDirectories({"):]
+ block = block[:block.index("setNodeSupportsAppOps")]
+ for legacy in ("ack.video_root", "ack.audio_root", "ack.photo_roots"):
+ assert legacy in block, f"{legacy} is not read as a fallback"
+ assert "ack.chat_link_preview !== false" in page, (
+ "an absent link-preview switch must read as on, not off")
+
+
+def test_the_attachment_root_falls_back_to_the_older_answer():
+ """
+ A 1.0 node's roots carry no `writable`, so nothing looks writable and the
+ paperclip would vanish. The group-wide `member_upload` flag is the only
+ answer such a node gives, and it is what gets used.
+ """
+ page = GROUP_PAGE.read_text(encoding="utf-8")
+ block = page[page.index("const writableRoots"):]
+ block = block[:block.index("const commonProps")]
+ assert "legacyNode" in block and "memberUpload" in block
+ assert "writable === undefined" in block
diff --git a/packages/meshbay-hub/tests/test_no_prompt_in_the_spa.py b/packages/meshbay-hub/tests/test_no_prompt_in_the_spa.py
new file mode 100644
index 0000000..d126a05
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_no_prompt_in_the_spa.py
@@ -0,0 +1,90 @@
+"""
+`window.prompt` does not exist in the desktop client.
+
+The same `static/` tree is the web page and the application (CLAUDE.md's "one
+UI source"), and Electron does not implement `prompt` — Chromium leaves it to
+the embedder and Electron declines. It does not return null: it **throws**,
+`Error: prompt() is not supported.`
+
+That made the Files toolbar's New folder button do nothing whatsoever. The call
+sat above its own try, so the click produced no folder, no error, and nothing
+on screen to react to — the failure looks exactly like a dead button, which is
+what it was reported as.
+
+Measured rather than assumed, against this repo's own Electron 44:
+
+ prompt('name?') -> Error: prompt() is not supported.
+ confirm('sure?') -> opens a real modal
+ alert('hi') -> opens a real modal
+
+So `confirm` and `alert` stay allowed and are used in a dozen places; only
+`prompt` is banned. Anything that needs typed input needs a field.
+"""
+
+import re
+from pathlib import Path
+
+import pytest
+
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+
+pytestmark = pytest.mark.skipif(not STATIC.exists(),
+ reason="SPA sources unavailable")
+
+# `prompt(` as a call, not `window.prompt` inside a comment or a longer
+# identifier like `mkdir_prompt` / `promptForName`.
+CALL = re.compile(r"(?<![\w.$])(?:window\.)?prompt\s*\(")
+
+
+def _code_only(source: str) -> str:
+ source = re.sub(r"/\*.*?\*/", "", source, flags=re.S)
+ return re.sub(r"^\s*//.*$", "", source, flags=re.M)
+
+
+def test_nothing_calls_prompt():
+ offenders = []
+ for path in sorted(STATIC.glob("*.js")):
+ if path.name == "sw.js":
+ continue
+ for i, line in enumerate(_code_only(
+ path.read_text(encoding="utf-8")).splitlines(), 1):
+ if CALL.search(line):
+ offenders.append(f"{path.name}:{i}: {line.strip()}")
+
+ assert not offenders, (
+ "prompt() throws in the desktop client, and the click that reaches it "
+ "does nothing at all:\n " + "\n ".join(offenders))
+
+
+def test_the_pattern_would_catch_a_real_call():
+ """
+ A guard that matches nothing passes over an empty set, which looks exactly
+ like success. Both spellings, and the near-misses it must not flag.
+ """
+ assert CALL.search("const n = prompt('x');")
+ assert CALL.search("const n = window.prompt('x');")
+ assert not CALL.search("t('group.mkdir_prompt')")
+ assert not CALL.search("promptForName();")
+ assert not CALL.search("this.prompt(1);")
+
+
+def test_creating_a_folder_uses_a_field():
+ """
+ The control the ban is about. A typed name needs somewhere to type it, and
+ an inline field can show the node's refusal beside the input rather than
+ after a dialog has closed.
+ """
+ source = (STATIC / "files-app.js").read_text(encoding="utf-8")
+ assert "tb-mkdir-input" in source
+ assert "newDirName" in source
+ assert "group.mkdir_prompt" in source, "the field has no label or placeholder"
+
+
+def test_leaving_the_folder_drops_a_half_typed_name():
+ """
+ Otherwise the folder is created where the person is no longer looking —
+ they navigated away, the draft came along, and the name lands in a
+ directory they were not thinking about.
+ """
+ source = (STATIC / "files-app.js").read_text(encoding="utf-8")
+ assert "useEffect(() => { setNewDirName(null); }, [currentPath]);" in source
diff --git a/packages/meshbay-hub/tests/test_search_media_merge.py b/packages/meshbay-hub/tests/test_search_media_merge.py
index 0312c57..65f85aa 100644
--- a/packages/meshbay-hub/tests/test_search_media_merge.py
+++ b/packages/meshbay-hub/tests/test_search_media_merge.py
@@ -71,12 +71,12 @@ def pipeline():
return "\n".join([
"const t = (k) => k;",
EXPORT.sub("", MERGE.read_text()),
- _block(VIDEO_APP, "function underVideoRoot(entry, videoRoot) {"),
+ _block(VIDEO_APP, "function underVideoRoot(entry, directories) {"),
_block(VIDEO_APP, "function buildSeasons(episodes) {"),
- _block(VIDEO_APP, "function groupVideoEntries(entries, videoRoot) {"),
+ _block(VIDEO_APP, "function groupVideoEntries(entries, videoDirectories) {"),
_block(MUSIC_APP, "function foldKey(s) {"),
- _block(MUSIC_APP, "function underAudioRoot(entry, audioRoot) {"),
- _block(MUSIC_APP, "function groupMusicEntries(entries, audioRoot) {"),
+ _block(MUSIC_APP, "function underAudioRoot(entry, directories) {"),
+ _block(MUSIC_APP, "function groupMusicEntries(entries, musicDirectories) {"),
_block(PHOTOS_APP, "function underAnyPhotoRoot(entry, photoRoots) {"),
_block(PHOTOS_APP, "function groupPhotoAlbums(entries, photoRoots) {"),
_const("SEARCH_VIDEO_ROOT"),
@@ -107,7 +107,7 @@ def _grid(tmp_path, pipeline, entries, salt="reader", local=()):
salt: {json.dumps(salt)},
isLocal: (g) => local.has(g),
}});
- const {{ movies, shows }} = groupVideoEntries(merged, SEARCH_VIDEO_ROOT);
+ const {{ movies, shows }} = groupVideoEntries(merged, [SEARCH_VIDEO_ROOT]);
console.log(JSON.stringify({{
movies: movies.map((e) => ({{
id: e.id, title: e.display_title || e.name, groupId: e.groupId,
@@ -136,7 +136,7 @@ def _albums(tmp_path, pipeline, entries, salt="reader", local=()):
salt: {json.dumps(salt)},
isLocal: (g) => local.has(g),
}});
- const {{ albums, tracks }} = groupMusicEntries(merged, SEARCH_AUDIO_ROOT);
+ const {{ albums, tracks }} = groupMusicEntries(merged, [SEARCH_AUDIO_ROOT]);
console.log(JSON.stringify({{
albums: albums.map((a) => ({{
artist: a.artist, album: a.album,
diff --git a/packages/meshbay-hub/tests/test_spa_imports.py b/packages/meshbay-hub/tests/test_spa_imports.py
new file mode 100644
index 0000000..230f33f
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_spa_imports.py
@@ -0,0 +1,87 @@
+"""
+Every import in the SPA points at a file that exports what it names.
+
+The failure this catches has a distinctive shape: nothing errors at build time,
+because there is no build; the browser resolves the module graph at load, finds
+a binding that is not there, and the page renders blank or the component simply
+does not appear. `node --check` cannot see it — it parses one file at a time —
+and neither can the source-reading guards, which look inside a file rather than
+between two.
+
+Written after the settings-page split, which moved two shared components into a
+new module and rewired eight files to import them from there. That is exactly
+the change where a rename lands in one file and not the other.
+
+It is a static check, not a load: it says the name is exported, not that the
+value is what the caller expects. `test_spa_syntax` covers parsing;
+`test_hook_ordering` covers the ordering fault that also presents as a missing
+component.
+"""
+
+import re
+from pathlib import Path
+
+import pytest
+
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+
+pytestmark = pytest.mark.skipif(not STATIC.exists(),
+ reason="SPA sources unavailable")
+
+# `import { a, b as c } from './x.js';` / `import * as p from ...` / default.
+IMPORT = re.compile(
+ r"^import\s+(?:\{([^}]*)\}|(\*\s+as\s+\w+)|(\w+))\s+from\s+'([^']+)';", re.M)
+EXPORT_BLOCK = re.compile(r"^export\s+\{([^}]*)\};", re.M)
+EXPORT_DECL = re.compile(
+ r"^export\s+(?:default\s+)?(?:async\s+)?(?:function|const|class|let|var)\s+(\w+)",
+ re.M)
+
+
+def _exported(source: str) -> set[str]:
+ names = set(EXPORT_DECL.findall(source))
+ for block in EXPORT_BLOCK.findall(source):
+ for raw in block.split(","):
+ name = raw.strip().split(" as ")[-1].strip()
+ if name:
+ names.add(name)
+ return names
+
+
+def test_every_named_import_resolves():
+ problems: list[str] = []
+ for path in sorted(STATIC.glob("*.js")):
+ source = path.read_text(encoding="utf-8")
+ for names, star, default, spec in IMPORT.findall(source):
+ # vendor/ is third-party, bundled, and does not use a form this
+ # reads. Its exports are covered by the app failing to start.
+ if not spec.startswith("./") or "vendor/" in spec:
+ continue
+ target = (path.parent / spec[2:]).resolve()
+ if not target.exists():
+ problems.append(f"{path.name}: imports {spec} — no such file")
+ continue
+ if star or default or not names.strip():
+ continue
+ available = _exported(target.read_text(encoding="utf-8"))
+ for raw in names.split(","):
+ name = raw.strip().split(" as ")[0].strip()
+ if name and name not in available:
+ problems.append(
+ f"{path.name}: imports {{{name}}} from {spec}, "
+ f"which does not export it")
+
+ assert not problems, "unresolved imports:\n" + "\n".join(problems)
+
+
+def test_the_check_can_see_a_real_module():
+ """
+ Guard against the parser quietly matching nothing — a regex that stopped
+ finding imports would make the test above pass over an empty set, which
+ looks exactly like success.
+ """
+ source = (STATIC / "apps.js").read_text(encoding="utf-8")
+ found = IMPORT.findall(source)
+ assert len(found) >= 5, (
+ "the import pattern no longer matches apps.js — this test is then "
+ "checking nothing")
+ assert "configurableApps" in _exported(source)
diff --git a/packages/meshbay-hub/tests/test_spa_syntax.py b/packages/meshbay-hub/tests/test_spa_syntax.py
new file mode 100644
index 0000000..352f84b
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_spa_syntax.py
@@ -0,0 +1,85 @@
+"""
+Every SPA module parses.
+
+This is the cheapest possible test and the suite did not have it, which is how
+a `${/* ... */''}` — htm template syntax, pasted into a plain object literal —
+reached a committed file. Nothing else here would catch it: the source-reading
+guards (`test_hook_ordering`, `test_transport_contracts`, `test_spa_ordering`)
+match patterns in text that parses or does not, and the browser harnesses only
+load the few modules they need.
+
+**`node --check foo.js` is not the check.** It reports success on exactly the
+file above: given a `.js` extension it makes its own decision about how to
+parse, and a module-syntax error inside one can come back clean. Copying to
+`.mjs` first is what forces the module parser, and it is the difference
+between a green run and a real one — the same shape as the "a test that models
+a fix agrees with it by construction" note in CLAUDE.md, one level lower.
+
+It says nothing about names, imports resolving, or hooks being in order. Those
+have their own tests. This one only says the file is JavaScript.
+"""
+
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+
+pytestmark = pytest.mark.skipif(
+ shutil.which("node") is None or not STATIC.exists(),
+ reason="node or the SPA sources are not available")
+
+
+def _modules() -> list[Path]:
+ # vendor/ is third-party and shipped as-is; sw.js is a service worker, a
+ # classic script by definition, and transport.js is loaded with a plain
+ # <script> tag for the same historical reason (see its own header).
+ files = sorted(STATIC.glob("*.js")) + sorted((STATIC / "locales").glob("*.js"))
+ return [f for f in files if f.name not in ("sw.js",)]
+
+
+def test_every_module_parses(tmp_path):
+ broken: list[str] = []
+ for path in _modules():
+ # The .mjs copy is the whole point — see this module's docstring.
+ copy = tmp_path / (path.stem + ".mjs")
+ copy.write_text(path.read_text(encoding="utf-8"), encoding="utf-8")
+ proc = subprocess.run(["node", "--check", str(copy)],
+ capture_output=True, text=True)
+ if proc.returncode != 0:
+ first = (proc.stderr or "").strip().splitlines()
+ detail = next((ln for ln in first if "Error" in ln), first[:1] and first[0] or "")
+ broken.append(f"{path.name}: {detail}")
+
+ assert not broken, "SPA modules that do not parse:\n" + "\n".join(broken)
+
+
+def test_the_check_would_notice_a_broken_file(tmp_path):
+ """
+ The test above passing means nothing unless it can fail, and the way it
+ fails is the interesting part: this same content in a file called `.js`
+ is reported as fine.
+ """
+ bad = "export const a = {\n ${/* not object syntax */''}\n b: 1,\n};\n"
+
+ as_js = tmp_path / "sample.js"
+ as_js.write_text(bad, encoding="utf-8")
+ lenient = subprocess.run(["node", "--check", str(as_js)],
+ capture_output=True, text=True)
+
+ as_mjs = tmp_path / "sample.mjs"
+ as_mjs.write_text(bad, encoding="utf-8")
+ strict = subprocess.run(["node", "--check", str(as_mjs)],
+ capture_output=True, text=True)
+
+ assert strict.returncode != 0, (
+ "the .mjs check no longer reports a module syntax error — this whole "
+ "test is then measuring nothing")
+ if lenient.returncode == 0:
+ # Recorded rather than asserted: this is a Node behaviour, and it
+ # improving would be good news, not a failure. The .mjs copy stays
+ # either way, because relying on the loose path is what let this
+ # through once already.
+ pass
diff --git a/packages/meshbay-hub/tests/test_transfers.py b/packages/meshbay-hub/tests/test_transfers.py
index 86d58cd..3316615 100644
--- a/packages/meshbay-hub/tests/test_transfers.py
+++ b/packages/meshbay-hub/tests/test_transfers.py
@@ -208,9 +208,17 @@ def test_the_colour_is_defined_for_that_mark():
# ── The file list ───────────────────────────────────────────────────────────
def test_a_folder_name_carries_no_trailing_slash():
- """The folder icon in the cell beside it already says what it is."""
+ """
+ The folder icon in the cell beside it already says what it is.
+
+ Anchored on `key=${full}`, which is the row that renders a directory. The
+ first `dir-row` in the file is the ".." row added later, whose only cell is
+ an ellipsis — slicing from there found no `${d}` and failed on a name it
+ had never looked at.
+ """
source = STATIC.joinpath("files-app.js").read_text(encoding="utf-8")
- row = source[source.index('class="file-row dir-row"'):]
- row = row[:row.index("</tr>")]
+ start = source.rindex("<tr", 0, source.index("key=${full}"))
+ row = source[start:source.index("</tr>", start)]
+ assert "dir-row" in row, "the anchor no longer lands on the directory row"
assert "${d}/" not in row, "the folder name is rendered with a trailing slash"
assert "${d}" in row
diff --git a/packages/meshbay-hub/tests/test_transport_contracts.py b/packages/meshbay-hub/tests/test_transport_contracts.py
index fee80bb..b462242 100644
--- a/packages/meshbay-hub/tests/test_transport_contracts.py
+++ b/packages/meshbay-hub/tests/test_transport_contracts.py
@@ -33,6 +33,16 @@ SPLIT_FILES = [APP, GROUP_PAGE, CHAT_APP, STATIC / "files-app.js",
STATIC / "music-app.js", STATIC / "music-player.js",
STATIC / "photos-app.js",
STATIC / "group-settings.js",
+ # Same reason as test_hook_ordering's STATIC_FILES: these are
+ # reached through the registry, so leaving one out here means it
+ # is simply never checked.
+ STATIC / "settings-ui.js", STATIC / "folder-tree.js",
+ STATIC / "chat-app-settings.js",
+ STATIC / "video-app-settings.js",
+ STATIC / "music-app-settings.js",
+ STATIC / "photos-app-settings.js",
+ STATIC / "helloworld-app.js",
+ STATIC / "helloworld-app-settings.js",
STATIC / "auth-page.js", STATIC / "explore-page.js",
CREATE_GROUP]
diff --git a/packages/meshbay-hub/tests/test_upload_controls_hidden.py b/packages/meshbay-hub/tests/test_upload_controls_hidden.py
index 94917d2..f6f476e 100644
--- a/packages/meshbay-hub/tests/test_upload_controls_hidden.py
+++ b/packages/meshbay-hub/tests/test_upload_controls_hidden.py
@@ -1,14 +1,22 @@
"""
-When the operator closes uploading, the controls go — both of them.
+When a directory is read-only, the controls that write to it go — both of them.
-There are two ways to put a file into a group and they are in different
+There are two ways to put a file into a group and they live in different
components: the Upload button in the Files toolbar, and the paperclip in the
chat composer. Hiding one and forgetting the other is the obvious mistake, and
-the second one is the easier to forget because it does not look like an upload.
+the paperclip is the easier to forget because it does not look like an upload.
Nothing here is a security property. **The node refuses the upload** — that is
-`test_member_upload_policy.py` in the node package. This is about not offering
-somebody a button whose only outcome is an error message.
+`test_root_writable_policy.py` and `test_security_regressions.py` in the node
+package. This is about not offering somebody a button whose only outcome is an
+error message.
+
+What the RO/RW refactor changed: there is no group-wide answer any more. Files
+uploads into *the root being browsed*, so its button follows that root's
+`writable`. Chat has no folder on screen, so the shell picks one for it. The
+two therefore read different things on purpose, and the tests below pin that
+each reads the right one — a stronger claim than the old "both read one
+boolean", which is why that assertion is gone rather than adapted.
"""
import re
@@ -17,10 +25,6 @@ from pathlib import Path
import pytest
STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
-# The group-page refactor split what used to be one app.js into one file per
-# "application" plus the group shell. mayUpload itself is still derived once,
-# in the shell (group-page.js) — Files and Chat each moved to their own file
-# and receive it as a prop, the same shape ChatPanel already took.
APP = STATIC / "app.js"
GROUP_PAGE = STATIC / "group-page.js"
FILES_APP = STATIC / "files-app.js"
@@ -36,45 +40,133 @@ def app() -> str:
return GROUP_PAGE.read_text(encoding="utf-8")
-def _component(app: str, name: str) -> str:
- start = app.index(f"\nfunction {name}(")
- end = app.find("\nfunction ", start + 1)
- return app[start:end if end != -1 else len(app)]
+def _component(source: str, name: str) -> str:
+ start = source.index(f"\nfunction {name}(")
+ end = source.find("\nfunction ", start + 1)
+ return source[start:end if end != -1 else len(source)]
# ── Both controls ───────────────────────────────────────────────────────────
def test_the_files_toolbar_hides_its_upload_button():
+ """
+ Gated on the root being browsed, not on a group-wide answer: with one
+ writable root and one read-only one, a single boolean would offer the
+ button in both and produce a refusal in one of them.
+ """
page = _component(FILES_APP.read_text(encoding="utf-8"), "FilesPanel")
toolbar = page[page.index("file-toolbar"):]
toolbar = toolbar[:toolbar.index("breadcrumbs")]
- assert "mayUpload &&" in toolbar, "the Upload button is offered regardless"
+ assert "currentRootWritable" in toolbar, (
+ "the Upload button is offered regardless of the directory's own flag")
+
+
+def test_the_files_upload_button_is_not_offered_at_the_top_of_a_group():
+ """
+ The top level is the set of roots, which is the operator's configuration
+ and not a directory on anyone's disk. There is nothing to upload *into*
+ there, and no root name to give the node.
+ """
+ page = _component(FILES_APP.read_text(encoding="utf-8"), "FilesPanel")
+ toolbar = page[page.index("file-toolbar"):]
+ toolbar = toolbar[:toolbar.index("breadcrumbs")]
+ assert "currentPath &&" in toolbar
+
+
+def test_the_new_folder_button_follows_the_same_rule_as_upload():
+ """
+ Both write to the operator's disk, so both need a writable root — the node
+ refuses either otherwise. It used to require `isNodeAdmin`, which
+ contradicted the node ("a member who can add a file can organise where it
+ goes") and hid the control from everyone who could have used it.
+ """
+ page = _component(FILES_APP.read_text(encoding="utf-8"), "FilesPanel")
+ decl = page[page.index("const canCreateDir"):]
+ decl = decl[:decl.index(";") + 1]
+ assert "currentRootWritable" in decl
+ assert "currentPath" in decl, (
+ "the top of a group is the set of roots, not a directory to create in")
+ assert "isNodeAdmin" not in decl
+
+
+def test_an_icon_only_button_still_says_what_it_is():
+ """
+ The name moved into a tooltip to save toolbar width. A `title` is invisible
+ to a screen reader on a button with no text, so the label has to be there
+ as well — otherwise the control is simply unnamed for anyone not reading
+ with their eyes. The same goes for the field it opens, which has a
+ placeholder and no visible label.
+ """
+ page = _component(FILES_APP.read_text(encoding="utf-8"), "FilesPanel")
+ opener = page[page.index("canCreateDir && newDirName === null"):]
+ opener = opener[:opener.index("</button>")]
+ assert "title=" in opener and "aria-label=" in opener
+ assert "group.mkdir" in opener
+
+ field = page[page.index("canCreateDir && newDirName !== null"):]
+ field = field[:field.index("</span>")]
+ assert "aria-label=" in field, (
+ "the name field is labelled by a placeholder alone, which a screen "
+ "reader does not announce as a name")
def test_the_chat_composer_hides_its_paperclip():
chat = _component(CHAT_APP.read_text(encoding="utf-8"), "ChatPanel")
composer = chat[chat.index("chat-input-row"):]
- assert "mayUpload &&" in composer, (
+ assert "attachRoot ?" in composer, (
"the chat attachment is the second way in and is still offered")
-def test_both_read_the_same_answer(app):
- """Two derivations would eventually disagree, and the disagreement would
- be one of them offering an upload the node refuses."""
- assert re.search(r"const mayUpload = memberUpload \|\| isNodeAdmin;", app), (
- "mayUpload is no longer derived in one place")
- # Files and Chat both receive it from the same `commonProps` object the
- # shell spreads into whichever app tab is active — one derivation feeding
- # one object, rather than two hand-written prop attributes that could
- # drift apart.
+def test_the_paperclip_says_why_rather_than_vanishing():
+ """
+ A control that disappears leaves the reader no way to find out what would
+ bring it back. A group with no writable directory is a state an operator
+ can fix, so it is worth naming.
+ """
+ chat = _component(CHAT_APP.read_text(encoding="utf-8"), "ChatPanel")
+ composer = chat[chat.index("chat-input-row"):]
+ assert "chat.attach_read_only" in composer
+
+
+# ── One derivation, in the shell ────────────────────────────────────────────
+
+def test_the_attachment_directory_is_decided_once(app):
+ """
+ Two derivations would eventually disagree, and the disagreement would be
+ one of them offering an upload the node refuses.
+ """
+ assert re.search(r"const attachRoot = ", app), (
+ "attachRoot is no longer derived in one place")
props = app[app.index("const commonProps = {"):app.index("return html`")]
- assert "mayUpload," in props or "mayUpload:" in props, (
- "mayUpload is not in the shared props object every app receives")
+ assert "attachRoot," in props or "attachRoot:" in props, (
+ "attachRoot is not in the shared props object every app receives")
-def test_the_operator_keeps_their_own_controls(app):
- assert "memberUpload || isNodeAdmin" in app, (
- "turning uploads off would hide the operator's own upload button")
+def test_an_unavailable_root_is_not_offered_as_a_destination(app):
+ """
+ `writable` is configuration and stays true while a drive is unplugged or
+ ejected. Offering it anyway produces a refusal from the node with no
+ explanation on screen.
+ """
+ block = app[app.index("const writableRoots"):]
+ block = block[:block.index("const attachRoot")]
+ assert "available" in block
+
+
+def test_files_uploads_into_the_root_it_is_showing():
+ """
+ The client has to name the destination now, because the node cannot choose
+ between several writable roots without guessing — and a guess here means a
+ file landing in a directory nobody was looking at.
+ """
+ page = _component(FILES_APP.read_text(encoding="utf-8"), "FilesPanel")
+ upload = page[page.index("const uploadFile"):]
+ upload = upload[:upload.index("const makeDirectory")]
+ assert "dir: uploadDir" in upload, "the node is left to choose the folder"
+ assert "const uploadDir = currentPath" in upload, (
+ "the destination is not the folder on screen")
+ assert "root: uploadRoot" in upload, (
+ "a node too old for `dir` reads `root`, and gets nothing without it")
# ── Learning the answer ─────────────────────────────────────────────────────
@@ -82,54 +174,105 @@ def test_the_operator_keeps_their_own_controls(app):
def test_the_answer_comes_from_the_node(app):
"""Not from the hub, which has no say in what may be written to someone
else's disk, and no way to be believed about it."""
- assert "ack.member_upload !== false" in app, (
- "the handshake ack is what carries this")
- assert "hubFetch" not in app[app.index("ack.member_upload") - 400:
- app.index("ack.member_upload")]
+ assert "if (indexMsg.roots) setNodeRoots(indexMsg.roots)" in app, (
+ "the roots table in the index payload is what carries this")
+ idx = app.index("setNodeRoots(indexMsg.roots)")
+ assert "hubFetch" not in app[idx - 400:idx]
def test_an_older_node_is_treated_as_permissive(app):
- """A node that predates the setting sends no such field. Reading a missing
- field as "off" would close every group on the older half of the network."""
+ """
+ A node speaking MNP 1.0 sends roots with no `writable` at all, plus the old
+ group-wide flag. Reading a missing field as "read-only" would close every
+ group on the older half of the network.
+ """
+ assert "ack.member_upload !== false" in app
assert "!== false" in app[app.index("ack.member_upload"):
app.index("ack.member_upload") + 60]
+ block = app[app.index("const legacyNode"):]
+ block = block[:block.index("const commonProps")]
+ assert "writable === undefined" in block, (
+ "nothing distinguishes a 1.0 node from one with no writable roots")
def test_a_change_reaches_people_already_connected(app):
- """The operator may be someone else entirely, changing it while you have
- the group open. A button that survives until the next reconnection is a
- button somebody presses."""
- assert "transport.onUploadPolicy" in app
+ """
+ The operator may be someone else entirely, ejecting a drive while you have
+ the group open. A file list that survives until the next reconnection is a
+ list somebody clicks.
+ """
+ assert "transport.onRootsChanged" in app
transport = TRANSPORT.read_text(encoding="utf-8")
- assert "member_upload_ack" in transport, "nothing routes the node's notice"
+ assert "root_eject_ack" in transport, "nothing routes the node's notice"
-def test_the_notice_still_answers_the_operators_own_request(app):
- """The same message is both a broadcast and the reply to the request that
- caused it — returning early on it would leave that request hanging until it
- timed out."""
+def test_the_notice_also_answers_the_operators_own_request():
+ """
+ The same message is both a broadcast and the reply to the request that
+ caused it.
+
+ Every other admin ack can be resolved and dropped, because its caller
+ already knows what it asked for and updates local state from that. These
+ are the ones the node *broadcasts*: every other connected client learns the
+ change from it, and the one that asked is the only one that does not,
+ because its own request swallowed its copy. Found on the root table, then
+ again on Chat's directory — where it meant the pane went on showing an
+ unsaved-looking draft after a save that had worked.
+ """
transport = TRANSPORT.read_text(encoding="utf-8")
- # Scoped to member_upload_ack's own handler, not everything up to the next
- # occurrence of "index_sync" — other handlers with their own, legitimate
- # early `return` (index_progress, set_scan_settings_ack: neither is ever a
- # reply anyone awaits) now sit between the two in the file.
- block = transport[transport.index("member_upload_ack"):]
- block = block[:block.index("apps_enabled_ack")]
- assert "return" not in block
+ block = transport[transport.index("msg.type.endsWith('_ack')"):]
+ block = block[:block.index("_uploaders")]
+ assert "BROADCAST_ACK_TYPES" in block and "_replayBroadcast" in block, (
+ "the initiating client resolves the ack and learns nothing from it")
# ── Changing it ─────────────────────────────────────────────────────────────
-def test_changing_it_is_signed(app):
+def test_changing_a_root_is_signed():
transport = TRANSPORT.read_text(encoding="utf-8")
- method = transport[transport.index("async setMemberUpload("):]
- method = method[:method.index("\n async ", 1)]
- assert "admin_challenge" in method and "_authorizeAdminOp" in method, (
- "an unsigned instruction would let any member turn uploads back on")
+ for method in ("updateRoot", "ejectRoot", "plugRoot"):
+ body = transport[transport.index(f"async {method}("):]
+ body = body[:body.index("\n async ", 1)]
+ assert "admin_challenge" in body and "_authorizeAdminOp" in body, (
+ f"{method} is unsigned — any member could use it")
def test_only_the_operator_is_offered_the_setting():
- panel = _component(GROUP_SETTINGS.read_text(encoding="utf-8"), "GroupSettingsPanel")
- section = panel[panel.index("members.uploads_title") - 400:
- panel.index("members.uploads_title")]
- assert "isNodeAdmin && connected" in section
+ panel = _component(GROUP_SETTINGS.read_text(encoding="utf-8"),
+ "GroupSettingsPanel")
+ section = panel[panel.index("settings_node.shared_directories_title") - 600:
+ panel.index("settings_node.shared_directories_title")]
+ assert "isNodeAdmin &&" in section
+
+
+def test_the_operator_is_offered_it_on_the_web_too():
+ """
+ An operator is not necessarily sitting at their node. The first version of
+ this section required the loopback API, which resolves to "not available"
+ in a browser — so it rendered for nobody on the web, while the upload
+ controls it replaced had worked there.
+ """
+ source = GROUP_SETTINGS.read_text(encoding="utf-8")
+ panel = _component(source, "GroupSettingsPanel")
+ section = panel[panel.index("settings_node.shared_directories_title") - 600:
+ panel.index("settings_node.shared_directories_title")]
+ assert "connected ||" in section, (
+ "the shared directories section still requires a local node")
+
+ table = _component(source, "SharedDirectoriesTable")
+ for call in ("transport.updateRoot", "transport.ejectRoot",
+ "transport.plugRoot", "transport.removeRoot",
+ "transport.addRoot"):
+ assert call in table, f"{call} has no MNP route from the table"
+
+
+def test_the_roots_shown_come_from_the_live_connection_when_there_is_one():
+ """
+ The loopback list is a second source, and the two drift: it is read once on
+ mount and after a change, while the MNP one is pushed. Preferring MNP also
+ keeps this table on the same data Files reads, so an eject shows in both at
+ the same instant.
+ """
+ panel = _component(GROUP_SETTINGS.read_text(encoding="utf-8"),
+ "GroupSettingsPanel")
+ assert "const effectiveRoots = (connected && mnpRoots" in panel