From ab44526a291fa673aa2850d105f6412a70a5341f Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Sun, 6 Sep 2026 19:03:22 +0200 Subject: feat(client): Phase 2 — per-app settings panes, folder tree, multi-directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ` - * 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` - - `; -} - -/** - * 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 onDraftChange(e.target.value)}> - - ${folders.map(p => html` - - `)} - - - - - ${msg && html`

- ${msg.text}

`} - - `; -} - -/** - * 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 ` setAddSelection(e.target.value)}> - - ${available.map((p) => html` - - `)} - - - - - - ${msg && html`

- ${msg.text}

`} - - `; -} // ── 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]); - - // 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 - // toggleApp(a.key)} /> - ${' '}${t(a.labelKey)} - - - `)} - - ${appsMsg && html`

${appsMsg}

`} + ${/* 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` + + <${Icon} name=${app.icon} />${' '}${t(app.labelKey)} + + `} 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`

${t('settings_app.disabled_hint')}

`} - `} + `)} + + ${appsMsg && html`

${appsMsg}

`} ${/* 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, `} - ${/* 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` - - <${Icon} name="server" />${' '}${t('settings_node.tmdb_title')} - - ${tmdbEnabled ? t('settings_node.tmdb_enabled') : t('settings_node.tmdb_disabled')} - - - `}> -

${t('settings_node.tmdb_hint')}

-
- <${ToggleSwitch} checked=${tmdbEnabled} disabled=${tmdbEnabledBusy} - onChange=${(v) => saveTmdbEnabled(v)} - label=${tmdbEnabled ? t('settings_node.tmdb_enabled') : t('settings_node.tmdb_disabled')} /> -
-
- -

- ${tmdbConfig && tmdbConfig.tokenCustomized - ? t('settings_node.tmdb_token_customized') - : t('settings_node.tmdb_token_default')} -

-
-
- -

${t('settings_node.tmdb_language_hint')}

-
- - ${tmdbMsg && html`

${tmdbMsg}

`} - - `} - - ${/* 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` - - <${Icon} name="music" />${' '}${t('settings_node.musicbrainz_title')} - - ${mbEnabled ? t('settings_node.musicbrainz_enabled') : t('settings_node.musicbrainz_disabled')} - - - `}> -

${t('settings_node.musicbrainz_hint')}

-
- <${ToggleSwitch} checked=${mbEnabled} disabled=${mbEnabledBusy} - onChange=${(v) => saveMusicbrainzEnabled(v)} - label=${mbEnabled ? t('settings_node.musicbrainz_enabled') : t('settings_node.musicbrainz_disabled')} /> -
- ${mbMsg && html`

${mbMsg}

`} - - `} - - ${/* 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"> -

${t('settings_node.directories_hint')}

- - ${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. */''} - - `} - ${/* 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. */ -- cgit v1.2.3