feat: add player invitation and autojoin functionality
Implement a system to invite players via generated URLs that automatically configure their name and role. - Add `InviteDialog` to allow users to generate and copy invite links - Implement autojoin logic in `ConnectDialog` based on URL parameters - Add an "+ invite" button to the `JournalPanel` player list - Ensure `autojoin` parameter is stripped on disconnect to allow manual connection later
This commit is contained in:
parent
1d0f2f7c3e
commit
7cfc0fd4f3
|
|
@ -27,6 +27,7 @@ export interface JournalPanelProps {
|
||||||
|
|
||||||
export const JournalPanel: Component<JournalPanelProps> = (props) => {
|
export const JournalPanel: Component<JournalPanelProps> = (props) => {
|
||||||
const stream = useJournalStream();
|
const stream = useJournalStream();
|
||||||
|
const [showInvite, setShowInvite] = createSignal(false);
|
||||||
|
|
||||||
// Player list (exclude gm and observer)
|
// Player list (exclude gm and observer)
|
||||||
const playerEntries = () =>
|
const playerEntries = () =>
|
||||||
|
|
@ -55,7 +56,7 @@ export const JournalPanel: Component<JournalPanelProps> = (props) => {
|
||||||
<JournalHeader onClose={props.onClose} />
|
<JournalHeader onClose={props.onClose} />
|
||||||
|
|
||||||
{/* Player list tabs */}
|
{/* Player list tabs */}
|
||||||
<Show when={stream.connected && playerEntries().length > 0}>
|
<Show when={stream.connected}>
|
||||||
<div class="flex items-center gap-1 px-3 py-1.5 border-b border-gray-100 bg-gray-50 overflow-x-auto shrink-0">
|
<div class="flex items-center gap-1 px-3 py-1.5 border-b border-gray-100 bg-gray-50 overflow-x-auto shrink-0">
|
||||||
<For each={playerEntries()}>
|
<For each={playerEntries()}>
|
||||||
{([name]) => (
|
{([name]) => (
|
||||||
|
|
@ -64,6 +65,13 @@ export const JournalPanel: Component<JournalPanelProps> = (props) => {
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowInvite(true)}
|
||||||
|
class="text-xs text-gray-400 hover:text-blue-600 px-1.5 py-0.5 rounded border border-dashed border-gray-300 hover:border-blue-400 whitespace-nowrap shrink-0"
|
||||||
|
title="Invite a player"
|
||||||
|
>
|
||||||
|
+ invite
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
|
|
@ -80,6 +88,11 @@ export const JournalPanel: Component<JournalPanelProps> = (props) => {
|
||||||
</div>
|
</div>
|
||||||
<JournalInput />
|
<JournalInput />
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
|
{/* Invite modal */}
|
||||||
|
<Show when={showInvite()}>
|
||||||
|
<InviteDialog onClose={() => setShowInvite(false)} />
|
||||||
|
</Show>
|
||||||
</aside>
|
</aside>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
@ -273,6 +286,22 @@ const ConnectDialog: Component = () => {
|
||||||
);
|
);
|
||||||
const [connecting, setConnecting] = createSignal(false);
|
const [connecting, setConnecting] = createSignal(false);
|
||||||
const [error, setError] = createSignal<string | null>(null);
|
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 handleConnect = async () => {
|
||||||
const name = playerName().trim() || stream.myName;
|
const name = playerName().trim() || stream.myName;
|
||||||
|
|
@ -299,49 +328,141 @@ const ConnectDialog: Component = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="w-full max-w-sm space-y-3">
|
<Show
|
||||||
<p class="text-sm text-gray-600 text-center">
|
when={!autoJoined() || error()}
|
||||||
Enter your name and role to join the session.
|
fallback={
|
||||||
<br />
|
<p class="text-sm text-gray-500 text-center">
|
||||||
<span class="text-xs text-gray-400">
|
{connecting() ? "Joining session…" : "Joined!"}
|
||||||
Connecting to {window.location.host}
|
</p>
|
||||||
</span>
|
}
|
||||||
</p>
|
>
|
||||||
<input
|
<div class="w-full max-w-sm space-y-3">
|
||||||
type="text"
|
<p class="text-sm text-gray-600 text-center">
|
||||||
value={playerName()}
|
Enter your name and role to join the session.
|
||||||
onInput={(e) => setPlayerName(e.currentTarget.value)}
|
<br />
|
||||||
onKeyDown={(e) => e.key === "Enter" && handleConnect()}
|
<span class="text-xs text-gray-400">
|
||||||
placeholder="Your name"
|
Connecting to {window.location.host}
|
||||||
class="w-full border border-gray-300 rounded px-3 py-1.5 text-sm"
|
</span>
|
||||||
autofocus
|
</p>
|
||||||
/>
|
<input
|
||||||
{/* Role selector */}
|
type="text"
|
||||||
<div class="flex gap-1">
|
value={playerName()}
|
||||||
{(["gm", "player", "observer"] as const).map((r) => (
|
onInput={(e) => setPlayerName(e.currentTarget.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && handleConnect()}
|
||||||
|
placeholder="Your name"
|
||||||
|
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" ? "GM" : r === "player" ? "Player" : "Observer"}
|
||||||
|
</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() ? "Connecting..." : "Join"}
|
||||||
|
</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">Invite Player</h3>
|
||||||
<button
|
<button
|
||||||
onClick={() => setRole(r)}
|
onClick={props.onClose}
|
||||||
class={`flex-1 rounded px-2 py-1.5 text-xs font-medium border transition-colors ${
|
class="text-gray-400 hover:text-gray-600 text-sm"
|
||||||
role() === r
|
>
|
||||||
? "bg-blue-600 text-white border-blue-600"
|
✕
|
||||||
: "bg-white text-gray-600 border-gray-300 hover:border-gray-400"
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500">
|
||||||
|
Enter a player name and share the link. They will join as a player.
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={playerName()}
|
||||||
|
onInput={(e) => setPlayerName(e.currentTarget.value)}
|
||||||
|
placeholder="Player name"
|
||||||
|
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"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{r === "gm" ? "GM" : r === "player" ? "Player" : "Observer"}
|
{copied() ? "Copied!" : "Copy"}
|
||||||
</button>
|
</button>
|
||||||
))}
|
</div>
|
||||||
</div>
|
</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() ? "Connecting..." : "Join"}
|
|
||||||
</button>
|
|
||||||
<Show when={error()}>
|
|
||||||
<p class="text-red-500 text-sm text-center">{error()}</p>
|
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -583,6 +583,9 @@ export function disconnectStream(): void {
|
||||||
setState("connected", false);
|
setState("connected", false);
|
||||||
setState("connectionStatus", "disconnected");
|
setState("connectionStatus", "disconnected");
|
||||||
setState("players", {});
|
setState("players", {});
|
||||||
|
|
||||||
|
// Strip autojoin param so the dialog shows normally on next connect
|
||||||
|
removeUrlParam("autojoin");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue