diff options
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 }; |