summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-07 10:35:09 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-07 10:35:09 +0200
commit2c0903c648e24b4e2adf20492398e8b67d033b49 (patch)
tree0435f298010f0f946362f28baebbe88337ca8768 /packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
parent0ed078c92cabab1dab0f70f321562032ea549ce6 (diff)
parenteeda274d751c537f4ecef3087994a16a9517478f (diff)
downloadmeshbay-2c0903c648e24b4e2adf20492398e8b67d033b49.tar.gz
Merge branch 'refactor/groups-phase1'
Groups refactor, phases 1-3. The root model replaces the old `upload` flag and group-wide `member_upload` with per-root `writable`/`removable`/`ejected`, carried by a `RootSet` that both front doors — the loopback API and signed MNP — reach through the same `ops` functions. MNP goes to 1.1, additively: the roots table now rides on `index_delta`, so a root added, removed, ejected or plugged reaches every connected client instead of only whoever reloaded. The group UI becomes a plugin architecture: an application is a registry entry in `apps.js` plus its own files, with directories stored generically by `ops.set_app_directories` under whatever the app is called. A reference application, hidden behind `?dev=1`, is what makes that claim testable — adding it is what found the two places still naming apps by hand. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
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.js1183
1 files changed, 482 insertions, 701 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 1c6ca71..c80cff5 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
@@ -1,179 +1,381 @@
import {
html, useState, useEffect, useCallback, useMemo, useRef,
} from './vendor/htm-preact.js';
-import { t, getLocale, LOCALES } from './i18n.js';
+import { t } from './i18n.js';
import { Icon } from './icon.js';
+import { CollapsibleSection, ToggleSwitch } from './settings-ui.js';
import { hubFetch, navigate } from './hub-client.js';
-import { APPS } from './apps.js';
+import { availableApps, configurableApps } 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`
- <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>
- `;
-}
+// ── Shared Directories Table ────────────────────────────────────────────
/**
- * 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.
+ * 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. Not called after a root change: see
+ * `run()` for why the node's own push is what settles it
+ * mode — "live" (default) or "local"
+ * localRoots / onLocalRootsChange — the array, in "local" mode
*/
-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>
- `;
-}
+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);
+ // `{ text, error }` — a refusal has to look like one. Every message here was
+ // a `settings-hint`, which is dim grey body text, so "two roots would both
+ // be called uploads" read as a footnote to the section rather than as the
+ // reason nothing happened.
+ const [msg, setMsg] = useState(null);
+ const [indexProgress, setIndexProgress] = useState(null);
+ const say = useCallback((text) => setMsg(text ? { text, error: false } : null), []);
+ const refuse = useCallback((text) => setMsg({ text, error: true }), []);
-/**
- * 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>
+ // Rendered at the foot of the section, under the Add button — the last thing
+ // below the control that caused it, rather than above a table the eye has
+ // already moved past.
+ const message = !msg ? '' : html`
+ <p class=${msg.error ? 'error-msg' : 'settings-hint'}
+ role=${msg.error ? 'alert' : 'status'}
+ style="margin-top:10px">${msg.text}</p>`;
+ 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);
+
+ // Paths are the operator's own view and do not cross MNP: the roots table
+ // rides in the index payload, which every member receives, and it tells them
+ // what exists and whether it is readable — never where on the operator's
+ // disk it lives. So the column appears when the answer is actually
+ // available (the loopback API, which is already this machine only) and is
+ // left out otherwise, rather than printing a row of blanks.
+ const hasPaths = displayRoots.some((r) => r.path);
+
+ // 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;
+
+ // Deliberately no index refresh after a root change.
+ //
+ // Adding a root makes the node reload, which rescans — minutes on a real
+ // library — and the reload is fire-and-forget for that reason. Fetching the
+ // index in the moment after therefore returns the set from *before* it, and
+ // `applyIndex` writes that over the roots the ack had just delivered: the
+ // new directory appeared for one paint and vanished, which is what "it only
+ // shows up after a refresh" was.
+ //
+ // Nothing is lost by waiting. The ack carries the new table immediately, and
+ // the delta the node pushes when the scan finishes carries it again along
+ // with the files.
+ const run = useCallback(async (work) => {
+ setBusy(true); setMsg(null);
+ try {
+ await work();
+ if (onRootsChange) await onRootsChange();
+ return true;
+ } catch (err) {
+ refuse(platform.bridgeMessage(err));
+ return false;
+ } finally { setBusy(false); }
+ }, [onRootsChange, refuse]);
+
+ 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'));
+ });
+ if (ok) say(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'));
+ });
+ }, [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) say(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) say(t('node.root_added')); }
+ }, [pathDraft, addRootAtPath, isLocal]);
+
+ const addControls = !canEdit ? '' : html`
+ ${platform.rootPicker.available ? html`
+ <button class="btn btn-small btn-secondary" style="margin-top:8px"
+ disabled=${busy} onClick=${doPickRoot}>
+ <${Icon} name="folder-plus" /> ${t('node.add_root')}
+ </button>
+ ` : addingByPath ? html`
+ <div class="sdt-add-row">
+ <input class="sdt-add-input" type="text" value=${pathDraft}
+ placeholder=${t('node.root_path_placeholder')}
+ disabled=${busy}
+ onInput=${(e) => setPathDraft(e.target.value)}
+ onKeyDown=${(e) => { if (e.key === 'Enter') doAddByPath(); }} />
+ <button class="btn btn-small btn-secondary" disabled=${busy || !pathDraft.trim()}
+ onClick=${doAddByPath}>${t('node.add_root')}</button>
+ <button class="btn btn-small" disabled=${busy}
+ onClick=${() => { setAddingByPath(false); setPathDraft(''); }}>
+ ${t('settings.cancel')}</button>
</div>
+ <p class="settings-hint">${t('node.root_path_hint')}</p>
+ ` : html`
<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)}
+ disabled=${busy} onClick=${() => setAddingByPath(true)}>
+ <${Icon} name="folder-plus" /> ${t('node.add_root')}
</button>
- ${msg && html`<p class=${msg.ok ? 'success-msg' : 'error-msg'} style="margin-top:8px">
- ${msg.text}</p>`}
- </div>
+ `}
`;
-}
-
-/**
- * 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 `<select>`: pick a folder to add from the
- * same `rootFolderOptions` the Videos/Music pickers use, list what is
- * already configured with a remove button each, and one Save signs the
- * whole resulting set in one op (same shape as the app-enable checkboxes
- * below — several changes staged, one signature).
- */
-function PhotoRootsRow({ folders, value, busy, msg, onSave }) {
- const [draft, setDraft] = useState(value || []);
- useEffect(() => { setDraft(value || []); }, [value]);
- const [addSelection, setAddSelection] = useState('');
-
- const available = folders.filter((p) => !draft.includes(p));
- const addRoot = () => {
- if (!addSelection || draft.includes(addSelection)) return;
- setDraft((prev) => [...prev, addSelection].sort());
- setAddSelection('');
- };
- const removeRoot = (path) => setDraft((prev) => prev.filter((p) => p !== path));
- const unchanged = draft.length === (value || []).length
- && draft.every((p) => (value || []).includes(p));
+ if (!displayRoots.length) {
+ return html`
+ <div class="shared-directories-table">
+ <p class="settings-hint">${t('settings_node.shared_directories_hint')}</p>
+ ${addControls}
+ ${message}
+ </div>
+ `;
+ }
return html`
- <div class="settings-root-row">
- <div class="settings-root-row-title">
- <${Icon} name="image" />
- <h4>${t('settings_node.photo_roots_title')}</h4>
- </div>
- <p class="settings-hint">${t('settings_node.photo_roots_hint')}</p>
- ${draft.length === 0 && html`
- <p class="settings-hint">${t('settings_node.photo_roots_none')}</p>
- `}
- ${draft.length > 0 && html`
- <ul class="settings-root-list">
- ${draft.map((p) => html`
- <li key=${p} class="settings-root-list-item">
- <span>${' '.repeat(p.split('/').length - 1)}${p.split('/').pop()}</span>
- <button class="link-btn" disabled=${busy} onClick=${() => removeRoot(p)}
- title=${t('settings_node.photo_roots_remove')}>
- <${Icon} name="close" /></button>
- </li>
- `)}
- </ul>
+ <div class="shared-directories-table">
+ <table class="shared-dirs-tbl">
+ <thead>
+ <tr>
+ <th class="sdt-col-dir">${t('node.directory')}</th>
+ ${hasPaths && html`<th class="sdt-col-path">${t('node.root_path')}</th>`}
+ ${canEdit && html`<th class="sdt-col-toggle">${t('node.root_rw')}</th>`}
+ ${canEdit && !isLocal && html`<th class="sdt-col-toggle">${t('node.removable')}</th>`}
+ <th class="sdt-col-actions"></th>
+ </tr>
+ </thead>
+ <tbody>
+ ${displayRoots.map(r => {
+ const rowClass = r.ejected ? 'sdt-row-ejected'
+ : (!isLocal && r.available === false) ? 'sdt-row-unavail' : '';
+ return html`
+ <tr class=${rowClass} key=${r.name}>
+ <td class="sdt-col-dir">
+ <span class="sdt-dir-name">
+ <${Icon} name="folder" />
+ ${r.name}
+ </span>
+ ${r.ejected && html`
+ <span class="node-root-badge node-root-badge-warn">${t('group.root_ejected')}</span>`}
+ ${!isLocal && r.available === false && !r.ejected && html`
+ <span class="node-root-badge node-root-badge-warn">${t('node.unavailable')}</span>`}
+ </td>
+ ${hasPaths && html`
+ <td class="sdt-col-path" title=${r.path || ''}>${r.path || ''}</td>`}
+ ${canEdit && html`
+ <td class="sdt-col-toggle">
+ <${ToggleSwitch} checked=${!!r.writable} disabled=${busy || !!r.ejected}
+ onChange=${(v) => doUpdateRoot(r.name, { writable: v })} />
+ </td>
+ `}
+ ${canEdit && !isLocal && html`
+ <td class="sdt-col-toggle">
+ <${ToggleSwitch} checked=${!!r.removable} disabled=${busy}
+ onChange=${(v) => doUpdateRoot(r.name, { removable: v })} />
+ </td>
+ `}
+ <td class="sdt-col-actions">
+ ${canEdit && !isLocal && html`
+ <button class="sdt-action-btn" disabled=${busy || !r.removable}
+ title=${r.ejected ? t('group.root_plug') : t('group.root_eject')}
+ onClick=${() => r.ejected ? doPlugRoot(r.name) : doEjectRoot(r.name)}>
+ ${r.ejected ? '\u{1F50C}' : '\u{23CF}'}
+ </button>
+ `}
+ ${canEdit && html`
+ <button class="sdt-action-btn sdt-action-danger"
+ disabled=${busy || displayRoots.length < 2}
+ title=${displayRoots.length < 2
+ ? t('node.root_remove_last') : t('node.remove_root')}
+ onClick=${() => doRemoveRoot(r.name)}>
+ \u{2715}
+ </button>
+ `}
+ </td>
+ </tr>
+ `; })}
+ </tbody>
+ </table>
+ ${addControls}
+ ${message}
+ ${indexProgress && indexProgress.scanning && html`
+ <div class="index-progress" style="margin-top:8px">
+ <div class="index-progress-bar">
+ <div class="index-progress-fill" style="width:${
+ indexProgress.total_bytes
+ ? Math.min(100, Math.round(
+ 100 * indexProgress.scanned_bytes / indexProgress.total_bytes))
+ : 0}%"></div>
+ </div>
+ <div class="index-progress-label">${t('wizard.indexing_progress', {
+ pct: indexProgress.total_bytes
+ ? Math.min(100, Math.round(
+ 100 * indexProgress.scanned_bytes / indexProgress.total_bytes))
+ : 0,
+ })}</div>
+ </div>
`}
- <div class="settings-row">
- <label class="settings-label">
- <select value=${addSelection} disabled=${busy || available.length === 0}
- onChange=${(e) => setAddSelection(e.target.value)}>
- <option value="">${t('settings_node.photo_roots_add_placeholder')}</option>
- ${available.map((p) => html`
- <option key=${p} value=${p}>
- ${' '.repeat(p.split('/').length - 1)}${p.split('/').pop()}
- </option>
- `)}
- </select>
- </label>
- <button class="btn btn-small btn-secondary" disabled=${busy || !addSelection}
- onClick=${addRoot}>${t('settings_node.photo_roots_add')}</button>
- </div>
- <button class="btn btn-small btn-secondary" style="margin-top:8px"
- disabled=${busy || unchanged} onClick=${() => onSave(draft)}>
- ${busy ? t('settings_node.scan_saving') : t('settings_node.photo_roots_save')}
- </button>
- ${msg && html`<p class=${msg.ok ? 'success-msg' : 'error-msg'} style="margin-top:8px">
- ${msg.text}</p>`}
</div>
`;
}
+
// ── Members Panel ────────────────────────────────────────────────────────
/**
@@ -187,14 +389,12 @@ function PhotoRootsRow({ folders, value, busy, msg, onSave }) {
*/
function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
isNodeAdmin, userId, operatorPaired, connected,
- memberUpload, onMemberUpload,
+ mnpRoots,
enabledApps, onEnabledApps,
scanSettings, onScanSettings,
- tmdbConfig, onTmdbConfig, onTmdbEnabled,
- musicbrainzConfig, onMusicbrainzEnabled,
- entries, nodeDirs, videoRoot, onVideoRoot,
- audioRoot, onAudioRoot,
- photoRoots, onPhotoRoots, onRefreshIndex,
+ entries, nodeDirs,
+ appSettings, nodeSupportsAppOps,
+ onAppDirectories, onRefreshIndex,
onPaired, onLeft }) {
const [members, setMembers] = useState([]);
const [adminId, setAdminId] = useState('');
@@ -214,6 +414,29 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
// 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 {
@@ -332,40 +555,16 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
}
}, [pairCode, transportRef, userId]);
- const [uploadBusy, setUploadBusy] = useState(false);
- const [uploadMsg, setUploadMsg] = useState('');
-
- /**
- * Close or open uploading for everyone who is not the operator.
- *
- * Signed, like removing a member: the node refuses an unsigned instruction,
- * so this is a request to the node rather than a decision taken here. The
- * button does not move until the node has said it did it.
- */
- const setUploads = useCallback(async (allowed) => {
- const transport = transportRef && transportRef.current;
- setUploadMsg('');
- setUploadBusy(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.setMemberUpload(allowed, signFn);
- if (onMemberUpload) onMemberUpload(allowed);
- } catch (err) {
- setUploadMsg(err.message);
- } finally {
- setUploadBusy(false);
- }
- }, [transportRef, onMemberUpload]);
+ // 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);
+ // `availableApps()` rather than the raw registry: an app the reader is not
+ // shown must not be turned on for the whole group by falling back to "all of
+ // them". Found by adding one that is hidden by default — an ordinary app
+ // would never have exposed the difference.
+ const activeApps = enabledApps && enabledApps.length
+ ? enabledApps : availableApps().map(a => a.key);
/**
* Toggle one app in or out of the group's enabled set. Same shape as
@@ -446,146 +645,21 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
}
}, [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]);
+ // ── Per-app settings ────────────────────────────────────────────────
+ //
+ // What every app's settings pane is given, and the one operation the page
+ // performs on their behalf. TMDB, MusicBrainz and each app's folder pickers
+ // used to be hand-written sections here, ~470 lines of them, each with its
+ // own draft state and save handler saying the same thing about a different
+ // key. They live in `<app>-app-settings.js` now; this is the whole of what
+ // the page still knows about any of it.
- 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
- // <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(() => {
+ // Every folder anywhere in the group's shared index. `entries[].path` is a
+ // file's containing directory (files-app.js's convention), so every ancestor
+ // prefix of it is a real folder; `nodeDirs` covers the ones with nothing in
+ // them yet. Derived here rather than in the picker so all of them agree, and
+ // so it is computed once per change instead of once per open.
+ const folderOptions = useMemo(() => {
const set = new Set();
const addAncestors = (path) => {
if (!path) return;
@@ -597,109 +671,30 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
return [...set].sort();
}, [entries, nodeDirs]);
- const [videoRootDraft, setVideoRootDraft] = useState(videoRoot || '');
- useEffect(() => { setVideoRootDraft(videoRoot || ''); }, [videoRoot]);
- const [videoRootBusy, setVideoRootBusy] = useState(false);
- const [videoRootMsg, setVideoRootMsg] = useState(null);
-
/**
- * Which folder is the Videos app's entry point for this group — same
- * shape as toggleApp/saveScanSettings: signed, and the picker does not
- * claim success until the node confirms it.
+ * Point one app at folders — the only app-specific operation this page
+ * performs, and it is generic.
*
- * Changing an *already-set* root is destructive to every member's Videos
- * tab (a different set of files, possibly none in common) — the operator
- * confirms that explicitly. Setting it for the first time is not: there is
- * nothing yet to lose.
+ * Everything else a pane needs it does itself with the transport it is
+ * given. That is the line: what every app has (directories) is here, what
+ * one app alone has (a TMDB key, a link-preview switch) is in its own file,
+ * and adding an app that only needs directories touches neither.
*/
- const saveVideoRoot = useCallback(async () => {
- const next = videoRootDraft;
- const current = videoRoot || '';
- if (next === current) return;
- if (current && !confirm(t('settings_node.video_root_change_confirm'))) return;
- const transport = transportRef && transportRef.current;
- setVideoRootMsg(null);
- setVideoRootBusy(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.setVideoRoot(next, signFn);
- if (onVideoRoot) onVideoRoot(next);
- setVideoRootMsg({ text: t('settings_node.scan_saved'), ok: true });
- } catch (err) {
- setVideoRootMsg({ text: err.message, ok: false });
- } finally {
- setVideoRootBusy(false);
- }
- }, [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(null);
-
- 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 saveAppDirectories = useCallback(async (appKey, paths) => {
const transport = transportRef && transportRef.current;
- setAudioRootMsg(null);
- 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({ text: t('settings_node.scan_saved'), ok: true });
- } catch (err) {
- setAudioRootMsg({ text: err.message, ok: false });
- } finally {
- setAudioRootBusy(false);
+ if (!transport || !transport.connected) {
+ throw new Error(t('node.root_no_route'));
}
- }, [transportRef, onAudioRoot, audioRootDraft, audioRoot]);
-
- // Photos app's own entry points — a set (docs/photos.md §2.1), unlike
- // videoRoot/audioRoot above. No "removing a root is destructive" confirm
- // dialog: removing one root only drops that root's albums from view, it
- // does not replace the whole tab's content the way changing video_root
- // does.
- const [photoRootsBusy, setPhotoRootsBusy] = useState(false);
- const [photoRootsMsg, setPhotoRootsMsg] = useState(null);
-
- const savePhotoRoots = useCallback(async (nextRoots) => {
- const transport = transportRef && transportRef.current;
- setPhotoRootsMsg(null);
- setPhotoRootsBusy(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.setPhotoRoots(nextRoots, signFn);
- if (onPhotoRoots) onPhotoRoots(nextRoots);
- setPhotoRootsMsg({ text: t('settings_node.scan_saved'), ok: true });
- } catch (err) {
- setPhotoRootsMsg({ text: err.message, ok: false });
- } finally {
- setPhotoRootsBusy(false);
+ // A node too old for the generic op still answers the three per-app
+ // messages that came before it, so an operator on one keeps the ability
+ // they had rather than being handed a control that times out.
+ if (transport.supportsAppOps) {
+ await transport.setAppDirectories(appKey, paths, adminSignFn);
+ } else {
+ await transport.setAppDirectoriesLegacy(appKey, paths, adminSignFn);
}
- }, [transportRef, onPhotoRoots]);
+ if (onAppDirectories) onAppDirectories(appKey, paths);
+ }, [transportRef, adminSignFn, onAppDirectories]);
const [removing, setRemoving] = useState('');
@@ -884,28 +879,70 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
</div>
`}
- ${/* 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">
- <p class="settings-hint">${t('members.apps_hint')}</p>
- <ul class="apps-toggle-list">
- ${APPS.map(a => html`
- <li key=${a.key} class="settings-row">
- <label class="settings-label">
- <input type="checkbox" checked=${activeApps.includes(a.key)}
- disabled=${appsBusy}
- onChange=${() => toggleApp(a.key)} />
- ${' '}${t(a.labelKey)}
- </label>
- </li>
- `)}
- </ul>
- ${appsMsg && html`<p class="error-msg">${appsMsg}</p>`}
+ ${/* 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">
+ <p class="settings-hint">${t('settings_node.shared_directories_hint')}</p>
+ ${!connected && nodeDetected && html`
+ <p class="settings-hint">${t('settings_node.roots_offline_hint')}</p>`}
+ ${/* Read-only against a node that predates the root operations:
+ writable, removable, eject and plug have no older equivalent
+ to fall back to, and an unknown message type is dropped
+ unanswered — a thirty-second wait ending in a timeout, with
+ nothing on screen to say the node simply cannot do it. */''}
+ ${connected && !nodeSupportsAppOps && !nodeDetected && html`
+ <p class="settings-hint">${t('settings_node.roots_node_too_old')}</p>`}
+ <${SharedDirectoriesTable}
+ roots=${effectiveRoots}
+ groupId=${groupId}
+ transport=${transportRef.current}
+ signFn=${adminSignFn}
+ readOnly=${connected && !nodeSupportsAppOps && !nodeDetected}
+ nodeDetected=${nodeDetected}
+ onRootsChange=${loadNodeInfo}
+ onRefreshIndex=${onRefreshIndex} />
</${CollapsibleSection}>
`}
+ ${/* One collapsible section per application, from the registry.
+ Adding an app adds an entry to `apps.js` and a settings file; this
+ loop names none of them. The toggle in the header *is* the
+ enablement control — a separate checkbox list somewhere else meant
+ the operator turned an app on in one place and configured it in
+ another, with the two able to disagree.
+
+ Collapsed by default, and the settings inside are not rendered at
+ all while the app is off: a form for something that is not running
+ is a form whose Save button does nothing anyone can see. */
+ isNodeAdmin && connected && configurableApps().map((app) => html`
+ <${CollapsibleSection} key=${app.key} defaultOpen=${false} title=${html`
+ <span class="settings-meta-title">
+ <${Icon} name=${app.icon} />${' '}${t(app.labelKey)}
+ </span>
+ `} action=${html`
+ <${ToggleSwitch} checked=${activeApps.includes(app.key)}
+ disabled=${appsBusy}
+ onChange=${() => toggleApp(app.key)} />
+ `}>
+ ${!nodeSupportsAppOps && app.key === 'chat'
+ ? html`<p class="settings-hint">${t('settings_node.app_node_too_old')}</p>`
+ : activeApps.includes(app.key)
+ ? html`<${app.Settings}
+ roots=${effectiveRoots} dirs=${folderOptions}
+ settings=${appSettings}
+ saveDirectories=${(paths) => saveAppDirectories(app.key, paths)}
+ transport=${transportRef.current} signFn=${adminSignFn} />`
+ : html`<p class="settings-hint">${t('settings_app.disabled_hint')}</p>`}
+ </${CollapsibleSection}>
+ `)}
+
+ ${appsMsg && html`<p class="error-msg">${appsMsg}</p>`}
+
${/* 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. */
@@ -936,262 +973,6 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
</${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`
- <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">
- <${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">
- ${t('settings_node.tmdb_token_label')}
- <input type="password" placeholder=${t('settings_node.tmdb_token_placeholder')}
- value=${tmdbTokenDraft} disabled=${tmdbBusy}
- onInput=${e => setTmdbTokenDraft(e.target.value)} />
- </label>
- <p class="settings-hint">
- ${tmdbConfig && tmdbConfig.tokenCustomized
- ? t('settings_node.tmdb_token_customized')
- : t('settings_node.tmdb_token_default')}
- </p>
- </div>
- <div class="settings-row">
- <label class="settings-label">
- ${t('settings_node.tmdb_language_label')}
- <select value=${tmdbLanguage} disabled=${tmdbBusy}
- onChange=${e => setTmdbLanguage(e.target.value)}>
- ${LOCALES.map(l => html`
- <option key=${l.code} value=${TMDB_LANGUAGE_BY_LOCALE[l.code]}>
- ${l.name}
- </option>
- `)}
- </select>
- </label>
- <p class="settings-hint">${t('settings_node.tmdb_language_hint')}</p>
- </div>
- <button class="btn btn-small btn-secondary" style="margin-top:8px"
- disabled=${tmdbBusy} onClick=${() => saveTmdbConfig()}>
- ${tmdbBusy ? t('settings_node.scan_saving') : t('settings_node.tmdb_save')}
- </button>
- ${tmdbMsg && html`<p class="settings-hint">${tmdbMsg}</p>`}
- </${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`
- <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">
- <${ToggleSwitch} checked=${mbEnabled} disabled=${mbEnabledBusy}
- onChange=${(v) => saveMusicbrainzEnabled(v)}
- label=${mbEnabled ? t('settings_node.musicbrainz_enabled') : t('settings_node.musicbrainz_disabled')} />
- </div>
- ${mbMsg && html`<p class="settings-hint">${mbMsg}</p>`}
- </${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">
- <p class="settings-hint">${t('settings_node.directories_hint')}</p>
-
- ${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`
- <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`
- <div class="node-root ${!r.available ? 'node-root-unavailable' : ''}"
- key=${r.name}>
- <div class="node-root-info">
- <span class="node-root-name">
- <${Icon} name="folder" />
- ${r.name}
- </span>
- ${r.upload && html`
- <span class="node-root-badge">${t('node.upload_root')}</span>`}
- ${!r.available && html`
- <span class="node-root-badge node-root-badge-warn">
- ${t('node.unavailable')}</span>`}
- </div>
- ${nodeRoots.length > 1 && !r.upload && html`
- <button class="btn btn-small btn-danger"
- disabled=${nodeBusy}
- onClick=${async () => {
- 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')}</button>`}
- </div>
- `)}
- <button class="btn btn-small btn-secondary" style="margin-top:8px"
- disabled=${nodeBusy}
- onClick=${async () => {
- 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')}
- </button>
- ${nodeIndexProgress && nodeIndexProgress.scanning && html`
- <div class="index-progress" style="margin-top:8px">
- <div class="index-progress-bar">
- <div class="index-progress-fill" style="width:${
- nodeIndexProgress.total_bytes
- ? Math.min(100, Math.round(
- 100 * nodeIndexProgress.scanned_bytes / nodeIndexProgress.total_bytes))
- : 0}%"></div>
- </div>
- <div class="index-progress-label">${t('wizard.indexing_progress', {
- pct: nodeIndexProgress.total_bytes
- ? Math.min(100, Math.round(
- 100 * nodeIndexProgress.scanned_bytes / nodeIndexProgress.total_bytes))
- : 0,
- })}</div>
- ${nodeIndexProgress.current_dir && html`
- <div class="index-progress-dir">
- ${t('wizard.indexing_current_dir', { dir: nodeIndexProgress.current_dir })}
- </div>
- `}
- </div>
- `}
- </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`
- <${CollapsibleSection} titleKey="members.uploads_title">
- <div class="settings-row">
- <${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>`}
- </${CollapsibleSection}>
- `}
-
- ${/* Upload toggle via loopback when MNP not connected */
- nodeDetected && !connected && html`
- <${CollapsibleSection} titleKey="members.uploads_title">
- <div class="settings-row">
- <${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')} />
- </div>
- <p class="settings-hint">${t('members.uploads_hint')}</p>
- </${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. */
@@ -1334,4 +1115,4 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
const URL_RE = /\bhttps?:\/\/[^\s<>"']+/gi;
-export { GroupSettingsPanel };
+export { GroupSettingsPanel, SharedDirectoriesTable };