${error && html`
${error}
`}
${/* Inviting needs the node: it is the node that wraps the group key and
issues the code, not the hub. Public groups admit anyone — no invite.
The form stays in the DOM so a brief reconnect does not destroy the
input the user is typing into — controls are disabled instead. */
isAdmin && group?.join_policy !== 'open' && html`
${t('members.invite_title')}
${!connected ? html`
${t('group.offline_title')}
` : !operatorPaired ? html`
${isNodeAdmin ? t('members.invite_needs_pairing')
: t('members.invite_ask_operator')}
` : ''}
`}
${isNodeAdmin && !operatorPaired && connected && html`
${t('members.pair_title')}
${t('members.pair_hint')}
${pairStatus && html`
${pairStatus === 'paired' ? t('members.pair_success') : pairStatus}
`}
`}
${/* Which group "applications" members see. New ones (Videos, Music,
Photos) show up here automatically as they register in apps.js —
nothing about this section changes to add one. */
isNodeAdmin && connected && html`
<${CollapsibleSection} titleKey="members.apps_title">
${t('members.apps_hint')}
${appsMsg && html`
${appsMsg}
`}
${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`
<${CollapsibleSection} titleKey="settings_node.scan_title" defaultOpen=${false}>
${t('settings_node.scan_hint')}
${t('settings_node.scan_reconcile_label')}
setReconcileMinutes(Number(e.target.value))} />
${t('settings_node.scan_debounce_label')}
setDebounceSeconds(Number(e.target.value))} />
${scanBusy ? t('settings_node.scan_saving') : t('settings_node.scan_save')}
${scanMsg && html`
${scanMsg}
`}
${CollapsibleSection}>
`}
${/* The on/off switch is per-group (2026-08-24); the custom token and
query language stay node-wide, one shared credential/cache
(docs/mediacenter.md §5.5). Both are new outbound third-party
traffic the node did not have before the Videos app, so both are
signed operator settings, not display preferences — but two
independent ones now, saved separately. */
isNodeAdmin && connected && html`
<${CollapsibleSection} defaultOpen=${false} title=${html`
<${Icon} name="server" />${' '}${t('settings_node.tmdb_title')}
${tmdbEnabled ? t('settings_node.tmdb_enabled') : t('settings_node.tmdb_disabled')}
`}>
${t('settings_node.tmdb_hint')}
<${ToggleSwitch} checked=${tmdbEnabled} disabled=${tmdbEnabledBusy}
onChange=${(v) => saveTmdbEnabled(v)}
label=${tmdbEnabled ? t('settings_node.tmdb_enabled') : t('settings_node.tmdb_disabled')} />
${t('settings_node.tmdb_language_label')}
setTmdbLanguage(e.target.value)}>
${LOCALES.map(l => html`
${l.name}
`)}
${t('settings_node.tmdb_language_hint')}
saveTmdbConfig()}>
${tmdbBusy ? t('settings_node.scan_saving') : t('settings_node.tmdb_save')}
${tmdbMsg && html`
${tmdbMsg}
`}
${CollapsibleSection}>
`}
${/* Same two-part shape as TMDB above: the on/off switch is per-group,
the contact string stays node-wide (docs/musicbay.md §3.2) —
one operator identity, not a per-group concern. Unlike TMDB
there is no token field: MusicBrainz's read endpoints need no
credential, just a descriptive User-Agent contact. */
isNodeAdmin && connected && html`
<${CollapsibleSection} defaultOpen=${false} title=${html`
<${Icon} name="music" />${' '}${t('settings_node.musicbrainz_title')}
${mbEnabled ? t('settings_node.musicbrainz_enabled') : t('settings_node.musicbrainz_disabled')}
`}>
${t('settings_node.musicbrainz_hint')}
<${ToggleSwitch} checked=${mbEnabled} disabled=${mbEnabledBusy}
onChange=${(v) => saveMusicbrainzEnabled(v)}
label=${mbEnabled ? t('settings_node.musicbrainz_enabled') : t('settings_node.musicbrainz_disabled')} />
saveMusicbrainzConfig()}>
${mbBusy ? t('settings_node.scan_saving') : t('settings_node.musicbrainz_save')}
${mbMsg && html`
${mbMsg}
`}
${CollapsibleSection}>
`}
${/* Which folder is the Videos app's entry point for this group —
per-group like uploads, not node-wide like TMDB (mediacenter.md
§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
&& ((nodeDetected && nodeRoots.length > 0)
|| activeApps.includes('video') || activeApps.includes('music')
|| activeApps.includes('photo')) && html`
<${CollapsibleSection} titleKey="settings_node.directories_title">
${t('settings_node.directories_hint')}
${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" />
`}
${activeApps.includes('photo') && html`
<${PhotoRootsRow}
folders=${rootFolderOptions} value=${photoRoots}
busy=${photoRootsBusy} msg=${photoRootsMsg} onSave=${savePhotoRoots} />
`}
${/* 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`
<${Icon} name="server" />
${t('settings_node.roots')}
${nodeMsg && html`
${nodeMsg}
`}
${nodeRoots.map(r => html`
<${Icon} name="folder" />
${r.name}
${r.upload && html`
${t('node.upload_root')} `}
${!r.available && html`
${t('node.unavailable')} `}
${nodeRoots.length > 1 && !r.upload && html`
{
if (!confirm(t('node.root_remove_confirm', { name: r.name }))) return;
const countBefore = nodeRoots.length;
setNodeBusy(true); setNodeMsg('');
try {
await platform.node.call('DELETE',
'/api/groups/' + groupId + '/roots/' + encodeURIComponent(r.name));
await platform.node.call('POST', '/api/reload');
setNodeMsg(t('node.root_removed'));
await waitForRootCount(countBefore - 1);
// Folders (unlike files) only ever arrive via a full
// index_sync, never index_delta (daemon.py's ongoing
// push has no `dirs` field) — without this, the
// Videos/Music root pickers kept offering a folder
// that no longer existed until the page was reloaded.
if (onRefreshIndex) await onRefreshIndex();
} catch (err) { setNodeMsg(platform.bridgeMessage(err)); }
finally { setNodeBusy(false); }
}}>
${t('node.remove_root')} `}
`)}
{
const chosen = await platform.rootPicker.choose();
if (!chosen) return;
const countBefore = nodeRoots.length;
setNodeBusy(true); setNodeMsg(''); setNodeIndexProgress(null);
try {
await platform.node.call('POST',
'/api/groups/' + groupId + '/roots',
{ path: chosen.path, name: chosen.name });
await platform.node.call('POST', '/api/reload');
// The root is already scanning in the background on the
// node regardless of whether anyone watches this — see
// the "closing the client" test in test_hot_reload_*.py.
// This is only about not leaving the operator staring at
// an unchanged screen while it happens.
await platform.watchIndexProgress(groupId, setNodeIndexProgress);
setNodeMsg(t('node.root_added'));
await waitForRootCount(countBefore + 1);
// See the matching comment on root removal above — a new
// folder needs a full index_sync to show up anywhere that
// reads `nodeDirs` (the Videos/Music root pickers), not
// just in this section's own node-roots list.
if (onRefreshIndex) await onRefreshIndex();
} catch (err) { setNodeMsg(platform.bridgeMessage(err)); }
finally { setNodeBusy(false); }
}}>
<${Icon} name="folder-plus" /> ${t('node.add_root')}
${nodeIndexProgress && nodeIndexProgress.scanning && html`
${t('wizard.indexing_progress', {
pct: nodeIndexProgress.total_bytes
? Math.min(100, Math.round(
100 * nodeIndexProgress.scanned_bytes / nodeIndexProgress.total_bytes))
: 0,
})}
${nodeIndexProgress.current_dir && html`
${t('wizard.indexing_current_dir', { dir: nodeIndexProgress.current_dir })}
`}
`}
`}
${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`
<${CollapsibleSection} titleKey="members.uploads_title">
<${ToggleSwitch} checked=${memberUpload} disabled=${uploadBusy}
onChange=${() => setUploads(!memberUpload)}
label=${memberUpload ? t('members.uploads_on') : t('members.uploads_off')} />
${t('members.uploads_hint')}
${uploadMsg && html`
${uploadMsg}
`}
${CollapsibleSection}>
`}
${/* Upload toggle via loopback when MNP not connected */
nodeDetected && !connected && html`
<${CollapsibleSection} titleKey="members.uploads_title">
<${ToggleSwitch} checked=${memberUpload} disabled=${nodeBusy}
onChange=${async () => {
setNodeBusy(true); setNodeMsg('');
try {
const newVal = !memberUpload;
await platform.node.call('PUT',
'/api/groups/' + groupId + '/member-upload',
{ allowed: newVal });
if (onMemberUpload) onMemberUpload(newVal);
} catch (err) { setNodeMsg(platform.bridgeMessage(err)); }
finally { setNodeBusy(false); }
}}
label=${memberUpload ? t('members.uploads_on') : t('members.uploads_off')} />
${t('members.uploads_hint')}
${CollapsibleSection}>
`}
${/* Delete/leave — node detach first (reversible), then hub delete
(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')}>
${isOwner ? t('members.danger_delete_hint')
: t('members.danger_leave_hint')}
${isOwner
? html`
{
if (!confirm(t('group.delete_group_confirm', { name: group.name }))) return;
try {
// Node detach first (reversible), then hub delete (irreversible)
if (nodeDetected && nodeGroupName) {
try {
await platform.node.call('POST', '/api/groups/detach',
{ name: nodeGroupName });
} catch (detachErr) {
if (!confirm(t('settings_node.detach_failed_continue'))) return;
}
}
await hubFetch('/v1/groups/' + groupId, { method: 'DELETE', token });
navigate('/');
window.location.reload();
} catch (err) { setError(err.message); }
}}>${t('group.delete_group')}
`
: html`
{
if (!confirm(t('group.leave_confirm', { name: group.name }))) return;
try {
await hubFetch('/v1/groups/' + groupId + '/leave',
{ method: 'POST', token });
if (onLeft) onLeft(groupId);
} catch (err) { setError(err.message); }
}}>${t('group.leave')}
`}
${CollapsibleSection}>
`}
${connected && html`
${t('device.mine_title')}
${t('device.mine_hint')}
${deviceMsg && html`
${deviceMsg}
`}
${devices.length === 0
? html`
${t('device.mine_empty')}
`
: html`
${devices.map(d => html`
${d.pk_ed25519.slice(0, 16)}…
${d.is_this_one && html`
${t('device.this_one')} ${' '}
`}
${d.pinned_via}${d.label ? ' · ' + d.label : ''}
${!d.is_this_one && devices.length > 1 && html`
revokeDevice(d)}>
${t('device.revoke')}
`}
`)}
`}
`}
<${CollapsibleSection} title=${`${t('group.tab_members')} (${members.length})`}>
${t('admin.col_username')}
${t('members.group_role')}
${members.map(m => html`
${m.username}
${m.user_id === adminId
? html`${t('members.owner')} `
: html`${t('members.member')} `
}
${isAdmin && m.user_id !== adminId && html`
{
if (!confirm(t('members.remove_confirm', { user: m.username }))) return;
removeMember(m);
}}>
${removing === m.user_id ? '...' : t('members.remove')}
`}
`)}
${isAdmin && members.length > 1 && html`
${t('members.remove_hint')}
`}
${CollapsibleSection}>
`;
}
// ── Chat Panel ──────────────────────────────────────────────────────────
/**
* Message text with its links made clickable.
*
* Only http and https, and built as elements rather than markup: a message is
* something another member wrote, so it must never become HTML. `javascript:`
* and `data:` are not matched at all, and the anchors carry noopener so the new
* tab cannot reach back into this one.
*/
const URL_RE = /\bhttps?:\/\/[^\s<>"']+/gi;
export { GroupSettingsPanel };