diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-06 16:05:39 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-06 16:05:39 +0200 |
| commit | e76e27868b30a2b00b1ba42dd8e7ee6071e0c0d7 (patch) | |
| tree | 3c23483207d77adf3b67f290b67a57ce185fb1a1 /packages/meshbay-hub/src/meshbay_hub/static/group-settings.js | |
| parent | 0ed078c92cabab1dab0f70f321562032ea549ce6 (diff) | |
| download | meshbay-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.js | 449 |
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 }; |