aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/settings-ui.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/settings-ui.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/settings-ui.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/settings-ui.js92
1 files changed, 92 insertions, 0 deletions
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 };