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:
parent
53c33587fb
commit
862ba9c01b
|
|
@ -29,6 +29,7 @@
|
|||
"solid-js": "^1.9.3",
|
||||
"three": "^0.183.2",
|
||||
"three-3mf-exporter": "^45.1.0",
|
||||
"ws": "^8.21.0",
|
||||
"yarn-spinner-loader": "^0.1.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
|
|
@ -46,6 +47,7 @@
|
|||
"@types/jest": "^30.0.0",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^22.19.13",
|
||||
"@types/ws": "^8.18.1",
|
||||
"husky": "^9.1.7",
|
||||
"jest": "^29.7.0",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
|
|
@ -10693,9 +10695,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
|
||||
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
|
||||
"version": "8.21.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
|
||||
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@
|
|||
"solid-js": "^1.9.3",
|
||||
"three": "^0.183.2",
|
||||
"three-3mf-exporter": "^45.1.0",
|
||||
"ws": "^8.21.0",
|
||||
"yarn-spinner-loader": "^0.1.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
|
|
@ -66,6 +67,7 @@
|
|||
"@types/jest": "^30.0.0",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^22.19.13",
|
||||
"@types/ws": "^8.18.1",
|
||||
"husky": "^9.1.7",
|
||||
"jest": "^29.7.0",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
|
|
|
|||
24
src/App.tsx
24
src/App.tsx
|
|
@ -57,13 +57,18 @@ const App: Component = () => {
|
|||
return (
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
{/* 桌面端固定侧边栏 */}
|
||||
<DesktopSidebar fileTree={fileTree()} pathHeadings={pathHeadings()} />
|
||||
<DesktopSidebar
|
||||
fileTree={fileTree()}
|
||||
pathHeadings={pathHeadings()}
|
||||
onDataSourceOpen={() => setIsDataSourceOpen(true)}
|
||||
/>
|
||||
{/* 移动端抽屉式侧边栏 */}
|
||||
<MobileSidebar
|
||||
isOpen={isSidebarOpen()}
|
||||
onClose={() => setIsSidebarOpen(false)}
|
||||
fileTree={fileTree()}
|
||||
pathHeadings={pathHeadings()}
|
||||
onDataSourceOpen={() => setIsDataSourceOpen(true)}
|
||||
/>
|
||||
<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">
|
||||
|
|
@ -79,19 +84,14 @@ const App: Component = () => {
|
|||
<div class="flex-1" />
|
||||
<button
|
||||
onClick={() => setIsJournalOpen((v) => !v)}
|
||||
class={`text-sm px-2 py-1 rounded hover:bg-gray-100 ${
|
||||
isJournalOpen() ? "text-blue-600 font-medium" : "text-gray-500"
|
||||
class={`w-8 h-8 rounded flex items-center justify-center text-sm ${
|
||||
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
|
||||
onClick={() => setIsDocOpen(true)}
|
||||
|
|
|
|||
|
|
@ -210,7 +210,6 @@ export function createContentServer(
|
|||
port: number,
|
||||
distPath: string = distDir,
|
||||
host: string = "0.0.0.0",
|
||||
mqttPort: number = 1883,
|
||||
): ContentServer {
|
||||
let contentIndex: ContentIndex = {};
|
||||
|
||||
|
|
@ -275,28 +274,24 @@ export function createContentServer(
|
|||
// ---- Journal / MQTT broker ----
|
||||
let journal: Awaited<ReturnType<typeof createJournalServer>> | null = null;
|
||||
|
||||
createJournalServer(contentDir, mqttPort).then((j) => {
|
||||
journal = j;
|
||||
});
|
||||
|
||||
// 创建请求处理器
|
||||
// 创建 HTTP 服务器 BEFORE journal so we can pass it in
|
||||
const handleRequest = createRequestHandler(
|
||||
contentDir,
|
||||
distPath,
|
||||
() => contentIndex,
|
||||
);
|
||||
|
||||
// 创建 HTTP 服务器
|
||||
const server = createServer(handleRequest);
|
||||
|
||||
createJournalServer(contentDir, server).then((j) => {
|
||||
journal = j;
|
||||
});
|
||||
|
||||
server.listen(port, host, () => {
|
||||
const displayHost = host === "0.0.0.0" ? "localhost" : host;
|
||||
console.log(`\n开发服务器已启动:http://${displayHost}:${port}`);
|
||||
console.log(`内容目录:${contentDir}`);
|
||||
console.log(`静态资源目录:${distPath}`);
|
||||
console.log(
|
||||
`MQTT 代理端口:${mqttPort} (connect via tcp://${displayHost}:${mqttPort})\n`,
|
||||
);
|
||||
console.log(`Journal 连接:ws://${displayHost}:${port}\n`);
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
@ -319,7 +314,6 @@ export const serveCommand: ServeCommandHandler = async (dir, options) => {
|
|||
const contentDir = resolve(dir);
|
||||
const port = parseInt(options.port, 10);
|
||||
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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ program
|
|||
.description("运行一个 web 服务器预览目录中的内容,并实时监听更新")
|
||||
.argument("[dir]", "要预览的目录", ".")
|
||||
.option("-p, --port <port>", "HTTP 端口号", "3000")
|
||||
.option("--mqtt-port <port>", "MQTT 代理端口号", "1883")
|
||||
.option("-h, --host <host>", "主机地址", "0.0.0.0")
|
||||
.action(serveCommand);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
/**
|
||||
* Journal stream persistence — MQTT broker + JSONL append + session manifest
|
||||
*
|
||||
* Starts an aedes MQTT broker and connects to itself to:
|
||||
* 1. Persist all stream messages to .ttrpg/sessions/{id}/stream.jsonl
|
||||
* 2. Manage session lifecycle via retained meta topics
|
||||
* Runs an aedes MQTT broker over WebSocket, attached to the existing
|
||||
* HTTP server via the upgrade event. No separate TCP port.
|
||||
*
|
||||
* 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";
|
||||
|
|
@ -18,6 +18,8 @@ import {
|
|||
unlinkSync,
|
||||
writeFileSync,
|
||||
} from "fs";
|
||||
import type { Server as HttpServer } from "http";
|
||||
import type { AedesPublishPacket } from "aedes";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
|
|
@ -34,13 +36,8 @@ interface SessionManifest {
|
|||
}
|
||||
|
||||
export interface JournalServer {
|
||||
/** MQTT broker instance */
|
||||
broker: import("aedes").Aedes;
|
||||
/** TCP server the broker is attached to */
|
||||
tcpServer: import("net").Server;
|
||||
/** Persistence client (server subscribes to itself) */
|
||||
persistenceClient: import("mqtt").MqttClient | null;
|
||||
/** Clean shutdown */
|
||||
wsServer: import("ws").WebSocketServer;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
|
|
@ -66,10 +63,6 @@ function streamPath(dataRoot: string, id: string): string {
|
|||
return join(sessionDir(dataRoot, id), "stream.jsonl");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Manifest
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function loadManifest(dataRoot: string): SessionManifest {
|
||||
const p = manifestPath(dataRoot);
|
||||
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");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSONL persistence
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function appendStream(dataRoot: string, id: string, line: string): void {
|
||||
const sd = sessionDir(dataRoot, id);
|
||||
mkdirSync(sd, { recursive: true });
|
||||
|
|
@ -98,114 +87,109 @@ function appendStream(dataRoot: string, id: string, line: string): void {
|
|||
// Server factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const $SESSIONS = "ttrpg/$SESSIONS";
|
||||
|
||||
export async function createJournalServer(
|
||||
contentDir: string,
|
||||
mqttPort: number,
|
||||
httpServer: HttpServer,
|
||||
): Promise<JournalServer> {
|
||||
const root = dataDir(contentDir);
|
||||
console.log(`[journal] data dir: ${root}`);
|
||||
|
||||
// ---- MQTT Broker ----
|
||||
const { Aedes: AedesFactory } = await import("aedes");
|
||||
const { createServer } = await import("net");
|
||||
|
||||
const broker = new AedesFactory();
|
||||
const tcpServer = createServer(broker.handle.bind(broker));
|
||||
|
||||
tcpServer.listen(mqttPort, "0.0.0.0", () => {
|
||||
console.log(`[journal] MQTT broker on port ${mqttPort}`);
|
||||
});
|
||||
// ---- WebSocket server (attached to HTTP server) ----
|
||||
const { WebSocketServer } = await import("ws");
|
||||
const wsServer = new WebSocketServer({ noServer: true });
|
||||
|
||||
// ---- Persistence + Manifest client ----
|
||||
let client: import("mqtt").MqttClient | null = null;
|
||||
|
||||
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);
|
||||
httpServer.on("upgrade", (req, socket, head) => {
|
||||
wsServer.handleUpgrade(req, socket, head, (ws) => {
|
||||
broker.handle(ws as unknown as import("net").Socket);
|
||||
});
|
||||
});
|
||||
|
||||
client.on("message", (topic, payload) => {
|
||||
const parts = topic.split("/");
|
||||
if (parts.length < 3) return;
|
||||
const sessionId = parts[1];
|
||||
const subtopic = parts[2];
|
||||
|
||||
if (subtopic === "stream") {
|
||||
// Persist every stream message
|
||||
// ---- Persistence (internal subscribe, no separate MQTT client) ----
|
||||
broker.subscribe(
|
||||
"ttrpg/+/stream",
|
||||
(packet, cb) => {
|
||||
const sessionId = extractSessionId(packet.topic);
|
||||
if (sessionId) {
|
||||
try {
|
||||
appendStream(root, sessionId, payload.toString());
|
||||
appendStream(root, sessionId, packet.payload.toString());
|
||||
} catch (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] broker available at ws://<host>:<port>");
|
||||
|
||||
return {
|
||||
broker,
|
||||
tcpServer,
|
||||
persistenceClient: client,
|
||||
wsServer,
|
||||
async close() {
|
||||
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) => tcpServer.close(() => r()));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function extractSessionId(topic: string): string | null {
|
||||
const parts = topic.split("/");
|
||||
return parts.length >= 3 ? parts[1] : null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const $SESSIONS = "ttrpg/$SESSIONS";
|
||||
|
||||
function handleMetaChange(
|
||||
root: string,
|
||||
sessionId: string,
|
||||
client: import("mqtt").MqttClient,
|
||||
broker: import("aedes").Aedes,
|
||||
rawPayload: string,
|
||||
): void {
|
||||
const manifest = loadManifest(root);
|
||||
|
||||
try {
|
||||
const meta = JSON.parse(rawPayload) as SessionMeta | null;
|
||||
|
||||
if (!meta || !meta.name) {
|
||||
// Tombstone: empty or invalid payload → delete session
|
||||
if (manifest.sessions[sessionId]) {
|
||||
delete manifest.sessions[sessionId];
|
||||
|
||||
// Wipe files
|
||||
const sp = streamPath(root, sessionId);
|
||||
if (existsSync(sp)) unlinkSync(sp);
|
||||
const sd = sessionDir(root, sessionId);
|
||||
try {
|
||||
rmdirSync(sd);
|
||||
} catch {
|
||||
/* not empty, fine */
|
||||
/* */
|
||||
}
|
||||
|
||||
console.log(`[journal] session deleted: ${sessionId}`);
|
||||
}
|
||||
} else {
|
||||
// Create or update
|
||||
manifest.sessions[sessionId] = {
|
||||
name: meta.name,
|
||||
created: meta.created || Date.now(),
|
||||
|
|
@ -215,12 +199,17 @@ function handleMetaChange(
|
|||
}
|
||||
|
||||
saveManifest(root, manifest);
|
||||
|
||||
// Republish full manifest as a retained message
|
||||
client.publish($SESSIONS, JSON.stringify(manifest), {
|
||||
broker.publish(
|
||||
{
|
||||
topic: $SESSIONS,
|
||||
payload: Buffer.from(JSON.stringify(manifest)),
|
||||
qos: 1,
|
||||
retain: true,
|
||||
});
|
||||
cmd: "publish" as const,
|
||||
dup: false,
|
||||
},
|
||||
() => {},
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(`[journal] meta parse err for ${sessionId}:`, e);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
export interface ServeOptions {
|
||||
port: string;
|
||||
host?: string;
|
||||
mqttPort?: string;
|
||||
}
|
||||
|
||||
export interface CompileOptions {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ export interface SidebarProps {
|
|||
onClose: () => void;
|
||||
fileTree?: FileNode[];
|
||||
pathHeadings?: Record<string, TocNode[]>;
|
||||
onDataSourceOpen?: () => void;
|
||||
}
|
||||
|
||||
interface SidebarContentProps {
|
||||
|
|
@ -16,6 +17,7 @@ interface SidebarContentProps {
|
|||
currentPath: string;
|
||||
onClose: () => void;
|
||||
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="flex items-center justify-between">
|
||||
<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}>
|
||||
<button
|
||||
onClick={props.onClose}
|
||||
|
|
@ -48,6 +58,7 @@ const SidebarContent: Component<SidebarContentProps> = (props) => {
|
|||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 文件树滚动区域 */}
|
||||
<div class="flex-1 overflow-y-auto p-4 min-h-0">
|
||||
|
|
@ -121,6 +132,7 @@ export const MobileSidebar: Component<SidebarProps> = (props) => {
|
|||
pathHeadings={pathHeadings()}
|
||||
currentPath={location.pathname}
|
||||
onClose={props.onClose}
|
||||
onDataSourceOpen={props.onDataSourceOpen}
|
||||
/>
|
||||
</aside>
|
||||
</>
|
||||
|
|
@ -133,6 +145,7 @@ export const MobileSidebar: Component<SidebarProps> = (props) => {
|
|||
export const DesktopSidebar: Component<{
|
||||
fileTree?: FileNode[];
|
||||
pathHeadings?: Record<string, TocNode[]>;
|
||||
onDataSourceOpen?: () => void;
|
||||
}> = (props) => {
|
||||
const location = useLocation();
|
||||
const [selfFileTree, setSelfFileTree] = createSignal<FileNode[]>([]);
|
||||
|
|
@ -158,6 +171,7 @@ export const DesktopSidebar: Component<{
|
|||
currentPath={location.pathname}
|
||||
onClose={() => {}}
|
||||
isDesktop
|
||||
onDataSourceOpen={props.onDataSourceOpen}
|
||||
/>
|
||||
</aside>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,65 +1,26 @@
|
|||
/**
|
||||
* ConnectionBar — connect form, session picker, player name
|
||||
*
|
||||
* Three states: disconnected → connecting → connected
|
||||
* ConnectionBar — session picker, player info (connected state only)
|
||||
*/
|
||||
|
||||
import { Component, createSignal, Show, For, onMount } from "solid-js";
|
||||
import { Component, createSignal, Show, For } from "solid-js";
|
||||
import {
|
||||
connectStream,
|
||||
hydrateFromServer,
|
||||
useJournalStream,
|
||||
sessions,
|
||||
setMyName,
|
||||
createSession,
|
||||
deleteSession,
|
||||
disconnectStream,
|
||||
} 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";
|
||||
|
||||
export const ConnectionBar: Component = () => {
|
||||
const stream = useJournalStream();
|
||||
const [brokerUrl, setBrokerUrl] = 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);
|
||||
|
||||
onMount(() => {
|
||||
setBrokerUrl(stream.brokerUrl ?? "");
|
||||
setPlayerName(stream.myName);
|
||||
});
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
|
||||
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 name = newSessionName().trim();
|
||||
if (!name) return;
|
||||
|
|
@ -75,7 +36,6 @@ export const ConnectionBar: Component = () => {
|
|||
};
|
||||
|
||||
const handleSessionSelect = async (id: string) => {
|
||||
if (!stream.connected) return;
|
||||
try {
|
||||
await hydrateFromServer(id);
|
||||
journalSetState("sessionId", id);
|
||||
|
|
@ -84,74 +44,36 @@ export const ConnectionBar: Component = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const statusColor = () =>
|
||||
stream.connected
|
||||
? "bg-green-500"
|
||||
: connecting()
|
||||
? "bg-yellow-400"
|
||||
: "bg-gray-400";
|
||||
const handleDisconnect = () => {
|
||||
disconnectStream();
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="border-b border-gray-200 p-3 space-y-2 text-sm">
|
||||
{/* Status + session name */}
|
||||
<div class="border-b border-gray-200 px-3 py-2 space-y-2 text-sm">
|
||||
{/* Status row */}
|
||||
<div class="flex items-center gap-2">
|
||||
<span class={`w-2 h-2 rounded-full ${statusColor()}`} />
|
||||
<span class="text-xs text-gray-500">
|
||||
{stream.connected
|
||||
? "connected"
|
||||
: connecting()
|
||||
? "connecting..."
|
||||
: "disconnected"}
|
||||
</span>
|
||||
<span class="w-2 h-2 rounded-full bg-green-500" />
|
||||
<span class="text-xs text-gray-500">connected</span>
|
||||
<Show when={stream.sessionId}>
|
||||
<span class="text-xs font-mono bg-gray-100 px-1.5 py-0.5 rounded">
|
||||
{stream.sessionId}
|
||||
</span>
|
||||
</Show>
|
||||
<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
|
||||
onClick={() => {} /* disconnect */}
|
||||
onClick={handleDisconnect}
|
||||
class="text-xs text-gray-400 hover:text-red-500"
|
||||
title="Disconnect"
|
||||
>
|
||||
⏻
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* Disconnected: connect form */}
|
||||
<Show when={!stream.connected}>
|
||||
<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"}>
|
||||
{/* GM session management */}
|
||||
<Show when={stream.myName === "gm"}>
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-xs text-gray-500">Sessions:</span>
|
||||
|
|
@ -213,12 +135,8 @@ export const ConnectionBar: Component = () => {
|
|||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Connected: player info (player only) */}
|
||||
<Show when={stream.connected && stream.myName !== "gm"}>
|
||||
<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 when={error()}>
|
||||
<p class="text-red-500 text-xs">{error()}</p>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,11 +1,17 @@
|
|||
/**
|
||||
* 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 { useJournalStream } from "../stores/journalStream";
|
||||
import { Component, Show, createSignal, onMount } from "solid-js";
|
||||
import {
|
||||
connectStream,
|
||||
hydrateFromServer,
|
||||
useJournalStream,
|
||||
setMyName,
|
||||
} from "../stores/journalStream";
|
||||
import { ConnectionBar } from "./ConnectionBar";
|
||||
import { StreamView } from "./StreamView";
|
||||
import { ComposePanel } from "./ComposePanel";
|
||||
|
|
@ -20,22 +26,25 @@ export const JournalPanel: Component<JournalPanelProps> = (props) => {
|
|||
|
||||
return (
|
||||
<Show when={props.open}>
|
||||
{/* Mobile overlay */}
|
||||
<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}
|
||||
/>
|
||||
<aside
|
||||
class={`
|
||||
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]
|
||||
md:relative md:top-0 md:z-0
|
||||
`}
|
||||
class="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"
|
||||
>
|
||||
{/* Header */}
|
||||
<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>
|
||||
<Show when={stream.connected}>
|
||||
<span
|
||||
class="w-2 h-2 rounded-full bg-green-500"
|
||||
title="Connected"
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
<button
|
||||
onClick={props.onClose}
|
||||
class="text-gray-400 hover:text-gray-600 text-sm"
|
||||
|
|
@ -44,18 +53,92 @@ export const JournalPanel: Component<JournalPanelProps> = (props) => {
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<Show
|
||||
when={stream.connected}
|
||||
fallback={
|
||||
<div class="flex-1 flex items-center justify-center p-4">
|
||||
<ConnectDialog />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ConnectionBar />
|
||||
|
||||
{/* Stream */}
|
||||
<div class="flex-1 min-h-0">
|
||||
<StreamView />
|
||||
</div>
|
||||
|
||||
{/* Compose */}
|
||||
<Show when={stream.connected}>
|
||||
<ComposePanel />
|
||||
</Show>
|
||||
</aside>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue