aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/group-settings.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-settings.js287
1 files changed, 199 insertions, 88 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
index 03162d9..62665a8 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
@@ -17,6 +17,89 @@ const TMDB_LANGUAGE_BY_LOCALE = {
ja: 'ja-JP', de: 'de-DE', it: 'it-IT', nl: 'nl-NL', pl: 'pl-PL',
};
+/**
+ * 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, defaultOpen = true, children }) {
+ const [open, setOpen] = useState(defaultOpen);
+ return html`
+ <div class="settings-section">
+ <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>
+ ${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>
+ `;
+}
+
+/**
+ * Which folder is an app's entry point for this group — the shared shape
+ * behind both the Videos and Music root pickers (docs/musicbay.md's
+ * amended §2.1): a depth-indented <select> over every folder the group's
+ * index already knows about, a Save button that only enables once the
+ * draft actually differs, and a confirm prompt only when replacing an
+ * *already-set* root (setting one for the first time has nothing to lose).
+ */
+function RootFolderRow({
+ icon, titleKey, hintKey, folders, value, draft, onDraftChange,
+ busy, msg, onSave, noneKey, saveKey,
+}) {
+ return html`
+ <div class="settings-root-row">
+ <div class="settings-root-row-title">
+ <${Icon} name=${icon} />
+ <h4>${t(titleKey)}</h4>
+ </div>
+ <p class="settings-hint">${t(hintKey)}</p>
+ <div class="settings-row">
+ <label class="settings-label">
+ <select value=${draft} disabled=${busy} onChange=${e => onDraftChange(e.target.value)}>
+ <option value="">${t(noneKey)}</option>
+ ${folders.map(p => html`
+ <option key=${p} value=${p}>
+ ${' '.repeat(p.split('/').length - 1)}${p.split('/').pop()}
+ </option>
+ `)}
+ </select>
+ </label>
+ </div>
+ <button class="btn btn-small btn-secondary" style="margin-top:8px"
+ disabled=${busy || draft === (value || '')} onClick=${onSave}>
+ ${busy ? t('settings_node.scan_saving') : t(saveKey)}
+ </button>
+ ${msg && html`<p class="settings-hint">${msg}</p>`}
+ </div>
+ `;
+}
+
// ── Members Panel ────────────────────────────────────────────────────────
/**
@@ -36,6 +119,7 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
tmdbConfig, onTmdbConfig, onTmdbEnabled,
musicbrainzConfig, onMusicbrainzConfig, onMusicbrainzEnabled,
entries, nodeDirs, videoRoot, onVideoRoot,
+ audioRoot, onAudioRoot,
onPaired, onLeft }) {
const [members, setMembers] = useState([]);
const [adminId, setAdminId] = useState('');
@@ -431,9 +515,11 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
// `entries[].path` is each file's containing directory (files-app.js's own
// convention), so every ancestor prefix of it is a real folder, and
// `nodeDirs` covers ones with nothing in them yet. A flat, depth-indented
- // <select> rather than a live folder browser: choosing the Videos root is
- // a rare, one-off decision, not something worth a whole navigable tree for.
- const videoRootFolders = useMemo(() => {
+ // <select> rather than a live folder browser: choosing an app's root is a
+ // rare, one-off decision, not something worth a whole navigable tree for.
+ // Shared between the Videos and Music root pickers below — same folder
+ // set either way.
+ const rootFolderOptions = useMemo(() => {
const set = new Set();
const addAncestors = (path) => {
if (!path) return;
@@ -486,6 +572,39 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
}
}, [transportRef, onVideoRoot, videoRootDraft, videoRoot]);
+ // Same shape as the Videos root above — the Music app's own entry point
+ // (docs/musicbay.md's amended §2.1).
+ const [audioRootDraft, setAudioRootDraft] = useState(audioRoot || '');
+ useEffect(() => { setAudioRootDraft(audioRoot || ''); }, [audioRoot]);
+ const [audioRootBusy, setAudioRootBusy] = useState(false);
+ const [audioRootMsg, setAudioRootMsg] = useState('');
+
+ const saveAudioRoot = useCallback(async () => {
+ const next = audioRootDraft;
+ const current = audioRoot || '';
+ if (next === current) return;
+ if (current && !confirm(t('settings_node.audio_root_change_confirm'))) return;
+ const transport = transportRef && transportRef.current;
+ setAudioRootMsg('');
+ setAudioRootBusy(true);
+ try {
+ if (!transport || !transport.connected) {
+ throw new Error('Not connected to the node');
+ }
+ const sk = transport.sessionKeys && transport.sessionKeys.skEdB64;
+ const signFn = (sk && window.MeshBayKeys)
+ ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript)
+ : null;
+ await transport.setAudioRoot(next, signFn);
+ if (onAudioRoot) onAudioRoot(next);
+ setAudioRootMsg(t('settings_node.scan_saved'));
+ } catch (err) {
+ setAudioRootMsg(err.message);
+ } finally {
+ setAudioRootBusy(false);
+ }
+ }, [transportRef, onAudioRoot, audioRootDraft, audioRoot]);
+
const [removing, setRemoving] = useState('');
/**
@@ -655,8 +774,7 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
Photos) show up here automatically as they register in apps.js —
nothing about this section changes to add one. */
isNodeAdmin && connected && html`
- <div class="settings-section">
- <h3 class="settings-heading">${t('members.apps_title')}</h3>
+ <${CollapsibleSection} titleKey="members.apps_title">
<p class="settings-hint">${t('members.apps_hint')}</p>
<ul class="apps-toggle-list">
${APPS.map(a => html`
@@ -671,15 +789,14 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
`)}
</ul>
${appsMsg && html`<p class="error-msg">${appsMsg}</p>`}
- </div>
+ </${CollapsibleSection}>
`}
${/* How hard the node works watching its own disk — indexer.py
DirectoryIndexer. A performance knob, not a permission: it
changes nothing about who can see or do what. */
isNodeAdmin && connected && html`
- <div class="settings-section">
- <h3 class="settings-heading">${t('settings_node.scan_title')}</h3>
+ <${CollapsibleSection} titleKey="settings_node.scan_title" defaultOpen=${false}>
<p class="settings-hint">${t('settings_node.scan_hint')}</p>
<div class="settings-row">
<label class="settings-label">
@@ -702,7 +819,7 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
${scanBusy ? t('settings_node.scan_saving') : t('settings_node.scan_save')}
</button>
${scanMsg && html`<p class="settings-hint">${scanMsg}</p>`}
- </div>
+ </${CollapsibleSection}>
`}
${/* The on/off switch is per-group (2026-08-24); the custom token and
@@ -712,15 +829,19 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
signed operator settings, not display preferences — but two
independent ones now, saved separately. */
isNodeAdmin && connected && html`
- <div class="settings-section">
- <h3 class="settings-heading">${t('settings_node.tmdb_title')}</h3>
+ <${CollapsibleSection} defaultOpen=${false} title=${html`
+ <span class="settings-meta-title">
+ <${Icon} name="server" />${' '}${t('settings_node.tmdb_title')}
+ <span class="settings-meta-badge ${tmdbEnabled ? 'on' : ''}">
+ ${tmdbEnabled ? t('settings_node.tmdb_enabled') : t('settings_node.tmdb_disabled')}
+ </span>
+ </span>
+ `}>
<p class="settings-hint">${t('settings_node.tmdb_hint')}</p>
<div class="settings-row">
- <label class="settings-label">
- <input type="checkbox" checked=${tmdbEnabled} disabled=${tmdbEnabledBusy}
- onChange=${(e) => saveTmdbEnabled(e.target.checked)} />
- ${' '}${tmdbEnabled ? t('settings_node.tmdb_enabled') : t('settings_node.tmdb_disabled')}
- </label>
+ <${ToggleSwitch} checked=${tmdbEnabled} disabled=${tmdbEnabledBusy}
+ onChange=${(v) => saveTmdbEnabled(v)}
+ label=${tmdbEnabled ? t('settings_node.tmdb_enabled') : t('settings_node.tmdb_disabled')} />
</div>
<div class="settings-row">
<label class="settings-label">
@@ -754,7 +875,7 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
${tmdbBusy ? t('settings_node.scan_saving') : t('settings_node.tmdb_save')}
</button>
${tmdbMsg && html`<p class="settings-hint">${tmdbMsg}</p>`}
- </div>
+ </${CollapsibleSection}>
`}
${/* Same two-part shape as TMDB above: the on/off switch is per-group,
@@ -763,15 +884,19 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
there is no token field: MusicBrainz's read endpoints need no
credential, just a descriptive User-Agent contact. */
isNodeAdmin && connected && html`
- <div class="settings-section">
- <h3 class="settings-heading">${t('settings_node.musicbrainz_title')}</h3>
+ <${CollapsibleSection} defaultOpen=${false} title=${html`
+ <span class="settings-meta-title">
+ <${Icon} name="music" />${' '}${t('settings_node.musicbrainz_title')}
+ <span class="settings-meta-badge ${mbEnabled ? 'on' : ''}">
+ ${mbEnabled ? t('settings_node.musicbrainz_enabled') : t('settings_node.musicbrainz_disabled')}
+ </span>
+ </span>
+ `}>
<p class="settings-hint">${t('settings_node.musicbrainz_hint')}</p>
<div class="settings-row">
- <label class="settings-label">
- <input type="checkbox" checked=${mbEnabled} disabled=${mbEnabledBusy}
- onChange=${(e) => saveMusicbrainzEnabled(e.target.checked)} />
- ${' '}${mbEnabled ? t('settings_node.musicbrainz_enabled') : t('settings_node.musicbrainz_disabled')}
- </label>
+ <${ToggleSwitch} checked=${mbEnabled} disabled=${mbEnabledBusy}
+ onChange=${(v) => saveMusicbrainzEnabled(v)}
+ label=${mbEnabled ? t('settings_node.musicbrainz_enabled') : t('settings_node.musicbrainz_disabled')} />
</div>
<div class="settings-row">
<label class="settings-label">
@@ -791,7 +916,7 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
${mbBusy ? t('settings_node.scan_saving') : t('settings_node.musicbrainz_save')}
</button>
${mbMsg && html`<p class="settings-hint">${mbMsg}</p>`}
- </div>
+ </${CollapsibleSection}>
`}
${/* Which folder is the Videos app's entry point for this group —
@@ -799,36 +924,36 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
§5.6). Until one is chosen, the Videos tab says so instead of
listing anything, and the node runs no TMDB/thumbnail work for
this group at all (daemon.py's _enrich_new_video_entries). */
- isNodeAdmin && connected && html`
- <div class="settings-section">
- <h3 class="settings-heading">${t('settings_node.video_root_title')}</h3>
- <p class="settings-hint">${t('settings_node.video_root_hint')}</p>
- <div class="settings-row">
- <label class="settings-label">
- <select value=${videoRootDraft} disabled=${videoRootBusy}
- onChange=${e => setVideoRootDraft(e.target.value)}>
- <option value="">${t('settings_node.video_root_none')}</option>
- ${videoRootFolders.map(p => html`
- <option key=${p} value=${p}>
- ${'  '.repeat(p.split('/').length - 1)}${p.split('/').pop()}
- </option>
- `)}
- </select>
- </label>
- </div>
- <button class="btn btn-small btn-secondary" style="margin-top:8px"
- disabled=${videoRootBusy || videoRootDraft === (videoRoot || '')}
- onClick=${saveVideoRoot}>
- ${videoRootBusy ? t('settings_node.scan_saving') : t('settings_node.video_root_save')}
- </button>
- ${videoRootMsg && html`<p class="settings-hint">${videoRootMsg}</p>`}
- </div>
- `}
+ isNodeAdmin && connected
+ && ((nodeDetected && nodeRoots.length > 0)
+ || activeApps.includes('video') || activeApps.includes('music')) && html`
+ <${CollapsibleSection} titleKey="settings_node.directories_title">
+ <p class="settings-hint">${t('settings_node.directories_hint')}</p>
- ${/* Roots management (Electron-only, when node is local) */
+ ${activeApps.includes('video') && html`
+ <${RootFolderRow} icon="video"
+ titleKey="settings_node.video_root_title" hintKey="settings_node.video_root_hint"
+ folders=${rootFolderOptions} value=${videoRoot}
+ draft=${videoRootDraft} onDraftChange=${setVideoRootDraft}
+ busy=${videoRootBusy} msg=${videoRootMsg} onSave=${saveVideoRoot}
+ noneKey="settings_node.video_root_none" saveKey="settings_node.video_root_save" />
+ `}
+ ${activeApps.includes('music') && html`
+ <${RootFolderRow} icon="music"
+ titleKey="settings_node.audio_root_title" hintKey="settings_node.audio_root_hint"
+ folders=${rootFolderOptions} value=${audioRoot}
+ draft=${audioRootDraft} onDraftChange=${setAudioRootDraft}
+ busy=${audioRootBusy} msg=${audioRootMsg} onSave=${saveAudioRoot}
+ noneKey="settings_node.audio_root_none" saveKey="settings_node.audio_root_save" />
+ `}
+ ${/* Roots management (Electron-only, when node is local) — folded into
+ the same Directories section as the two root pickers above. */
nodeDetected && nodeRoots.length > 0 && html`
- <div class="settings-section">
- <h3 class="settings-heading">${t('settings_node.roots')}</h3>
+ <div class="settings-root-row">
+ <div class="settings-root-row-title">
+ <${Icon} name="server" />
+ <h4>${t('settings_node.roots')}</h4>
+ </div>
${nodeMsg && html`<p class="settings-hint">${nodeMsg}</p>`}
<div class="node-roots">
${nodeRoots.map(r => html`
@@ -912,38 +1037,29 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
</div>
</div>
`}
+ </${CollapsibleSection}>
+ `}
${/* Operator only, and only with a live connection: the node is what
holds and enforces this, so there is nothing to show or change
without one. */ isNodeAdmin && connected && html`
- <div class="settings-section">
- <h3 class="settings-heading">${t('members.uploads_title')}</h3>
+ <${CollapsibleSection} titleKey="members.uploads_title">
<div class="settings-row">
- <span class="settings-label">
- ${memberUpload ? t('members.uploads_on') : t('members.uploads_off')}
- </span>
- <button class="admin-btn" disabled=${uploadBusy}
- onClick=${() => setUploads(!memberUpload)}>
- ${uploadBusy ? '...'
- : (memberUpload ? t('members.uploads_disable')
- : t('members.uploads_enable'))}
- </button>
+ <${ToggleSwitch} checked=${memberUpload} disabled=${uploadBusy}
+ onChange=${() => setUploads(!memberUpload)}
+ label=${memberUpload ? t('members.uploads_on') : t('members.uploads_off')} />
</div>
<p class="settings-hint">${t('members.uploads_hint')}</p>
${uploadMsg && html`<p class="error-msg">${uploadMsg}</p>`}
- </div>
+ </${CollapsibleSection}>
`}
${/* Upload toggle via loopback when MNP not connected */
nodeDetected && !connected && html`
- <div class="settings-section">
- <h3 class="settings-heading">${t('members.uploads_title')}</h3>
+ <${CollapsibleSection} titleKey="members.uploads_title">
<div class="settings-row">
- <span class="settings-label">
- ${memberUpload ? t('members.uploads_on') : t('members.uploads_off')}
- </span>
- <button class="admin-btn" disabled=${nodeBusy}
- onClick=${async () => {
+ <${ToggleSwitch} checked=${memberUpload} disabled=${nodeBusy}
+ onChange=${async () => {
setNodeBusy(true); setNodeMsg('');
try {
const newVal = !memberUpload;
@@ -953,21 +1069,19 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
if (onMemberUpload) onMemberUpload(newVal);
} catch (err) { setNodeMsg(platform.bridgeMessage(err)); }
finally { setNodeBusy(false); }
- }}>
- ${memberUpload ? t('members.uploads_disable')
- : t('members.uploads_enable')}
- </button>
+ }}
+ label=${memberUpload ? t('members.uploads_on') : t('members.uploads_off')} />
</div>
<p class="settings-hint">${t('members.uploads_hint')}</p>
- </div>
+ </${CollapsibleSection}>
`}
${/* Delete/leave — node detach first (reversible), then hub delete
- (irreversible). */ html`
- <div class="settings-section">
- <h3 class="settings-heading">
- ${isOwner ? t('group.delete_group') : t('group.leave')}
- </h3>
+ (irreversible). Closed by default: a danger-zone action is one
+ click away either way, but not the first thing seen on open. */
+ html`
+ <${CollapsibleSection} defaultOpen=${false}
+ title=${isOwner ? t('group.delete_group') : t('group.leave')}>
<div class="settings-row">
<span class="settings-label">
${isOwner ? t('members.danger_delete_hint')
@@ -1004,7 +1118,7 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
}}>${t('group.leave')}</button>
`}
</div>
- </div>
+ </${CollapsibleSection}>
`}
${connected && html`
@@ -1045,10 +1159,7 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
</div>
`}
- <div class="settings-section">
- <h3 class="settings-heading">
- ${t('group.tab_members')} (${members.length})
- </h3>
+ <${CollapsibleSection} title=${`${t('group.tab_members')} (${members.length})`}>
<table class="admin-table">
<thead>
<tr>
@@ -1085,7 +1196,7 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
${isAdmin && members.length > 1 && html`
<p class="settings-hint">${t('members.remove_hint')}</p>
`}
- </div>
+ </${CollapsibleSection}>
</div>
`;
}