diff options
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/group-settings.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/group-settings.js | 1183 |
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 }; |