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.
|
* Auto-connects to ws://{current host}:{current port} — no URL input needed.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import { Component, Show, createSignal, For } from "solid-js";
|
||||||
Component,
|
import { useJournalStream } from "../stores/journalStream";
|
||||||
Show,
|
|
||||||
createSignal,
|
|
||||||
onMount,
|
|
||||||
For,
|
|
||||||
createEffect,
|
|
||||||
} from "solid-js";
|
|
||||||
import {
|
|
||||||
connectStream,
|
|
||||||
hydrateFromServer,
|
|
||||||
useJournalStream,
|
|
||||||
setMyName,
|
|
||||||
setMyRole,
|
|
||||||
setSessionId,
|
|
||||||
sessions,
|
|
||||||
createSession,
|
|
||||||
disconnectStream,
|
|
||||||
} from "../stores/journalStream";
|
|
||||||
import { StreamView } from "./StreamView";
|
import { StreamView } from "./StreamView";
|
||||||
import { JournalInput } from "./JournalInput";
|
import { JournalInput } from "./JournalInput";
|
||||||
import { JournalContext } from "./JournalContext";
|
import { JournalContext } from "./JournalContext";
|
||||||
|
import { JournalHeader } from "./JournalHeader";
|
||||||
|
import { ConnectDialog } from "./ConnectDialog";
|
||||||
|
import { InviteDialog } from "./InviteDialog";
|
||||||
|
|
||||||
export interface JournalPanelProps {
|
export interface JournalPanelProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
|
@ -54,7 +40,7 @@ export const JournalPanel: Component<JournalPanelProps> = (props) => {
|
||||||
{/* Panel — always mounted for exit animation, visibility toggled */}
|
{/* Panel — always mounted for exit animation, visibility toggled */}
|
||||||
<aside
|
<aside
|
||||||
class={`fixed top-16 right-0 bottom-0 z-50 bg-white border-l border-gray-200
|
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 ${
|
transition-transform duration-300 ease-in-out ${
|
||||||
props.open ? "translate-x-0" : "translate-x-full"
|
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
|
// UI components
|
||||||
export { JournalPanel } from "./JournalPanel";
|
export { JournalPanel } from "./JournalPanel";
|
||||||
export type { JournalPanelProps } 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 { ConnectionBar } from "./ConnectionBar";
|
||||||
export { StreamView } from "./StreamView";
|
export { StreamView } from "./StreamView";
|
||||||
export { StreamMessageCard } from "./StreamMessage";
|
export { StreamMessageCard } from "./StreamMessage";
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue