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
This commit is contained in:
parent
3d957706e9
commit
2c2b7b781d
|
|
@ -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<string | null>(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 (
|
||||
<Show
|
||||
when={!autoJoined() || error()}
|
||||
fallback={
|
||||
<p class="text-sm text-gray-500 text-center">
|
||||
{connecting() ? "正在加入会话…" : "已加入!"}
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<div class="w-full max-w-sm space-y-3">
|
||||
<p class="text-sm text-gray-600 text-center">
|
||||
请输入你的名字和角色来加入会话。
|
||||
<br />
|
||||
<span class="text-xs text-gray-400">
|
||||
连接到 {window.location.host}
|
||||
</span>
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
value={playerName()}
|
||||
onInput={(e) => 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 */}
|
||||
<div class="flex gap-1">
|
||||
{(["gm", "player", "observer"] as const).map((r) => (
|
||||
<button
|
||||
onClick={() => setRole(r)}
|
||||
class={`flex-1 rounded px-2 py-1.5 text-xs font-medium border transition-colors ${
|
||||
role() === r
|
||||
? "bg-blue-600 text-white border-blue-600"
|
||||
: "bg-white text-gray-600 border-gray-300 hover:border-gray-400"
|
||||
}`}
|
||||
>
|
||||
{r === "gm" ? "主持人" : r === "player" ? "玩家" : "观察者"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<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() ? "连接中..." : "加入"}
|
||||
</button>
|
||||
<Show when={error()}>
|
||||
<p class="text-red-500 text-sm text-center">{error()}</p>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
|
|
@ -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<CreateSessionDialogProps> = (
|
||||
props,
|
||||
) => {
|
||||
const [name, setName] = createSignal("");
|
||||
let inputRef!: HTMLInputElement;
|
||||
|
||||
const handleCreate = () => {
|
||||
const trimmed = name().trim();
|
||||
if (!trimmed) return;
|
||||
createSession(trimmed);
|
||||
setName("");
|
||||
props.onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Show when={props.open}>
|
||||
<div class="absolute inset-0 z-50 flex items-center justify-center bg-black/20">
|
||||
<div class="bg-white rounded-lg border border-gray-200 shadow-xl w-[260px] p-4 space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold text-gray-800">新建会话</h3>
|
||||
<button
|
||||
onClick={props.onClose}
|
||||
class="text-gray-400 hover:text-gray-600 text-sm"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500">输入新会话的名称。</p>
|
||||
<input
|
||||
ref={inputRef!}
|
||||
type="text"
|
||||
value={name()}
|
||||
onInput={(e) => 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
|
||||
/>
|
||||
<div class="flex justify-end gap-1">
|
||||
<button
|
||||
onClick={props.onClose}
|
||||
class="px-3 py-1 text-xs text-gray-600 hover:text-gray-800"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={!name().trim()}
|
||||
class="bg-blue-600 text-white rounded px-3 py-1 text-xs font-medium hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
创建
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
|
|
@ -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<InviteDialogProps> = (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 (
|
||||
<div class="absolute inset-0 z-50 flex items-center justify-center bg-black/20">
|
||||
<div class="bg-white rounded-lg border border-gray-200 shadow-xl w-[280px] p-4 space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold text-gray-800">邀请玩家</h3>
|
||||
<button
|
||||
onClick={props.onClose}
|
||||
class="text-gray-400 hover:text-gray-600 text-sm"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500">
|
||||
输入玩家名字并分享链接,对方将以玩家身份加入。
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
value={playerName()}
|
||||
onInput={(e) => setPlayerName(e.currentTarget.value)}
|
||||
placeholder="玩家名字"
|
||||
class="w-full border border-gray-300 rounded px-2 py-1 text-xs"
|
||||
autofocus
|
||||
/>
|
||||
<div class="flex gap-1">
|
||||
<input
|
||||
id="invite-link-input"
|
||||
type="text"
|
||||
value={inviteLink()}
|
||||
readonly
|
||||
class="flex-1 border border-gray-200 rounded px-2 py-1 text-xs bg-gray-50 text-gray-600 font-mono truncate"
|
||||
/>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
class={`shrink-0 rounded px-2 py-1 text-xs font-medium transition-colors ${
|
||||
copied()
|
||||
? "bg-green-100 text-green-700"
|
||||
: "bg-blue-600 text-white hover:bg-blue-700"
|
||||
}`}
|
||||
>
|
||||
{copied() ? "已复制!" : "复制"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -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<JournalHeaderProps> = (props) => {
|
||||
const stream = useJournalStream();
|
||||
|
||||
const handleDisconnect = () => disconnectStream();
|
||||
|
||||
return (
|
||||
<div class="flex items-center justify-between px-3 py-2 border-b border-gray-200 shrink-0">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span
|
||||
class="w-2 h-2 rounded-full shrink-0"
|
||||
classList={{
|
||||
"bg-gray-400": stream.connectionStatus === "disconnected",
|
||||
"bg-yellow-400 animate-pulse":
|
||||
stream.connectionStatus === "connecting",
|
||||
"bg-green-500": stream.connectionStatus === "connected",
|
||||
"bg-red-500": stream.connectionStatus === "error",
|
||||
}}
|
||||
title={stream.connectionStatus}
|
||||
/>
|
||||
<Show
|
||||
when={stream.connected && stream.sessionId}
|
||||
fallback={
|
||||
<h2 class="text-sm font-semibold text-gray-700 truncate">未连接</h2>
|
||||
}
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-semibold text-gray-700 truncate leading-tight flex items-center gap-1">
|
||||
{stream.myName}
|
||||
<Show when={stream.myRole !== "gm"}>
|
||||
<span
|
||||
class={`text-[10px] px-1 rounded ${
|
||||
stream.myRole === "observer"
|
||||
? "bg-gray-200 text-gray-500"
|
||||
: "bg-blue-100 text-blue-600"
|
||||
}`}
|
||||
>
|
||||
{stream.myRole}
|
||||
</span>
|
||||
</Show>
|
||||
</p>
|
||||
{/* Clickable subtitle — session dropdown (GM only) */}
|
||||
<Show when={stream.myRole === "gm"}>
|
||||
<SessionDropdown />
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<Show when={stream.connected}>
|
||||
<button
|
||||
onClick={handleDisconnect}
|
||||
class="text-xs text-gray-400 hover:text-red-500 px-1"
|
||||
title="断开连接"
|
||||
>
|
||||
⏻
|
||||
</button>
|
||||
</Show>
|
||||
<button
|
||||
onClick={props.onClose}
|
||||
class="text-gray-400 hover:text-gray-600 text-sm px-1"
|
||||
title="隐藏面板"
|
||||
>
|
||||
▸
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -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<JournalPanelProps> = (props) => {
|
|||
{/* Panel — always mounted for exit animation, visibility toggled */}
|
||||
<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] shadow-lg
|
||||
flex flex-col w-full max-w-md md:w-105 shadow-lg
|
||||
transition-transform duration-300 ease-in-out ${
|
||||
props.open ? "translate-x-0" : "translate-x-full"
|
||||
}`}
|
||||
|
|
@ -107,381 +93,3 @@ export const JournalPanel: Component<JournalPanelProps> = (props) => {
|
|||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Journal Header — status dot, player name, session dropdown, disconnect & close
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface JournalHeaderProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const JournalHeader: Component<JournalHeaderProps> = (props) => {
|
||||
const stream = useJournalStream();
|
||||
const manifest = sessions;
|
||||
const [dropdownOpen, setDropdownOpen] = createSignal(false);
|
||||
const [newSessionName, setNewSessionName] = createSignal("");
|
||||
const [showNewInput, setShowNewInput] = createSignal(false);
|
||||
let dropdownRef!: HTMLDivElement;
|
||||
let newSessionInputRef!: HTMLInputElement;
|
||||
|
||||
const handleSessionSelect = async (id: string) => {
|
||||
try {
|
||||
await hydrateFromServer(id);
|
||||
setSessionId(id);
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
setDropdownOpen(false);
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
const name = newSessionName().trim();
|
||||
if (!name) return;
|
||||
createSession(name);
|
||||
setNewSessionName("");
|
||||
setShowNewInput(false);
|
||||
};
|
||||
|
||||
// Focus the input when it appears
|
||||
createEffect(() => {
|
||||
if (showNewInput() && newSessionInputRef) {
|
||||
newSessionInputRef.focus();
|
||||
}
|
||||
});
|
||||
|
||||
const handleDisconnect = () => disconnectStream();
|
||||
|
||||
// Close dropdown on outside click
|
||||
if (typeof document !== "undefined") {
|
||||
document.addEventListener("click", (e) => {
|
||||
if (dropdownRef && !dropdownRef.contains(e.target as Node)) {
|
||||
setDropdownOpen(false);
|
||||
setShowNewInput(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="flex items-center justify-between px-3 py-2 border-b border-gray-200 shrink-0">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span
|
||||
class="w-2 h-2 rounded-full shrink-0"
|
||||
classList={{
|
||||
"bg-gray-400": stream.connectionStatus === "disconnected",
|
||||
"bg-yellow-400 animate-pulse":
|
||||
stream.connectionStatus === "connecting",
|
||||
"bg-green-500": stream.connectionStatus === "connected",
|
||||
"bg-red-500": stream.connectionStatus === "error",
|
||||
}}
|
||||
title={stream.connectionStatus}
|
||||
/>
|
||||
<Show
|
||||
when={stream.connected && stream.sessionId}
|
||||
fallback={
|
||||
<h2 class="text-sm font-semibold text-gray-700 truncate">未连接</h2>
|
||||
}
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-semibold text-gray-700 truncate leading-tight flex items-center gap-1">
|
||||
{stream.myName}
|
||||
<Show when={stream.myRole !== "gm"}>
|
||||
<span
|
||||
class={`text-[10px] px-1 rounded ${
|
||||
stream.myRole === "observer"
|
||||
? "bg-gray-200 text-gray-500"
|
||||
: "bg-blue-100 text-blue-600"
|
||||
}`}
|
||||
>
|
||||
{stream.myRole}
|
||||
</span>
|
||||
</Show>
|
||||
</p>
|
||||
{/* Clickable subtitle — session dropdown (GM only) */}
|
||||
<Show when={stream.myRole === "gm"}>
|
||||
<div ref={dropdownRef} class="relative">
|
||||
<button
|
||||
onClick={() => setDropdownOpen((v) => !v)}
|
||||
class="text-[10px] text-gray-400 hover:text-gray-600 truncate leading-tight cursor-pointer text-left"
|
||||
>
|
||||
{stream.sessionName || stream.sessionId}{" "}
|
||||
<span class="text-gray-300">▾</span>
|
||||
</button>
|
||||
<Show when={dropdownOpen()}>
|
||||
<div class="absolute top-full left-0 mt-1 w-48 bg-white border border-gray-200 rounded shadow-lg z-50 py-1 max-h-48 overflow-y-auto">
|
||||
<For each={Object.entries(manifest().sessions)}>
|
||||
{([id, meta]) => (
|
||||
<button
|
||||
onClick={() => handleSessionSelect(id)}
|
||||
class={`w-full text-left px-3 py-1 text-xs hover:bg-gray-100 flex items-center gap-1 ${
|
||||
id === stream.sessionId
|
||||
? "bg-blue-50 text-blue-700"
|
||||
: "text-gray-700"
|
||||
}`}
|
||||
>
|
||||
<span class="truncate flex-1">{meta.name || id}</span>
|
||||
<Show when={id === stream.sessionId}>
|
||||
<span class="text-blue-500 shrink-0">✓</span>
|
||||
</Show>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
<div class="border-t border-gray-100 my-0.5" />
|
||||
<Show
|
||||
when={!showNewInput()}
|
||||
fallback={
|
||||
<div class="px-3 py-1 flex gap-1">
|
||||
<input
|
||||
ref={newSessionInputRef}
|
||||
type="text"
|
||||
value={newSessionName()}
|
||||
onInput={(e) =>
|
||||
setNewSessionName(e.currentTarget.value)
|
||||
}
|
||||
placeholder="会话名称"
|
||||
class="flex-1 border border-gray-300 rounded px-1.5 py-0.5 text-xs"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleCreate();
|
||||
if (e.key === "Escape") setShowNewInput(false);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
class="bg-blue-600 text-white rounded px-2 py-0.5 text-xs hover:bg-blue-700"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowNewInput(true);
|
||||
}}
|
||||
class="w-full text-left px-3 py-1 text-xs text-blue-600 hover:bg-gray-100"
|
||||
>
|
||||
+ 新建会话
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<Show when={stream.connected}>
|
||||
<button
|
||||
onClick={handleDisconnect}
|
||||
class="text-xs text-gray-400 hover:text-red-500 px-1"
|
||||
title="断开连接"
|
||||
>
|
||||
⏻
|
||||
</button>
|
||||
</Show>
|
||||
<button
|
||||
onClick={props.onClose}
|
||||
class="text-gray-400 hover:text-gray-600 text-sm px-1"
|
||||
title="隐藏面板"
|
||||
>
|
||||
▸
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 [role, setRole] = createSignal<"gm" | "player" | "observer">(
|
||||
stream.myRole,
|
||||
);
|
||||
const [connecting, setConnecting] = createSignal(false);
|
||||
const [error, setError] = createSignal<string | null>(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 (
|
||||
<Show
|
||||
when={!autoJoined() || error()}
|
||||
fallback={
|
||||
<p class="text-sm text-gray-500 text-center">
|
||||
{connecting() ? "正在加入会话…" : "已加入!"}
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<div class="w-full max-w-sm space-y-3">
|
||||
<p class="text-sm text-gray-600 text-center">
|
||||
请输入你的名字和角色来加入会话。
|
||||
<br />
|
||||
<span class="text-xs text-gray-400">
|
||||
连接到 {window.location.host}
|
||||
</span>
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
value={playerName()}
|
||||
onInput={(e) => 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 */}
|
||||
<div class="flex gap-1">
|
||||
{(["gm", "player", "observer"] as const).map((r) => (
|
||||
<button
|
||||
onClick={() => setRole(r)}
|
||||
class={`flex-1 rounded px-2 py-1.5 text-xs font-medium border transition-colors ${
|
||||
role() === r
|
||||
? "bg-blue-600 text-white border-blue-600"
|
||||
: "bg-white text-gray-600 border-gray-300 hover:border-gray-400"
|
||||
}`}
|
||||
>
|
||||
{r === "gm" ? "主持人" : r === "player" ? "玩家" : "观察者"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<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() ? "连接中..." : "加入"}
|
||||
</button>
|
||||
<Show when={error()}>
|
||||
<p class="text-red-500 text-sm text-center">{error()}</p>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Invite dialog — input player name, copy invite link
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface InviteDialogProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const InviteDialog: Component<InviteDialogProps> = (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 (
|
||||
<div class="absolute inset-0 z-50 flex items-center justify-center bg-black/20">
|
||||
<div class="bg-white rounded-lg border border-gray-200 shadow-xl w-[280px] p-4 space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold text-gray-800">邀请玩家</h3>
|
||||
<button
|
||||
onClick={props.onClose}
|
||||
class="text-gray-400 hover:text-gray-600 text-sm"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500">
|
||||
输入玩家名字并分享链接,对方将以玩家身份加入。
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
value={playerName()}
|
||||
onInput={(e) => setPlayerName(e.currentTarget.value)}
|
||||
placeholder="玩家名字"
|
||||
class="w-full border border-gray-300 rounded px-2 py-1 text-xs"
|
||||
autofocus
|
||||
/>
|
||||
<div class="flex gap-1">
|
||||
<input
|
||||
id="invite-link-input"
|
||||
type="text"
|
||||
value={inviteLink()}
|
||||
readonly
|
||||
class="flex-1 border border-gray-200 rounded px-2 py-1 text-xs bg-gray-50 text-gray-600 font-mono truncate"
|
||||
/>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
class={`shrink-0 rounded px-2 py-1 text-xs font-medium transition-colors ${
|
||||
copied()
|
||||
? "bg-green-100 text-green-700"
|
||||
: "bg-blue-600 text-white hover:bg-blue-700"
|
||||
}`}
|
||||
>
|
||||
{copied() ? "已复制!" : "复制"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,92 @@
|
|||
/**
|
||||
* SessionDropdown — session switcher for the journal header (GM only)
|
||||
*/
|
||||
import { Component, createSignal, For, Show } from "solid-js";
|
||||
import {
|
||||
sessions,
|
||||
setSessionId,
|
||||
hydrateFromServer,
|
||||
useJournalStream,
|
||||
} from "../stores/journalStream";
|
||||
import { CreateSessionDialog } from "./CreateSessionDialog";
|
||||
|
||||
export const SessionDropdown: Component = () => {
|
||||
const stream = useJournalStream();
|
||||
const manifest = sessions;
|
||||
const [dropdownOpen, setDropdownOpen] = createSignal(false);
|
||||
const [showCreateDialog, setShowCreateDialog] = createSignal(false);
|
||||
let dropdownRef!: HTMLDivElement;
|
||||
|
||||
const handleSessionSelect = (id: string) => {
|
||||
if (id === stream.sessionId) {
|
||||
setDropdownOpen(false);
|
||||
return;
|
||||
}
|
||||
setDropdownOpen(false);
|
||||
setSessionId(id);
|
||||
hydrateFromServer(id).catch(() => {});
|
||||
};
|
||||
|
||||
// Close dropdown on outside click
|
||||
if (typeof document !== "undefined") {
|
||||
document.addEventListener("click", (e) => {
|
||||
if (dropdownRef && !dropdownRef.contains(e.target as Node)) {
|
||||
setDropdownOpen(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={dropdownRef} class="relative">
|
||||
<button
|
||||
onClick={() => setDropdownOpen((v) => !v)}
|
||||
class="text-[10px] text-gray-400 hover:text-gray-600 truncate leading-tight cursor-pointer text-left"
|
||||
>
|
||||
{stream.sessionName || stream.sessionId}{" "}
|
||||
<span class="text-gray-300">▾</span>
|
||||
</button>
|
||||
<Show when={dropdownOpen()}>
|
||||
<div
|
||||
class="absolute top-full left-0 mt-1 w-48 bg-white border border-gray-200 rounded shadow-lg z-50 py-1 max-h-48 overflow-y-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<For each={Object.entries(manifest().sessions)}>
|
||||
{([id, meta]) => (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSessionSelect(id);
|
||||
}}
|
||||
class={`w-full text-left px-3 py-1 text-xs hover:bg-gray-100 flex items-center gap-1 ${
|
||||
id === stream.sessionId
|
||||
? "bg-blue-50 text-blue-700"
|
||||
: "text-gray-700"
|
||||
}`}
|
||||
>
|
||||
<span class="truncate flex-1">{meta.name || id}</span>
|
||||
<Show when={id === stream.sessionId}>
|
||||
<span class="text-blue-500 shrink-0">✓</span>
|
||||
</Show>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
<div class="border-t border-gray-100 my-0.5" />
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
setShowCreateDialog(true);
|
||||
}}
|
||||
class="w-full text-left px-3 py-1 text-xs text-blue-600 hover:bg-gray-100"
|
||||
>
|
||||
+ 新建会话
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
<CreateSessionDialog
|
||||
open={showCreateDialog()}
|
||||
onClose={() => setShowCreateDialog(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -37,6 +37,11 @@ export type {
|
|||
// UI components
|
||||
export { JournalPanel } from "./JournalPanel";
|
||||
export type { JournalPanelProps } from "./JournalPanel";
|
||||
export { JournalHeader } from "./JournalHeader";
|
||||
export { ConnectDialog } from "./ConnectDialog";
|
||||
export { InviteDialog } from "./InviteDialog";
|
||||
export { SessionDropdown } from "./SessionDropdown";
|
||||
export { CreateSessionDialog } from "./CreateSessionDialog";
|
||||
export { ConnectionBar } from "./ConnectionBar";
|
||||
export { StreamView } from "./StreamView";
|
||||
export { StreamMessageCard } from "./StreamMessage";
|
||||
|
|
|
|||
Loading…
Reference in New Issue