diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-06 19:03:22 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-06 19:03:22 +0200 |
| commit | ab44526a291fa673aa2850d105f6412a70a5341f (patch) | |
| tree | 5940f18acfc15fc732eb65d90de346920461b8c8 /packages/meshbay-hub/src/meshbay_hub/static/group-settings.js | |
| parent | 85a2ec47b7ad334208a3dbb091fadccc7631785c (diff) | |
| download | meshbay-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.js | 608 |
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. */ |