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 */}