aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/settings-ui.js
blob: 9b7a286743c2f0be6bddfb8a79b1838cdb2ab0e8 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
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 };