diff options
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.js | 513 |
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>`; +} |