aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--docs/apps.md57
-rw-r--r--docs/refactor-groups.md72
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/webapp.py8
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/apps.js52
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/chat-app-settings.js68
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/folder-tree.js256
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-page.js107
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-settings.js608
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js27
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js27
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js27
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js27
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js27
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js27
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js27
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js27
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js27
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js27
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/music-app-settings.js61
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/music-app.js22
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/photos-app-settings.js45
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/photos-app.js10
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/search-page.js61
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/settings-ui.js92
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css74
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js78
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/video-app-settings.js128
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/video-app.js24
-rw-r--r--packages/meshbay-hub/tests/test_app_settings_plugin.py277
-rw-r--r--packages/meshbay-hub/tests/test_hook_ordering.py6
-rw-r--r--packages/meshbay-hub/tests/test_search_media_merge.py12
-rw-r--r--packages/meshbay-hub/tests/test_spa_syntax.py85
-rw-r--r--packages/meshbay-hub/tests/test_transport_contracts.py8
-rw-r--r--packages/meshbay-node/src/meshbay_node/ui/app.py27
34 files changed, 1861 insertions, 647 deletions
diff --git a/docs/apps.md b/docs/apps.md
index 7ce1e73..7819dc9 100644
--- a/docs/apps.md
+++ b/docs/apps.md
@@ -146,6 +146,12 @@ async def set_enabled_apps(group_id, apps, set_by="") -> list[str]: ...
from exactly one place: `webrtc_server.py`'s `_admin_exec_apps_enabled`, after
`_verify_admin_sig` — nothing is applied before the signature checks out.
+**An app's directories are the same shape one level down** (2026-09-06):
+`ops.set_app_directories(state, group_id, app_key, paths)`, stored under
+`<app_key>_directories`, reached by one MNP message (`app_directories`) and one
+loopback route. Adding an app adds no function, no message type and no route —
+which is what "plugin architecture" has to mean to be worth the phrase.
+
`_do_apps_enabled` in `webrtc_server.py` validates before it ever issues a
challenge:
- `apps` non-empty — the operator can never lock a group down to nothing.
@@ -170,6 +176,37 @@ table. Changing it broadcasts `apps_enabled_ack` to everyone already connected
waiting for a reconnection. The root ops (`root_update_ack`, `root_eject_ack`,
`root_plug_ack`) broadcast the same way, through `onRootsChanged`.
+### 3b. An app's settings
+
+Each app that has settings exports a component from
+`static/<app>-app-settings.js` and names it in its `apps.js` entry. The Settings
+page renders one collapsible section per registry entry, with the app's own
+on/off switch in the header — the toggle *is* the enablement control, rather
+than a checkbox list somewhere else that could disagree with it.
+
+Every pane takes the same props, and nothing else: `roots`, `dirs`, `settings`,
+`saveDirectories` (bound to this app), `transport`, `signFn`. The split is the
+point — **what every app has, the page does generically; what one app alone
+has, the pane does itself.** Pointing an app at folders goes through
+`saveDirectories`; a TMDB credential or a link-preview switch is the pane's own
+business, made with the transport it is handed. An app that only needs
+directories therefore touches neither `group-settings.js` nor `group-page.js`,
+and `test_app_settings_plugin.py` fails if either of them starts naming apps
+again.
+
+Two constraints that are not obvious:
+
+- **A pane must not import `group-settings.js`.** That is a cycle
+ (`group-settings` → `apps` → pane → `group-settings`), and ES modules answer
+ it with a temporal-dead-zone `ReferenceError` at first render — the component
+ does not appear, with nothing in the console to say why. The shared widgets
+ (`CollapsibleSection`, `ToggleSwitch`, `useSaver`) live in `settings-ui.js`
+ for this reason.
+- **A new module must be added to `_ASSETS`** in `meshbay_hub/api/webapp.py`.
+ A file reached through the registry is not imported by name anywhere, so
+ nothing else would notice it changing, and a browser would go on serving the
+ cached copy. `test_asset_versioning` enforces it.
+
**Client side:** `apps.js`'s `visibleApps(enabledKeys)` filters the registry;
`group-page.js` calls it with `enabledApps` state (from the ack, `null` until
one arrives, which `visibleApps` reads as "show everything registered" — a
@@ -187,8 +224,17 @@ registry, so a newly-registered app gets a checkbox for free.
`icon.js` — do not re-implement `formatSize`, the download pipeline, or
`Icon`.
2. **Register it** in `apps.js`'s `APPS` array: `{ key, icon, labelKey,
- Component }`. `key` is the wire identifier — it must match what you add to
- the node's allow-list next.
+ Component, Settings? }`. `key` is the wire identifier — it must match what
+ you add to the node's allow-list next, and it is also the row an app's
+ directories are stored under (`<key>_directories`). One identifier per app,
+ everywhere; `test_app_settings_plugin.py` checks the registry against
+ `ALLOWED_APPS`.
+2b. **`<name>-app-settings.js`**, if the app has anything to configure,
+ exporting a component that takes `{ roots, dirs, settings,
+ saveDirectories, transport, signFn }` and nothing else (§3b). Folders go
+ through `saveDirectories`; anything only this app has, it does itself with
+ the transport. **Do not import `group-settings.js`** — that is a cycle, and
+ it fails as a component that silently does not render.
3. **Node-side allow-list**: add the key to `ALLOWED_APPS` in
`webrtc_server.py`. Without this the node refuses `apps_enabled` for any
set naming it (`"Unknown app(s): ..."`), so an operator can never turn it
@@ -196,7 +242,7 @@ registry, so a newly-registered app gets a checkbox for free.
4. **i18n**: at minimum, a `group.tab_<name>` key (the tab's tooltip/label,
reused as the Settings checkbox label) in all ten `static/locales/*.js`
files. `test_locales.py` holds them to the same key set.
-5. **`webapp.py`'s `_ASSETS`** tuple: add the new file. This is the
+5. **`webapp.py`'s `_ASSETS`** tuple: add both new files. This is the
cache-busting hash's input list — a file imported by the page but missing
here can change without the served URL changing, which is the exact bug
class `test_asset_versioning.py` exists for. Forgetting this step used to be
@@ -216,6 +262,11 @@ registry, so a newly-registered app gets a checkbox for free.
No protocol change, no hub change, no `daemon.py` change — steps 3 and 6 are
the only node-side touches, and both are allow-lists, not new wire messages.
+Directories in particular need nothing server-side at all: `app_directories` is
+one generic op keyed by the app's name (§3), and an app storing its folders
+under a key nobody wrote code for is the case
+`test_app_directories.py::test_an_app_nobody_wrote_code_for_stores_its_directories`
+pins.
## 5. What does not exist yet
diff --git a/docs/refactor-groups.md b/docs/refactor-groups.md
index 3d7412b..f94a18b 100644
--- a/docs/refactor-groups.md
+++ b/docs/refactor-groups.md
@@ -1,14 +1,14 @@
# Groups Refactor — Per-Root Permissions & App Plugin Architecture
-> Status: **Phase 1 complete and reviewed** (2026-09-06). Phase 2 and 3 not started.
+> Status: **Phases 1 and 2 complete** (2026-09-06). Phase 3 not started.
>
> This is the most significant refactoring of the project. It changes how roots
> are permissioned, how group applications are configured, and how the Settings
> and Create Group pages are structured.
>
> §7b records what the review of Phase 1 found and how the plan below was wrong
-> where it was wrong. Read it before starting Phase 2 — two of its entries are
-> rules the later phases have to follow, not one-off fixes.
+> where it was wrong; §7c does the same for Phase 2. Read both before starting
+> Phase 3 — several entries are rules rather than one-off fixes.
---
@@ -752,3 +752,69 @@ configured directory.
- `test_ops.py::test_a_backslash_path_written_into_node_toml_stays_parseable`
fails on any non-Windows machine and always has — it builds a
`PurePosixPath` from a Windows path. Unrelated to this refactor, left alone.
+
+---
+
+## 7c. Phase 2 as built (2026-09-06)
+
+The plan held. Four things were done differently, and one of them is a rule.
+
+### The rule
+
+**A settings key added to the client must be added ten times.** `test_locales`
+holds the nine other catalogues to `en.js`, so a missing key is a failing test
+rather than a silent gap — but Phase 2 added 27 keys, and doing them one file
+at a time is how the Phase 1 gap happened. Write the table, generate the
+insert.
+
+**And a second one, which cost a bug in this phase:** `node --check foo.js`
+does **not** reliably report a module syntax error. It accepted a file with
+`${/* ... */''}` — htm template syntax, pasted into a plain object literal —
+and reported success. Copying to `.mjs` first forces the module parser, which
+reports it. `test_spa_syntax.py` now does that for every module; the suite had
+no syntax check at all before, which is how the file was committed.
+
+### Done differently
+
+- **No migration script.** The plan (§4.3) called for one to rename
+ `video_root` → `video_directories` in `roster.db`. Instead the roster falls
+ back to the old key when the new one is unset, and the first save through the
+ new path leaves it behind. A script that has to be run by hand on the machine
+ where it matters is a step that does not happen; a fallback is one that
+ cannot be skipped.
+- **`music`, not `audio`.** The app's registry key was `music` while its
+ storage said `audio_root` and its ops said `set_audio_root`. One identifier
+ per app now — the registry key — with the correspondence in exactly one
+ table (`Roster.LEGACY_DIR_KEYS`).
+- **One storage shape.** `set_app_directory` (single) writes a one-element
+ list, so there is no scalar form anywhere below the wire. `video_root` and
+ friends survive on the handshake ack only, *derived* from the list rather
+ than stored beside it — a second stored value drifts within one run, which
+ reads as "it works after a restart".
+- **The panes call the transport themselves.** The plan had every pane report
+ through one `onSave`, which would have made the page a dispatcher naming
+ every app's settings keys — the thing the phase exists to remove. The line
+ is: what every app has (directories) the page does, generically; what one
+ app alone has (a TMDB key, a link-preview switch) the pane does with the
+ transport it is handed. An app that only wants directories touches neither
+ file, which is `test_app_settings_plugin.py`'s subject.
+
+### Worth knowing
+
+- `settings-ui.js` exists because `group-settings` → `apps` → a pane →
+ `group-settings` is an import cycle, and ES modules answer that with a
+ temporal-dead-zone `ReferenceError` at first render — the component simply
+ does not appear, which is the fault already recorded in CLAUDE.md about hook
+ ordering. The shared widgets live outside both.
+- The folder picker asks the node for **nothing**. The tree is derived from
+ paths the client already holds, so it shows what the group's index contains
+ and no more — a folder the node never indexed does not exist as far as the
+ group is concerned. There is no folder-browsing protocol and this does not
+ add one.
+- `_ASSETS` in `webapp.py` had to grow by six. Modules reached through the
+ registry rather than imported by name are exactly the ones nothing else would
+ notice changing, and a stale one is served from a browser cache with no
+ version bump. `test_asset_versioning` caught it.
+- **What Phase 3 still owes:** the HelloWorld app (§4.1) — which is the actual
+ proof of the above, since every test here reads source rather than adding an
+ app and watching it work — plus the CLI polish and the Windows pass.
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py
index a1807ea..58040b6 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py
@@ -38,6 +38,14 @@ _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",
# 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 03f85ae..6e62a42 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/apps.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/apps.js
@@ -3,25 +3,46 @@ 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';
/**
* 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, alwaysEnabled: true },
- { 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 registry filtered to what this group has enabled, in registry order. */
@@ -31,4 +52,9 @@ function visibleApps(enabledKeys) {
return APPS.filter(a => a.alwaysEnabled || enabled.has(a.key));
}
-export { APPS, visibleApps };
+/** The apps the Settings page offers a section for, in registry order. */
+function configurableApps() {
+ return APPS.filter(a => !a.alwaysEnabled && a.Settings);
+}
+
+export { APPS, 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..68569b9
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/chat-app-settings.js
@@ -0,0 +1,68 @@
+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="btn btn-small btn-secondary" style="margin-top:4px"
+ 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/folder-tree.js b/packages/meshbay-hub/src/meshbay_hub/static/folder-tree.js
new file mode 100644
index 0000000..1af6573
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/folder-tree.js
@@ -0,0 +1,256 @@
+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 button-plus-modal pairing every settings pane wants, so none of them
+ * has to hold `open` state of its own.
+ */
+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] : []);
+
+ return html`
+ <div class="settings-row">
+ <label class="settings-label">${label}</label>
+ ${hint && html`<p class="settings-hint">${hint}</p>`}
+ <div class="ftp-field">
+ <div class="ftp-field-value">
+ ${chosen.length
+ ? chosen.map((p) => html`<code key=${p} class="ftp-chip">${p}</code>`)
+ : html`<span class="settings-hint">${t('folder_tree.nothing_selected')}</span>`}
+ </div>
+ <button class="btn btn-small btn-secondary" disabled=${disabled}
+ onClick=${() => setOpen(true)}>
+ <${Icon} name="folder" /> ${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 dfde172..747cb74 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
@@ -128,15 +128,18 @@ 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);
// MusicBrainz on/off (per-group) — docs/musicbay.md §3.2.
const [musicbrainzConfig, setMusicbrainzConfig] = useState(null);
const onPlayQueue = useCallback((tracks, startIndex) => {
@@ -226,8 +229,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
@@ -249,10 +253,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;
@@ -326,9 +331,19 @@ 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 || [],
+ });
+ setChatDirectory(ack.chat_directory || '');
+ setChatLinkPreview(ack.chat_link_preview !== false);
setMusicbrainzConfig({
enabled: ack.musicbrainz_enabled !== false,
});
@@ -344,9 +359,18 @@ 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) => {
@@ -600,15 +624,36 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
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.
+ const appSettings = useMemo(() => ({
+ videoDirectories: appDirs('video'),
+ musicDirectories: appDirs('music'),
+ photoDirectories: appDirs('photo'),
+ chatDirectory,
+ chatLinkPreview,
+ tmdbEnabled: tmdbConfig ? tmdbConfig.enabled !== false : true,
+ tmdbLanguage: (tmdbConfig && tmdbConfig.language) || '',
+ tmdbTokenCustomized: Boolean(tmdbConfig && tmdbConfig.tokenCustomized),
+ musicbrainzEnabled: musicbrainzConfig
+ ? musicbrainzConfig.enabled !== false : true,
+ }), [appDirs, chatDirectory, chatLinkPreview, tmdbConfig, musicbrainzConfig]);
+
const commonProps = {
groupId, transportRef, gekRef, status, username,
entries, availableEntries, nodeDirs, nodeRoots,
setEntries, setNodeDirs, setNodeRoots, applyIndex,
isNodeAdmin, operatorPaired, attachRoot, 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.
+ videoDirectories: appDirs('video'),
+ musicDirectories: appDirs('music'),
+ photoDirectories: appDirs('photo'),
tmdbConfig,
musicbrainzConfig, onPlayQueue,
};
@@ -739,18 +784,14 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
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}
+ ${/* 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 eeed786..5d0ab07 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
@@ -1,178 +1,13 @@
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 { APPS, 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>
- `;
-}
-
-/**
- * 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>
- `;
-}
-
-/**
- * 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>
- </div>
- <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)}
- </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));
-
- 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="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>
- `;
-}
// ── Shared Directories Table ────────────────────────────────────────────
@@ -525,11 +360,8 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
mnpRoots,
enabledApps, onEnabledApps,
scanSettings, onScanSettings,
- tmdbConfig, onTmdbConfig, onTmdbEnabled,
- musicbrainzConfig, onMusicbrainzEnabled,
- entries, nodeDirs, videoRoot, onVideoRoot,
- audioRoot, onAudioRoot,
- photoRoots, onPhotoRoots, onRefreshIndex,
+ entries, nodeDirs,
+ appSettings, onAppDirectories, onRefreshIndex,
onPaired, onLeft }) {
const [members, setMembers] = useState([]);
const [adminId, setAdminId] = useState('');
@@ -775,146 +607,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]);
-
- 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]);
+ // ── 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.
- // 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;
@@ -926,109 +633,23 @@ 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 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);
- }
- }, [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 saveAppDirectories = useCallback(async (appKey, paths) => {
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);
+ if (!transport || !transport.connected) {
+ throw new Error(t('node.root_no_route'));
}
- }, [transportRef, onPhotoRoots]);
+ await transport.setAppDirectories(appKey, paths, adminSignFn);
+ if (onAppDirectories) onAppDirectories(appKey, paths);
+ }, [transportRef, adminSignFn, onAppDirectories]);
const [removing, setRemoving] = useState('');
@@ -1235,27 +856,37 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
</${CollapsibleSection}>
`}
- ${/* 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.filter(a => !a.alwaysEnabled).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>`}
+ ${/* 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)} />
+ `}>
+ ${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
@@ -1287,123 +918,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} />
- `}
- ${/* Root management moved to SharedDirectoriesTable above. */''}
- </${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. */
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 ea14814..d5d1429 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -818,6 +818,33 @@ export default {
'settings_node.photo_roots_save': 'Speichern',
'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.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.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.',
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 cbb790f..8c5cbe5 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -606,6 +606,33 @@ export default {
'settings_node.photo_roots_save': 'Save',
'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.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.directories_title': 'App directories',
'settings_node.directories_hint': 'Which shared folders the Videos, Music and Photos apps use as their entry point(s).',
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 3921532..4a7384f 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -814,6 +814,33 @@ export default {
'settings_node.photo_roots_save': 'Guardar',
'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.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.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.',
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 4e60eb9..bee4ae9 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -832,6 +832,33 @@ export default {
'settings_node.photo_roots_save': 'Enregistrer',
'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.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.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.',
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 38fc241..e39d91d 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -828,6 +828,33 @@ export default {
'settings_node.photo_roots_save': 'Salva',
'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.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.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.',
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 7890ab7..75a3aa2 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -812,6 +812,33 @@ export default {
'settings_node.photo_roots_save': '保存',
'settings_node.shared_directories_title': '共有ディレクトリ',
'settings_node.shared_directories_hint': 'このグループと共有されているフォルダー。読み書きを切り替えてアップロードを許可し、外付けドライブにはリムーバブルを設定します。',
+ 'folder_tree.title_single': 'フォルダーを選択',
+ 'folder_tree.title_multi': 'フォルダーを選択',
+ 'folder_tree.choose': '選択…',
+ '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.directories_title': 'アプリのディレクトリ',
'settings_node.directories_hint': '共有フォルダと、動画・音楽・写真の各アプリがそれぞれの起点として使用するフォルダです。',
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 cdd4720..7070288 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -830,6 +830,33 @@ export default {
'settings_node.photo_roots_save': 'Opslaan',
'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.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.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.',
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 d3826f5..c252e82 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -856,6 +856,33 @@ export default {
'settings_node.photo_roots_save': 'Zapisz',
'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.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.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.',
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 6706c13..7fb6c8c 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
@@ -815,6 +815,33 @@ export default {
'settings_node.photo_roots_save': 'Salvar',
'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.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.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.',
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 f62d6fd..e0886a2 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
@@ -799,6 +799,33 @@ export default {
'settings_node.photo_roots_save': '保存',
'settings_node.shared_directories_title': '共享目录',
'settings_node.shared_directories_hint': '与此群组共享的文件夹。切换读写以允许上传,标记为可移除用于外置驱动器。',
+ 'folder_tree.title_single': '选择文件夹',
+ 'folder_tree.title_multi': '选择文件夹',
+ 'folder_tree.choose': '选择…',
+ '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.directories_title': '应用目录',
'settings_node.directories_hint': '共享文件夹,以及“视频”“音乐”和“照片”应用各自使用哪个(些)作为入口。',
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..01174b1
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/music-app-settings.js
@@ -0,0 +1,61 @@
+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="btn btn-small btn-secondary" style="margin-top:4px"
+ 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 212745a..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, availableEntries, audioRoot, musicbrainzConfig, onPlayQueue,
+ groupId, transportRef, gekRef, status, entries, availableEntries,
+ musicDirectories, musicbrainzConfig, onPlayQueue,
hideFilter,
}) {
const [mode, setMode] = useState(loadViewMode);
@@ -466,8 +468,10 @@ function MusicApp({
const setModeAndSave = (m) => { setMode(m); saveViewMode(m); };
const musicEntries = availableEntries || entries;
+ const configured = (musicDirectories || []).length > 0;
const { tracks, artists, albums } = useMemo(
- () => groupMusicEntries(musicEntries, audioRoot), [musicEntries, audioRoot]);
+ () => groupMusicEntries(musicEntries, musicDirectories),
+ [musicEntries, musicDirectories]);
const needle = filter.trim().toLowerCase();
const filteredArtists = useMemo(() => {
@@ -493,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/photos-app-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/photos-app-settings.js
new file mode 100644
index 0000000..81e6cc1
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/photos-app-settings.js
@@ -0,0 +1,45 @@
+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="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 { 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 211c836..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, availableEntries, photoRoots, setError,
+ groupId, transportRef, gekRef, status, entries, availableEntries,
+ photoDirectories, setError,
hideFilter, readOnly,
}) {
const [openDir, setOpenDir] = useState(null);
@@ -332,7 +333,8 @@ function PhotosApp({
const photoEntries = availableEntries || entries;
const albums = useMemo(
- () => groupPhotoAlbums(photoEntries, photoRoots), [photoEntries, photoRoots]);
+ () => groupPhotoAlbums(photoEntries, photoDirectories),
+ [photoEntries, photoDirectories]);
const needle = filter.trim().toLowerCase();
const filteredAlbums = useMemo(() => (!needle ? albums : albums.filter(
@@ -347,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 6e4e5b9..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,
@@ -725,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}
@@ -739,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} />
@@ -752,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 eaf1298..1354340 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/style.css
+++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css
@@ -1240,6 +1240,18 @@ 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; }
+.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;
@@ -4054,3 +4066,65 @@ 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-panel, var(--bg)); 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-hover); }
+.ftp-row.blocked { opacity: 0.4; }
+.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; font-size: 0.72em; padding: 1px 5px; border-radius: 3px;
+ border: 1px solid var(--border); color: var(--text-dim);
+ text-transform: uppercase; letter-spacing: 0.03em;
+}
+.ftp-badge.warn { color: var(--danger, #ef4444); 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-hover); border: 1px solid var(--border);
+}
+.ftp-actions { display: flex; justify-content: flex-end; gap: 8px; }
+.ftp-field { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
+.ftp-field-value { flex: 1 1 200px; display: flex; flex-wrap: wrap; gap: 4px; }
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
index 8df7700..785b926 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
@@ -85,6 +85,7 @@ const ADMIN_OP_TYPES = new Set([
'musicbrainz_enabled', 'file_delete', 'dir_delete',
'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',
]);
@@ -335,6 +336,9 @@ class MeshBayTransport {
set onUploadPolicy(fn) { this._onUploadPolicy = fn; }
set onRootsChanged(fn) { this._onRootsChanged = fn; }
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; }
@@ -1178,6 +1182,68 @@ 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.
+ */
+ 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
@@ -2291,6 +2357,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.
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..b2d5015
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/video-app-settings.js
@@ -0,0 +1,128 @@
+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="btn btn-small btn-secondary" style="margin-top:4px"
+ 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>
+
+ <button class="btn btn-small btn-secondary" 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 d03d843..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, availableEntries, onPreview, videoRoot, tmdbConfig, isNodeAdmin,
+ groupId, transportRef, gekRef, status, entries, availableEntries, onPreview,
+ videoDirectories, tmdbConfig, isNodeAdmin,
hideFilter, onNeedConn,
}) {
const [mode, setMode] = useState(loadViewMode);
@@ -1057,8 +1059,12 @@ 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(videoEntries, videoRoot), [videoEntries, videoRoot]);
+ () => groupVideoEntries(videoEntries, videoDirectories),
+ [videoEntries, videoDirectories]);
const needle = filter.trim().toLowerCase();
const filteredMovies = useMemo(() => (typeFilter === 'series' ? [] : !needle ? movies : movies.filter(
@@ -1073,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..00a07b0
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_app_settings_plugin.py
@@ -0,0 +1,277 @@
+"""
+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"}
+ assert calls - page_level == {"setAppDirectories"}, (
+ f"the settings page performs app-specific operations: "
+ f"{sorted(calls - page_level - {'setAppDirectories'})}")
+
+
+# ── 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
diff --git a/packages/meshbay-hub/tests/test_hook_ordering.py b/packages/meshbay-hub/tests/test_hook_ordering.py
index f292b3e..5719be5 100644
--- a/packages/meshbay-hub/tests/test_hook_ordering.py
+++ b/packages/meshbay-hub/tests/test_hook_ordering.py
@@ -37,6 +37,12 @@ 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",
"auth-page.js", "explore-page.js", "create-group-page.js",
]
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_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_transport_contracts.py b/packages/meshbay-hub/tests/test_transport_contracts.py
index fee80bb..3c6d022 100644
--- a/packages/meshbay-hub/tests/test_transport_contracts.py
+++ b/packages/meshbay-hub/tests/test_transport_contracts.py
@@ -33,6 +33,14 @@ 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 / "auth-page.js", STATIC / "explore-page.js",
CREATE_GROUP]
diff --git a/packages/meshbay-node/src/meshbay_node/ui/app.py b/packages/meshbay-node/src/meshbay_node/ui/app.py
index ef86ce3..130c59e 100644
--- a/packages/meshbay-node/src/meshbay_node/ui/app.py
+++ b/packages/meshbay-node/src/meshbay_node/ui/app.py
@@ -434,6 +434,33 @@ def create_ui_app(state: dict) -> FastAPI:
raise HTTPException(400, "apps must be a non-empty list")
return await _op(lambda: ops.set_enabled_apps(state, group_id, apps))
+ # ── App directories (operator only, localhost) ────────────────────────
+ #
+ # The loopback twin of the `app_directories` MNP op. One endpoint for every
+ # application, keyed by the app's own name, so adding one needs no route
+ # here — the same reason the op is generic. `ALLOWED_APPS` is checked on
+ # the MNP path; here the caller is already on localhost holding the run
+ # token, and `ops` refuses a directory outside the group's roots either
+ # way, so an unknown key writes one unread settings row and nothing else.
+
+ @app.put("/api/groups/{group_id}/app-directories/{app_key}")
+ async def set_app_directories(group_id: str, app_key: str, payload: dict):
+ dirs = payload.get("directories")
+ if not isinstance(dirs, list):
+ raise HTTPException(400, "directories must be a list")
+ return await _op(lambda: ops.set_app_directories(
+ state, group_id, app_key, [str(d) for d in dirs]))
+
+ @app.put("/api/groups/{group_id}/chat-directory")
+ async def set_chat_directory(group_id: str, payload: dict):
+ return await _op(lambda: ops.set_chat_directory(
+ state, group_id, str(payload.get("path") or "")))
+
+ @app.put("/api/groups/{group_id}/chat-link-preview")
+ async def set_chat_link_preview(group_id: str, payload: dict):
+ return await _op(lambda: ops.set_chat_link_preview(
+ state, group_id, bool(payload.get("enabled", True))))
+
# ── Scan settings (operator only, localhost) ──────────────────────────
@app.put("/api/groups/{group_id}/scan-settings")