aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/apps.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js100
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/files-app.js35
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-page.js34
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-settings.js449
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js14
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js14
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/music-app.js5
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/photos-app.js5
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css36
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js62
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/video-app.js5
20 files changed, 522 insertions, 297 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/apps.js b/packages/meshbay-hub/src/meshbay_hub/static/apps.js
index 47b5bba..03f85ae 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/apps.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/apps.js
@@ -18,7 +18,7 @@ import { PhotosApp } from './photos-app.js';
*/
const APPS = [
{ key: 'chat', icon: 'chat', labelKey: 'group.tab_chat', Component: ChatPanel },
- { key: 'files', icon: 'folder', labelKey: 'group.tab_files', Component: FilesPanel },
+ { key: 'files', icon: 'folder', labelKey: 'group.tab_files', Component: FilesPanel, alwaysEnabled: true },
{ key: 'video', icon: 'video', labelKey: 'group.tab_video', Component: VideoApp },
{ key: 'music', icon: 'music', labelKey: 'group.tab_music', Component: MusicApp },
{ key: 'photo', icon: 'image', labelKey: 'group.tab_photos', Component: PhotosApp },
@@ -28,7 +28,7 @@ const APPS = [
function visibleApps(enabledKeys) {
const enabled = new Set(
enabledKeys && enabledKeys.length ? enabledKeys : APPS.map(a => a.key));
- return APPS.filter(a => enabled.has(a.key));
+ return APPS.filter(a => a.alwaysEnabled || enabled.has(a.key));
}
export { APPS, visibleApps };
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js
index d86521f..a5676ba 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js
@@ -5,7 +5,7 @@ import { t } from './i18n.js';
import { HUB, hubFetch, session, navigate } from './hub-client.js';
import * as platform from './platform.js';
import { Icon } from './icon.js';
-import { APPS } from './apps.js';
+import { SharedDirectoriesTable } from './group-settings.js';
export function CreateGroupPage(props) {
if (platform.node.available) return html`<${CreateGroupWizard} ...${props} />`;
@@ -109,13 +109,6 @@ function CreateGroupWizard({ token, username, onCreated, allowPublicGroups = tru
const [description, setDescription] = useState('');
const [joinPolicy, setJoinPolicy] = useState('invite');
const [roots, setRoots] = useState([]);
- const [uploadIdx, setUploadIdx] = useState(0);
- const [enabledApps, setEnabledApps] = useState(() => APPS.map(a => a.key));
- const toggleWizardApp = useCallback((key) => {
- setEnabledApps(prev => prev.includes(key)
- ? prev.filter(k => k !== key)
- : [...prev, key]);
- }, []);
const [setupSteps, setSetupSteps] = useState([]);
const [setupError, setSetupError] = useState('');
@@ -166,20 +159,9 @@ function CreateGroupWizard({ token, username, onCreated, allowPublicGroups = tru
useEffect(() => { detectNode(); }, [detectNode]);
- const addRoot = useCallback(async () => {
- const chosen = await platform.rootPicker.choose();
- if (!chosen) return;
- if (roots.some(r => r.path === chosen.path)) return;
- setRoots(prev => [...prev, chosen]);
- }, [roots]);
-
- const removeRoot = useCallback((idx) => {
- setRoots(prev => {
- const next = prev.filter((_, i) => i !== idx);
- if (uploadIdx >= next.length && next.length > 0) setUploadIdx(0);
- return next;
- });
- }, [uploadIdx]);
+ const handleLocalRootsChange = useCallback((newRoots) => {
+ setRoots(newRoots);
+ }, []);
const runSetup = useCallback(async () => {
setStep(2);
@@ -189,7 +171,6 @@ function CreateGroupWizard({ token, username, onCreated, allowPublicGroups = tru
{ label: t('wizard.step_attach'), status: 'pending' },
];
steps.push({ label: t('wizard.step_index'), status: 'pending' });
- steps.push({ label: t('wizard.step_apps'), status: 'pending' });
if (roots.length > 1)
steps.push({ label: t('wizard.step_add_roots'), status: 'pending' });
steps.push({ label: t('wizard.step_gek'), status: 'pending' });
@@ -231,11 +212,8 @@ function CreateGroupWizard({ token, username, onCreated, allowPublicGroups = tru
// 2. Attach to node with first root
update('running');
- const mainRoot = roots[uploadIdx] || roots[0];
+ const mainRoot = roots[0];
const attachBody = { name: name.trim(), shared_dir: mainRoot.path };
- if (roots.length === 1 || uploadIdx === 0) {
- attachBody.upload_dir = mainRoot.path;
- }
await platform.node.call('POST', '/api/groups/attach', attachBody);
await platform.node.call('POST', '/api/reload');
update('done');
@@ -247,22 +225,13 @@ function CreateGroupWizard({ token, username, onCreated, allowPublicGroups = tru
update('done');
advance();
- // 4. Set enabled apps
- update('running');
- await withRetry(() => platform.node.call(
- 'PUT', `/api/groups/${gid}/apps`, { apps: enabledApps }));
- update('done');
- advance();
-
- // 5. Add extra roots (if >1)
+ // 4. Add extra roots (if >1)
if (roots.length > 1) {
update('running');
- for (let i = 0; i < roots.length; i++) {
- if (i === (uploadIdx < roots.length ? uploadIdx : 0)) continue;
+ for (let i = 1; i < roots.length; i++) {
const r = roots[i];
await withRetry(() => platform.node.call('POST', `/api/groups/${gid}/roots`, {
- path: r.path, name: r.name,
- upload: i === uploadIdx,
+ path: r.path, name: r.name, writable: !!r.writable, removable: !!r.removable,
}));
}
await platform.waitForRootsIndexed(gid, setIndexProgress);
@@ -270,13 +239,13 @@ function CreateGroupWizard({ token, username, onCreated, allowPublicGroups = tru
advance();
}
- // 6. GEK init
+ // 5. GEK init
update('running');
await withRetry(() => platform.node.call('POST', `/api/groups/${gid}/gek`));
update('done');
advance();
- // 7. Generate pairing code
+ // 6. Generate pairing code
update('running');
const pairResult = await platform.node.call('POST', '/api/operator/pair');
if (pairResult && pairResult.code) {
@@ -293,7 +262,7 @@ function CreateGroupWizard({ token, username, onCreated, allowPublicGroups = tru
update('error');
setSetupError(platform.bridgeMessage(err));
}
- }, [name, description, joinPolicy, roots, uploadIdx, enabledApps, token, onCreated]);
+ }, [name, description, joinPolicy, roots, token, onCreated]);
// Step 0: Node detection
if (step === 0) {
@@ -349,7 +318,7 @@ function CreateGroupWizard({ token, username, onCreated, allowPublicGroups = tru
</div>`;
}
if (step === 1) {
- const canProceed = name.trim() && roots.length > 0 && enabledApps.length > 0;
+ const canProceed = name.trim() && roots.length > 0;
return html`<div class="page-content">
<h2>${t('wizard.title')}</h2>
${error && html`<div class="error-msg" style="margin-bottom:16px">${error}</div>`}
@@ -398,52 +367,11 @@ function CreateGroupWizard({ token, username, onCreated, allowPublicGroups = tru
`}
<div class="settings-section">
- <h3 class="settings-heading">${t('members.apps_title')}</h3>
- <p style="font-size:0.85em;color:var(--text-dim);margin-bottom:8px">
- ${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=${enabledApps.includes(a.key)}
- onChange=${() => toggleWizardApp(a.key)} />
- ${' '}${t(a.labelKey)}
- </label>
- </li>
- `)}
- </ul>
- ${enabledApps.length === 0 && html`
- <p class="error-msg">${t('members.apps_need_one')}</p>`}
- </div>
-
- <div class="settings-section">
<h3 class="settings-heading">${t('wizard.directories')}</h3>
<p style="font-size:0.85em;color:var(--text-dim);margin-bottom:8px">
${t('wizard.directories_hint')}</p>
- ${roots.map((r, i) => html`
- <div class="wizard-root" key=${r.path}>
- <div class="wizard-root-info">
- <${Icon} name="folder" />
- <span class="wizard-root-name">${r.name}</span>
- <span class="wizard-root-path">${r.path}</span>
- ${i === uploadIdx && html`
- <span class="node-root-badge">${t('wizard.upload_target')}</span>`}
- </div>
- <div class="wizard-root-actions">
- ${roots.length > 1 && i !== uploadIdx && html`
- <button class="btn btn-small btn-secondary"
- onClick=${() => setUploadIdx(i)}>
- ${t('wizard.set_upload')}</button>`}
- <button class="btn btn-small btn-danger"
- onClick=${() => removeRoot(i)}>
- ${t('wizard.remove')}</button>
- </div>
- </div>
- `)}
- <button class="btn btn-secondary" style="margin-top:8px"
- onClick=${addRoot}>
- <${Icon} name="folder-plus" /> ${t('wizard.add_directory')}
- </button>
+ <${SharedDirectoriesTable} mode="local"
+ localRoots=${roots} onLocalRootsChange=${handleLocalRootsChange} />
</div>
<div style="display:flex;gap:8px;margin-top:16px">
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/files-app.js b/packages/meshbay-hub/src/meshbay_hub/static/files-app.js
index 195c385..56311ae 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/files-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/files-app.js
@@ -212,6 +212,9 @@ function FilesPanel({
const unavailableHere = currentPath
? []
: subdirs.filter(d => rootState.get(d) && rootState.get(d).available === false);
+ const currentRootName = currentPath ? currentPath.split('/')[0] : '';
+ const currentRoot = currentRootName ? rootState.get(currentRootName) : null;
+ const currentRootWritable = currentRoot ? currentRoot.writable : false;
// A member cannot create a folder at the top of a group: that level is the
// set of roots, which is the operator's configuration and not a directory on
// anyone's disk. The node refuses it, so offering it would only produce an
@@ -338,7 +341,7 @@ function FilesPanel({
${status === 'connected' && html`
<div class="file-toolbar">
<div class="toolbar-group">
- ${mayUpload && html`
+ ${currentPath && currentRootWritable && html`
<label class="tb-btn primary">
<${Icon} name="upload" /> ${t('group.upload')}
<input type="file" multiple style="display:none"
@@ -419,16 +422,40 @@ function FilesPanel({
const full = currentPath ? currentPath + '/' + d : d;
const inside = entriesUnder(entries, full);
const bytes = inside.reduce((n, f) => n + (f.entry.size || 0), 0);
+ const rs = rootState.get(d);
+ const isEjected = rs && rs.ejected;
+ const isUnavail = unavailableHere.includes(d);
+ const isRemovable = rs && rs.removable;
return html`
- <tr class="file-row dir-row" key=${full} onClick=${() => setCurrentPath(full)}>
+ <tr class="file-row dir-row${isEjected ? ' root-ejected' : ''}" key=${full}
+ onClick=${() => { if (!isEjected) setCurrentPath(full); }}>
<td class="sel-cell">
<input type="checkbox" checked=${selected.has(dirKey(d))}
onClick=${(ev) => ev.stopPropagation()}
onChange=${() => toggle(dirKey(d))} />
</td>
- <td>${unavailableHere.includes(d) ? '\u{26A0}' : '\u{1F4C1}'}</td>
- <td>${d}${unavailableHere.includes(d) ? html`
+ <td>${isEjected ? '\u{23CF}' : isUnavail ? '\u{26A0}' : '\u{1F4C1}'}</td>
+ <td>${d}${isEjected ? html`
+ <span class="root-offline"> ${t('group.root_ejected')}</span>
+ ` : isUnavail ? html`
<span class="root-offline"> ${t('group.root_unavailable')}</span>
+ ` : ''}${rs && rs.writable && !isEjected ? html`
+ <span class="root-rw" title="${t('group.root_writable')}" style="margin-left:8px;opacity:0.5;font-size:0.9em">✎</span>
+ ` : ''}${isRemovable && isNodeAdmin && operatorPaired ? html`
+ <button class="btn-small root-eject-btn" title=${isEjected ? t('group.root_plug') : t('group.root_eject')}
+ onClick=${(ev) => {
+ ev.stopPropagation();
+ const transport = transportRef.current;
+ if (!transport) return;
+ const sk = transport.sessionKeys && transport.sessionKeys.skEdB64;
+ const signFn = (sk && window.MeshBayKeys)
+ ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript)
+ : null;
+ const fn = isEjected
+ ? () => transport.plugRoot(groupId, d, signFn)
+ : () => transport.ejectRoot(groupId, d, signFn);
+ fn().catch((err) => setError(err.message));
+ }}>${isEjected ? '\u{1F50C}' : '\u{23CF}'}</button>
` : ''}</td>
<td class="file-size">${inside.length ? formatSize(bytes) : ''}</td>
${showGroup && html`<td></td>`}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
index 466af53..1b2661c 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
@@ -1,5 +1,5 @@
import {
- html, useState, useEffect, useCallback, useRef,
+ html, useState, useEffect, useCallback, useRef, useMemo,
} from './vendor/htm-preact.js';
import { t } from './i18n.js';
import { Icon } from './icon.js';
@@ -88,9 +88,8 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
const [nodeRoots, setNodeRoots] = useState([]);
const [isNodeAdmin, setIsNodeAdmin] = useState(false);
- // Whether ordinary members may upload here. The node decides and enforces it;
- // this only says whether to offer the controls. Defaults to true so a node
- // that predates the setting behaves as it always did.
+ // DEPRECATED: memberUpload is now derived from per-root writable flags.
+ // Kept as state only for backward compat with nodes that still send it.
const [memberUpload, setMemberUpload] = useState(true);
// Which applications this group has enabled, from the node. Falls back to
// every registered app when a node predates the setting (or hasn't answered
@@ -348,6 +347,11 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
transport.onPhotoRoots = (roots) => setPhotoRoots(roots);
transport.onMusicbrainzEnabled = (enabled) =>
setMusicbrainzConfig((prev) => ({ ...(prev || {}), enabled }));
+ transport.onRootsChanged = (msg) => {
+ if (msg.roots) {
+ setNodeRoots(msg.roots);
+ }
+ };
// The node's own scan (a root added while we were already connected,
// or reconcile catching one back up) — never the entries, just
// enough to animate the sidebar dot. Guaranteed a final push at the
@@ -536,8 +540,10 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
}, [groupId, token, descDraft, onGroupUpdated]);
// Asked in two places — the Files toolbar and the chat composer — so it is
- // answered once. The operator is never locked out of their own node.
- const mayUpload = memberUpload || isNodeAdmin;
+ // answered once. Per-root writable flags replace the old binary toggle;
+ // falls back to the legacy memberUpload for old nodes.
+ const hasWritableRoot = nodeRoots.some((r) => r.writable);
+ const mayUpload = hasWritableRoot || memberUpload || isNodeAdmin;
// A single dispatcher so any app can open the right modal without owning
// video/preview state itself — Files' table and Chat's attachments both
@@ -566,9 +572,21 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
setPreviewEntry(entry);
}, [entries, onPlayQueue, onStopMusic]);
+ const unavailRoots = useMemo(() => {
+ const s = new Set();
+ for (const r of nodeRoots) if (!r.available) s.add(r.name);
+ return s;
+ }, [nodeRoots]);
+ const availableEntries = useMemo(
+ () => entries.filter((e) => {
+ const root = (e.path || '').split('/')[0];
+ return !root || !unavailRoots.has(root);
+ }), [entries, unavailRoots]);
+
const commonProps = {
groupId, transportRef, gekRef, status, username,
- entries, nodeDirs, nodeRoots, setEntries, setNodeDirs, setNodeRoots, applyIndex,
+ entries, availableEntries, nodeDirs, nodeRoots,
+ setEntries, setNodeDirs, setNodeRoots, applyIndex,
isNodeAdmin, operatorPaired, mayUpload, userId, setError, onPreview,
onRefreshIndex: refreshIndex, onActivity: touchActivity,
videoRoot, onVideoRoot: (path) => setVideoRoot(path),
@@ -699,8 +717,6 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
transportRef=${transportRef} gekRef=${gekRef}
isNodeAdmin=${isNodeAdmin} userId=${userId}
operatorPaired=${operatorPaired} connected=${status === 'connected'}
- memberUpload=${memberUpload}
- onMemberUpload=${(allowed) => setMemberUpload(allowed)}
enabledApps=${enabledApps}
onEnabledApps=${(keys) => setEnabledApps(keys)}
scanSettings=${scanSettings}
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 };
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
index 46168cf..f929873 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -805,7 +805,9 @@ export default {
'settings_node.photo_roots_add': 'Hinzufügen',
'settings_node.photo_roots_remove': 'Entfernen',
'settings_node.photo_roots_save': 'Speichern',
- 'settings_node.directories_title': 'Verzeichnisse',
+ 'settings_node.shared_directories_title': 'Freigegebene Verzeichnisse',
+ 'settings_node.shared_directories_hint': 'Ordner, die mit dieser Gruppe geteilt werden. Lesen/Schreiben umschalten, um Uploads zu erlauben. Als wechselbar markieren für externe Laufwerke.',
+ 'settings_node.directories_title': 'App-Verzeichnisse',
'settings_node.directories_hint': 'Freigegebene Ordner und welchen davon die Videos-, Musik- und Fotos-Apps als eigene(n) Einstiegspunkt(e) nutzen.',
// Create-group wizard
@@ -909,4 +911,7 @@ export default {
'node.tab_peers': 'Peers',
'node.tab_audit': 'Audit',
'node.tab_settings': 'Settings',
+ 'node.root_rw': 'Lesen/Schreiben',
+ 'node.root_ro': 'Nur lesen',
+ 'node.removable': 'wechselbar',
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
index 2d38e4b..f6c47fe 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -169,6 +169,10 @@ export default {
'device.approve_btn': 'Approve',
'device.approved': 'Device linked.',
'group.root_unavailable': '(unavailable — the drive is disconnected)',
+ 'group.root_ejected': '(ejected)',
+ 'group.root_writable': 'Read/Write',
+ 'group.root_eject': 'Eject',
+ 'group.root_plug': 'Plug in',
'group.view': 'View',
'group.delete': 'Delete',
'group.delete_confirm': 'Delete {name}?',
@@ -599,8 +603,10 @@ export default {
'settings_node.photo_roots_add': 'Add',
'settings_node.photo_roots_remove': 'Remove',
'settings_node.photo_roots_save': 'Save',
- 'settings_node.directories_title': 'Directories',
- 'settings_node.directories_hint': 'Shared folders, and which of them the Videos, Music and Photos apps use as their own entry point(s).',
+ 'settings_node.shared_directories_title': 'Shared directories',
+ 'settings_node.shared_directories_hint': 'Folders shared with this group. Toggle read-write to allow uploads, mark as removable for external drives.',
+ 'settings_node.directories_title': 'App directories',
+ 'settings_node.directories_hint': 'Which shared folders the Videos, Music and Photos apps use as their entry point(s).',
// Members
'members.col_role': 'Role',
@@ -754,6 +760,10 @@ export default {
'node.no_gek': 'No group key',
'node.roots': 'Directories',
'node.upload_root': 'uploads',
+ 'node.directory': 'Directory',
+ 'node.root_rw': 'Writable',
+ 'node.root_ro': 'read-only',
+ 'node.removable': 'Removable',
'node.unavailable': 'unavailable',
'node.add_root': 'Add directory',
'node.remove_root': 'Remove',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
index 471ad82..a8c1296 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -801,7 +801,9 @@ export default {
'settings_node.photo_roots_add': 'Añadir',
'settings_node.photo_roots_remove': 'Quitar',
'settings_node.photo_roots_save': 'Guardar',
- 'settings_node.directories_title': 'Directorios',
+ 'settings_node.shared_directories_title': 'Directorios compartidos',
+ 'settings_node.shared_directories_hint': 'Carpetas compartidas con este grupo. Active lectura-escritura para permitir subidas, marque como extraíble para unidades externas.',
+ 'settings_node.directories_title': 'Directorios de apps',
'settings_node.directories_hint': 'Carpetas compartidas, y cuál de ellas usan las apps de Vídeos, Música y Fotos como su(s) propio(s) punto(s) de entrada.',
// Create group wizard
@@ -905,4 +907,7 @@ export default {
'node.tab_peers': 'Peers',
'node.tab_audit': 'Audit',
'node.tab_settings': 'Settings',
+ 'node.root_rw': 'lectura-escritura',
+ 'node.root_ro': 'solo lectura',
+ 'node.removable': 'extraíble',
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
index 48f26a2..6d36e41 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -168,6 +168,10 @@ export default {
'device.approve_btn': 'Approuver',
'device.approved': 'Appareil lié.',
'group.root_unavailable': '(indisponible — le disque est déconnecté)',
+ 'group.root_ejected': '(éjecté)',
+ 'group.root_writable': 'Lecture/Écriture',
+ 'group.root_eject': 'Éjecter',
+ 'group.root_plug': 'Reconnecter',
'group.view': 'Afficher',
'group.delete': 'Supprimer',
'group.delete_confirm': 'Supprimer {name} ?',
@@ -731,6 +735,10 @@ export default {
'node.root_remove_confirm': 'Retirer « {name} » de ce groupe ?',
'node.root_removed': 'Répertoire retiré. Un redémarrage est recommandé pour mettre à jour l\'index.',
'node.upload_root': 'uploads',
+ 'node.directory': 'Répertoire',
+ 'node.root_rw': 'Écriture',
+ 'node.root_ro': 'lecture seule',
+ 'node.removable': 'Amovible',
'node.attach_group': 'Ajouter un groupe',
'node.attach_pick': 'Groupe à héberger',
'node.attach_dir': 'Répertoire partagé',
@@ -816,8 +824,10 @@ export default {
'settings_node.photo_roots_add': 'Ajouter',
'settings_node.photo_roots_remove': 'Retirer',
'settings_node.photo_roots_save': 'Enregistrer',
- 'settings_node.directories_title': 'Répertoires',
- 'settings_node.directories_hint': 'Dossiers partagés, et lequel d\'entre eux les applications Vidéos, Musique et Photos utilisent comme leur(s) propre(s) point(s) d\'entrée.',
+ 'settings_node.shared_directories_title': 'Répertoires partagés',
+ 'settings_node.shared_directories_hint': 'Dossiers partagés avec ce groupe. Activez lecture-écriture pour autoriser les envois, marquez comme amovible pour les disques externes.',
+ 'settings_node.directories_title': 'Répertoires des applications',
+ 'settings_node.directories_hint': 'Quel(s) dossier(s) partagés les applications Vidéos, Musique et Photos utilisent comme leur(s) propre(s) point(s) d\'entrée.',
// Create group wizard
'wizard.title': 'Créer un groupe',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
index 564f34b..508d69c 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -815,7 +815,9 @@ export default {
'settings_node.photo_roots_add': 'Aggiungi',
'settings_node.photo_roots_remove': 'Rimuovi',
'settings_node.photo_roots_save': 'Salva',
- 'settings_node.directories_title': 'Directory',
+ 'settings_node.shared_directories_title': 'Directory condivise',
+ 'settings_node.shared_directories_hint': 'Cartelle condivise con questo gruppo. Attiva lettura-scrittura per consentire il caricamento, segna come rimovibile per unità esterne.',
+ 'settings_node.directories_title': 'Directory delle app',
'settings_node.directories_hint': 'Cartelle condivise, e quale di esse le app Video, Musica e Foto usano come proprio/i punto/i di ingresso.',
// Create-group wizard
@@ -919,4 +921,7 @@ export default {
'node.tab_peers': 'Peers',
'node.tab_audit': 'Audit',
'node.tab_settings': 'Settings',
+ 'node.root_rw': 'lettura-scrittura',
+ 'node.root_ro': 'sola lettura',
+ 'node.removable': 'rimovibile',
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
index 3eed305..cfd65da 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -799,7 +799,9 @@ export default {
'settings_node.photo_roots_add': '追加',
'settings_node.photo_roots_remove': '削除',
'settings_node.photo_roots_save': '保存',
- 'settings_node.directories_title': 'ディレクトリ',
+ 'settings_node.shared_directories_title': '共有ディレクトリ',
+ 'settings_node.shared_directories_hint': 'このグループと共有されているフォルダー。読み書きを切り替えてアップロードを許可し、外付けドライブにはリムーバブルを設定します。',
+ 'settings_node.directories_title': 'アプリのディレクトリ',
'settings_node.directories_hint': '共有フォルダと、動画・音楽・写真の各アプリがそれぞれの起点として使用するフォルダです。',
// Wizard
@@ -903,4 +905,7 @@ export default {
'node.tab_peers': 'Peers',
'node.tab_audit': 'Audit',
'node.tab_settings': 'Settings',
+ 'node.root_rw': '読み書き',
+ 'node.root_ro': '読み取り専用',
+ 'node.removable': 'リムーバブル',
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
index 711a22f..0569efb 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -817,7 +817,9 @@ export default {
'settings_node.photo_roots_add': 'Toevoegen',
'settings_node.photo_roots_remove': 'Verwijderen',
'settings_node.photo_roots_save': 'Opslaan',
- 'settings_node.directories_title': 'Mappen',
+ 'settings_node.shared_directories_title': 'Gedeelde mappen',
+ 'settings_node.shared_directories_hint': 'Mappen gedeeld met deze groep. Schakel lezen-schrijven in om uploads toe te staan, markeer als verwijderbaar voor externe schijven.',
+ 'settings_node.directories_title': 'App-mappen',
'settings_node.directories_hint': 'Gedeelde mappen, en welke daarvan de Video\'s-, Muziek- en Foto\'s-apps als eigen startpunt(en) gebruiken.',
// Create group wizard
@@ -921,4 +923,7 @@ export default {
'node.tab_peers': 'Peers',
'node.tab_audit': 'Audit',
'node.tab_settings': 'Settings',
+ 'node.root_rw': 'lezen-schrijven',
+ 'node.root_ro': 'alleen-lezen',
+ 'node.removable': 'verwijderbaar',
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
index 67615a2..92c853b 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -843,7 +843,9 @@ export default {
'settings_node.photo_roots_add': 'Dodaj',
'settings_node.photo_roots_remove': 'Usuń',
'settings_node.photo_roots_save': 'Zapisz',
- 'settings_node.directories_title': 'Katalogi',
+ 'settings_node.shared_directories_title': 'Katalogi udostępnione',
+ 'settings_node.shared_directories_hint': 'Foldery udostępnione tej grupie. Przełącz odczyt-zapis, aby zezwolić na przesyłanie, oznacz jako wymienny dla dysków zewnętrznych.',
+ 'settings_node.directories_title': 'Katalogi aplikacji',
'settings_node.directories_hint': 'Katalogi udostępnione oraz to, który z nich aplikacje Wideo, Muzyka i Zdjęcia traktują jako własny punkt (punkty) wejścia.',
// Create-group wizard
@@ -947,4 +949,7 @@ export default {
'node.tab_peers': 'Peers',
'node.tab_audit': 'Audit',
'node.tab_settings': 'Settings',
+ 'node.root_rw': 'odczyt-zapis',
+ 'node.root_ro': 'tylko odczyt',
+ 'node.removable': 'wymienny',
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
index 2d07994..cd5f7e8 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
@@ -802,7 +802,9 @@ export default {
'settings_node.photo_roots_add': 'Adicionar',
'settings_node.photo_roots_remove': 'Remover',
'settings_node.photo_roots_save': 'Salvar',
- 'settings_node.directories_title': 'Diretórios',
+ 'settings_node.shared_directories_title': 'Diretórios compartilhados',
+ 'settings_node.shared_directories_hint': 'Pastas compartilhadas com este grupo. Alterne leitura-escrita para permitir uploads, marque como removível para unidades externas.',
+ 'settings_node.directories_title': 'Diretórios de apps',
'settings_node.directories_hint': 'Pastas compartilhadas, e qual delas os apps Vídeos, Música e Fotos tratam como seu(s) próprio(s) ponto(s) de entrada.',
// Create group wizard
@@ -906,4 +908,7 @@ export default {
'node.tab_peers': 'Peers',
'node.tab_audit': 'Audit',
'node.tab_settings': 'Settings',
+ 'node.root_rw': 'leitura-escrita',
+ 'node.root_ro': 'somente leitura',
+ 'node.removable': 'removível',
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
index fd0c7f9..b80ac08 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
@@ -786,7 +786,9 @@ export default {
'settings_node.photo_roots_add': '添加',
'settings_node.photo_roots_remove': '移除',
'settings_node.photo_roots_save': '保存',
- 'settings_node.directories_title': '目录',
+ 'settings_node.shared_directories_title': '共享目录',
+ 'settings_node.shared_directories_hint': '与此群组共享的文件夹。切换读写以允许上传,标记为可移除用于外置驱动器。',
+ 'settings_node.directories_title': '应用目录',
'settings_node.directories_hint': '共享文件夹,以及“视频”“音乐”和“照片”应用各自使用哪个(些)作为入口。',
// Create group wizard
@@ -891,4 +893,7 @@ export default {
'node.tab_peers': 'Peers',
'node.tab_audit': 'Audit',
'node.tab_settings': 'Settings',
+ 'node.root_rw': '读写',
+ 'node.root_ro': '只读',
+ 'node.removable': '可移除',
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/music-app.js b/packages/meshbay-hub/src/meshbay_hub/static/music-app.js
index 616f169..212745a 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/music-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/music-app.js
@@ -453,7 +453,7 @@ function FlatList({ tracks, artists, onPlayQueue }) {
// -- shell --------------------------------------------------------------------
function MusicApp({
- groupId, transportRef, gekRef, status, entries, audioRoot, musicbrainzConfig, onPlayQueue,
+ groupId, transportRef, gekRef, status, entries, availableEntries, audioRoot, musicbrainzConfig, onPlayQueue,
hideFilter,
}) {
const [mode, setMode] = useState(loadViewMode);
@@ -465,8 +465,9 @@ function MusicApp({
const setModeAndSave = (m) => { setMode(m); saveViewMode(m); };
+ const musicEntries = availableEntries || entries;
const { tracks, artists, albums } = useMemo(
- () => groupMusicEntries(entries, audioRoot), [entries, audioRoot]);
+ () => groupMusicEntries(musicEntries, audioRoot), [musicEntries, audioRoot]);
const needle = filter.trim().toLowerCase();
const filteredArtists = useMemo(() => {
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/photos-app.js b/packages/meshbay-hub/src/meshbay_hub/static/photos-app.js
index e6f3482..211c836 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/photos-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/photos-app.js
@@ -322,7 +322,7 @@ function AlbumView({ album, entries, transportRef, gekRef, setError, onBack, rea
// ── shell ────────────────────────────────────────────────────────────────────
function PhotosApp({
- groupId, transportRef, gekRef, status, entries, photoRoots, setError,
+ groupId, transportRef, gekRef, status, entries, availableEntries, photoRoots, setError,
hideFilter, readOnly,
}) {
const [openDir, setOpenDir] = useState(null);
@@ -330,8 +330,9 @@ function PhotosApp({
useEffect(() => { setOpenDir(null); setFilter(''); }, [groupId]);
+ const photoEntries = availableEntries || entries;
const albums = useMemo(
- () => groupPhotoAlbums(entries, photoRoots), [entries, photoRoots]);
+ () => groupPhotoAlbums(photoEntries, photoRoots), [photoEntries, photoRoots]);
const needle = filter.trim().toLowerCase();
const filteredAlbums = useMemo(() => (!needle ? albums : albums.filter(
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css
index c183d7e..51f9d70 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/style.css
+++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css
@@ -1267,6 +1267,7 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
/* A modern on/off switch — replaces a checkbox or a "Turn on/off" button
wherever the setting is a straight binary. */
.toggle-switch {
+ position: relative;
display: inline-flex;
align-items: center;
gap: 10px;
@@ -2542,6 +2543,41 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; }
.node-roots-header { margin-bottom: 8px; }
+/* Shared directories table */
+.shared-dirs-tbl { width: 100%; border-collapse: collapse; border: none; }
+.shared-dirs-tbl th {
+ text-align: left; font-size: 0.78em; font-weight: 500;
+ color: var(--text-dim); padding: 0 12px 6px 0;
+ text-transform: uppercase; letter-spacing: 0.04em;
+ border: none;
+}
+.shared-dirs-tbl td { padding: 7px 12px 7px 0; border: none; vertical-align: middle; }
+.shared-dirs-tbl tbody tr + tr td { border-top: 1px solid var(--border); }
+.sdt-col-dir { min-width: 140px; }
+.sdt-col-toggle { width: 90px; text-align: center; }
+.sdt-col-toggle th { text-align: center; }
+.sdt-col-toggle .toggle-switch { justify-content: center; }
+.sdt-col-actions { white-space: nowrap; text-align: right; }
+.sdt-action-btn {
+ background: none; border: 1px solid var(--border); border-radius: 4px;
+ padding: 4px 7px; cursor: pointer; font-size: 0.85em; color: var(--text-dim);
+ line-height: 1; vertical-align: middle;
+}
+.sdt-action-btn + .sdt-action-btn { margin-left: 6px; }
+.sdt-action-btn:hover:not(:disabled) { background: var(--bg-hover); color: var(--text); }
+.sdt-action-btn:disabled { opacity: 0.3; cursor: default; }
+.sdt-action-danger:hover:not(:disabled) { color: var(--danger, #ef4444); border-color: var(--danger, #ef4444); }
+.sdt-dir-name { display: inline-flex; align-items: center; gap: 6px; font-weight: 500; }
+.sdt-dir-name .icon { width: 16px; height: 16px; flex-shrink: 0; }
+.sdt-row-ejected { opacity: 0.5; }
+.sdt-row-unavail { opacity: 0.6; }
+.root-eject-btn {
+ background: none; border: 1px solid var(--border); border-radius: 4px;
+ padding: 2px 6px; cursor: pointer; font-size: 0.85em; color: var(--text-dim);
+ line-height: 1;
+}
+.root-eject-btn:hover { background: var(--bg-hover); }
+
.node-root {
display: flex;
align-items: center;
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
index ea1e70a..482574f 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
@@ -77,7 +77,8 @@ const ADMIN_OP_TYPES = new Set([
'photo_roots',
'musicbrainz_enabled', 'file_delete', 'dir_delete',
'member_upload', 'apps_enabled', 'set_scan_settings', 'member_revoke',
- 'root_add', 'root_remove', 'member_unpin', 'gek_rotate', 'group_attach',
+ 'root_add', 'root_remove', 'root_update', 'root_eject', 'root_plug',
+ 'member_unpin', 'gek_rotate', 'group_attach',
'group_detach', 'invite_create',
]);
@@ -217,7 +218,7 @@ window.addEventListener('hashchange', () => {
// The `v: '0.1'` on every other message in this file is the historical value
// and is read by nothing; it is left alone deliberately. The range is
// negotiated once, at the start, not restated per message.
-const MNP_V = '1.0';
+const MNP_V = '1.1';
const MNP_V_MIN = '1.0';
// Codes a NODE sends us, in its own vocabulary (meshbay_common/handshake.py's
@@ -325,6 +326,7 @@ class MeshBayTransport {
set onIndexSync(fn) { this._onIndexSync = fn; }
set onIndexDelta(fn) { this._onIndexDelta = fn; }
set onUploadPolicy(fn) { this._onUploadPolicy = fn; }
+ set onRootsChanged(fn) { this._onRootsChanged = fn; }
set onAppsEnabled(fn) { this._onAppsEnabled = fn; }
set onTmdbConfig(fn) { this._onTmdbConfig = fn; }
set onTmdbEnabled(fn) { this._onTmdbEnabled = fn; }
@@ -1452,11 +1454,12 @@ class MeshBayTransport {
return msg;
}
- async addRoot(groupId, path, { name, kind, upload } = {}, signFn) {
+ async addRoot(groupId, path, { name, kind, writable, removable } = {}, signFn) {
const msg = await this._sendAndWait({
- type: 'root_add', v: '0.1',
+ type: 'root_add', v: '1.1',
group_id: groupId, path,
- name: name || '', kind: kind || 'generic', upload: !!upload,
+ name: name || '', kind: kind || 'generic',
+ writable: !!writable, removable: !!removable,
});
if (msg.type === 'error') throw new Error(msg.detail);
if (msg.type === 'admin_challenge') {
@@ -1477,6 +1480,48 @@ class MeshBayTransport {
return msg;
}
+ async updateRoot(groupId, rootName, { writable, removable } = {}, signFn) {
+ const updates = [];
+ if (writable !== undefined) updates.push(`rw=${writable ? 'on' : 'off'}`);
+ if (removable !== undefined) updates.push(`rem=${removable ? 'on' : 'off'}`);
+ const subject = updates.length ? `${rootName}:${updates.join(',')}` : rootName;
+ const msg = await this._sendAndWait({
+ type: 'root_update', v: '1.1',
+ group_id: groupId, root_name: rootName,
+ ...(writable !== undefined && { writable }),
+ ...(removable !== undefined && { removable }),
+ });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ if (msg.type === 'admin_challenge') {
+ return this._authorizeAdminOp(msg, 'root_update', subject, signFn);
+ }
+ return msg;
+ }
+
+ async ejectRoot(groupId, rootName, signFn) {
+ const msg = await this._sendAndWait({
+ type: 'root_eject', v: '1.1',
+ group_id: groupId, root_name: rootName,
+ });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ if (msg.type === 'admin_challenge') {
+ return this._authorizeAdminOp(msg, 'root_eject', rootName, signFn);
+ }
+ return msg;
+ }
+
+ async plugRoot(groupId, rootName, signFn) {
+ const msg = await this._sendAndWait({
+ type: 'root_plug', v: '1.1',
+ group_id: groupId, root_name: rootName,
+ });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ if (msg.type === 'admin_challenge') {
+ return this._authorizeAdminOp(msg, 'root_plug', rootName, signFn);
+ }
+ return msg;
+ }
+
async unpinMember(userId, signFn) {
const msg = await this._sendAndWait({
type: 'member_unpin', v: '0.1', user_id: userId,
@@ -2277,6 +2322,13 @@ class MeshBayTransport {
this._onMusicbrainzEnabled(Boolean(msg.enabled));
}
+ // A root's writable/removable flags changed, or a root was ejected/plugged.
+ // Broadcast to all peers so everyone sees the change.
+ if ((msg.type === 'root_update_ack' || msg.type === 'root_eject_ack'
+ || msg.type === 'root_plug_ack') && this._onRootsChanged) {
+ this._onRootsChanged(msg);
+ }
+
// The operator's node is scanning — never the entries themselves, just
// enough to animate a presence dot. Pushed periodically while it runs,
// plus once more on the transition back to idle (daemon.py
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/video-app.js b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js
index 4f52b16..d03d843 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/video-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js
@@ -1040,7 +1040,7 @@ function FlatList({ movies, shows, transportRef, gekRef, onPreview, onNeedConn }
// ── shell ────────────────────────────────────────────────────────────────────
function VideoApp({
- groupId, transportRef, gekRef, status, entries, onPreview, videoRoot, tmdbConfig, isNodeAdmin,
+ groupId, transportRef, gekRef, status, entries, availableEntries, onPreview, videoRoot, tmdbConfig, isNodeAdmin,
hideFilter, onNeedConn,
}) {
const [mode, setMode] = useState(loadViewMode);
@@ -1056,8 +1056,9 @@ function VideoApp({
const setModeAndSave = (m) => { setMode(m); saveViewMode(m); };
+ const videoEntries = availableEntries || entries;
const { movies, shows } = useMemo(
- () => groupVideoEntries(entries, videoRoot), [entries, videoRoot]);
+ () => groupVideoEntries(videoEntries, videoRoot), [videoEntries, videoRoot]);
const needle = filter.trim().toLowerCase();
const filteredMovies = useMemo(() => (typeFilter === 'series' ? [] : !needle ? movies : movies.filter(