diff options
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.js | 287 |
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> `; } |