From e76e27868b30a2b00b1ba42dd8e7ee6071e0c0d7 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Sun, 6 Sep 2026 16:05:39 +0200 Subject: feat: groups refactor Phase 1 — root RO/RW model + shared directories UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/meshbay_hub/static/group-settings.js | 449 +++++++++++++-------- 1 file changed, 276 insertions(+), 173 deletions(-) (limited to 'packages/meshbay-hub/src/meshbay_hub/static/group-settings.js') 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` +
+

${t('settings_node.shared_directories_hint')}

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

${msg}

`} + + + + + ${!readOnly && html``} + ${!readOnly && !isLocal && html``} + + + + + ${displayRoots.map(r => { + const rowClass = r.ejected ? 'sdt-row-ejected' + : (!isLocal && !r.available) ? 'sdt-row-unavail' : ''; + return html` + + + ${!readOnly && html` + + `} + ${!readOnly && !isLocal && html` + + `} + + + `; })} + +
${t('node.directory')}${t('node.root_rw')}${t('node.removable')}
+ + <${Icon} name="folder" /> + ${r.name} + + ${r.ejected && html` + ${t('group.root_ejected')}`} + ${!isLocal && !r.available && !r.ejected && html` + ${t('node.unavailable')}`} + + <${ToggleSwitch} checked=${!!r.writable} disabled=${busy || !!r.ejected} + onChange=${(v) => doUpdateRoot(r.name, { writable: v })} /> + + <${ToggleSwitch} checked=${!!r.removable} disabled=${busy} + onChange=${(v) => doUpdateRoot(r.name, { removable: v })} /> + + ${!readOnly && !isLocal && html` + + + `} +
+ ${!readOnly && (isLocal || nodeAvail) && html` + + `} + ${indexProgress && indexProgress.scanning && html` +
+
+
+
+
${t('wizard.indexing_progress', { + pct: indexProgress.total_bytes + ? Math.min(100, Math.round( + 100 * indexProgress.scanned_bytes / indexProgress.total_bytes)) + : 0, + })}
+
+ `} +
+ `; +} + + // ── Members Panel ──────────────────────────────────────────────────────── /** @@ -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, `} + ${/* 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"> +

${t('settings_node.shared_directories_hint')}

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

${t('members.apps_hint')}