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) => {
|
||||
const stream = useJournalStream();
|
||||
const [showInvite, setShowInvite] = createSignal(false);
|
||||
|
||||
// Player list (exclude gm and observer)
|
||||
const playerEntries = () =>
|
||||
|
|
@ -55,7 +56,7 @@ export const JournalPanel: Component<JournalPanelProps> = (props) => {
|
|||
<JournalHeader onClose={props.onClose} />
|
||||
|
||||
{/* 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">
|
||||
<For each={playerEntries()}>
|
||||
{([name]) => (
|
||||
|
|
@ -64,6 +65,13 @@ export const JournalPanel: Component<JournalPanelProps> = (props) => {
|
|||
</span>
|
||||
)}
|
||||
</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>
|
||||
</Show>
|
||||
|
||||
|
|
@ -80,6 +88,11 @@ export const JournalPanel: Component<JournalPanelProps> = (props) => {
|
|||
</div>
|
||||
<JournalInput />
|
||||
</Show>
|
||||
|
||||
{/* Invite modal */}
|
||||
<Show when={showInvite()}>
|
||||
<InviteDialog onClose={() => setShowInvite(false)} />
|
||||
</Show>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
|
|
@ -273,6 +286,22 @@ const ConnectDialog: Component = () => {
|
|||
);
|
||||
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;
|
||||
|
|
@ -299,6 +328,14 @@ const ConnectDialog: Component = () => {
|
|||
};
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={!autoJoined() || error()}
|
||||
fallback={
|
||||
<p class="text-sm text-gray-500 text-center">
|
||||
{connecting() ? "Joining session…" : "Joined!"}
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<div class="w-full max-w-sm space-y-3">
|
||||
<p class="text-sm text-gray-600 text-center">
|
||||
Enter your name and role to join the session.
|
||||
|
|
@ -343,5 +380,89 @@ const ConnectDialog: Component = () => {
|
|||
<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
|
||||
onClick={props.onClose}
|
||||
class="text-gray-400 hover:text-gray-600 text-sm"
|
||||
>
|
||||
✕
|
||||
</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"
|
||||
}`}
|
||||
>
|
||||
{copied() ? "Copied!" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -583,6 +583,9 @@ export function disconnectStream(): void {
|
|||
setState("connected", false);
|
||||
setState("connectionStatus", "disconnected");
|
||||
setState("players", {});
|
||||
|
||||
// Strip autojoin param so the dialog shows normally on next connect
|
||||
removeUrlParam("autojoin");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Reference in New Issue