import { html, useState, useEffect, useCallback, useMemo, useRef, } from './vendor/htm-preact.js'; import { t, getLocale, LOCALES } from './i18n.js'; import { Icon } from './icon.js'; import { hubFetch, navigate } from './hub-client.js'; import { APPS } from './apps.js'; import * as platform from './platform.js'; // MeshBay's own locale codes (i18n.js LOCALES) to the language tag TMDB // expects — the two don't share a format (MeshBay's "en" vs TMDB's // required region, "en-US"). Used only to pre-fill the TMDB language field // with the operator's own current UI language, a reasonable default they // can still change; the node never guesses this on its own. const TMDB_LANGUAGE_BY_LOCALE = { en: 'en-US', fr: 'fr-FR', es: 'es-ES', 'pt-BR': 'pt-BR', 'zh-CN': 'zh-CN', 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`
${open && html`
${children}
`}
`; } /** * 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 * 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` `; } /** * 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 onDraftChange(e.target.value)}> ${folders.map(p => html` `)} ${msg && html`

${msg.text}

`} `; } /** * Which folder(s) are the Photos app's entry points for this group — a * *set*, unlike RootFolderRow's single value above (docs/photos.md §2.1: a * photo library is routinely scattered across several folders). An * add/remove list rather than a ` setAddSelection(e.target.value)}> ${available.map((p) => html` `)} ${msg && html`

${msg.text}

`} `; } // ── Shared Directories Table ──────────────────────────────────────────── /** * A group's root directories, and the operator's controls over them. * * One component, two modes, because the Create Group wizard and the Settings * page were drifting apart while showing the same thing: * * mode="live" — a hosted group. Every change is a signed operator op sent * over MNP, or the loopback API when the node is on this * machine and there is no live connection. * mode="local" — the wizard, before the group exists. Changes are held in * an array the caller owns; nothing is persisted until the * group is attached. * * **Both paths matter and neither is optional.** The operator of a node is not * necessarily sitting at it: they may be signing in from any browser, and the * only thing that reaches their node from there is MNP. An earlier version of * this read its roots exclusively from the loopback API, which resolves to * "not available" in a browser — so the section rendered for nobody on the * web, while the controls it replaced had worked there. `mnpRoots` is the * source whenever a connection exists; the loopback list is the fallback for * a local node that is not currently connected (a group still scanning, say). * * Props: * roots — the node's current roots: { name, path, writable, * removable, ejected, available, kind } * groupId — the group id * transport — MeshBayTransport instance, or null when not connected * signFn — signing function for admin ops * nodeDetected — whether the loopback node API answers * readOnly — suppress every edit control * onRootsChange — called after a change, to re-read the loopback list * onRefreshIndex — full index refresh, needed after an add or a remove * mode — "live" (default) or "local" * localRoots / onLocalRootsChange — the array, in "local" mode */ function SharedDirectoriesTable({ roots, groupId, transport, signFn, nodeDetected: nodeAvail, readOnly, onRootsChange, onRefreshIndex, mode = 'live', localRoots, onLocalRootsChange }) { const isLocal = mode === 'local'; const serverRoots = isLocal ? (localRoots || []) : (roots || []); const [busy, setBusy] = useState(false); const [msg, setMsg] = useState(''); const [indexProgress, setIndexProgress] = useState(null); const [pathDraft, setPathDraft] = useState(''); const [addingByPath, setAddingByPath] = useState(false); // A toggle has to move under the finger, and the answer only comes back // when the node has signed, written node.toml and pushed the new table. // The patch is therefore held until the incoming `roots` actually agrees // with it — clearing it when the request resolves (which is what this did) // drops it in the frame *before* the new table arrives, so the switch // visibly snaps back and then forward again. const [optimistic, setOptimistic] = useState({}); useEffect(() => { setOptimistic((prev) => { const keys = Object.keys(prev); if (!keys.length) return prev; const next = {}; let changed = false; for (const name of keys) { const server = serverRoots.find(r => r.name === name); const patch = prev[name]; // Gone from the table, or the server now says what we asked for: // either way this patch has nothing left to hide. const settled = !server || Object.keys(patch).every(k => server[k] === patch[k]); if (settled) changed = true; else next[name] = patch; } return changed ? next : prev; }); }, [serverRoots]); const displayRoots = serverRoots.map(r => optimistic[r.name] ? { ...r, ...optimistic[r.name] } : r); // Which door a change goes through. MNP first: it is the only one that // exists for an operator on the web, and it is signed, which the loopback // API is not (it is authorized by being on localhost with the run token). const overMnp = !isLocal && transport && transport.connected; const overLoopback = !isLocal && !overMnp && nodeAvail; const canEdit = !readOnly && (isLocal || overMnp || overLoopback); const rootUrl = (name, suffix = '') => '/api/groups/' + groupId + '/roots/' + encodeURIComponent(name) + suffix; const run = useCallback(async (work, { refreshIndex = false } = {}) => { setBusy(true); setMsg(''); try { await work(); if (onRootsChange) await onRootsChange(); if (refreshIndex && onRefreshIndex) await onRefreshIndex(); return true; } catch (err) { setMsg(platform.bridgeMessage(err)); return false; } finally { setBusy(false); } }, [onRootsChange, onRefreshIndex]); const doUpdateRoot = useCallback(async (rootName, updates) => { if (isLocal) { if (onLocalRootsChange) { onLocalRootsChange((localRoots || []).map(r => r.name === rootName ? { ...r, ...updates } : r)); } return; } setOptimistic(prev => ({ ...prev, [rootName]: { ...(prev[rootName] || {}), ...updates }, })); const ok = await run(async () => { if (overMnp) await transport.updateRoot(groupId, rootName, updates, signFn); else if (overLoopback) await platform.node.call('PATCH', rootUrl(rootName), updates); else throw new Error(t('node.root_no_route')); }); // Only a failure clears the patch here; a success waits for the node's // own table, so the switch never travels backwards on its way forwards. if (!ok) { setOptimistic(prev => { const next = { ...prev }; delete next[rootName]; return next; }); } }, [isLocal, localRoots, onLocalRootsChange, overMnp, overLoopback, transport, groupId, signFn, run]); const doEjectRoot = useCallback((rootName) => run(async () => { if (overMnp) await transport.ejectRoot(groupId, rootName, signFn); else if (overLoopback) await platform.node.call('PUT', rootUrl(rootName, '/eject')); else throw new Error(t('node.root_no_route')); }), [overMnp, overLoopback, transport, groupId, signFn, run]); const doPlugRoot = useCallback((rootName) => run(async () => { if (overMnp) await transport.plugRoot(groupId, rootName, signFn); else if (overLoopback) await platform.node.call('PUT', rootUrl(rootName, '/plug')); else throw new Error(t('node.root_no_route')); }), [overMnp, overLoopback, transport, groupId, signFn, run]); const doRemoveRoot = useCallback(async (rootName) => { if (isLocal) { if (onLocalRootsChange) { onLocalRootsChange((localRoots || []).filter(r => r.name !== rootName)); } return; } if (!confirm(t('node.root_remove_confirm', { name: rootName }))) return; const ok = await run(async () => { if (overMnp) await transport.removeRoot(groupId, rootName, signFn); else if (overLoopback) { await platform.node.call('DELETE', rootUrl(rootName)); await platform.node.call('POST', '/api/reload'); } else throw new Error(t('node.root_no_route')); }, { refreshIndex: true }); if (ok) setMsg(t('node.root_removed')); }, [isLocal, localRoots, onLocalRootsChange, overMnp, overLoopback, transport, groupId, signFn, run]); // Adding a root needs a directory that exists on the *node's* filesystem. // With the node on this machine that is a native folder picker; from any // other browser the operator has to type the path, because nothing in a web // page can browse a remote disk. Both end at the same signed op. const addRootAtPath = useCallback(async (path, name) => { if (isLocal) { if ((localRoots || []).some(r => r.path === path)) return true; const isFirst = (localRoots || []).length === 0; // The first directory is writable so a new group can receive an upload // without the operator having to find this switch first. Every later // one is read-only until they say otherwise. if (onLocalRootsChange) { onLocalRootsChange([...(localRoots || []), { name, path, writable: isFirst, removable: false }]); } return true; } setIndexProgress(null); return run(async () => { if (overMnp) { await transport.addRoot(groupId, path, { name }, signFn); } else if (overLoopback) { await platform.node.call('POST', '/api/groups/' + groupId + '/roots', { path, name }); await platform.node.call('POST', '/api/reload'); await platform.watchIndexProgress(groupId, setIndexProgress); } else throw new Error(t('node.root_no_route')); }, { refreshIndex: true }); }, [isLocal, localRoots, onLocalRootsChange, overMnp, overLoopback, transport, groupId, signFn, run]); const doPickRoot = useCallback(async () => { const chosen = await platform.rootPicker.choose(); if (!chosen) return; const ok = await addRootAtPath(chosen.path, chosen.name); if (ok && !isLocal) setMsg(t('node.root_added')); }, [addRootAtPath, isLocal]); const doAddByPath = useCallback(async () => { const path = pathDraft.trim(); if (!path) return; // The name is the node's business — it derives the basename and refuses a // duplicate. Sending one guessed from a string typed here would be a // second opinion about something already decided in one place. const ok = await addRootAtPath(path, ''); if (ok) { setPathDraft(''); setAddingByPath(false); if (!isLocal) setMsg(t('node.root_added')); } }, [pathDraft, addRootAtPath, isLocal]); const addControls = !canEdit ? '' : html` ${platform.rootPicker.available ? html` ` : addingByPath ? html`
setPathDraft(e.target.value)} onKeyDown=${(e) => { if (e.key === 'Enter') doAddByPath(); }} />

