feat: implement room joining by link and add dedicated room-utils store

This commit is contained in:
pixii
2025-03-06 10:24:44 +01:00
parent 3217b2c4c3
commit e8e36e6674
6 changed files with 202 additions and 240 deletions

View File

@ -8,22 +8,14 @@
Helper, Helper,
} from "flowbite-svelte"; } from "flowbite-svelte";
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
import { sessionStore } from "/stores/sessionStore"; import { loading, join_error, create_error, rejoinRoomCode, rejoinRoomSessionData, requestJoinRoom, requestCreateRoom, joinSession, checkSessionData } from "../stores/roomStore";
let loading: "join" | "create" | false = false;
let rejoinRoomCode = "";
let rejoinRoomSessionData = {
sessionToken: "",
userId: "",
};
let joinRoomId = ""; let joinRoomId = "";
let join_error: string | false = false;
let create_error: string | false = false;
let inputRef: HTMLInputElement | null = null; let inputRef: HTMLInputElement | null = null;
function formatInput(event: any) { function formatInput(event: any) {
let rawValue = event.target.value.replace(/\D/g, ""); let rawValue = event.target.value.replace(/\D/g, "");
join_error = false; join_error.set(false);
if (rawValue.length > 6) { if (rawValue.length > 6) {
rawValue = rawValue.slice(0, 6); rawValue = rawValue.slice(0, 6);
@ -36,217 +28,27 @@
joinRoomId = formattedValue; joinRoomId = formattedValue;
if (joinRoomId.length > 6) { if (joinRoomId.length > 6) {
requestJoinRoom(); requestJoinRoom(joinRoomId);
} }
} }
function handleKeyDown(event: any) { function handleKeyDown(event: any) {
if (event.key === "Backspace") { if (event.key === "Backspace") {
join_error = false; join_error.set(false);
let cursorPosition = event.target.selectionStart; let cursorPosition = event.target.selectionStart;
if (cursorPosition === 4) { if (cursorPosition === 4) {
// If cursor is at "-" position, delete the number before it
joinRoomId = joinRoomId.slice(0, 2); joinRoomId = joinRoomId.slice(0, 2);
event.preventDefault(); event.preventDefault();
} }
} }
} }
async function requestJoinRoom(joinCode = joinRoomId) {
if (loading) return;
loading = "join";
join_error = false;
try {
const controller = new AbortController();
// 5s timeout
const timeout = setTimeout(() => controller.abort(), 5000);
const response = await fetch(`/api/room/join`, {
method: "POST",
body: JSON.stringify({
JoinCode: joinCode.replaceAll("-", ""),
UsernameProposal: "UsernameProposal",
}),
headers: {
"Content-Type": "application/json",
},
signal: controller.signal,
});
clearTimeout(timeout);
if (!response.ok) {
const data: { StatusCode: string; Message: string } =
await response.json();
// TODO i18n here on StatusCode if not use Message
if (["invalid_join_code"].includes(data?.StatusCode)) {
join_error = "no_room_found";
} else if (data?.Message) {
join_error = data?.Message;
} else {
throw new Error("Server error");
}
return;
}
const data: {
SessionToken: string;
PlayerId: string;
Username: string;
Permissions: any;
} = await response.json();
const SessionToken = data.SessionToken;
const UserId = data.PlayerId;
joinSession(SessionToken, UserId);
} catch (error: any) {
if (error.name === "AbortError") {
join_error = "timeout";
} else {
join_error = "request_failed";
}
console.error("Error joining room: ", error);
} finally {
loading = false;
setTimeout(() => {
focusInput();
}, 50);
}
}
async function requestCreateRoom() {
if (loading) return;
loading = "create";
create_error = false;
try {
const controller = new AbortController();
// 5s timeout
const timeout = setTimeout(() => controller.abort(), 5000);
const response = await fetch(`/api/room/create`, {
method: "POST",
body: JSON.stringify({
UsernameProposal: "UsernameProposal",
}),
headers: {
"Content-Type": "application/json",
},
signal: controller.signal,
});
clearTimeout(timeout);
if (!response.ok) {
throw new Error("Server error");
}
const data:
| {
SessionToken: string;
PlayerId: string;
Username: string;
Permissions: any;
}
| { error: string } = await response.json();
if (response.ok) {
const SessionToken = data.SessionToken;
const UserId = data.PlayerId;
sessionStore.connect(SessionToken, UserId);
} else {
create_error = data.error || "room_creation_failed";
}
} catch (error: any) {
if (error.name === "AbortError") {
create_error = "timeout";
} else {
create_error = String(error);
}
console.error("Error creating room:", error);
} finally {
loading = false;
}
}
function focusInput() { function focusInput() {
inputRef?.focus(); inputRef?.focus();
} }
function joinSession(sessionToken: string, userId: string) {
try {
sessionStore.connect(sessionToken, userId);
} catch (error: any) {
join_error = "request_failed";
console.error("Error joining room session: ", error);
} finally {
loading = false;
setTimeout(() => {
focusInput();
}, 50);
}
}
async function checkSessionToken(
sessionToken: string | undefined,
): Promise<boolean> {
if (!sessionToken) return false;
const params = new URLSearchParams({
sessionToken: sessionToken,
});
const res = await fetch(`/api/check/session?${params}`);
return res.status == 200;
}
async function checkJoinCode(
joinCode: string | undefined,
): Promise<boolean> {
if (!joinCode) return false;
const params = new URLSearchParams({
JoinCode: joinCode,
});
const res = await fetch(`/api/check/joinCode?${params}`);
return res.status == 200;
}
async function checkSessionData() {
const currentSessionData: {
sessionToken?: string;
userId?: string;
joinCode?: string;
} = JSON.parse(localStorage.getItem("currentSessionIds") || "{}");
if (await checkSessionToken(currentSessionData.sessionToken)) {
// Session is still valid
rejoinRoomSessionData = currentSessionData as any;
return;
}
if (await checkJoinCode(currentSessionData.joinCode)) {
// joinCode is still valid
rejoinRoomCode = currentSessionData.joinCode as string;
return;
}
const lastSessionData: {
sessionToken?: string;
userId?: string;
joinCode?: string;
} = JSON.parse(localStorage.getItem("lastSessionIds") || "{}");
if (await checkSessionToken(lastSessionData.sessionToken)) {
// Session is still valid
rejoinRoomSessionData = lastSessionData as any;
return;
}
if (await checkJoinCode(lastSessionData.joinCode)) {
// joinCode is still valid
rejoinRoomCode = lastSessionData.joinCode as string;
return;
}
}
// focus room code input on mount
onMount(() => { onMount(() => {
focusInput(); focusInput();
checkSessionData(); checkSessionData();
@ -254,19 +56,19 @@
</script> </script>
<div class="w-full max-w-md"> <div class="w-full max-w-md">
{#if rejoinRoomSessionData?.sessionToken} {#if $rejoinRoomSessionData?.sessionToken}
<div class="mb-6"> <div class="mb-6">
<Button <Button
class="bg-primary-400 focus:bg-primary-100 dark:bg-primary-800 focus:dark:bg-primary-700 focus:ring-0 text-dark w-full overflow-hidden rounded-xl border-1 border-primary-800" class="bg-primary-400 focus:bg-primary-100 dark:bg-primary-800 focus:dark:bg-primary-700 focus:ring-0 text-dark w-full overflow-hidden rounded-xl border-1 border-primary-800"
size="lg" size="lg"
disabled={loading && loading != "create"} disabled={$loading && $loading != "create"}
on:click={() => on:click={() =>
joinSession( joinSession(
rejoinRoomSessionData?.sessionToken, $rejoinRoomSessionData?.sessionToken,
rejoinRoomSessionData?.userId, $rejoinRoomSessionData?.userId,
)} )}
> >
{#if loading == "create"} {#if $loading == "create"}
<Spinner <Spinner
class="text-primary-350 dark:text-primary-200 me-3" class="text-primary-350 dark:text-primary-200 me-3"
size="4" size="4"
@ -274,12 +76,12 @@
{/if} {/if}
{$_("landing_page.connect_room.rejoin_last_room")} {$_("landing_page.connect_room.rejoin_last_room")}
</Button> </Button>
{#if create_error} {#if $create_error}
<Helper class="mt-2"> <Helper class="mt-2">
<span class="text-red-900 dark:text-red-300 text-sm"> <span class="text-red-900 dark:text-red-300 text-sm">
{$_(`error_messages.${create_error}`, { {$_(`error_messages.${$create_error}`, {
default: $_("error_messages.error_message", { default: $_("error_messages.error_message", {
values: { error_message: create_error }, values: { error_message: $create_error },
}), }),
})} })}
</span> </span>
@ -290,15 +92,15 @@
{$_("landing_page.connect_room.or")} {$_("landing_page.connect_room.or")}
</div> </div>
{/if} {/if}
{#if rejoinRoomCode} {#if $rejoinRoomCode}
<div class="mb-6"> <div class="mb-6">
<Button <Button
class="bg-primary-400 focus:bg-primary-100 dark:bg-primary-800 focus:dark:bg-primary-700 focus:ring-0 text-dark w-full overflow-hidden rounded-xl border-1 border-primary-800" class="bg-primary-400 focus:bg-primary-100 dark:bg-primary-800 focus:dark:bg-primary-700 focus:ring-0 text-dark w-full overflow-hidden rounded-xl border-1 border-primary-800"
size="lg" size="lg"
disabled={loading && loading != "create"} disabled={$loading && $loading != "create"}
on:click={() => requestJoinRoom(rejoinRoomCode)} on:click={() => requestJoinRoom($rejoinRoomCode)}
> >
{#if loading == "create"} {#if $loading == "create"}
<Spinner <Spinner
class="text-primary-350 dark:text-primary-200 me-3" class="text-primary-350 dark:text-primary-200 me-3"
size="4" size="4"
@ -306,14 +108,13 @@
{/if} {/if}
{$_("landing_page.connect_room.join_last_room")} {$_("landing_page.connect_room.join_last_room")}
</Button> </Button>
{#if create_error} {#if $create_error}
<Helper class="mt-2"> <Helper class="mt-2">
<span class="text-red-900 dark:text-red-300 text-sm"> <span class="text-red-900 dark:text-red-300 text-sm">
{$_(`error_messages.${create_error}`, { {$_(`error_messages.${$create_error}`, {
default: $_("error_messages.error_message", { default: $_("error_messages.error_message", {
values: { error_message: create_error }, values: { error_message: $create_error },
}), })})}
})}
</span> </span>
</Helper> </Helper>
{/if} {/if}
@ -330,7 +131,7 @@
<InputAddon <InputAddon
class="w-10 text-center bg-primary-400 dark:bg-primary-800" class="w-10 text-center bg-primary-400 dark:bg-primary-800"
> >
{#if loading == "join"} {#if $loading == "join"}
<div class="w-full flex justify-center"> <div class="w-full flex justify-center">
<Spinner <Spinner
class="text-primary-350 dark:text-primary-200" class="text-primary-350 dark:text-primary-200"
@ -344,23 +145,23 @@
<input <input
class="bg-primary-200 px-4 focus:bg-primary-100 text-black dark:text-white mr-md dark:bg-primary-700 dark:focus:bg-primary-600 w-full border-0 focus:outline-none h-12" class="bg-primary-200 px-4 focus:bg-primary-100 text-black dark:text-white mr-md dark:bg-primary-700 dark:focus:bg-primary-600 w-full border-0 focus:outline-none h-12"
placeholder={$_("landing_page.connect_room.enter_room_code")} placeholder={$_("landing_page.connect_room.enter_room_code")}
class:cursor-not-allowed={loading} class:cursor-not-allowed={$loading}
class:opacity-50={loading} class:opacity-50={$loading}
disabled={!!loading} disabled={!!$loading}
bind:this={inputRef} bind:this={inputRef}
bind:value={joinRoomId} bind:value={joinRoomId}
on:input={formatInput} on:input={formatInput}
on:keydown={handleKeyDown} on:keydown={handleKeyDown}
/> />
</ButtonGroup> </ButtonGroup>
{#if join_error} {#if $join_error}
<Helper class="mt-2"> <Helper class="mt-2">
<span class="text-red-900 dark:text-red-300 text-sm"> <span class="text-red-900 dark:text-red-300 text-sm">
{$_(`error_messages.${join_error}`, { {$_(`error_messages.${$join_error}`, {
default: $_("error_messages.error_message", { default: $_("error_messages.error_message", {
values: { error_message: join_error }, values: { error_message: $join_error },
}), })}
})} )}
</span> </span>
</Helper> </Helper>
{/if} {/if}
@ -372,10 +173,10 @@
<Button <Button
class="bg-primary-400 focus:bg-primary-100 dark:bg-primary-800 focus:dark:bg-primary-700 focus:ring-0 text-dark w-full overflow-hidden rounded-xl border-1 border-primary-800" class="bg-primary-400 focus:bg-primary-100 dark:bg-primary-800 focus:dark:bg-primary-700 focus:ring-0 text-dark w-full overflow-hidden rounded-xl border-1 border-primary-800"
size="lg" size="lg"
disabled={loading && loading != "create"} disabled={$loading && $loading != "create"}
on:click={requestCreateRoom} on:click={requestCreateRoom}
> >
{#if loading == "create"} {#if $loading == "create"}
<Spinner <Spinner
class="text-primary-350 dark:text-primary-200 me-3" class="text-primary-350 dark:text-primary-200 me-3"
size="4" size="4"
@ -383,14 +184,13 @@
{/if} {/if}
{$_("landing_page.connect_room.create_a_room")} {$_("landing_page.connect_room.create_a_room")}
</Button> </Button>
{#if create_error} {#if $create_error}
<Helper class="mt-2"> <Helper class="mt-2">
<span class="text-red-900 dark:text-red-300 text-sm"> <span class="text-red-900 dark:text-red-300 text-sm">
{$_(`error_messages.${create_error}`, { {$_(`error_messages.${$create_error}`, {
default: $_("error_messages.error_message", { default: $_("error_messages.error_message", {
values: { error_message: create_error }, values: { error_message: $create_error },
}), })})}
})}
</span> </span>
</Helper> </Helper>
{/if} {/if}

View File

@ -58,7 +58,7 @@
function copyGameLinkToClipboard() { function copyGameLinkToClipboard() {
navigator.clipboard navigator.clipboard
.writeText( .writeText(
`${window.location.origin}/join/${$sessionStore.joinCode}`, `${window.location.origin}/Game?join=${$sessionStore.joinCode}`,
) )
.then(() => { .then(() => {
copied = true; copied = true;
@ -70,6 +70,10 @@
sessionStore.leaveRoom(); sessionStore.leaveRoom();
showLeaveModal = false; showLeaveModal = false;
} }
function startGame() {
// TODO start game
}
</script> </script>
<!-- Modal: Confirm Leave Room --> <!-- Modal: Confirm Leave Room -->
@ -173,6 +177,7 @@
> >
</div> </div>
<!-- TODO Grid not fully responsive -->
<div class="grid md:grid-flow-col grid-flow-row justify-center mt-6 mb-2 gap-4"> <div class="grid md:grid-flow-col grid-flow-row justify-center mt-6 mb-2 gap-4">
<!-- Rename (This) Player --> <!-- Rename (This) Player -->
{#if sessionStore.isConnected()} {#if sessionStore.isConnected()}
@ -235,11 +240,11 @@
</Popover> </Popover>
<!-- Start game button --> <!-- Start game button -->
{#if sessionStore.getPlayerPermissions().isHost} {#if sessionStore.getPlayerPermissions().isHost && sessionStore.getState().gameState === GameState.Lobby}
<Button <Button
class="w-xs mx-auto text-dark bg-green-200 dark:bg-green-900 hover:bg-green-200 dark:hover:bg-green-900 backdrop-blur-lg border border-black/20 dark:border-white/20 shadow-lg p-4 rounded-2xl flex items-center justify-between transition-all cursor-pointer" class="w-xs mx-auto text-dark bg-green-200 dark:bg-green-900 hover:bg-green-200 dark:hover:bg-green-900 backdrop-blur-lg border border-black/20 dark:border-white/20 shadow-lg p-4 rounded-2xl flex items-center justify-between transition-all cursor-pointer"
on:click={() => { on:click={() => {
copyGameCodeToClipboard(); startGame();
}} }}
> >
<div class="grid justify-items-start"> <div class="grid justify-items-start">
@ -320,7 +325,7 @@
<Button <Button
outline={true} outline={true}
color="alternative" color="alternative"
class="p-2! text-red-800 hover:bg-red-500" class="p-2! text-blue-800 hover:bg-blue-500"
size="lg" size="lg"
on:click={() => { on:click={() => {
showRenameModal = true; showRenameModal = true;

View File

@ -5,8 +5,18 @@
import { GameState, sessionStore } from "../stores/sessionStore"; import { GameState, sessionStore } from "../stores/sessionStore";
import { Spinner } from "flowbite-svelte"; import { Spinner } from "flowbite-svelte";
import { SvelteDate } from "svelte/reactivity"; import { SvelteDate } from "svelte/reactivity";
import { requestJoinRoom } from "../stores/roomStore";
onMount(async () => {
const params = new URLSearchParams(window.location.search);
const joinParam = params.get('join');
console.log('Join param:', joinParam);
if(joinParam) {
await requestJoinRoom(joinParam);
// Maybe show message instead redirecting to / if the join was unsuccessful
}
onMount(() => {
if (!sessionStore.hasSessionData()) { if (!sessionStore.hasSessionData()) {
console.warn("No sessionData found! Go back home."); console.warn("No sessionData found! Go back home.");
window.history.replaceState({}, "", "/"); window.history.replaceState({}, "", "/");

View File

@ -0,0 +1,146 @@
import { writable } from 'svelte/store';
import { sessionStore } from './sessionStore';
export const loading = writable<"join" | "create" | false>(false);
export const join_error = writable<string | false>(false);
export const create_error = writable<string | false>(false);
export const rejoinRoomCode = writable<string>("");
export const rejoinRoomSessionData = writable<{ sessionToken: string; userId: string }>({ sessionToken: "", userId: "" });
export async function requestJoinRoom(joinCode: string) {
loading.set("join");
join_error.set(false);
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
const response = await fetch(`/api/room/join`, {
method: "POST",
body: JSON.stringify({
JoinCode: joinCode.replaceAll("-", ""),
UsernameProposal: "UsernameProposal",
}),
headers: {
"Content-Type": "application/json",
},
signal: controller.signal,
});
clearTimeout(timeout);
if (!response.ok) {
const data: { StatusCode: string; Message: string } = await response.json();
if (["invalid_join_code"].includes(data?.StatusCode)) {
join_error.set("no_room_found");
} else if (data?.Message) {
join_error.set(data?.Message);
} else {
throw new Error("Server error");
}
return;
}
const data: { SessionToken: string; PlayerId: string; Username: string; Permissions: any } = await response.json();
const SessionToken = data.SessionToken;
const UserId = data.PlayerId;
joinSession(SessionToken, UserId);
} catch (error: any) {
if (error.name === "AbortError") {
join_error.set("timeout");
} else {
join_error.set("request_failed");
}
console.error("Error joining room: ", error);
} finally {
loading.set(false);
}
}
export async function requestCreateRoom() {
loading.set("create");
create_error.set(false);
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
const response = await fetch(`/api/room/create`, {
method: "POST",
body: JSON.stringify({
UsernameProposal: "UsernameProposal",
}),
headers: {
"Content-Type": "application/json",
},
signal: controller.signal,
});
clearTimeout(timeout);
if (!response.ok) {
throw new Error("Server error");
}
const data: { SessionToken: string; PlayerId: string; Username: string; Permissions: any } = await response.json();
const SessionToken = data.SessionToken;
const UserId = data.PlayerId;
sessionStore.connect(SessionToken, UserId);
} catch (error: any) {
if (error.name === "AbortError") {
create_error.set("timeout");
} else {
create_error.set(String(error));
}
console.error("Error creating room:", error);
} finally {
loading.set(false);
}
}
export function joinSession(sessionToken: string, userId: string) {
try {
sessionStore.connect(sessionToken, userId);
} catch (error: any) {
join_error.set("request_failed");
console.error("Error joining room session: ", error);
} finally {
loading.set(false);
}
}
export async function checkSessionToken(sessionToken: string | undefined): Promise<boolean> {
if (!sessionToken) return false;
const params = new URLSearchParams({ sessionToken: sessionToken });
const res = await fetch(`/api/check/session?${params}`);
return res.status == 200;
}
export async function checkJoinCode(joinCode: string | undefined): Promise<boolean> {
if (!joinCode) return false;
const params = new URLSearchParams({ JoinCode: joinCode });
const res = await fetch(`/api/check/joinCode?${params}`);
return res.status == 200;
}
export async function checkSessionData() {
const currentSessionData: { sessionToken?: string; userId?: string; joinCode?: string } = JSON.parse(localStorage.getItem("currentSessionIds") || "{}");
if (await checkSessionToken(currentSessionData.sessionToken)) {
rejoinRoomSessionData.set(currentSessionData as any);
return;
}
if (await checkJoinCode(currentSessionData.joinCode)) {
rejoinRoomCode.set(currentSessionData.joinCode as string);
return;
}
const lastSessionData: { sessionToken?: string; userId?: string; joinCode?: string } = JSON.parse(localStorage.getItem("lastSessionIds") || "{}");
if (await checkSessionToken(lastSessionData.sessionToken)) {
rejoinRoomSessionData.set(lastSessionData as any);
return;
}
if (await checkJoinCode(lastSessionData.joinCode)) {
rejoinRoomCode.set(lastSessionData.joinCode as string);
return;
}
}

View File

@ -38,6 +38,7 @@ interface SessionData {
interface RoomInfoObj { interface RoomInfoObj {
RoomId: string; RoomId: string;
JoinCode: string; JoinCode: string;
TopCard: any;
GameState: GameState; GameState: GameState;
CardDeckId: number; CardDeckId: number;
Winner?: string; Winner?: string;