diff options
Diffstat (limited to 'packages/meshbay-hub/src')
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( |