Port popup to React; fix backend port colliding with Vite dev server

Popup is now a real React app (App.tsx/main.tsx via createRoot),
wired through @wxt-dev/module-react. tsconfig.json needed an explicit
jsx: "react-jsx" -- WXT's generated .wxt/tsconfig.json doesn't set
it, so tsc --noEmit failed on JSX syntax even though the Vite build
itself was fine.

Root cause of the actually-reported bug (blank popup, predating the
React port too): the backend defaulted to port 3000, the same port
WXT's dev server uses for this extension. With both running, the
popup's script tags pointed at the Vite dev server but the backend
answered instead, so main.tsx never loaded -- "View Page Source"
showed raw unbundled dev-mode HTML pointing at localhost:3000.
Backend now binds :8080 (see backend commit), and
BACKEND_HTTP_URL/BACKEND_WS_URL + host_permissions here follow it.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YXiHuScXrjxBh7yFGAPq3B
This commit is contained in:
2026-09-07 11:56:05 +02:00
co-authored by claude
parent 3302a78536
commit 58e32caa5c
10 changed files with 662 additions and 278 deletions
+71
View File
@@ -0,0 +1,71 @@
import { useEffect, useState } from 'react';
import { browser } from 'wxt/browser';
import { DEFAULT_CONNECTION_STATUS, DEFAULT_LOCK_STATE, type ConnectionStatus, type LockState } from '@/shared/types';
import type { NexaMessage } from '@/shared/messaging';
const CONNECTION_LABELS: Record<ConnectionStatus, string> = {
connecting: 'Connecting…',
connected: 'Connected',
disconnected: 'Disconnected — retrying',
'auth-error': 'Sign-in failed — retrying',
};
export function App() {
const [lockState, setLockState] = useState<LockState>(DEFAULT_LOCK_STATE);
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>(DEFAULT_CONNECTION_STATUS);
useEffect(() => {
browser.runtime
.sendMessage({ type: 'nexa:get-lock-state' } satisfies NexaMessage)
.then((state: LockState | undefined) => setLockState(state ?? DEFAULT_LOCK_STATE));
browser.runtime
.sendMessage({ type: 'nexa:get-connection-status' } satisfies NexaMessage)
.then((status: ConnectionStatus | undefined) => setConnectionStatus(status ?? DEFAULT_CONNECTION_STATUS));
const listener = (message: NexaMessage) => {
if (message?.type === 'nexa:lock-state-changed') {
setLockState(message.state);
} else if (message?.type === 'nexa:connection-status-changed') {
setConnectionStatus(message.status);
}
};
browser.runtime.onMessage.addListener(listener);
return () => browser.runtime.onMessage.removeListener(listener);
}, []);
return (
<>
<header>
<h1>Nexa</h1>
<p className="subtitle">Trading companion</p>
</header>
<section className="connection">
<span className={`connection-dot connection-${connectionStatus}`} />
{' '}
{CONNECTION_LABELS[connectionStatus]}
</section>
<section className="status">
{lockState.locked ? (
<>
<div className="badge badge-locked">🔒 Locked</div>
<p className="reason">{lockState.reason ?? 'No reason provided.'}</p>
<p className="scope">Scope: {lockState.scope}</p>
</>
) : (
<div className="badge badge-unlocked">Unlocked</div>
)}
</section>
<a
className="thresholds-link"
href="#"
title="Threshold settings are not part of this milestone"
>
Adjust loss thresholds →
</a>
</>
);
}
+1 -1
View File
@@ -7,6 +7,6 @@
</head>
<body>
<div id="app"></div>
<script type="module" src="./main.ts"></script>
<script type="module" src="./main.tsx"></script>
</body>
</html>
-71
View File
@@ -1,71 +0,0 @@
import { browser } from 'wxt/browser';
import { DEFAULT_CONNECTION_STATUS, DEFAULT_LOCK_STATE, type ConnectionStatus, type LockState } from '@/shared/types';
import type { NexaMessage } from '@/shared/messaging';
const app = document.getElementById('app');
if (!app) throw new Error('Popup root element #app not found.');
app.innerHTML = `
<header>
<h1>Nexa</h1>
<p class="subtitle">Trading companion</p>
</header>
<section class="connection" id="connection"></section>
<section class="status" id="status"></section>
<a class="thresholds-link" id="thresholds-link" href="#" title="Threshold settings are not part of this milestone">
Adjust loss thresholds →
</a>
`;
const connectionEl = document.getElementById('connection') as HTMLElement;
const statusEl = document.getElementById('status') as HTMLElement;
function escapeHtml(value: string): string {
const div = document.createElement('div');
div.textContent = value;
return div.innerHTML;
}
const CONNECTION_LABELS: Record<ConnectionStatus, string> = {
connecting: 'Connecting…',
connected: 'Connected',
disconnected: 'Disconnected — retrying',
'auth-error': 'Sign-in failed — retrying',
};
function renderConnection(status: ConnectionStatus): void {
connectionEl.innerHTML = `<span class="connection-dot connection-${status}"></span> ${CONNECTION_LABELS[status]}`;
}
function renderStatus(state: LockState): void {
statusEl.innerHTML = state.locked
? `<div class="badge badge-locked">🔒 Locked</div>
<p class="reason">${escapeHtml(state.reason ?? 'No reason provided.')}</p>
<p class="scope">Scope: ${escapeHtml(state.scope)}</p>`
: `<div class="badge badge-unlocked">Unlocked</div>`;
}
async function loadLockState(): Promise<LockState> {
const message: NexaMessage = { type: 'nexa:get-lock-state' };
const state = (await browser.runtime.sendMessage(message)) as LockState | undefined;
return state ?? DEFAULT_LOCK_STATE;
}
async function loadConnectionStatus(): Promise<ConnectionStatus> {
const message: NexaMessage = { type: 'nexa:get-connection-status' };
const status = (await browser.runtime.sendMessage(message)) as ConnectionStatus | undefined;
return status ?? DEFAULT_CONNECTION_STATUS;
}
loadLockState().then(renderStatus);
loadConnectionStatus().then(renderConnection);
browser.runtime.onMessage.addListener((message: NexaMessage) => {
if (message?.type === 'nexa:lock-state-changed') {
renderStatus(message.state);
} else if (message?.type === 'nexa:connection-status-changed') {
renderConnection(message.status);
}
});
+12
View File
@@ -0,0 +1,12 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App';
const root = document.getElementById('app');
if (!root) throw new Error('Popup root element #app not found.');
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>,
);