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 { SharedDirectoriesTable } from './group-settings.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`
${t('create_group.title')}
${t('create_group.hint')}
${error && html`
${error}
`}
`;
}
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 [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 handleLocalRootsChange = useCallback((newRoots) => {
setRoots(newRoots);
}, []);
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' });
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');
// The first root is attached with the group, so its RW switch has to
// travel with it — writing it and then correcting it afterwards would
// leave a window where a group the operator marked read-only accepts
// uploads.
const mainRoot = roots[0];
const attachBody = {
name: name.trim(),
shared_dir: mainRoot.path,
writable: mainRoot.writable !== false,
};
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. Add extra roots (if >1)
if (roots.length > 1) {
update('running');
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, writable: !!r.writable, removable: !!r.removable,
}));
}
await platform.waitForRootsIndexed(gid, setIndexProgress);
update('done');
advance();
}
// 5. GEK init
update('running');
await withRetry(() => platform.node.call('POST', `/api/groups/${gid}/gek`));
update('done');
advance();
// 6. 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, token, onCreated]);
// Step 0: Node detection
if (step === 0) {
if (nodeStatus === null) {
return html`
${t('wizard.title')}
${t('wizard.detecting')}
`;
}
if (!nodeStatus.detected) {
const canStart = nodeStatus.installed || nodeStatus.configured;
return html`
${t('wizard.title')}
${canStart
? t('setup.node_not_running')
: t('setup.node_not_installed')}
${!canStart && html`
${t('setup.node_install_hint')}
`}
${error && html`
${error}
`}
${canStart && html`
`}
`;
}
}
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`
${t('wizard.title')}
${error
? t('wizard.wrong_account')
: t('wizard.detecting')}
${error && html`
`}
`;
}
if (step === 1) {
const canProceed = name.trim() && roots.length > 0;
return html`
${t('wizard.title')}
${error && html`
${error}
`}
${allowPublicGroups && html`
`}
${t('wizard.directories')}
${t('wizard.directories_hint')}
<${SharedDirectoriesTable} mode="local"
localRoots=${roots} onLocalRootsChange=${handleLocalRootsChange} />
`;
}
// 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`
${t('wizard.title')}
${t('wizard.setting_up')}
${setupSteps.map((s, i) => html`
${s.status === 'running' ? html`` :
s.status === 'done' ? '✓' :
s.status === 'error' ? '✗' : '○'}
${s.label}
`)}
${indexProgress && indexProgress.scanning && html`
${t('wizard.indexing_progress', { pct })}
${indexProgress.current_dir && html`
${t('wizard.indexing_current_dir', { dir: indexProgress.current_dir })}
`}
`}
${setupError && html`
${setupError}
`}
`;
}
// Step 3: Done
return html`
${t('wizard.done_title')}
${t('wizard.done_message')}
`;
}