From 4d4715b4ee8a54f7a5e84a177aaaa1988fafec9e Mon Sep 17 00:00:00 2001 From: lila Date: Fri, 17 Apr 2026 21:33:40 +0200 Subject: [PATCH] feat(web): add multiplayer layout route and landing page - multiplayer.tsx: layout route wrapping all multiplayer children with WsProvider, auth guard via beforeLoad - multiplayer/index.tsx: create/join landing page - POST /api/v1/lobbies to create, navigates to lobby waiting room - POST /api/v1/lobbies/:code/join to join, normalizes code to uppercase before sending - loading states per action, error display, Enter key on join input - imports Lobby type from @lila/shared (single source of truth) --- apps/web/src/routeTree.gen.ts | 111 +++++++++++++- apps/web/src/routes/multiplayer.tsx | 21 +++ .../web/src/routes/multiplayer/game.$code.tsx | 9 ++ apps/web/src/routes/multiplayer/index.tsx | 145 ++++++++++++++++++ .../src/routes/multiplayer/lobby.$code.tsx | 9 ++ 5 files changed, 292 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/routes/multiplayer.tsx create mode 100644 apps/web/src/routes/multiplayer/game.$code.tsx create mode 100644 apps/web/src/routes/multiplayer/index.tsx create mode 100644 apps/web/src/routes/multiplayer/lobby.$code.tsx diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index ce1cdf1..96c3044 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -10,15 +10,24 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as PlayRouteImport } from './routes/play' +import { Route as MultiplayerRouteImport } from './routes/multiplayer' import { Route as LoginRouteImport } from './routes/login' import { Route as AboutRouteImport } from './routes/about' import { Route as IndexRouteImport } from './routes/index' +import { Route as MultiplayerIndexRouteImport } from './routes/multiplayer/index' +import { Route as MultiplayerLobbyCodeRouteImport } from './routes/multiplayer/lobby.$code' +import { Route as MultiplayerGameCodeRouteImport } from './routes/multiplayer/game.$code' const PlayRoute = PlayRouteImport.update({ id: '/play', path: '/play', getParentRoute: () => rootRouteImport, } as any) +const MultiplayerRoute = MultiplayerRouteImport.update({ + id: '/multiplayer', + path: '/multiplayer', + getParentRoute: () => rootRouteImport, +} as any) const LoginRoute = LoginRouteImport.update({ id: '/login', path: '/login', @@ -34,38 +43,89 @@ const IndexRoute = IndexRouteImport.update({ path: '/', getParentRoute: () => rootRouteImport, } as any) +const MultiplayerIndexRoute = MultiplayerIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => MultiplayerRoute, +} as any) +const MultiplayerLobbyCodeRoute = MultiplayerLobbyCodeRouteImport.update({ + id: '/lobby/$code', + path: '/lobby/$code', + getParentRoute: () => MultiplayerRoute, +} as any) +const MultiplayerGameCodeRoute = MultiplayerGameCodeRouteImport.update({ + id: '/game/$code', + path: '/game/$code', + getParentRoute: () => MultiplayerRoute, +} as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute '/about': typeof AboutRoute '/login': typeof LoginRoute + '/multiplayer': typeof MultiplayerRouteWithChildren '/play': typeof PlayRoute + '/multiplayer/': typeof MultiplayerIndexRoute + '/multiplayer/game/$code': typeof MultiplayerGameCodeRoute + '/multiplayer/lobby/$code': typeof MultiplayerLobbyCodeRoute } export interface FileRoutesByTo { '/': typeof IndexRoute '/about': typeof AboutRoute '/login': typeof LoginRoute '/play': typeof PlayRoute + '/multiplayer': typeof MultiplayerIndexRoute + '/multiplayer/game/$code': typeof MultiplayerGameCodeRoute + '/multiplayer/lobby/$code': typeof MultiplayerLobbyCodeRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/about': typeof AboutRoute '/login': typeof LoginRoute + '/multiplayer': typeof MultiplayerRouteWithChildren '/play': typeof PlayRoute + '/multiplayer/': typeof MultiplayerIndexRoute + '/multiplayer/game/$code': typeof MultiplayerGameCodeRoute + '/multiplayer/lobby/$code': typeof MultiplayerLobbyCodeRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/about' | '/login' | '/play' + fullPaths: + | '/' + | '/about' + | '/login' + | '/multiplayer' + | '/play' + | '/multiplayer/' + | '/multiplayer/game/$code' + | '/multiplayer/lobby/$code' fileRoutesByTo: FileRoutesByTo - to: '/' | '/about' | '/login' | '/play' - id: '__root__' | '/' | '/about' | '/login' | '/play' + to: + | '/' + | '/about' + | '/login' + | '/play' + | '/multiplayer' + | '/multiplayer/game/$code' + | '/multiplayer/lobby/$code' + id: + | '__root__' + | '/' + | '/about' + | '/login' + | '/multiplayer' + | '/play' + | '/multiplayer/' + | '/multiplayer/game/$code' + | '/multiplayer/lobby/$code' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute AboutRoute: typeof AboutRoute LoginRoute: typeof LoginRoute + MultiplayerRoute: typeof MultiplayerRouteWithChildren PlayRoute: typeof PlayRoute } @@ -78,6 +138,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof PlayRouteImport parentRoute: typeof rootRouteImport } + '/multiplayer': { + id: '/multiplayer' + path: '/multiplayer' + fullPath: '/multiplayer' + preLoaderRoute: typeof MultiplayerRouteImport + parentRoute: typeof rootRouteImport + } '/login': { id: '/login' path: '/login' @@ -99,13 +166,51 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } + '/multiplayer/': { + id: '/multiplayer/' + path: '/' + fullPath: '/multiplayer/' + preLoaderRoute: typeof MultiplayerIndexRouteImport + parentRoute: typeof MultiplayerRoute + } + '/multiplayer/lobby/$code': { + id: '/multiplayer/lobby/$code' + path: '/lobby/$code' + fullPath: '/multiplayer/lobby/$code' + preLoaderRoute: typeof MultiplayerLobbyCodeRouteImport + parentRoute: typeof MultiplayerRoute + } + '/multiplayer/game/$code': { + id: '/multiplayer/game/$code' + path: '/game/$code' + fullPath: '/multiplayer/game/$code' + preLoaderRoute: typeof MultiplayerGameCodeRouteImport + parentRoute: typeof MultiplayerRoute + } } } +interface MultiplayerRouteChildren { + MultiplayerIndexRoute: typeof MultiplayerIndexRoute + MultiplayerGameCodeRoute: typeof MultiplayerGameCodeRoute + MultiplayerLobbyCodeRoute: typeof MultiplayerLobbyCodeRoute +} + +const MultiplayerRouteChildren: MultiplayerRouteChildren = { + MultiplayerIndexRoute: MultiplayerIndexRoute, + MultiplayerGameCodeRoute: MultiplayerGameCodeRoute, + MultiplayerLobbyCodeRoute: MultiplayerLobbyCodeRoute, +} + +const MultiplayerRouteWithChildren = MultiplayerRoute._addFileChildren( + MultiplayerRouteChildren, +) + const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, AboutRoute: AboutRoute, LoginRoute: LoginRoute, + MultiplayerRoute: MultiplayerRouteWithChildren, PlayRoute: PlayRoute, } export const routeTree = rootRouteImport diff --git a/apps/web/src/routes/multiplayer.tsx b/apps/web/src/routes/multiplayer.tsx new file mode 100644 index 0000000..9acb22f --- /dev/null +++ b/apps/web/src/routes/multiplayer.tsx @@ -0,0 +1,21 @@ +import { createFileRoute, Outlet, redirect } from "@tanstack/react-router"; +import { WsProvider } from "../lib/ws-provider.js"; +import { authClient } from "../lib/auth-client.js"; + +export const Route = createFileRoute("/multiplayer")({ + component: MultiplayerLayout, + beforeLoad: async () => { + const { data: session } = await authClient.getSession(); + if (!session) { + throw redirect({ to: "/login" }); + } + }, +}); + +function MultiplayerLayout() { + return ( + + + + ); +} diff --git a/apps/web/src/routes/multiplayer/game.$code.tsx b/apps/web/src/routes/multiplayer/game.$code.tsx new file mode 100644 index 0000000..2b46605 --- /dev/null +++ b/apps/web/src/routes/multiplayer/game.$code.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/multiplayer/game/$code')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/multiplayer/game/$code"!
+} diff --git a/apps/web/src/routes/multiplayer/index.tsx b/apps/web/src/routes/multiplayer/index.tsx new file mode 100644 index 0000000..55f2da6 --- /dev/null +++ b/apps/web/src/routes/multiplayer/index.tsx @@ -0,0 +1,145 @@ +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { useState } from "react"; +import type { Lobby } from "@lila/shared"; + +const API_URL = (import.meta.env["VITE_API_URL"] as string) || ""; + +type LobbySuccessResponse = { success: true; data: Lobby }; +type LobbyErrorResponse = { success: false; error: string }; +type LobbyApiResponse = LobbySuccessResponse | LobbyErrorResponse; + +export const Route = createFileRoute("/multiplayer/")({ + component: MultiplayerPage, +}); + +function MultiplayerPage() { + const navigate = useNavigate(); + const [joinCode, setJoinCode] = useState(""); + const [isCreating, setIsCreating] = useState(false); + const [isJoining, setIsJoining] = useState(false); + const [error, setError] = useState(null); + + const handleCreate = async (): Promise => { + setIsCreating(true); + setError(null); + try { + const response = await fetch(`${API_URL}/api/v1/lobbies`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + }); + const data = (await response.json()) as LobbyApiResponse; + if (!data.success) { + setError(data.error); + return; + } + void navigate({ + to: "/multiplayer/lobby/$code", + params: { code: data.data.code }, + }); + } catch { + setError("Could not connect to server. Please try again."); + } finally { + setIsCreating(false); + } + }; + + const handleJoin = async (): Promise => { + const code = joinCode.trim().toUpperCase(); + if (!code) { + setError("Please enter a lobby code."); + return; + } + setIsJoining(true); + setError(null); + try { + const response = await fetch(`${API_URL}/api/v1/lobbies/${code}/join`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + }); + const data = (await response.json()) as LobbyApiResponse; + if (!data.success) { + setError(data.error); + return; + } + void navigate({ + to: "/multiplayer/lobby/$code", + params: { code: data.data.code }, + }); + } catch { + setError("Could not connect to server. Please try again."); + } finally { + setIsJoining(false); + } + }; + + return ( +
+
+

+ Multiplayer +

+ + {error &&

{error}

} + + {/* Create lobby */} +
+

+ Create a lobby +

+

+ Start a new game and invite friends with a code. +

+ +
+ +
+ + {/* Join lobby */} +
+

Join a lobby

+

+ Enter the code shared by your host. +

+ setJoinCode(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + void handleJoin().catch((err) => { + console.error("Join lobby error:", err); + }); + } + }} + maxLength={10} + disabled={isCreating || isJoining} + /> + +
+
+
+ ); +} diff --git a/apps/web/src/routes/multiplayer/lobby.$code.tsx b/apps/web/src/routes/multiplayer/lobby.$code.tsx new file mode 100644 index 0000000..32d44d9 --- /dev/null +++ b/apps/web/src/routes/multiplayer/lobby.$code.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/multiplayer/lobby/$code')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/multiplayer/lobby/$code"!
+}