summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js513
1 files changed, 513 insertions, 0 deletions
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
new file mode 100644
index 0000000..b23b504
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js
@@ -0,0 +1,513 @@
+import {
+ html, useState, useEffect, useCallback, useRef,
+} from './vendor/htm-preact.js';
+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';
+
+export function CreateGroupPage(props) {
+ if (platform.node.available) return html`<${CreateGroupWizard} ...${props} />`;
+ return html`<${CreateGroupFormSimple} ...${props} />`;
+}
+
+function CreateGroupFormSimple({ token, onCreated, allowPublicGroups = true }) {
+ const [name, setName] = useState('');
+ const [description, setDescription] = useState('');
+ const [joinPolicy, setJoinPolicy] = useState('invite');
+ const [error, setError] = useState('');
+ const [loading, setLoading] = useState(false);
+
+ const onSubmit = async (e) => {
+ e.preventDefault();
+ if (!name.trim()) return;
+ setLoading(true);
+ setError('');
+ try {
+ const body = { name: name.trim(), join_policy: joinPolicy,
+ visibility: joinPolicy === 'open' ? 'public' : 'private' };
+ if (description.trim()) body.description = description.trim().slice(0, 512);
+ const data = await hubFetch('/v1/groups', {
+ method: 'POST', token, body,
+ });
+
+ if (onCreated) onCreated();
+ navigate('/');
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return html`
+ <div>
+ <h2>${t('create_group.title')}</h2>
+ <p class="page-message">${t('create_group.hint')}</p>
+ ${error && html`<div class="error-msg" style="margin-bottom:16px">${error}</div>`}
+
+ <form onSubmit=${onSubmit}>
+ <div class="settings-section">
+ <div class="form-field">
+ <label class="form-label">${t('create_group.name')}</label>
+ <input type="text" placeholder="${t('create_group.name_placeholder')}"
+ value=${name} onInput=${e => setName(e.target.value)} required autofocus />
+ </div>
+
+ <div class="form-field" style="margin-bottom:0">
+ <label class="form-label">${t('create_group.description')}</label>
+ <textarea class="form-textarea" rows="3" maxlength="512"
+ placeholder="${t('create_group.description_hint')}"
+ value=${description}
+ onInput=${e => setDescription(e.target.value)} />
+ <div class="form-char-count">${description.length}/512</div>
+ </div>
+ </div>
+
+ ${allowPublicGroups && html`
+ <div class="settings-section">
+ <h3 class="settings-heading">${t('create_group.join_policy')}</h3>
+ <div class="choice-list">
+ <label class="choice ${joinPolicy === 'invite' ? 'selected' : ''}">
+ <input type="radio" name="join_policy" checked=${joinPolicy === 'invite'}
+ onChange=${() => setJoinPolicy('invite')} />
+ <${Icon} name="lock" cls="choice-icon" />
+ <span class="choice-text">
+ <span class="choice-title">${t('create_group.invite')}</span>
+ <span class="choice-desc">${t('create_group.invite_desc')}</span>
+ </span>
+ </label>
+ <label class="choice ${joinPolicy === 'open' ? 'selected' : ''}">
+ <input type="radio" name="join_policy" checked=${joinPolicy === 'open'}
+ onChange=${() => setJoinPolicy('open')} />
+ <${Icon} name="globe" cls="choice-icon" />
+ <span class="choice-text">
+ <span class="choice-title">${t('create_group.open')}</span>
+ <span class="choice-desc">${t('create_group.open_desc')}</span>
+ </span>
+ </label>
+ </div>
+ </div>
+ `}
+
+ <button class="btn-primary" type="submit" disabled=${loading}>
+ ${loading ? t('create_group.creating') : t('create_group.submit')}
+ </button>
+ </form>
+ </div>
+ `;
+}
+
+function CreateGroupWizard({ token, username, onCreated, allowPublicGroups = true }) {
+ const [step, setStep] = useState(0);
+ const [nodeStatus, setNodeStatus] = useState(null);
+ const [nodeStarting, setNodeStarting] = useState(false);
+ const [error, setError] = useState('');
+
+ const [name, setName] = useState('');
+ 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('');
+ const [groupId, setGroupId] = useState('');
+ const [indexProgress, setIndexProgress] = useState(null);
+
+ const linkNodeKey = useCallback(async (pk) => {
+ if (!pk) return;
+ try {
+ await hubFetch('/v1/users/me/node_key', {
+ method: 'PUT', token, body: { pk_node_ed25519: pk },
+ });
+ } catch { /* already linked or same key */ }
+ }, [token]);
+
+ const detectNode = useCallback(async () => {
+ setNodeStatus(null);
+ setError('');
+ try {
+ const result = await platform.node.detect();
+ if (result.detected) {
+ await linkNodeKey(result.pk_node_ed25519);
+ setNodeStatus(result);
+ setStep(1);
+ return;
+ }
+ const inst = await platform.node.installed();
+ setNodeStatus({ detected: false, configured: result.configured, installed: inst.installed });
+ } catch (err) {
+ setError(err.message);
+ setNodeStatus({ detected: false });
+ }
+ }, [token, linkNodeKey]);
+
+ const startNode = useCallback(async () => {
+ setNodeStarting(true);
+ setError('');
+ try {
+ const result = await platform.node.start({ hubUrl: HUB, username, token });
+ setNodeStatus({ detected: true, ...result });
+ setNodeStarting(false);
+ setStep(1);
+ } catch (err) {
+ setError(platform.bridgeMessage(err));
+ setNodeStarting(false);
+ }
+ }, [linkNodeKey]);
+
+ 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 runSetup = useCallback(async () => {
+ setStep(2);
+ setSetupError('');
+ const steps = [
+ { label: t('wizard.step_create_hub'), status: 'pending' },
+ { 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' });
+ steps.push({ label: t('wizard.step_pair'), status: 'pending' });
+ setSetupSteps([...steps]);
+ setIndexProgress(null);
+
+ let si = 0;
+ const update = (status) => {
+ steps[si].status = status;
+ setSetupSteps([...steps]);
+ };
+ const advance = () => { si++; };
+
+ const withRetry = async (fn, attempts = 5, delayMs = 400) => {
+ for (let i = 0; i < attempts; i++) {
+ try {
+ return await fn();
+ } catch (err) {
+ const msg = String((err && err.message) || '');
+ const notHostedYet = /not configured on this node|not hosted on this node/i.test(msg);
+ if (!notHostedYet || i === attempts - 1) throw err;
+ await new Promise((r) => setTimeout(r, delayMs));
+ }
+ }
+ };
+
+ try {
+ // 1. Create group on hub
+ update('running');
+ const body = { name: name.trim(), join_policy: joinPolicy,
+ visibility: joinPolicy === 'open' ? 'public' : 'private' };
+ if (description.trim()) body.description = description.trim().slice(0, 512);
+ const data = await hubFetch('/v1/groups', { method: 'POST', token, body });
+ const gid = data.group_id;
+ setGroupId(gid);
+ update('done');
+ advance();
+
+ // 2. Attach to node with first root
+ update('running');
+ const mainRoot = roots[uploadIdx] || 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');
+ advance();
+
+ // 3. Wait for initial scan
+ update('running');
+ await platform.waitForGroupHosted(gid, setIndexProgress);
+ 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)
+ if (roots.length > 1) {
+ update('running');
+ for (let i = 0; i < roots.length; i++) {
+ if (i === (uploadIdx < roots.length ? uploadIdx : 0)) continue;
+ const r = roots[i];
+ await withRetry(() => platform.node.call('POST', `/api/groups/${gid}/roots`, {
+ path: r.path, name: r.name,
+ upload: i === uploadIdx,
+ }));
+ }
+ await platform.waitForRootsIndexed(gid, setIndexProgress);
+ update('done');
+ advance();
+ }
+
+ // 6. GEK init
+ update('running');
+ await withRetry(() => platform.node.call('POST', `/api/groups/${gid}/gek`));
+ update('done');
+ advance();
+
+ // 7. Generate pairing code
+ update('running');
+ const pairResult = await platform.node.call('POST', '/api/operator/pair');
+ if (pairResult && pairResult.code) {
+ await platform.node.setPairingCode(pairResult.code);
+ session.pendingJoinCode = pairResult.code;
+ }
+ update('done');
+
+ try { await platform.node.call('POST', '/api/reload'); } catch { /* best effort */ }
+
+ setStep(3);
+ if (onCreated) onCreated();
+ } catch (err) {
+ update('error');
+ setSetupError(platform.bridgeMessage(err));
+ }
+ }, [name, description, joinPolicy, roots, uploadIdx, enabledApps, token, onCreated]);
+
+ // Step 0: Node detection
+ if (step === 0) {
+ if (nodeStatus === null) {
+ return html`<div class="page-content">
+ <h2>${t('wizard.title')}</h2>
+ <p class="page-message">${t('wizard.detecting')}</p>
+ </div>`;
+ }
+ if (!nodeStatus.detected) {
+ const canStart = nodeStatus.installed || nodeStatus.configured;
+ return html`<div class="page-content">
+ <h2>${t('wizard.title')}</h2>
+ <p class="page-message">${canStart
+ ? t('setup.node_not_running')
+ : t('setup.node_not_installed')}</p>
+ ${!canStart && html`<p class="page-message" style="margin-top:8px">
+ ${t('setup.node_install_hint')}</p>`}
+ ${error && html`<div class="error-msg" style="margin-top:8px">${error}</div>`}
+ <div style="display:flex;gap:8px;margin-top:16px;flex-wrap:wrap">
+ ${canStart && html`
+ <button class="btn btn-primary" disabled=${nodeStarting}
+ onClick=${startNode}>
+ ${nodeStarting ? t('setup.node_starting') : t('setup.node_start')}</button>`}
+ <button class="btn btn-secondary" onClick=${detectNode}>
+ ${t('wizard.retry')}</button>
+ </div>
+ </div>`;
+ }
+ }
+
+ const provisionAttempted = useRef(false);
+ useEffect(() => {
+ if (step === 1 && nodeStatus && !nodeStarting
+ && !provisionAttempted.current
+ && (nodeStatus.status === 'waiting_for_account'
+ || nodeStatus.status === 'waiting_for_node_key'
+ || nodeStatus.status === 'starting')) {
+ provisionAttempted.current = true;
+ startNode();
+ }
+ }, [step, nodeStatus, nodeStarting, startNode]);
+
+ if (step === 1 && nodeStatus
+ && nodeStatus.status !== 'running' && nodeStatus.status !== undefined) {
+ return html`<div class="page-content">
+ <h2>${t('wizard.title')}</h2>
+ <p class="page-message">${error
+ ? t('wizard.wrong_account')
+ : t('wizard.detecting')}</p>
+ ${error && html`<button class="btn btn-secondary" onClick=${detectNode}>
+ ${t('wizard.retry')}</button>`}
+ </div>`;
+ }
+ if (step === 1) {
+ const canProceed = name.trim() && roots.length > 0 && enabledApps.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>`}
+
+ <div class="settings-section">
+ <div class="form-field">
+ <label class="form-label">${t('create_group.name')}</label>
+ <input type="text" placeholder="${t('create_group.name_placeholder')}"
+ value=${name} onInput=${e => setName(e.target.value)} required autofocus />
+ </div>
+
+ <div class="form-field">
+ <label class="form-label">${t('create_group.description')}</label>
+ <textarea class="form-textarea" rows="3" maxlength="512"
+ placeholder="${t('create_group.description_hint')}"
+ value=${description}
+ onInput=${e => setDescription(e.target.value)} />
+ <div class="form-char-count">${description.length}/512</div>
+ </div>
+ </div>
+
+ ${allowPublicGroups && html`
+ <div class="settings-section">
+ <h3 class="settings-heading">${t('create_group.join_policy')}</h3>
+ <div class="choice-list">
+ <label class="choice ${joinPolicy === 'invite' ? 'selected' : ''}">
+ <input type="radio" name="join_policy" checked=${joinPolicy === 'invite'}
+ onChange=${() => setJoinPolicy('invite')} />
+ <${Icon} name="lock" cls="choice-icon" />
+ <span class="choice-text">
+ <span class="choice-title">${t('create_group.invite')}</span>
+ <span class="choice-desc">${t('create_group.invite_desc')}</span>
+ </span>
+ </label>
+ <label class="choice ${joinPolicy === 'open' ? 'selected' : ''}">
+ <input type="radio" name="join_policy" checked=${joinPolicy === 'open'}
+ onChange=${() => setJoinPolicy('open')} />
+ <${Icon} name="globe" cls="choice-icon" />
+ <span class="choice-text">
+ <span class="choice-title">${t('create_group.open')}</span>
+ <span class="choice-desc">${t('create_group.open_desc')}</span>
+ </span>
+ </label>
+ </div>
+ </div>
+ `}
+
+ <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>
+ </div>
+
+ <div style="display:flex;gap:8px;margin-top:16px">
+ <button class="btn btn-primary" disabled=${!canProceed}
+ onClick=${runSetup}>
+ ${t('wizard.create_and_setup')}</button>
+ </div>
+ </div>`;
+ }
+
+ // Step 2: Automatic setup progress
+ if (step === 2) {
+ const pct = indexProgress && indexProgress.total_bytes
+ ? Math.min(100, Math.round(100 * indexProgress.scanned_bytes / indexProgress.total_bytes))
+ : 0;
+ return html`<div class="page-content">
+ <h2>${t('wizard.title')}</h2>
+ <p class="page-message">${t('wizard.setting_up')}</p>
+ <div class="wizard-progress">
+ ${setupSteps.map((s, i) => html`
+ <div class="wizard-step wizard-step-${s.status}" key=${i}>
+ <span class="wizard-step-icon">
+ ${s.status === 'running' ? html`<span class="spinner"></span>` :
+ s.status === 'done' ? '✓' :
+ s.status === 'error' ? '✗' : '○'}
+ </span>
+ <span>${s.label}</span>
+ </div>
+ `)}
+ </div>
+ ${indexProgress && indexProgress.scanning && html`
+ <div class="index-progress" style="margin-top:12px">
+ <div class="index-progress-bar">
+ <div class="index-progress-fill" style="width:${pct}%"></div>
+ </div>
+ <div class="index-progress-label">${t('wizard.indexing_progress', { pct })}</div>
+ ${indexProgress.current_dir && html`
+ <div class="index-progress-dir">
+ ${t('wizard.indexing_current_dir', { dir: indexProgress.current_dir })}
+ </div>
+ `}
+ </div>
+ `}
+ ${setupError && html`
+ <div class="error-msg" style="margin-top:16px">${setupError}</div>
+ <div style="display:flex;gap:8px;margin-top:8px">
+ <button class="btn btn-primary" onClick=${runSetup}>
+ ${t('wizard.retry')}</button>
+ <button class="btn btn-secondary" onClick=${() => {
+ if (onCreated) onCreated();
+ navigate('/');
+ }}>
+ ${t('wizard.finish_later')}</button>
+ </div>
+ `}
+ </div>`;
+ }
+
+ // Step 3: Done
+ return html`<div class="page-content">
+ <h2>${t('wizard.done_title')}</h2>
+ <p class="page-message">${t('wizard.done_message')}</p>
+ <button class="btn btn-primary" style="margin-top:16px"
+ onClick=${() => navigate(`/groups/${groupId}`)}>
+ ${t('wizard.go_to_group')}</button>
+ </div>`;
+}