import { useState, useEffect, useRef, useCallback } from 'preact/hooks';
let meshLoaded = false;
let meshLoadPromise = null;
function ensureMeshClient() {
if (meshLoaded) return Promise.resolve();
if (meshLoadPromise) return meshLoadPromise;
meshLoadPromise = new Promise((resolve, reject) => {
if (window.MobuxMesh) {
meshLoaded = true;
return resolve();
}
const s = document.createElement('script');
s.src = '/static/mesh-client.js';
s.async = false;
s.onload = () => {
meshLoaded = true;
resolve();
};
s.onerror = () => reject(new Error('Failed to load mesh-client.js'));
document.body.appendChild(s);
});
return meshLoadPromise;
}
function getMesh() {
return window.MobuxMesh || null;
}
function CredDialog({ peer, note, onConfirm, onCancel }) {
const userRef = useRef(null);
const pinRef = useRef(null);
useEffect(() => {
userRef.current?.focus();
}, []);
const submit = (e) => {
e.preventDefault();
const user = (userRef.current?.value || '').trim();
const pin = (pinRef.current?.value || '').trim();
if (!user || !pin) return;
onConfirm(user, pin);
};
return (
<dialog class="session-dialog" open>
<form method="dialog" onSubmit={submit}>
<h3>Sign in to {peer}</h3>
{note && <p class="hint" style="padding:0 0 8px;text-align:left">{note}</p>}
<input ref={userRef} placeholder="user" autocomplete="off" required />
<input ref={pinRef} placeholder="PIN" type="password" inputmode="numeric" autocomplete="off" required />
<div class="dialog-actions">
<button type="button" class="btn-cancel" onClick={onCancel}>Cancel</button>
<button type="submit" class="btn-create">Connect</button>
</div>
</form>
</dialog>
);
}
function AddHostDialog({ onConfirm, onCancel }) {
const hostRef = useRef(null);
useEffect(() => {
hostRef.current?.focus();
}, []);
const submit = (e) => {
e.preventDefault();
const host = (hostRef.current?.value || '').trim();
if (!host) return;
onConfirm(host);
};
return (
<dialog class="session-dialog" open>
<form method="dialog" onSubmit={submit}>
<h3>Add host</h3>
<input ref={hostRef} placeholder="host or host:port" autocomplete="off" required />
<div class="dialog-actions">
<button type="button" class="btn-cancel" onClick={onCancel}>Cancel</button>
<button type="submit" class="btn-create">Add</button>
</div>
</form>
</dialog>
);
}
function PeerList({ peers, errorMsg, selectedPeer, manualPeers, onSelect, onRemoveManual, onAddHost, onClose }) {
return (
<div class="peer-list">
{}
<button
class={`peer-option${!selectedPeer ? ' selected' : ''}`}
type="button"
onClick={async () => { await onSelect(''); onClose(); }}
>
<div class="peer-info">
<span class="peer-name">This host</span>
<span class="peer-sub">current node</span>
</div>
{!selectedPeer && <span class="peer-check">✓</span>}
</button>
{}
{(peers || []).map((p) => {
const peerId = `${p.host}:${p.port}`;
const sub = p.version ? `v${p.version}` : 'unreachable';
const sel = selectedPeer === peerId;
return (
<button
key={peerId}
class={`peer-option${sel ? ' selected' : ''}`}
type="button"
onClick={async () => { await onSelect(peerId); onClose(); }}
>
{p.reachable != null && (
<span
class={`peer-status ${p.reachable ? 'peer-up' : 'peer-down'}`}
title={p.reachable ? 'reachable' : 'unreachable'}
/>
)}
<div class="peer-info">
<span class="peer-name">{p.name}</span>
<span class="peer-sub">{sub}</span>
</div>
{sel && <span class="peer-check">✓</span>}
</button>
);
})}
{}
{(manualPeers || []).map((peerId) => {
const sel = selectedPeer === peerId;
return (
<button
key={peerId}
class={`peer-option${sel ? ' selected' : ''}`}
type="button"
onClick={async () => { await onSelect(peerId); onClose(); }}
>
<div class="peer-info">
<span class="peer-name">{peerId}</span>
<span class="peer-sub">manual</span>
</div>
{sel && <span class="peer-check">✓</span>}
<button
class="peer-remove"
type="button"
aria-label={`Remove ${peerId}`}
onClick={(e) => { e.stopPropagation(); onRemoveManual(peerId); }}
>
✕
</button>
</button>
);
})}
{errorMsg && <div class="peer-error">{errorMsg}</div>}
{!errorMsg && (!peers || !peers.length) && !(manualPeers || []).length && (
<p class="hint">No other hosts discovered.</p>
)}
<button class="peer-add" type="button" onClick={onAddHost}>+ Add host</button>
</div>
);
}
export function HostPicker() {
const [ready, setReady] = useState(false);
const [open, setOpen] = useState(false);
const [selectedPeer, setSelectedPeer] = useState('');
const [peers, setPeers] = useState([]);
const [manualPeers, setManualPeers] = useState([]);
const [peerError, setPeerError] = useState(null);
const [loading, setLoading] = useState(false);
const [credDialog, setCredDialog] = useState(null); const [addHostDialog, setAddHostDialog] = useState(false);
const dropdownRef = useRef(null);
useEffect(() => {
ensureMeshClient()
.then(() => {
const m = getMesh();
if (m) setSelectedPeer(m.getPeer() || '');
setReady(true);
})
.catch((e) => {
console.warn('HostPicker: mesh-client.js failed to load:', e.message);
setReady(true);
});
}, []);
const notifyPeerChanged = useCallback(() => {
window.dispatchEvent(new CustomEvent('mobux:peer-changed'));
if (typeof window.refreshSessions === 'function') window.refreshSessions();
}, []);
useEffect(() => {
if (!open) return;
const handler = (e) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target)) {
setOpen(false);
}
};
document.addEventListener('mousedown', handler, true);
return () => document.removeEventListener('mousedown', handler, true);
}, [open]);
const openPicker = useCallback(async () => {
setOpen(true);
setLoading(true);
setPeerError(null);
try {
const res = await fetch('/api/peers');
if (!res.ok) {
let msg = `Peer discovery failed (${res.status}).`;
try {
const body = await res.json();
if (body?.error?.message) msg = body.error.message;
} catch (_) {}
setPeerError(msg);
setPeers([]);
} else {
const body = await res.json();
setPeers(body.peers || []);
}
} catch (e) {
setPeerError(`Peer discovery failed: ${e.message}`);
setPeers([]);
}
const m = getMesh();
setManualPeers(m ? m.getManualPeers() : []);
setLoading(false);
}, []);
const selectPeer = useCallback(async (peer) => {
const m = getMesh();
if (!m) return;
if (!peer) {
m.setPeer('');
setSelectedPeer('');
notifyPeerChanged();
return;
}
m.setPeer(peer);
if (!m.getPeerCred(peer)) {
await new Promise((resolve) => {
setCredDialog({
peer,
note: null,
onConfirm: (user, pin) => {
m.setPeerCred(peer, user, pin);
setCredDialog(null);
resolve(true);
},
onCancel: () => {
m.setPeer('');
setCredDialog(null);
resolve(false);
},
});
});
}
setSelectedPeer(m.getPeer() || '');
notifyPeerChanged();
}, [notifyPeerChanged]);
const removeManual = useCallback((peerId) => {
const m = getMesh();
if (!m) return;
const wasActive = m.getPeer() === peerId;
m.removeManualPeer(peerId);
setManualPeers(m.getManualPeers());
if (wasActive) {
setSelectedPeer('');
notifyPeerChanged();
}
}, [notifyPeerChanged]);
const handleAddHost = useCallback(async (rawHost) => {
const m = getMesh();
setAddHostDialog(false);
if (!m) return;
const peerId = m.addManualPeer(rawHost);
if (!peerId) {
alert('Invalid host. Use "host" or "host:port".');
return;
}
setManualPeers(m.getManualPeers());
await selectPeer(peerId);
setOpen(false);
}, [selectPeer]);
if (!ready) return null;
const label = selectedPeer || 'This host';
return (
<div class="spa-host-picker" ref={dropdownRef}>
<button
class="host-trigger"
type="button"
onClick={() => (open ? setOpen(false) : openPicker())}
aria-label={`Active host: ${label}`}
>
<span class="host-label">{label}</span>
<span class="host-caret">▾</span>
</button>
{open && (
<div class="host-dropdown open spa-host-dropdown">
{loading ? (
<div class="peer-list"><p class="hint">Loading hosts…</p></div>
) : (
<PeerList
peers={peers}
errorMsg={peerError}
selectedPeer={selectedPeer}
manualPeers={manualPeers}
onSelect={selectPeer}
onRemoveManual={removeManual}
onAddHost={() => setAddHostDialog(true)}
onClose={() => setOpen(false)}
/>
)}
</div>
)}
{credDialog && (
<CredDialog
peer={credDialog.peer}
note={credDialog.note}
onConfirm={credDialog.onConfirm}
onCancel={credDialog.onCancel}
/>
)}
{addHostDialog && (
<AddHostDialog
onConfirm={handleAddHost}
onCancel={() => setAddHostDialog(false)}
/>
)}
</div>
);
}