From 2c2b7b781da7b711b09000d696b23c62c0a556c7 Mon Sep 17 00:00:00 2001
From: hypercross
Date: Wed, 8 Jul 2026 00:31:35 +0800
Subject: [PATCH] feat(journal): implement session management UI components
Add a suite of dialogs and header components to manage journal
sessions, including:
- ConnectDialog for joining sessions with name and role selection
- CreateSessionDialog for starting new sessions
- InviteDialog for generating and copying player invite links
- SessionDropdown for switching between sessions (GM only)
- JournalHeader for displaying connection status and user info
---
src/components/journal/ConnectDialog.tsx | 118 +++++
.../journal/CreateSessionDialog.tsx | 72 ++++
src/components/journal/InviteDialog.tsx | 84 ++++
src/components/journal/JournalHeader.tsx | 79 ++++
src/components/journal/JournalPanel.tsx | 404 +-----------------
src/components/journal/SessionDropdown.tsx | 92 ++++
src/components/journal/index.ts | 5 +
7 files changed, 456 insertions(+), 398 deletions(-)
create mode 100644 src/components/journal/ConnectDialog.tsx
create mode 100644 src/components/journal/CreateSessionDialog.tsx
create mode 100644 src/components/journal/InviteDialog.tsx
create mode 100644 src/components/journal/JournalHeader.tsx
create mode 100644 src/components/journal/SessionDropdown.tsx
diff --git a/src/components/journal/ConnectDialog.tsx b/src/components/journal/ConnectDialog.tsx
new file mode 100644
index 0000000..052c747
--- /dev/null
+++ b/src/components/journal/ConnectDialog.tsx
@@ -0,0 +1,118 @@
+/**
+ * ConnectDialog — name + role picker to join a session
+ */
+import { Component, createSignal, onMount, Show } from "solid-js";
+import {
+ connectStream,
+ hydrateFromServer,
+ useJournalStream,
+ setMyName,
+ setMyRole,
+ setSessionId,
+} from "../stores/journalStream";
+
+export const ConnectDialog: Component = () => {
+ const stream = useJournalStream();
+ const [playerName, setPlayerName] = createSignal(stream.myName);
+ const [role, setRole] = createSignal<"gm" | "player" | "observer">(
+ stream.myRole,
+ );
+ const [connecting, setConnecting] = createSignal(false);
+ const [error, setError] = createSignal(null);
+ const [autoJoined, setAutoJoined] = createSignal(false);
+
+ // Auto-join when URL has both session and player params
+ onMount(() => {
+ const params = new URL(window.location.href).searchParams;
+ if (
+ params.has("session") &&
+ params.has("player") &&
+ params.has("autojoin")
+ ) {
+ setPlayerName(params.get("player") || "");
+ setRole("player");
+ setAutoJoined(true);
+ 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);
+ setMyRole(role());
+ const sessionId = stream.sessionId || "default";
+ setSessionId(sessionId);
+ await hydrateFromServer(sessionId);
+ await connectStream(sessionId, brokerUrl);
+ } catch (e) {
+ const msg = e instanceof Error ? e.message : "Connection failed";
+ setError(msg);
+ } finally {
+ setConnecting(false);
+ }
+ };
+
+ return (
+
+ {connecting() ? "正在加入会话…" : "已加入!"}
+
+ }
+ >
+
+
+ 请输入你的名字和角色来加入会话。
+
+
+ 连接到 {window.location.host}
+
+
+
setPlayerName(e.currentTarget.value)}
+ onKeyDown={(e) => e.key === "Enter" && handleConnect()}
+ placeholder="你的名字"
+ class="w-full border border-gray-300 rounded px-3 py-1.5 text-sm"
+ autofocus
+ />
+ {/* Role selector */}
+
+ {(["gm", "player", "observer"] as const).map((r) => (
+
+ ))}
+
+
+
+ {error()}
+
+
+
+ );
+};
diff --git a/src/components/journal/CreateSessionDialog.tsx b/src/components/journal/CreateSessionDialog.tsx
new file mode 100644
index 0000000..a37f2aa
--- /dev/null
+++ b/src/components/journal/CreateSessionDialog.tsx
@@ -0,0 +1,72 @@
+/**
+ * CreateSessionDialog — modal for naming a new session
+ */
+import { Component, createSignal, Show } from "solid-js";
+import { createSession } from "../stores/journalStream";
+
+interface CreateSessionDialogProps {
+ open: boolean;
+ onClose: () => void;
+}
+
+export const CreateSessionDialog: Component = (
+ props,
+) => {
+ const [name, setName] = createSignal("");
+ let inputRef!: HTMLInputElement;
+
+ const handleCreate = () => {
+ const trimmed = name().trim();
+ if (!trimmed) return;
+ createSession(trimmed);
+ setName("");
+ props.onClose();
+ };
+
+ return (
+
+
+
+
+
新建会话
+
+
+
输入新会话的名称。
+
setName(e.currentTarget.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") handleCreate();
+ if (e.key === "Escape") props.onClose();
+ }}
+ placeholder="会话名称"
+ class="w-full border border-gray-300 rounded px-2 py-1.5 text-sm"
+ autofocus
+ />
+
+
+
+
+
+
+
+ );
+};
diff --git a/src/components/journal/InviteDialog.tsx b/src/components/journal/InviteDialog.tsx
new file mode 100644
index 0000000..6f4ff94
--- /dev/null
+++ b/src/components/journal/InviteDialog.tsx
@@ -0,0 +1,84 @@
+/**
+ * InviteDialog — input player name, copy invite link
+ */
+import { Component, createSignal } from "solid-js";
+import { useJournalStream } from "../stores/journalStream";
+
+interface InviteDialogProps {
+ onClose: () => void;
+}
+
+export const InviteDialog: Component = (props) => {
+ const stream = useJournalStream();
+ const [playerName, setPlayerName] = createSignal("");
+ const [copied, setCopied] = createSignal(false);
+
+ const inviteLink = () => {
+ const url = new URL(window.location.href);
+ url.searchParams.set("session", stream.sessionId || "default");
+ if (playerName().trim()) {
+ url.searchParams.set("player", playerName().trim());
+ }
+ url.searchParams.set("autojoin", "1");
+ return url.toString();
+ };
+
+ const handleCopy = async () => {
+ try {
+ await navigator.clipboard.writeText(inviteLink());
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ } catch {
+ const input = document.getElementById(
+ "invite-link-input",
+ ) as HTMLInputElement;
+ if (input) input.select();
+ }
+ };
+
+ return (
+
+
+
+
邀请玩家
+
+
+
+ 输入玩家名字并分享链接,对方将以玩家身份加入。
+
+
setPlayerName(e.currentTarget.value)}
+ placeholder="玩家名字"
+ class="w-full border border-gray-300 rounded px-2 py-1 text-xs"
+ autofocus
+ />
+
+
+
+
+
+
+ );
+};
diff --git a/src/components/journal/JournalHeader.tsx b/src/components/journal/JournalHeader.tsx
new file mode 100644
index 0000000..0628e06
--- /dev/null
+++ b/src/components/journal/JournalHeader.tsx
@@ -0,0 +1,79 @@
+/**
+ * JournalHeader — status dot, player name, session dropdown, disconnect & close
+ */
+import { Component, Show } from "solid-js";
+import { useJournalStream, disconnectStream } from "../stores/journalStream";
+import { SessionDropdown } from "./SessionDropdown";
+
+interface JournalHeaderProps {
+ onClose: () => void;
+}
+
+export const JournalHeader: Component = (props) => {
+ const stream = useJournalStream();
+
+ const handleDisconnect = () => disconnectStream();
+
+ return (
+
+
+
+
未连接
+ }
+ >
+
+
+ {stream.myName}
+
+
+ {stream.myRole}
+
+
+
+ {/* Clickable subtitle — session dropdown (GM only) */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/src/components/journal/JournalPanel.tsx b/src/components/journal/JournalPanel.tsx
index 2d2d1df..0f3df92 100644
--- a/src/components/journal/JournalPanel.tsx
+++ b/src/components/journal/JournalPanel.tsx
@@ -5,28 +5,14 @@
* Auto-connects to ws://{current host}:{current port} — no URL input needed.
*/
-import {
- Component,
- Show,
- createSignal,
- onMount,
- For,
- createEffect,
-} from "solid-js";
-import {
- connectStream,
- hydrateFromServer,
- useJournalStream,
- setMyName,
- setMyRole,
- setSessionId,
- sessions,
- createSession,
- disconnectStream,
-} from "../stores/journalStream";
+import { Component, Show, createSignal, For } from "solid-js";
+import { useJournalStream } from "../stores/journalStream";
import { StreamView } from "./StreamView";
import { JournalInput } from "./JournalInput";
import { JournalContext } from "./JournalContext";
+import { JournalHeader } from "./JournalHeader";
+import { ConnectDialog } from "./ConnectDialog";
+import { InviteDialog } from "./InviteDialog";
export interface JournalPanelProps {
open: boolean;
@@ -54,7 +40,7 @@ export const JournalPanel: Component = (props) => {
{/* Panel — always mounted for exit animation, visibility toggled */}