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/settings-ui.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/settings-ui.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/settings-ui.js | 92 |
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 }; |