aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-06 16:05:39 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-06 16:05:39 +0200
commite76e27868b30a2b00b1ba42dd8e7ee6071e0c0d7 (patch)
tree3c23483207d77adf3b67f290b67a57ce185fb1a1 /packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
parent0ed078c92cabab1dab0f70f321562032ea549ce6 (diff)
downloadmeshbay-e76e27868b30a2b00b1ba42dd8e7ee6071e0c0d7.tar.gz
feat: groups refactor Phase 1 — root RO/RW model + shared directories UI
Replace the upload boolean with per-root writable/removable/ejected flags. Backend: new ops (update_root, eject_root, plug_root), MNP 1.1 protocol messages, live RootSet updates so API always reflects current state, CLI root subcommand (add/remove/set/list/eject/plug). Frontend: SharedDirectoriesTable with optimistic toggle switches, eject/plug in Files and Settings, upload gated on root.writable, ejected-root filtering in all media apps, updated Create Group wizard, 10-locale i18n. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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.js449
1 files changed, 276 insertions, 173 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..de6f8c0 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
@@ -174,6 +174,254 @@ function PhotoRootsRow({ folders, value, busy, msg, onSave }) {
`;
}
+// ── Shared Directories Table ────────────────────────────────────────────
+
+/**
+ * Reusable table of a group's root directories with per-root controls.
+ *
+ * Used in both the Settings page (with full edit controls) and the Create
+ * Group wizard (with add-only). Each root shows its name, a writable
+ * toggle, a removable badge, and eject/plug buttons for removable roots.
+ *
+ * Props:
+ * roots — array of { name, writable, removable, ejected, available, kind }
+ * groupId — the group id
+ * transport — MeshBayTransport instance (null when not connected)
+ * signFn — signing function for admin ops
+ * platform — platform bridge (for Electron root picker)
+ * nodeDetected — whether local node API is available
+ * readOnly — suppress edit controls (default false)
+ * onRootsChange — callback(roots) after a change
+ * onRefreshIndex — trigger a full index refresh after add/remove
+ */
+/**
+ * Two modes:
+ * mode="live" — connected to a node, persists changes via MNP/loopback API
+ * mode="local" — during group creation, manages a local array, reports changes
+ * via onLocalRootsChange(roots)
+ */
+function SharedDirectoriesTable({ roots, groupId, transport, signFn,
+ nodeDetected: nodeAvail, readOnly,
+ onRootsChange, onRefreshIndex,
+ mode = 'live',
+ localRoots, onLocalRootsChange }) {
+ const isLocal = mode === 'local';
+ const [optimistic, setOptimistic] = useState({});
+ const serverRoots = isLocal ? (localRoots || []) : roots;
+ const displayRoots = serverRoots.map(r =>
+ optimistic[r.name] ? { ...r, ...optimistic[r.name] } : r);
+ const [busy, setBusy] = useState(false);
+ const [msg, setMsg] = useState('');
+ const [indexProgress, setIndexProgress] = useState(null);
+
+ 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 } }));
+ setBusy(true); setMsg('');
+ try {
+ if (transport && transport.connected) {
+ await transport.updateRoot(groupId, rootName, updates, signFn);
+ } else if (nodeAvail) {
+ await platform.node.call('PATCH',
+ '/api/groups/' + groupId + '/roots/' + encodeURIComponent(rootName),
+ updates);
+ }
+ if (onRootsChange) await onRootsChange();
+ } catch (err) { setMsg(err.message); }
+ finally {
+ setOptimistic(prev => { const next = { ...prev }; delete next[rootName]; return next; });
+ setBusy(false);
+ }
+ }, [isLocal, localRoots, onLocalRootsChange, transport, groupId, signFn, nodeAvail, onRootsChange]);
+
+ const doEjectRoot = useCallback(async (rootName) => {
+ if (isLocal) return;
+ setBusy(true); setMsg('');
+ try {
+ if (transport && transport.connected) {
+ await transport.ejectRoot(groupId, rootName, signFn);
+ } else if (nodeAvail) {
+ await platform.node.call('PUT',
+ '/api/groups/' + groupId + '/roots/' + encodeURIComponent(rootName) + '/eject');
+ }
+ if (onRootsChange) onRootsChange();
+ } catch (err) { setMsg(err.message); }
+ finally { setBusy(false); }
+ }, [isLocal, transport, groupId, signFn, nodeAvail, onRootsChange]);
+
+ const doPlugRoot = useCallback(async (rootName) => {
+ if (isLocal) return;
+ setBusy(true); setMsg('');
+ try {
+ if (transport && transport.connected) {
+ await transport.plugRoot(groupId, rootName, signFn);
+ } else if (nodeAvail) {
+ await platform.node.call('PUT',
+ '/api/groups/' + groupId + '/roots/' + encodeURIComponent(rootName) + '/plug');
+ }
+ if (onRootsChange) onRootsChange();
+ } catch (err) { setMsg(err.message); }
+ finally { setBusy(false); }
+ }, [isLocal, transport, groupId, signFn, nodeAvail, onRootsChange]);
+
+ 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;
+ setBusy(true); setMsg('');
+ try {
+ if (transport && transport.connected) {
+ await transport.removeRoot(groupId, rootName, signFn);
+ } else if (nodeAvail) {
+ await platform.node.call('DELETE',
+ '/api/groups/' + groupId + '/roots/' + encodeURIComponent(rootName));
+ await platform.node.call('POST', '/api/reload');
+ }
+ setMsg(t('node.root_removed'));
+ if (onRootsChange) onRootsChange();
+ if (onRefreshIndex) await onRefreshIndex();
+ } catch (err) { setMsg(err.message); }
+ finally { setBusy(false); }
+ }, [isLocal, localRoots, onLocalRootsChange, transport, groupId, signFn, nodeAvail, onRootsChange, onRefreshIndex]);
+
+ const doAddRoot = useCallback(async () => {
+ const chosen = await platform.rootPicker.choose();
+ if (!chosen) return;
+ if (isLocal) {
+ if ((localRoots || []).some(r => r.path === chosen.path)) return;
+ const isFirst = (localRoots || []).length === 0;
+ const newRoot = {
+ name: chosen.name, path: chosen.path,
+ writable: isFirst, removable: false,
+ };
+ if (onLocalRootsChange) onLocalRootsChange([...(localRoots || []), newRoot]);
+ return;
+ }
+ setBusy(true); setMsg(''); setIndexProgress(null);
+ try {
+ if (nodeAvail) {
+ await platform.node.call('POST',
+ '/api/groups/' + groupId + '/roots',
+ { path: chosen.path, name: chosen.name });
+ await platform.node.call('POST', '/api/reload');
+ await platform.watchIndexProgress(groupId, setIndexProgress);
+ }
+ setMsg(t('node.root_added'));
+ if (onRootsChange) onRootsChange();
+ if (onRefreshIndex) await onRefreshIndex();
+ } catch (err) { setMsg(platform.bridgeMessage(err)); }
+ finally { setBusy(false); }
+ }, [isLocal, localRoots, onLocalRootsChange, groupId, nodeAvail, onRootsChange, onRefreshIndex]);
+
+ if (!displayRoots || displayRoots.length === 0) {
+ return html`
+ <div class="shared-directories-table">
+ <p class="settings-hint">${t('settings_node.shared_directories_hint')}</p>
+ <button class="btn btn-small btn-secondary" style="margin-top:8px"
+ onClick=${doAddRoot}>
+ <${Icon} name="folder-plus" /> ${t('node.add_root')}
+ </button>
+ </div>
+ `;
+ }
+
+ return html`
+ <div class="shared-directories-table">
+ ${msg && html`<p class="settings-hint">${msg}</p>`}
+ <table class="shared-dirs-tbl">
+ <thead>
+ <tr>
+ <th class="sdt-col-dir">${t('node.directory')}</th>
+ ${!readOnly && html`<th class="sdt-col-toggle">${t('node.root_rw')}</th>`}
+ ${!readOnly && !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) ? '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 && !r.ejected && html`
+ <span class="node-root-badge node-root-badge-warn">${t('node.unavailable')}</span>`}
+ </td>
+ ${!readOnly && html`
+ <td class="sdt-col-toggle">
+ <${ToggleSwitch} checked=${!!r.writable} disabled=${busy || !!r.ejected}
+ onChange=${(v) => doUpdateRoot(r.name, { writable: v })} />
+ </td>
+ `}
+ ${!readOnly && !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">
+ ${!readOnly && !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>
+ <button class="sdt-action-btn sdt-action-danger" disabled=${busy}
+ title=${t('node.remove_root')}
+ onClick=${() => doRemoveRoot(r.name)}>
+ \u{2715}
+ </button>
+ `}
+ </td>
+ </tr>
+ `; })}
+ </tbody>
+ </table>
+ ${!readOnly && (isLocal || nodeAvail) && html`
+ <button class="btn btn-small btn-secondary" style="margin-top:8px"
+ disabled=${busy}
+ onClick=${doAddRoot}>
+ <${Icon} name="folder-plus" /> ${t('node.add_root')}
+ </button>
+ `}
+ ${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>
+ `;
+}
+
+
// ── Members Panel ────────────────────────────────────────────────────────
/**
@@ -187,7 +435,6 @@ function PhotoRootsRow({ folders, value, busy, msg, onSave }) {
*/
function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
isNodeAdmin, userId, operatorPaired, connected,
- memberUpload, onMemberUpload,
enabledApps, onEnabledApps,
scanSettings, onScanSettings,
tmdbConfig, onTmdbConfig, onTmdbEnabled,
@@ -332,36 +579,7 @@ 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('');
@@ -884,6 +1102,30 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
</div>
`}
+ ${/* Shared directories — the group's root folders. Shown to the
+ operator when the node is detected locally (Electron) or a live
+ MNP connection is available, so root properties can be toggled.
+ Appears early because it is the fundamental structural control. */
+ isNodeAdmin && (connected || nodeDetected) && nodeRoots.length > 0 && html`
+ <${CollapsibleSection} titleKey="settings_node.shared_directories_title">
+ <p class="settings-hint">${t('settings_node.shared_directories_hint')}</p>
+ <${SharedDirectoriesTable}
+ roots=${nodeRoots}
+ groupId=${groupId}
+ transport=${transportRef.current}
+ signFn=${(() => {
+ const sk = transportRef.current && transportRef.current.sessionKeys
+ && transportRef.current.sessionKeys.skEdB64;
+ return (sk && window.MeshBayKeys)
+ ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript)
+ : null;
+ })()}
+ nodeDetected=${nodeDetected}
+ onRootsChange=${loadNodeInfo}
+ onRefreshIndex=${onRefreshIndex} />
+ </${CollapsibleSection}>
+ `}
+
${/* Which group "applications" members see. New ones (Videos, Music,
Photos) show up here automatically as they register in apps.js —
nothing about this section changes to add one. */
@@ -891,7 +1133,7 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
<${CollapsibleSection} titleKey="members.apps_title">
<p class="settings-hint">${t('members.apps_hint')}</p>
<ul class="apps-toggle-list">
- ${APPS.map(a => html`
+ ${APPS.filter(a => !a.alwaysEnabled).map(a => html`
<li key=${a.key} class="settings-row">
<label class="settings-label">
<input type="checkbox" checked=${activeApps.includes(a.key)}
@@ -1049,146 +1291,7 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
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>
+ ${/* Root management moved to SharedDirectoriesTable above. */''}
</${CollapsibleSection}>
`}
@@ -1334,4 +1437,4 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
const URL_RE = /\bhttps?:\/\/[^\s<>"']+/gi;
-export { GroupSettingsPanel };
+export { GroupSettingsPanel, SharedDirectoriesTable };