feat: simplify journal connection via WebSocket upgrade

Refactor the journal server to run over WebSockets on the existing
HTTP server instead of a separate MQTT TCP port. This removes the need
for a separate port configuration and simplifies the connection flow
by auto-connecting to the current host.

- Remove `--mqtt-port` CLI option
- Attach Aedes broker to the HTTP server via WebSocket upgrade
- Update UI to remove manual broker URL and player name inputs
- Add "Content Source" access from the sidebar
- Update JournalPanel to auto-connect to the local server
This commit is contained in:
hypercross 2026-07-06 15:30:21 +08:00
parent 53c33587fb
commit 862ba9c01b
10 changed files with 242 additions and 242 deletions

8
package-lock.json generated
View File

@ -29,6 +29,7 @@
"solid-js": "^1.9.3", "solid-js": "^1.9.3",
"three": "^0.183.2", "three": "^0.183.2",
"three-3mf-exporter": "^45.1.0", "three-3mf-exporter": "^45.1.0",
"ws": "^8.21.0",
"yarn-spinner-loader": "^0.1.0", "yarn-spinner-loader": "^0.1.0",
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
@ -46,6 +47,7 @@
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/js-yaml": "^4.0.9", "@types/js-yaml": "^4.0.9",
"@types/node": "^22.19.13", "@types/node": "^22.19.13",
"@types/ws": "^8.18.1",
"husky": "^9.1.7", "husky": "^9.1.7",
"jest": "^29.7.0", "jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0", "jest-environment-jsdom": "^29.7.0",
@ -10693,9 +10695,9 @@
} }
}, },
"node_modules/ws": { "node_modules/ws": {
"version": "8.20.0", "version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=10.0.0" "node": ">=10.0.0"

View File

@ -52,6 +52,7 @@
"solid-js": "^1.9.3", "solid-js": "^1.9.3",
"three": "^0.183.2", "three": "^0.183.2",
"three-3mf-exporter": "^45.1.0", "three-3mf-exporter": "^45.1.0",
"ws": "^8.21.0",
"yarn-spinner-loader": "^0.1.0", "yarn-spinner-loader": "^0.1.0",
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
@ -66,6 +67,7 @@
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/js-yaml": "^4.0.9", "@types/js-yaml": "^4.0.9",
"@types/node": "^22.19.13", "@types/node": "^22.19.13",
"@types/ws": "^8.18.1",
"husky": "^9.1.7", "husky": "^9.1.7",
"jest": "^29.7.0", "jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0", "jest-environment-jsdom": "^29.7.0",

View File

@ -57,13 +57,18 @@ const App: Component = () => {
return ( return (
<div class="min-h-screen bg-gray-50"> <div class="min-h-screen bg-gray-50">
{/* 桌面端固定侧边栏 */} {/* 桌面端固定侧边栏 */}
<DesktopSidebar fileTree={fileTree()} pathHeadings={pathHeadings()} /> <DesktopSidebar
fileTree={fileTree()}
pathHeadings={pathHeadings()}
onDataSourceOpen={() => setIsDataSourceOpen(true)}
/>
{/* 移动端抽屉式侧边栏 */} {/* 移动端抽屉式侧边栏 */}
<MobileSidebar <MobileSidebar
isOpen={isSidebarOpen()} isOpen={isSidebarOpen()}
onClose={() => setIsSidebarOpen(false)} onClose={() => setIsSidebarOpen(false)}
fileTree={fileTree()} fileTree={fileTree()}
pathHeadings={pathHeadings()} pathHeadings={pathHeadings()}
onDataSourceOpen={() => setIsDataSourceOpen(true)}
/> />
<header class="fixed top-0 left-0 right-0 bg-white shadow z-30"> <header class="fixed top-0 left-0 right-0 bg-white shadow z-30">
<div class="max-w-4xl mx-auto px-4 py-4 flex items-center gap-4"> <div class="max-w-4xl mx-auto px-4 py-4 flex items-center gap-4">
@ -79,19 +84,14 @@ const App: Component = () => {
<div class="flex-1" /> <div class="flex-1" />
<button <button
onClick={() => setIsJournalOpen((v) => !v)} onClick={() => setIsJournalOpen((v) => !v)}
class={`text-sm px-2 py-1 rounded hover:bg-gray-100 ${ class={`w-8 h-8 rounded flex items-center justify-center text-sm ${
isJournalOpen() ? "text-blue-600 font-medium" : "text-gray-500" isJournalOpen()
? "bg-blue-100 text-blue-600"
: "text-gray-500 hover:bg-gray-100"
}`} }`}
title="Journal Stream" title="Journal"
> >
📋 Journal 📋
</button>
<button
onClick={() => setIsDataSourceOpen(true)}
class="text-sm text-gray-500 hover:text-gray-700 px-2 py-1 rounded hover:bg-gray-100"
title="Content Source"
>
📂
</button> </button>
<button <button
onClick={() => setIsDocOpen(true)} onClick={() => setIsDocOpen(true)}

View File

@ -210,7 +210,6 @@ export function createContentServer(
port: number, port: number,
distPath: string = distDir, distPath: string = distDir,
host: string = "0.0.0.0", host: string = "0.0.0.0",
mqttPort: number = 1883,
): ContentServer { ): ContentServer {
let contentIndex: ContentIndex = {}; let contentIndex: ContentIndex = {};
@ -275,28 +274,24 @@ export function createContentServer(
// ---- Journal / MQTT broker ---- // ---- Journal / MQTT broker ----
let journal: Awaited<ReturnType<typeof createJournalServer>> | null = null; let journal: Awaited<ReturnType<typeof createJournalServer>> | null = null;
createJournalServer(contentDir, mqttPort).then((j) => { // 创建 HTTP 服务器 BEFORE journal so we can pass it in
journal = j;
});
// 创建请求处理器
const handleRequest = createRequestHandler( const handleRequest = createRequestHandler(
contentDir, contentDir,
distPath, distPath,
() => contentIndex, () => contentIndex,
); );
// 创建 HTTP 服务器
const server = createServer(handleRequest); const server = createServer(handleRequest);
createJournalServer(contentDir, server).then((j) => {
journal = j;
});
server.listen(port, host, () => { server.listen(port, host, () => {
const displayHost = host === "0.0.0.0" ? "localhost" : host; const displayHost = host === "0.0.0.0" ? "localhost" : host;
console.log(`\n开发服务器已启动http://${displayHost}:${port}`); console.log(`\n开发服务器已启动http://${displayHost}:${port}`);
console.log(`内容目录:${contentDir}`); console.log(`内容目录:${contentDir}`);
console.log(`静态资源目录:${distPath}`); console.log(`静态资源目录:${distPath}`);
console.log( console.log(`Journal 连接ws://${displayHost}:${port}\n`);
`MQTT 代理端口:${mqttPort} (connect via tcp://${displayHost}:${mqttPort})\n`,
);
}); });
return { return {
@ -319,7 +314,6 @@ export const serveCommand: ServeCommandHandler = async (dir, options) => {
const contentDir = resolve(dir); const contentDir = resolve(dir);
const port = parseInt(options.port, 10); const port = parseInt(options.port, 10);
const host = options.host || "0.0.0.0"; const host = options.host || "0.0.0.0";
const mqttPort = parseInt(options.mqttPort || "1883", 10);
createContentServer(contentDir, port, distDir, host, mqttPort); createContentServer(contentDir, port, distDir, host);
}; };

View File

@ -17,7 +17,6 @@ program
.description("运行一个 web 服务器预览目录中的内容,并实时监听更新") .description("运行一个 web 服务器预览目录中的内容,并实时监听更新")
.argument("[dir]", "要预览的目录", ".") .argument("[dir]", "要预览的目录", ".")
.option("-p, --port <port>", "HTTP 端口号", "3000") .option("-p, --port <port>", "HTTP 端口号", "3000")
.option("--mqtt-port <port>", "MQTT 代理端口号", "1883")
.option("-h, --host <host>", "主机地址", "0.0.0.0") .option("-h, --host <host>", "主机地址", "0.0.0.0")
.action(serveCommand); .action(serveCommand);

View File

@ -1,11 +1,11 @@
/** /**
* Journal stream persistence MQTT broker + JSONL append + session manifest * Journal stream persistence MQTT broker + JSONL append + session manifest
* *
* Starts an aedes MQTT broker and connects to itself to: * Runs an aedes MQTT broker over WebSocket, attached to the existing
* 1. Persist all stream messages to .ttrpg/sessions/{id}/stream.jsonl * HTTP server via the upgrade event. No separate TCP port.
* 2. Manage session lifecycle via retained meta topics
* *
* No HTTP routes the JSONL files are served by the existing static server. * Persistence uses aedes's internal subscribe/publish API, so the
* server process doesn't need to connect to itself as an MQTT client.
*/ */
import { join, resolve } from "path"; import { join, resolve } from "path";
@ -18,6 +18,8 @@ import {
unlinkSync, unlinkSync,
writeFileSync, writeFileSync,
} from "fs"; } from "fs";
import type { Server as HttpServer } from "http";
import type { AedesPublishPacket } from "aedes";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Types // Types
@ -34,13 +36,8 @@ interface SessionManifest {
} }
export interface JournalServer { export interface JournalServer {
/** MQTT broker instance */
broker: import("aedes").Aedes; broker: import("aedes").Aedes;
/** TCP server the broker is attached to */ wsServer: import("ws").WebSocketServer;
tcpServer: import("net").Server;
/** Persistence client (server subscribes to itself) */
persistenceClient: import("mqtt").MqttClient | null;
/** Clean shutdown */
close(): Promise<void>; close(): Promise<void>;
} }
@ -66,10 +63,6 @@ function streamPath(dataRoot: string, id: string): string {
return join(sessionDir(dataRoot, id), "stream.jsonl"); return join(sessionDir(dataRoot, id), "stream.jsonl");
} }
// ---------------------------------------------------------------------------
// Manifest
// ---------------------------------------------------------------------------
function loadManifest(dataRoot: string): SessionManifest { function loadManifest(dataRoot: string): SessionManifest {
const p = manifestPath(dataRoot); const p = manifestPath(dataRoot);
if (!existsSync(p)) return { sessions: {} }; if (!existsSync(p)) return { sessions: {} };
@ -84,10 +77,6 @@ function saveManifest(dataRoot: string, m: SessionManifest): void {
writeFileSync(manifestPath(dataRoot), JSON.stringify(m, null, 2), "utf-8"); writeFileSync(manifestPath(dataRoot), JSON.stringify(m, null, 2), "utf-8");
} }
// ---------------------------------------------------------------------------
// JSONL persistence
// ---------------------------------------------------------------------------
function appendStream(dataRoot: string, id: string, line: string): void { function appendStream(dataRoot: string, id: string, line: string): void {
const sd = sessionDir(dataRoot, id); const sd = sessionDir(dataRoot, id);
mkdirSync(sd, { recursive: true }); mkdirSync(sd, { recursive: true });
@ -98,114 +87,109 @@ function appendStream(dataRoot: string, id: string, line: string): void {
// Server factory // Server factory
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const $SESSIONS = "ttrpg/$SESSIONS";
export async function createJournalServer( export async function createJournalServer(
contentDir: string, contentDir: string,
mqttPort: number, httpServer: HttpServer,
): Promise<JournalServer> { ): Promise<JournalServer> {
const root = dataDir(contentDir); const root = dataDir(contentDir);
console.log(`[journal] data dir: ${root}`); console.log(`[journal] data dir: ${root}`);
// ---- MQTT Broker ---- // ---- MQTT Broker ----
const { Aedes: AedesFactory } = await import("aedes"); const { Aedes: AedesFactory } = await import("aedes");
const { createServer } = await import("net");
const broker = new AedesFactory(); const broker = new AedesFactory();
const tcpServer = createServer(broker.handle.bind(broker));
tcpServer.listen(mqttPort, "0.0.0.0", () => { // ---- WebSocket server (attached to HTTP server) ----
console.log(`[journal] MQTT broker on port ${mqttPort}`); const { WebSocketServer } = await import("ws");
}); const wsServer = new WebSocketServer({ noServer: true });
// ---- Persistence + Manifest client ---- httpServer.on("upgrade", (req, socket, head) => {
let client: import("mqtt").MqttClient | null = null; wsServer.handleUpgrade(req, socket, head, (ws) => {
broker.handle(ws as unknown as import("net").Socket);
const { default: mqtt } = await import("mqtt");
client = mqtt.connect(`mqtt://127.0.0.1:${mqttPort}`, {
clientId: "journal-server",
});
client.on("connect", () => {
// 1. Persistence: append every stream message to JSONL
client!.subscribe("ttrpg/+/stream", { qos: 1 }, (err) => {
if (err) console.error("[journal] stream sub err:", err);
});
// 2. Session meta: manage manifest from retained meta topics
client!.subscribe("ttrpg/+/meta", { qos: 1 }, (err) => {
if (err) console.error("[journal] meta sub err:", err);
}); });
}); });
client.on("message", (topic, payload) => { // ---- Persistence (internal subscribe, no separate MQTT client) ----
const parts = topic.split("/"); broker.subscribe(
if (parts.length < 3) return; "ttrpg/+/stream",
const sessionId = parts[1]; (packet, cb) => {
const subtopic = parts[2]; const sessionId = extractSessionId(packet.topic);
if (sessionId) {
if (subtopic === "stream") {
// Persist every stream message
try { try {
appendStream(root, sessionId, payload.toString()); appendStream(root, sessionId, packet.payload.toString());
} catch (e) { } catch (e) {
console.error(`[journal] stream append err for ${sessionId}:`, e); console.error(`[journal] stream append err for ${sessionId}:`, e);
} }
} else if (subtopic === "meta") {
// Session lifecycle
handleMetaChange(root, sessionId, client!, payload.toString());
} }
}); cb();
},
() => {},
);
broker.subscribe(
"ttrpg/+/meta",
(packet, cb) => {
const sessionId = extractSessionId(packet.topic);
if (sessionId) {
handleMetaChange(root, sessionId, broker, packet.payload.toString());
}
cb();
},
() => {},
);
console.log("[journal] persistence listener active"); console.log("[journal] persistence listener active");
console.log("[journal] broker available at ws://<host>:<port>");
return { return {
broker, broker,
tcpServer, wsServer,
persistenceClient: client,
async close() { async close() {
console.log("[journal] shutting down..."); console.log("[journal] shutting down...");
client?.end(true); await new Promise<void>((r) => wsServer.close(() => r()));
await new Promise<void>((r) => broker.close(() => r())); await new Promise<void>((r) => broker.close(() => r()));
await new Promise<void>((r) => tcpServer.close(() => r()));
}, },
}; };
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Session lifecycle // Helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const $SESSIONS = "ttrpg/$SESSIONS"; function extractSessionId(topic: string): string | null {
const parts = topic.split("/");
return parts.length >= 3 ? parts[1] : null;
}
// ---------------------------------------------------------------------------
// Session lifecycle
// ---------------------------------------------------------------------------
function handleMetaChange( function handleMetaChange(
root: string, root: string,
sessionId: string, sessionId: string,
client: import("mqtt").MqttClient, broker: import("aedes").Aedes,
rawPayload: string, rawPayload: string,
): void { ): void {
const manifest = loadManifest(root); const manifest = loadManifest(root);
try { try {
const meta = JSON.parse(rawPayload) as SessionMeta | null; const meta = JSON.parse(rawPayload) as SessionMeta | null;
if (!meta || !meta.name) { if (!meta || !meta.name) {
// Tombstone: empty or invalid payload → delete session
if (manifest.sessions[sessionId]) { if (manifest.sessions[sessionId]) {
delete manifest.sessions[sessionId]; delete manifest.sessions[sessionId];
// Wipe files
const sp = streamPath(root, sessionId); const sp = streamPath(root, sessionId);
if (existsSync(sp)) unlinkSync(sp); if (existsSync(sp)) unlinkSync(sp);
const sd = sessionDir(root, sessionId); const sd = sessionDir(root, sessionId);
try { try {
rmdirSync(sd); rmdirSync(sd);
} catch { } catch {
/* not empty, fine */ /* */
} }
console.log(`[journal] session deleted: ${sessionId}`); console.log(`[journal] session deleted: ${sessionId}`);
} }
} else { } else {
// Create or update
manifest.sessions[sessionId] = { manifest.sessions[sessionId] = {
name: meta.name, name: meta.name,
created: meta.created || Date.now(), created: meta.created || Date.now(),
@ -215,12 +199,17 @@ function handleMetaChange(
} }
saveManifest(root, manifest); saveManifest(root, manifest);
broker.publish(
// Republish full manifest as a retained message {
client.publish($SESSIONS, JSON.stringify(manifest), { topic: $SESSIONS,
payload: Buffer.from(JSON.stringify(manifest)),
qos: 1, qos: 1,
retain: true, retain: true,
}); cmd: "publish" as const,
dup: false,
},
() => {},
);
} catch (e) { } catch (e) {
console.error(`[journal] meta parse err for ${sessionId}:`, e); console.error(`[journal] meta parse err for ${sessionId}:`, e);
} }

View File

@ -1,7 +1,6 @@
export interface ServeOptions { export interface ServeOptions {
port: string; port: string;
host?: string; host?: string;
mqttPort?: string;
} }
export interface CompileOptions { export interface CompileOptions {

View File

@ -8,6 +8,7 @@ export interface SidebarProps {
onClose: () => void; onClose: () => void;
fileTree?: FileNode[]; fileTree?: FileNode[];
pathHeadings?: Record<string, TocNode[]>; pathHeadings?: Record<string, TocNode[]>;
onDataSourceOpen?: () => void;
} }
interface SidebarContentProps { interface SidebarContentProps {
@ -16,6 +17,7 @@ interface SidebarContentProps {
currentPath: string; currentPath: string;
onClose: () => void; onClose: () => void;
isDesktop?: boolean; isDesktop?: boolean;
onDataSourceOpen?: () => void;
} }
/** /**
@ -37,6 +39,14 @@ const SidebarContent: Component<SidebarContentProps> = (props) => {
<div class="p-4 border-b border-b-gray-200"> <div class="p-4 border-b border-b-gray-200">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h2 class="text-lg font-bold text-gray-900"></h2> <h2 class="text-lg font-bold text-gray-900"></h2>
<div class="flex items-center gap-1">
<button
onClick={props.onDataSourceOpen}
class="text-gray-400 hover:text-gray-600 p-1 rounded hover:bg-gray-100"
title="Content Source"
>
📂
</button>
<Show when={!props.isDesktop}> <Show when={!props.isDesktop}>
<button <button
onClick={props.onClose} onClick={props.onClose}
@ -48,6 +58,7 @@ const SidebarContent: Component<SidebarContentProps> = (props) => {
</Show> </Show>
</div> </div>
</div> </div>
</div>
{/* 文件树滚动区域 */} {/* 文件树滚动区域 */}
<div class="flex-1 overflow-y-auto p-4 min-h-0"> <div class="flex-1 overflow-y-auto p-4 min-h-0">
@ -121,6 +132,7 @@ export const MobileSidebar: Component<SidebarProps> = (props) => {
pathHeadings={pathHeadings()} pathHeadings={pathHeadings()}
currentPath={location.pathname} currentPath={location.pathname}
onClose={props.onClose} onClose={props.onClose}
onDataSourceOpen={props.onDataSourceOpen}
/> />
</aside> </aside>
</> </>
@ -133,6 +145,7 @@ export const MobileSidebar: Component<SidebarProps> = (props) => {
export const DesktopSidebar: Component<{ export const DesktopSidebar: Component<{
fileTree?: FileNode[]; fileTree?: FileNode[];
pathHeadings?: Record<string, TocNode[]>; pathHeadings?: Record<string, TocNode[]>;
onDataSourceOpen?: () => void;
}> = (props) => { }> = (props) => {
const location = useLocation(); const location = useLocation();
const [selfFileTree, setSelfFileTree] = createSignal<FileNode[]>([]); const [selfFileTree, setSelfFileTree] = createSignal<FileNode[]>([]);
@ -158,6 +171,7 @@ export const DesktopSidebar: Component<{
currentPath={location.pathname} currentPath={location.pathname}
onClose={() => {}} onClose={() => {}}
isDesktop isDesktop
onDataSourceOpen={props.onDataSourceOpen}
/> />
</aside> </aside>
); );

View File

@ -1,65 +1,26 @@
/** /**
* ConnectionBar connect form, session picker, player name * ConnectionBar session picker, player info (connected state only)
*
* Three states: disconnected connecting connected
*/ */
import { Component, createSignal, Show, For, onMount } from "solid-js"; import { Component, createSignal, Show, For } from "solid-js";
import { import {
connectStream,
hydrateFromServer, hydrateFromServer,
useJournalStream, useJournalStream,
sessions, sessions,
setMyName,
createSession, createSession,
deleteSession, deleteSession,
disconnectStream,
} from "../stores/journalStream"; } from "../stores/journalStream";
import type { SessionMeta } from "../stores/journalStream";
import { createMemo } from "solid-js";
// Direct access to the store's setter for session switching
import { journalSetState } from "../stores/journalStream"; import { journalSetState } from "../stores/journalStream";
export const ConnectionBar: Component = () => { export const ConnectionBar: Component = () => {
const stream = useJournalStream(); const stream = useJournalStream();
const [brokerUrl, setBrokerUrl] = createSignal("");
const [newSessionName, setNewSessionName] = createSignal(""); const [newSessionName, setNewSessionName] = createSignal("");
const [playerName, setPlayerName] = createSignal("");
const [connecting, setConnecting] = createSignal(false);
const [error, setError] = createSignal<string | null>(null);
const [showNewSession, setShowNewSession] = createSignal(false); const [showNewSession, setShowNewSession] = createSignal(false);
const [error, setError] = createSignal<string | null>(null);
onMount(() => {
setBrokerUrl(stream.brokerUrl ?? "");
setPlayerName(stream.myName);
});
const manifest = sessions; const manifest = sessions;
const handleConnect = async () => {
const url = brokerUrl().trim();
if (!url) return;
setConnecting(true);
setError(null);
try {
// Save name first
if (playerName().trim()) {
setMyName(playerName().trim());
}
// Connect
const sessionId = stream.sessionId || "default";
await hydrateFromServer(sessionId);
await connectStream(sessionId, url);
} catch (e) {
setError(e instanceof Error ? e.message : "Connection failed");
} finally {
setConnecting(false);
}
};
const handleCreateSession = () => { const handleCreateSession = () => {
const name = newSessionName().trim(); const name = newSessionName().trim();
if (!name) return; if (!name) return;
@ -75,7 +36,6 @@ export const ConnectionBar: Component = () => {
}; };
const handleSessionSelect = async (id: string) => { const handleSessionSelect = async (id: string) => {
if (!stream.connected) return;
try { try {
await hydrateFromServer(id); await hydrateFromServer(id);
journalSetState("sessionId", id); journalSetState("sessionId", id);
@ -84,74 +44,36 @@ export const ConnectionBar: Component = () => {
} }
}; };
const statusColor = () => const handleDisconnect = () => {
stream.connected disconnectStream();
? "bg-green-500" };
: connecting()
? "bg-yellow-400"
: "bg-gray-400";
return ( return (
<div class="border-b border-gray-200 p-3 space-y-2 text-sm"> <div class="border-b border-gray-200 px-3 py-2 space-y-2 text-sm">
{/* Status + session name */} {/* Status row */}
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class={`w-2 h-2 rounded-full ${statusColor()}`} /> <span class="w-2 h-2 rounded-full bg-green-500" />
<span class="text-xs text-gray-500"> <span class="text-xs text-gray-500">connected</span>
{stream.connected
? "connected"
: connecting()
? "connecting..."
: "disconnected"}
</span>
<Show when={stream.sessionId}> <Show when={stream.sessionId}>
<span class="text-xs font-mono bg-gray-100 px-1.5 py-0.5 rounded"> <span class="text-xs font-mono bg-gray-100 px-1.5 py-0.5 rounded">
{stream.sessionId} {stream.sessionId}
</span> </span>
</Show> </Show>
<div class="flex-1" /> <div class="flex-1" />
<Show when={stream.connected}> <Show when={stream.myName !== "gm"}>
<span class="text-xs text-gray-400">Playing as {stream.myName}</span>
</Show>
<button <button
onClick={() => {} /* disconnect */} onClick={handleDisconnect}
class="text-xs text-gray-400 hover:text-red-500" class="text-xs text-gray-400 hover:text-red-500"
title="Disconnect" title="Disconnect"
> >
</button> </button>
</Show>
</div> </div>
{/* Disconnected: connect form */} {/* GM session management */}
<Show when={!stream.connected}> <Show when={stream.myName === "gm"}>
<div class="space-y-2">
<input
type="text"
value={brokerUrl()}
onInput={(e) => setBrokerUrl(e.currentTarget.value)}
placeholder="MQTT broker (e.g. tcp://192.168.1.5:1883)"
class="w-full border border-gray-300 rounded px-2 py-1 text-xs"
/>
<input
type="text"
value={playerName()}
onInput={(e) => setPlayerName(e.currentTarget.value)}
placeholder="Your name (GM or player)"
class="w-full border border-gray-300 rounded px-2 py-1 text-xs"
/>
<button
onClick={handleConnect}
disabled={connecting() || !brokerUrl().trim()}
class="w-full bg-blue-600 text-white rounded px-3 py-1 text-xs hover:bg-blue-700 disabled:opacity-50"
>
{connecting() ? "Connecting..." : "Connect"}
</button>
<Show when={error()}>
<p class="text-red-500 text-xs">{error()}</p>
</Show>
</div>
</Show>
{/* Connected: session management (GM only) */}
<Show when={stream.connected && stream.myName === "gm"}>
<div class="space-y-1"> <div class="space-y-1">
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<span class="text-xs text-gray-500">Sessions:</span> <span class="text-xs text-gray-500">Sessions:</span>
@ -213,12 +135,8 @@ export const ConnectionBar: Component = () => {
</div> </div>
</Show> </Show>
{/* Connected: player info (player only) */} <Show when={error()}>
<Show when={stream.connected && stream.myName !== "gm"}> <p class="text-red-500 text-xs">{error()}</p>
<div class="flex items-center gap-1 text-xs text-gray-500">
<span>Playing as:</span>
<span class="font-medium text-gray-700">{stream.myName}</span>
</div>
</Show> </Show>
</div> </div>
); );

View File

@ -1,11 +1,17 @@
/** /**
* JournalPanel right panel container for the journal stream * JournalPanel right panel container for the journal stream
* *
* Collapsible, overlays on mobile, pushes content on desktop. * Fixed overlay. Shows stream when connected, connect dialog when not.
* Auto-connects to ws://{current host}:{current port} — no URL input needed.
*/ */
import { Component, Show, createSignal } from "solid-js"; import { Component, Show, createSignal, onMount } from "solid-js";
import { useJournalStream } from "../stores/journalStream"; import {
connectStream,
hydrateFromServer,
useJournalStream,
setMyName,
} from "../stores/journalStream";
import { ConnectionBar } from "./ConnectionBar"; import { ConnectionBar } from "./ConnectionBar";
import { StreamView } from "./StreamView"; import { StreamView } from "./StreamView";
import { ComposePanel } from "./ComposePanel"; import { ComposePanel } from "./ComposePanel";
@ -20,22 +26,25 @@ export const JournalPanel: Component<JournalPanelProps> = (props) => {
return ( return (
<Show when={props.open}> <Show when={props.open}>
{/* Mobile overlay */}
<div <div
class="fixed inset-0 bg-black/30 z-40 md:hidden" class="fixed inset-0 bg-black/30 z-40 md:bg-transparent"
onClick={props.onClose} onClick={props.onClose}
/> />
<aside <aside
class={` class="fixed top-16 right-0 bottom-0 z-50 bg-white border-l border-gray-200
fixed top-16 right-0 bottom-0 z-50 bg-white border-l border-gray-200 flex flex-col w-full max-w-md md:w-[420px] shadow-lg"
flex flex-col
w-full max-w-md md:w-[420px]
md:relative md:top-0 md:z-0
`}
> >
{/* Header */} {/* Header */}
<div class="flex items-center justify-between px-3 py-2 border-b border-gray-200"> <div class="flex items-center justify-between px-3 py-2 border-b border-gray-200">
<div class="flex items-center gap-2">
<h2 class="text-sm font-semibold text-gray-700">Journal</h2> <h2 class="text-sm font-semibold text-gray-700">Journal</h2>
<Show when={stream.connected}>
<span
class="w-2 h-2 rounded-full bg-green-500"
title="Connected"
/>
</Show>
</div>
<button <button
onClick={props.onClose} onClick={props.onClose}
class="text-gray-400 hover:text-gray-600 text-sm" class="text-gray-400 hover:text-gray-600 text-sm"
@ -44,18 +53,92 @@ export const JournalPanel: Component<JournalPanelProps> = (props) => {
</button> </button>
</div> </div>
<Show
when={stream.connected}
fallback={
<div class="flex-1 flex items-center justify-center p-4">
<ConnectDialog />
</div>
}
>
<ConnectionBar /> <ConnectionBar />
{/* Stream */}
<div class="flex-1 min-h-0"> <div class="flex-1 min-h-0">
<StreamView /> <StreamView />
</div> </div>
{/* Compose */}
<Show when={stream.connected}>
<ComposePanel /> <ComposePanel />
</Show> </Show>
</aside> </aside>
</Show> </Show>
); );
}; };
// ---------------------------------------------------------------------------
// Connect dialog — one input: player name. Broker URL is always current host.
// ---------------------------------------------------------------------------
const ConnectDialog: Component = () => {
const stream = useJournalStream();
const [playerName, setPlayerName] = createSignal(stream.myName);
const [connecting, setConnecting] = createSignal(false);
const [error, setError] = createSignal<string | null>(null);
// Auto-connect if we have a name from localStorage
onMount(() => {
if (stream.myName && stream.myName !== "gm") {
handleConnect();
}
});
const handleConnect = async () => {
const name = playerName().trim() || stream.myName;
if (!name) return;
const brokerUrl = `ws://${window.location.host}`;
setConnecting(true);
setError(null);
try {
setMyName(name);
const sessionId = stream.sessionId || "default";
await hydrateFromServer(sessionId);
await connectStream(sessionId, brokerUrl);
} catch (e) {
setError(e instanceof Error ? e.message : "Connection failed");
} finally {
setConnecting(false);
}
};
return (
<div class="w-full max-w-sm space-y-3">
<p class="text-sm text-gray-600 text-center">
Enter your name to join the session.
<br />
<span class="text-xs text-gray-400">
Connecting to {window.location.host}
</span>
</p>
<input
type="text"
value={playerName()}
onInput={(e) => setPlayerName(e.currentTarget.value)}
onKeyDown={(e) => e.key === "Enter" && handleConnect()}
placeholder="Your name (GM or player)"
class="w-full border border-gray-300 rounded px-3 py-1.5 text-sm"
autofocus
/>
<button
onClick={handleConnect}
disabled={connecting() || !playerName().trim()}
class="w-full bg-blue-600 text-white rounded px-3 py-2 text-sm font-medium
hover:bg-blue-700 disabled:opacity-50"
>
{connecting() ? "Connecting..." : "Join"}
</button>
<Show when={error()}>
<p class="text-red-500 text-sm text-center">{error()}</p>
</Show>
</div>
);
};