aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-06 19:03:22 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-06 19:03:22 +0200
commitab44526a291fa673aa2850d105f6412a70a5341f (patch)
tree5940f18acfc15fc732eb65d90de346920461b8c8 /packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
parent85a2ec47b7ad334208a3dbb091fadccc7631785c (diff)
downloadmeshbay-ab44526a291fa673aa2850d105f6412a70a5341f.tar.gz
feat(client): Phase 2 — per-app settings panes, folder tree, multi-directory
Each app's settings were inlined in `group-settings.js` — TMDB, MusicBrainz, and one folder picker per app, each with its own draft state and save handler saying the same thing about a different key. They are one file per app now, reached through the `apps.js` registry, and the page that renders them names no application at all: adding one is a registry entry and a settings file. The line between the two is what makes that true. What every app has — folders — the page does generically, through one `saveDirectories` bound to the app. What one app alone has, its pane does itself with the transport it is handed. An app that only needs directories touches neither `group-settings.js` nor `group-page.js`, which is `test_app_settings_plugin.py`'s subject. `settings-ui.js` exists because a pane importing the page that renders it is a cycle, and ES modules answer that with a temporal-dead-zone ReferenceError at first render — a component that silently does not appear, the fault already recorded in CLAUDE.md about hook ordering. The flat depth-indented `<select>` of every folder in the library becomes a modal tree. It asks the node for nothing: the tree is derived from paths the client already holds, so it shows exactly what the group's index contains and adds no folder-browsing protocol. For Chat's attachment folder — the one directory that is written to rather than read — read-only roots are greyed out, so the node's refusal arrives before the operator picks rather than when somebody sends a file. Videos and Music take a list of folders. A library on two drives could not be described before; the only recourse was pointing the app at a parent containing both, which pulls in everything else under it. The scalar shapes survive on the wire alone, for a node speaking MNP 1.0, and the client reads them as a one-element list. Two things the tests caught that I would not have: `test_asset_versioning` — six new modules were missing from `_ASSETS`. Reached through the registry rather than imported by name, they are exactly the files nothing else would notice changing, and a stale one is served from cache with no version bump. And `node --check foo.js` does **not** reliably report a module syntax error: it accepted `${/* ... */''}` — htm template syntax pasted into a plain object literal — and reported success. A `.mjs` copy forces the module parser and reports it. The suite had no syntax check at all, which is how that reached a file; `test_spa_syntax.py` does it for every module now, and pins that the loose path is not what it uses. Suite: 12 failures, all pre-existing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/group-settings.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-settings.js608
1 files changed, 61 insertions, 547 deletions
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. */