${t('node.root_path_hint')}

` : html` `} `; if (!displayRoots.length) { return html`
${msg && html`

${msg}

`}

${t('settings_node.shared_directories_hint')}

${addControls}
`; } return html`
${msg && html`

${msg}

`} ${canEdit && html``} ${canEdit && !isLocal && html``} ${displayRoots.map(r => { const rowClass = r.ejected ? 'sdt-row-ejected' : (!isLocal && r.available === false) ? 'sdt-row-unavail' : ''; return html` ${/* Two roots can never share a name, so the name is the identity — but it is the *basename*, and two libraries under different parents look identical without this. */''} ${canEdit && html` `} ${canEdit && !isLocal && html` `} `; })}
${t('node.directory')} ${t('node.root_path')}${t('node.root_rw')}${t('node.removable')}
<${Icon} name="folder" /> ${r.name} ${r.ejected && html` ${t('group.root_ejected')}`} ${!isLocal && r.available === false && !r.ejected && html` ${t('node.unavailable')}`} ${r.path || ''} <${ToggleSwitch} checked=${!!r.writable} disabled=${busy || !!r.ejected} onChange=${(v) => doUpdateRoot(r.name, { writable: v })} /> <${ToggleSwitch} checked=${!!r.removable} disabled=${busy} onChange=${(v) => doUpdateRoot(r.name, { removable: v })} /> ${canEdit && !isLocal && html` `} ${canEdit && html` `}
${addControls} ${indexProgress && indexProgress.scanning && html`
${t('wizard.indexing_progress', { pct: indexProgress.total_bytes ? Math.min(100, Math.round( 100 * indexProgress.scanned_bytes / indexProgress.total_bytes)) : 0, })}
`}
`; } // ── Members Panel ──────────────────────────────────────────────────────── /** * Everything about the group that is not its files or its chat. * * Was "Members", which was a list with three unrelated forms stacked on top of * it and the group's own controls somewhere else entirely — leaving or deleting * a group lived in the header, beside its title. One tab now, in sections, with * the roster last: it is the part that grows without limit, and burying the * controls under two hundred names is how a tab stops being usable. */ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, isNodeAdmin, userId, operatorPaired, connected, mnpRoots, enabledApps, onEnabledApps, scanSettings, onScanSettings, tmdbConfig, onTmdbConfig, onTmdbEnabled, musicbrainzConfig, onMusicbrainzEnabled, entries, nodeDirs, videoRoot, onVideoRoot, audioRoot, onAudioRoot, photoRoots, onPhotoRoots, onRefreshIndex, onPaired, onLeft }) { const [members, setMembers] = useState([]); const [adminId, setAdminId] = useState(''); const [loading, setLoading] = useState(true); const [inviteUser, setInviteUser] = useState(''); const [inviting, setInviting] = useState(false); const [error, setError] = useState(''); // Node loopback state (Electron-only) const [nodeDetected, setNodeDetected] = useState(false); const [nodeRoots, setNodeRoots] = useState([]); const [nodeGroupName, setNodeGroupName] = useState(''); const [nodeBusy, setNodeBusy] = useState(false); const [nodeMsg, setNodeMsg] = useState(''); // Bytes-based indexing progress while a newly added directory is being // scanned — same source as the Create Group wizard's step, see // platform.watchIndexProgress. const [nodeIndexProgress, setNodeIndexProgress] = useState(null); // The roots to show, from whichever source can actually answer. // // `mnpRoots` comes from the index payload the node pushes over the live // connection, and is the only source an operator signing in from an // ordinary browser has. `nodeRoots` comes from the loopback API and exists // only on the machine running the node. Preferring MNP when connected also // keeps this table on the same data Files and the apps read, so an eject // shows in one place at the same instant it shows in the other. const effectiveRoots = (connected && mnpRoots && mnpRoots.length) ? mnpRoots : nodeRoots; // Declared here rather than inline at the call site: a function rebuilt on // every render is a new prop identity every render, and the callbacks that // close over it in the table below are memoised on it. const adminSignFn = useCallback((transcript) => { const sk = transportRef.current && transportRef.current.sessionKeys && transportRef.current.sessionKeys.skEdB64; if (!sk || !window.MeshBayKeys) { throw new Error(t('node.root_no_signing_key')); } return window.MeshBayKeys.signBytes(sk, transcript); }, [transportRef]); const loadNodeInfo = useCallback(async () => { if (!platform.node.available) return; try { const detect = await platform.node.detect(); if (!detect.detected) { setNodeDetected(false); return; } setNodeDetected(true); const data = await platform.node.call('GET', '/api/groups'); const groups = data.groups || []; const ng = groups.find(g => g.id === groupId); if (ng) { setNodeRoots(ng.roots || []); setNodeGroupName(ng.name || ''); } } catch { setNodeDetected(false); } }, [groupId]); useEffect(() => { loadNodeInfo(); }, [loadNodeInfo]); /** * Poll /api/groups until the root count actually matches what an * add/remove just did, instead of trusting a single loadNodeInfo() call * right after /api/reload. Found live: /api/reload is fire-and-forget on * the node (ops.start_reload schedules the real work and returns * immediately, deliberately — a brand-new group's initial scan can take * minutes, see its own docstring) — and the list this section renders * (ops.list_groups) reads the *runtime* root set * (groups_ctx[gid]["roots"]), which only gets replaced once * _reload_config_inner's retarget actually finishes, not the config-file * list add_root/remove_root already updated synchronously. A single * fetch right after can land in that gap and show the old count. */ const waitForRootCount = useCallback(async (expectedCount) => { for (let i = 0; i < 10; i++) { try { const data = await platform.node.call('GET', '/api/groups'); const ng = (data.groups || []).find(g => g.id === groupId); const roots = (ng && ng.roots) || []; if (roots.length === expectedCount) { setNodeRoots(roots); if (ng) setNodeGroupName(ng.name || ''); return true; } } catch { /* keep trying — the node may be mid-reload */ } await new Promise(r => setTimeout(r, 400)); } return false; }, [groupId]); const [inviteCode, setInviteCode] = useState(null); const [pairCode, setPairCode] = useState(''); const [pairStatus, setPairStatus] = useState(''); const [pairing, setPairing] = useState(false); // Your own devices on this node. Not a members feature — it is beside them // because this is where a live connection to the node exists. const [devices, setDevices] = useState([]); const [approveCode, setApproveCode] = useState(''); const [deviceMsg, setDeviceMsg] = useState(''); // Pairing lives here rather than in Settings because this is where a live // connection to the node exists — and it is offered only when the node itself // says this account is its operator (is_node_admin comes from the authenticated // handshake_ack, not from the hub). const loadDevices = useCallback(async () => { const transport = transportRef.current; if (!transport || !transport.connected) return; try { const out = await transport.listDevices(); setDevices(out.devices); } catch { /* a node that has none says so by listing none */ } }, [transportRef]); useEffect(() => { loadDevices(); }, [loadDevices]); const approveDevice = useCallback(async (e) => { e.preventDefault(); const code = approveCode.trim(); if (!code) return; setDeviceMsg(''); try { await transportRef.current.approveDevice(userId, code); setApproveCode(''); setDeviceMsg(t('device.approved')); await loadDevices(); } catch (err) { setDeviceMsg(err.message); } }, [approveCode, userId, transportRef, loadDevices]); const revokeDevice = useCallback(async (device) => { if (!confirm(t('device.revoke_confirm'))) return; setDeviceMsg(''); try { await transportRef.current.revokeDevice( userId, device.pk_ed25519, device.pk_x25519 || ''); await loadDevices(); } catch (err) { setDeviceMsg(err.message); } }, [userId, transportRef, loadDevices]); const doPair = useCallback(async (e) => { e.preventDefault(); const code = pairCode.trim(); if (!code) return; setPairing(true); setPairStatus(''); try { const transport = transportRef && transportRef.current; if (!transport || !transport.connected) throw new Error('Not connected to the node'); await transport.pairOperator(userId, code); setPairCode(''); setPairStatus('paired'); // The node has pinned this key as an operator key; the form has nothing // left to do. It used to stay put through a refresh, because what governed // it was the account, which pairing does not change. if (onPaired) onPaired(); } catch (err) { setPairStatus(err.message); } finally { setPairing(false); } }, [pairCode, transportRef, userId]); // DEPRECATED: upload toggle removed — per-root writable flag replaces it. const [appsBusy, setAppsBusy] = useState(false); const [appsMsg, setAppsMsg] = useState(''); const activeApps = enabledApps && enabledApps.length ? enabledApps : APPS.map(a => a.key); /** * Toggle one app in or out of the group's enabled set. Same shape as * `setUploads`: signed, and the checkbox does not move until the node has * said it did it. Refuses to submit an empty set client-side — the node * refuses it too, but there is no reason to make a round trip to learn that. */ const toggleApp = useCallback(async (key) => { const next = activeApps.includes(key) ? activeApps.filter(k => k !== key) : [...activeApps, key]; if (next.length === 0) { setAppsMsg(t('members.apps_need_one')); return; } const transport = transportRef && transportRef.current; setAppsMsg(''); setAppsBusy(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.setAppsEnabled(next, signFn); if (onEnabledApps) onEnabledApps(next); } catch (err) { setAppsMsg(err.message); } finally { setAppsBusy(false); } }, [transportRef, onEnabledApps, activeApps]); const [scanBusy, setScanBusy] = useState(false); const [scanMsg, setScanMsg] = useState(''); const [reconcileMinutes, setReconcileMinutes] = useState( scanSettings ? Math.round(scanSettings.reconcile_interval_secs / 60) : 10); const [debounceSeconds, setDebounceSeconds] = useState( scanSettings ? Math.round(scanSettings.debounce_secs) : 2); // The node is the source of truth; once it has answered, the fields track // it rather than whatever this browser guessed before connecting. useEffect(() => { if (!scanSettings) return; setReconcileMinutes(Math.round(scanSettings.reconcile_interval_secs / 60)); setDebounceSeconds(Math.round(scanSettings.debounce_secs)); }, [scanSettings]); /** * How often the reconciliation backstop runs, and how long a changed file * is left alone before being hashed. Same shape as toggleApp: signed, and * the fields do not claim success until the node has confirmed it. */ const saveScanSettings = useCallback(async () => { const transport = transportRef && transportRef.current; setScanMsg(''); setScanBusy(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.setScanSettings(reconcileMinutes * 60, debounceSeconds, signFn); const applied = { reconcile_interval_secs: reconcileMinutes * 60, debounce_secs: debounceSeconds, }; if (onScanSettings) onScanSettings(applied); setScanMsg(t('settings_node.scan_saved')); } catch (err) { setScanMsg(err.message); } finally { setScanBusy(false); } }, [transportRef, onScanSettings, reconcileMinutes, debounceSeconds]); const [tmdbBusy, setTmdbBusy] = useState(false); const [tmdbMsg, setTmdbMsg] = useState(''); const [tmdbTokenDraft, setTmdbTokenDraft] = useState(''); const [tmdbEnabledBusy, setTmdbEnabledBusy] = useState(false); const tmdbEnabled = tmdbConfig ? tmdbConfig.enabled : true; // Pre-filled from the operator's own current UI language the first time // this renders with nothing configured yet — a sensible default, not a // claim about what the node is actually using until they hit Save. const [tmdbLanguage, setTmdbLanguage] = useState( () => (tmdbConfig && tmdbConfig.language) || TMDB_LANGUAGE_BY_LOCALE[getLocale()] || 'en-US'); useEffect(() => { if (tmdbConfig && tmdbConfig.language) setTmdbLanguage(tmdbConfig.language); }, [tmdbConfig && tmdbConfig.language]); /** * Whether TMDB is used at all — per-group (2026-08-24, used to be bundled * into the same signed op as the token/language below): a real * media-library group and a test/demo group on the same node need not * share this decision. Saves immediately on toggle, same as an ordinary * checkbox-style setting elsewhere — there is nothing else on the form to * batch it with any more. */ const saveTmdbEnabled = useCallback(async (nextEnabled) => { const transport = transportRef && transportRef.current; setTmdbMsg(''); setTmdbEnabledBusy(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.setTmdbEnabled(nextEnabled, signFn); if (onTmdbEnabled) onTmdbEnabled(nextEnabled); } catch (err) { setTmdbMsg(err.message); } finally { setTmdbEnabledBusy(false); } }, [transportRef, onTmdbEnabled]); /** * An optional custom API token, and the language TMDB is queried in — * node-wide, not per-group (docs/mediacenter.md §5.5): one shared * credential and cache. Same shape as saveScanSettings: signed, and the * button does not claim success until the node confirms it. The token * field is cleared after a save either way: it is never echoed back by * the node (tmdb_config_ack carries only whether one is set, never the * value), so there is nothing to keep showing. */ const saveTmdbConfig = useCallback(async () => { const transport = transportRef && transportRef.current; setTmdbMsg(''); setTmdbBusy(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; const token = tmdbTokenDraft.trim(); await transport.setTmdbConfig(token || undefined, tmdbLanguage, signFn); setTmdbTokenDraft(''); if (onTmdbConfig) { onTmdbConfig({ tokenCustomized: token ? true : (tmdbConfig ? tmdbConfig.tokenCustomized : false), language: tmdbLanguage, }); } setTmdbMsg(t('settings_node.scan_saved')); } catch (err) { setTmdbMsg(err.message); } finally { setTmdbBusy(false); } }, [transportRef, onTmdbConfig, tmdbTokenDraft, tmdbConfig, tmdbLanguage]); // A node that has never had a language explicitly set would otherwise // query TMDB with none at all — which TMDB itself resolves to English, // regardless of who the operator is — even though this form already // *suggests* their own UI language as the value. Applied once, // automatically, the first time the operator (the only one who can sign // this) is actually connected to see it: a real default tied to whoever // runs this particular node, never a single hardcoded language for every // node. `tmdbConfig.language` being set at all — from this or from an // explicit save — is what stops it from ever firing again, so "unless // manually changed" holds regardless of which of the two set it first. const autoLanguageSetRef = useRef(false); useEffect(() => { if (!isNodeAdmin || !connected || !tmdbConfig || tmdbConfig.language) return; if (autoLanguageSetRef.current) return; autoLanguageSetRef.current = true; saveTmdbConfig(); }, [isNodeAdmin, connected, tmdbConfig, saveTmdbConfig]); const [mbMsg, setMbMsg] = useState(''); const [mbEnabledBusy, setMbEnabledBusy] = useState(false); const mbEnabled = musicbrainzConfig ? musicbrainzConfig.enabled : true; /** * Whether MusicBrainz is used at all — per-group from the start * (docs/musicbay.md §3.2/§6). Same shape as saveTmdbEnabled. */ const saveMusicbrainzEnabled = useCallback(async (nextEnabled) => { const transport = transportRef && transportRef.current; setMbMsg(''); setMbEnabledBusy(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.setMusicbrainzEnabled(nextEnabled, signFn); if (onMusicbrainzEnabled) onMusicbrainzEnabled(nextEnabled); } catch (err) { setMbMsg(err.message); } finally { setMbEnabledBusy(false); } }, [transportRef, onMusicbrainzEnabled]); // Every folder anywhere in the group's shared index, deepest included — // `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 // setInviteUser(e.target.value)} disabled=${!connected || !operatorPaired} required /> `} ${isNodeAdmin && !operatorPaired && connected && html`

