Swap emoji and unicode glyphs for Tabler components so icons inherit currentColor and font-size and render consistently across platforms. - add src/components/doc-icons.ts to resolve doc-entry frontmatter icon keys to components; frontmatter now stores semantic keys - replace the align-*.png assets with TbOutlineAlign* icons - render RevealManager action SVGs as components instead of injected innerHTML strings - carry completion icons via an optional CompletionItem.icon field
225 lines
7.3 KiB
TypeScript
225 lines
7.3 KiB
TypeScript
/**
|
|
* ConnectDialog — broker connection + session lobby (list / create / join)
|
|
*
|
|
* The broker connection and the active session are decoupled: this dialog
|
|
* connects to the broker, which makes the session list available, and joining
|
|
* or creating a session is a separate step that reuses that connection.
|
|
*/
|
|
import { Component, createSignal, For, onMount, Show } from "solid-js";
|
|
import { TbOutlineTrash } from "solid-icons/tb";
|
|
import {
|
|
connectBroker,
|
|
createSession,
|
|
joinSession,
|
|
resolveBrokerUrl,
|
|
sessions,
|
|
setMyName,
|
|
setMyRole,
|
|
useJournalStream,
|
|
} from "../stores/journalStream";
|
|
import { DeleteSessionDialog } from "./DeleteSessionDialog";
|
|
|
|
export const ConnectDialog: Component = () => {
|
|
const stream = useJournalStream();
|
|
const manifest = sessions;
|
|
const [playerName, setPlayerName] = createSignal(stream.myName);
|
|
const [role, setRole] = createSignal<"gm" | "player" | "observer">(
|
|
stream.myRole,
|
|
);
|
|
const [newSessionName, setNewSessionName] = createSignal("");
|
|
const [busy, setBusy] = createSignal(false);
|
|
const [error, setError] = createSignal<string | null>(null);
|
|
const [autoJoined, setAutoJoined] = createSignal(false);
|
|
const [sessionToDelete, setSessionToDelete] = createSignal<{
|
|
id: string;
|
|
name: string;
|
|
} | null>(null);
|
|
|
|
const ensureBroker = async () => {
|
|
if (stream.connected) return;
|
|
await connectBroker(resolveBrokerUrl());
|
|
};
|
|
|
|
const identity = () => ({
|
|
name: playerName().trim() || stream.myName,
|
|
role: role(),
|
|
});
|
|
|
|
const applyIdentity = () => {
|
|
const { name, role: r } = identity();
|
|
setMyName(name);
|
|
setMyRole(r);
|
|
};
|
|
|
|
const handleJoin = async (sessionId: string) => {
|
|
setBusy(true);
|
|
setError(null);
|
|
try {
|
|
applyIdentity();
|
|
await ensureBroker();
|
|
await joinSession(sessionId, identity());
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : "Connection failed");
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
const handleCreate = async () => {
|
|
const name = newSessionName().trim();
|
|
if (!name) return;
|
|
setBusy(true);
|
|
setError(null);
|
|
try {
|
|
applyIdentity();
|
|
await ensureBroker();
|
|
const id = await createSession(name);
|
|
if (id) await joinSession(id, identity());
|
|
setNewSessionName("");
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : "Connection failed");
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
// Auto-join when the URL carries session + player + autojoin params;
|
|
// otherwise enter the lobby so the session list is available up front.
|
|
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);
|
|
handleJoin(params.get("session")!);
|
|
return;
|
|
}
|
|
ensureBroker().catch((e) => {
|
|
setError(e instanceof Error ? e.message : "Connection failed");
|
|
});
|
|
});
|
|
|
|
return (
|
|
<Show
|
|
when={!autoJoined() || error()}
|
|
fallback={
|
|
<p class="text-sm text-gray-500 text-center">
|
|
{busy() ? "正在加入会话…" : "已加入!"}
|
|
</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" && handleCreate()}
|
|
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>
|
|
|
|
{/* Session list */}
|
|
<div class="space-y-1">
|
|
<p class="text-xs text-gray-500">会话</p>
|
|
<div class="max-h-40 overflow-y-auto border border-gray-200 rounded divide-y divide-gray-100">
|
|
<For each={Object.entries(manifest().sessions)}>
|
|
{([id, meta]) => (
|
|
<div class="flex items-center">
|
|
<button
|
|
onClick={() => handleJoin(id)}
|
|
disabled={busy()}
|
|
class="flex-1 min-w-0 text-left px-2 py-1.5 text-xs text-gray-700 hover:bg-gray-50 disabled:opacity-50 truncate"
|
|
>
|
|
{meta.name || id}
|
|
</button>
|
|
<Show when={role() === "gm"}>
|
|
<button
|
|
onClick={() =>
|
|
setSessionToDelete({ id, name: meta.name || id })
|
|
}
|
|
disabled={busy()}
|
|
class="shrink-0 px-2 py-1.5 text-[10px] text-gray-300 hover:text-red-500 disabled:opacity-50"
|
|
title="删除会话"
|
|
>
|
|
<TbOutlineTrash />
|
|
</button>
|
|
</Show>
|
|
</div>
|
|
)}
|
|
</For>
|
|
<Show when={Object.keys(manifest().sessions).length === 0}>
|
|
<p class="px-2 py-2 text-xs text-gray-400">
|
|
{stream.connected ? "还没有会话" : "正在加载会话…"}
|
|
</p>
|
|
</Show>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Create a new session and join it (GM only) */}
|
|
<Show when={role() === "gm"}>
|
|
<div class="flex gap-1">
|
|
<input
|
|
type="text"
|
|
value={newSessionName()}
|
|
onInput={(e) => setNewSessionName(e.currentTarget.value)}
|
|
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
|
|
placeholder="新会话名称"
|
|
class="flex-1 border border-gray-300 rounded px-2 py-1.5 text-sm"
|
|
/>
|
|
<button
|
|
onClick={handleCreate}
|
|
disabled={busy() || !newSessionName().trim()}
|
|
class="bg-blue-600 text-white rounded px-3 py-1.5 text-sm font-medium
|
|
hover:bg-blue-700 disabled:opacity-50 whitespace-nowrap"
|
|
>
|
|
新建并加入
|
|
</button>
|
|
</div>
|
|
</Show>
|
|
|
|
<Show when={error()}>
|
|
<p class="text-red-500 text-sm text-center">{error()}</p>
|
|
</Show>
|
|
|
|
<Show when={sessionToDelete()}>
|
|
{(target) => (
|
|
<DeleteSessionDialog
|
|
sessionId={target().id}
|
|
sessionName={target().name}
|
|
active={stream.sessionId === target().id}
|
|
onClose={() => setSessionToDelete(null)}
|
|
/>
|
|
)}
|
|
</Show>
|
|
</div>
|
|
</Show>
|
|
);
|
|
};
|