${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}
`}
`}
${/* Shared directories — the group's root folders, and the structural
control everything else in this page sits on top of, so it comes
first. Rendered whenever the operator has a route to their node:
a live MNP connection (any browser, anywhere) or the loopback API
(the node on this machine). It used to require the second, which
meant it rendered for nobody on the web. */
isNodeAdmin && (connected || nodeDetected) && html`
<${CollapsibleSection} titleKey="settings_node.shared_directories_title">
${t('settings_node.shared_directories_hint')}
${!connected && nodeDetected && html`
${t('settings_node.roots_offline_hint')}
`}
<${SharedDirectoriesTable}
roots=${effectiveRoots}
groupId=${groupId}
transport=${transportRef.current}
signFn=${adminSignFn}
nodeDetected=${nodeDetected}
onRootsChange=${loadNodeInfo}
onRefreshIndex=${onRefreshIndex} />
${CollapsibleSection}>
`}
${/* 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')} />
${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} />
`}
${/* Root management moved to SharedDirectoriesTable above. */''}
${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.owner_username ? `${group.name}@${group.owner_username}` : 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.owner_username ? `${group.name}@${group.owner_username}` : 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, SharedDirectoriesTable };