${t('members.pair_title')}

${t('members.pair_hint')}

${pairStatus && html`

${pairStatus === 'paired' ? t('members.pair_success') : pairStatus}

`}
setPairCode(e.target.value)} required />
`} ${/* 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} /> `} ${/* 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}

`} `} ${/* 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')}

${scanMsg && html`

${scanMsg}

`} `} ${/* 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')} />

${tmdbConfig && tmdbConfig.tokenCustomized ? t('settings_node.tmdb_token_customized') : t('settings_node.tmdb_token_default')}

${t('settings_node.tmdb_language_hint')}

${tmdbMsg && html`

${tmdbMsg}

`} `} ${/* 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}

`} `} ${/* 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. */''} `} ${/* 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` ` : html` `}
`} ${connected && html`

${t('device.mine_title')}

${t('device.mine_hint')}

${deviceMsg && html`

${deviceMsg}

`} ${devices.length === 0 ? html`

${t('device.mine_empty')}

` : html` `}

${t('device.approve_hint')}

setApproveCode(e.target.value)} />
`} <${CollapsibleSection} title=${`${t('group.tab_members')} (${members.length})`}> ${members.map(m => html` `)}
${t('admin.col_username')} ${t('members.group_role')}
${m.username} ${m.user_id === adminId ? html`${t('members.owner')}` : html`${t('members.member')}` } ${isAdmin && m.user_id !== adminId && html` `}
${isAdmin && members.length > 1 && html`

${t('members.remove_hint')}

`} `; } // ── 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 };