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