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)
This commit is contained in:
parent
9affe339c6
commit
4d4715b4ee
5 changed files with 292 additions and 3 deletions
|
|
@ -10,15 +10,24 @@
|
||||||
|
|
||||||
import { Route as rootRouteImport } from './routes/__root'
|
import { Route as rootRouteImport } from './routes/__root'
|
||||||
import { Route as PlayRouteImport } from './routes/play'
|
import { Route as PlayRouteImport } from './routes/play'
|
||||||
|
import { Route as MultiplayerRouteImport } from './routes/multiplayer'
|
||||||
import { Route as LoginRouteImport } from './routes/login'
|
import { Route as LoginRouteImport } from './routes/login'
|
||||||
import { Route as AboutRouteImport } from './routes/about'
|
import { Route as AboutRouteImport } from './routes/about'
|
||||||
import { Route as IndexRouteImport } from './routes/index'
|
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({
|
const PlayRoute = PlayRouteImport.update({
|
||||||
id: '/play',
|
id: '/play',
|
||||||
path: '/play',
|
path: '/play',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const MultiplayerRoute = MultiplayerRouteImport.update({
|
||||||
|
id: '/multiplayer',
|
||||||
|
path: '/multiplayer',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const LoginRoute = LoginRouteImport.update({
|
const LoginRoute = LoginRouteImport.update({
|
||||||
id: '/login',
|
id: '/login',
|
||||||
path: '/login',
|
path: '/login',
|
||||||
|
|
@ -34,38 +43,89 @@ const IndexRoute = IndexRouteImport.update({
|
||||||
path: '/',
|
path: '/',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} 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 {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/about': typeof AboutRoute
|
'/about': typeof AboutRoute
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
|
'/multiplayer': typeof MultiplayerRouteWithChildren
|
||||||
'/play': typeof PlayRoute
|
'/play': typeof PlayRoute
|
||||||
|
'/multiplayer/': typeof MultiplayerIndexRoute
|
||||||
|
'/multiplayer/game/$code': typeof MultiplayerGameCodeRoute
|
||||||
|
'/multiplayer/lobby/$code': typeof MultiplayerLobbyCodeRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/about': typeof AboutRoute
|
'/about': typeof AboutRoute
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
'/play': typeof PlayRoute
|
'/play': typeof PlayRoute
|
||||||
|
'/multiplayer': typeof MultiplayerIndexRoute
|
||||||
|
'/multiplayer/game/$code': typeof MultiplayerGameCodeRoute
|
||||||
|
'/multiplayer/lobby/$code': typeof MultiplayerLobbyCodeRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/about': typeof AboutRoute
|
'/about': typeof AboutRoute
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
|
'/multiplayer': typeof MultiplayerRouteWithChildren
|
||||||
'/play': typeof PlayRoute
|
'/play': typeof PlayRoute
|
||||||
|
'/multiplayer/': typeof MultiplayerIndexRoute
|
||||||
|
'/multiplayer/game/$code': typeof MultiplayerGameCodeRoute
|
||||||
|
'/multiplayer/lobby/$code': typeof MultiplayerLobbyCodeRoute
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
fullPaths: '/' | '/about' | '/login' | '/play'
|
fullPaths:
|
||||||
|
| '/'
|
||||||
|
| '/about'
|
||||||
|
| '/login'
|
||||||
|
| '/multiplayer'
|
||||||
|
| '/play'
|
||||||
|
| '/multiplayer/'
|
||||||
|
| '/multiplayer/game/$code'
|
||||||
|
| '/multiplayer/lobby/$code'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to: '/' | '/about' | '/login' | '/play'
|
to:
|
||||||
id: '__root__' | '/' | '/about' | '/login' | '/play'
|
| '/'
|
||||||
|
| '/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
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
IndexRoute: typeof IndexRoute
|
IndexRoute: typeof IndexRoute
|
||||||
AboutRoute: typeof AboutRoute
|
AboutRoute: typeof AboutRoute
|
||||||
LoginRoute: typeof LoginRoute
|
LoginRoute: typeof LoginRoute
|
||||||
|
MultiplayerRoute: typeof MultiplayerRouteWithChildren
|
||||||
PlayRoute: typeof PlayRoute
|
PlayRoute: typeof PlayRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -78,6 +138,13 @@ declare module '@tanstack/react-router' {
|
||||||
preLoaderRoute: typeof PlayRouteImport
|
preLoaderRoute: typeof PlayRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/multiplayer': {
|
||||||
|
id: '/multiplayer'
|
||||||
|
path: '/multiplayer'
|
||||||
|
fullPath: '/multiplayer'
|
||||||
|
preLoaderRoute: typeof MultiplayerRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/login': {
|
'/login': {
|
||||||
id: '/login'
|
id: '/login'
|
||||||
path: '/login'
|
path: '/login'
|
||||||
|
|
@ -99,13 +166,51 @@ declare module '@tanstack/react-router' {
|
||||||
preLoaderRoute: typeof IndexRouteImport
|
preLoaderRoute: typeof IndexRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
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 = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
IndexRoute: IndexRoute,
|
IndexRoute: IndexRoute,
|
||||||
AboutRoute: AboutRoute,
|
AboutRoute: AboutRoute,
|
||||||
LoginRoute: LoginRoute,
|
LoginRoute: LoginRoute,
|
||||||
|
MultiplayerRoute: MultiplayerRouteWithChildren,
|
||||||
PlayRoute: PlayRoute,
|
PlayRoute: PlayRoute,
|
||||||
}
|
}
|
||||||
export const routeTree = rootRouteImport
|
export const routeTree = rootRouteImport
|
||||||
|
|
|
||||||
21
apps/web/src/routes/multiplayer.tsx
Normal file
21
apps/web/src/routes/multiplayer.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<WsProvider>
|
||||||
|
<Outlet />
|
||||||
|
</WsProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
apps/web/src/routes/multiplayer/game.$code.tsx
Normal file
9
apps/web/src/routes/multiplayer/game.$code.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/multiplayer/game/$code')({
|
||||||
|
component: RouteComponent,
|
||||||
|
})
|
||||||
|
|
||||||
|
function RouteComponent() {
|
||||||
|
return <div>Hello "/multiplayer/game/$code"!</div>
|
||||||
|
}
|
||||||
145
apps/web/src/routes/multiplayer/index.tsx
Normal file
145
apps/web/src/routes/multiplayer/index.tsx
Normal file
|
|
@ -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<string | null>(null);
|
||||||
|
|
||||||
|
const handleCreate = async (): Promise<void> => {
|
||||||
|
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<void> => {
|
||||||
|
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 (
|
||||||
|
<div className="min-h-screen bg-linear-to-b from-purple-100 to-pink-50 flex items-center justify-center p-6">
|
||||||
|
<div className="bg-white rounded-2xl shadow-lg p-8 w-full max-w-md flex flex-col gap-6">
|
||||||
|
<h1 className="text-2xl font-bold text-center text-purple-800">
|
||||||
|
Multiplayer
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{error && <p className="text-red-500 text-sm text-center">{error}</p>}
|
||||||
|
|
||||||
|
{/* Create lobby */}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-700">
|
||||||
|
Create a lobby
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Start a new game and invite friends with a code.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
className="rounded bg-purple-600 px-4 py-2 text-white hover:bg-purple-500 disabled:opacity-50"
|
||||||
|
onClick={() => {
|
||||||
|
void handleCreate().catch((err) => {
|
||||||
|
console.error("Create lobby error:", err);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
disabled={isCreating || isJoining}
|
||||||
|
>
|
||||||
|
{isCreating ? "Creating..." : "Create Lobby"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-gray-200" />
|
||||||
|
|
||||||
|
{/* Join lobby */}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-700">Join a lobby</h2>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Enter the code shared by your host.
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
className="rounded border border-gray-300 px-3 py-2 text-sm uppercase tracking-widest focus:outline-none focus:ring-2 focus:ring-purple-400"
|
||||||
|
placeholder="Enter code (e.g. WOLF42)"
|
||||||
|
value={joinCode}
|
||||||
|
onChange={(e) => 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}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="rounded bg-gray-800 px-4 py-2 text-white hover:bg-gray-700 disabled:opacity-50"
|
||||||
|
onClick={() => {
|
||||||
|
void handleJoin().catch((err) => {
|
||||||
|
console.error("Join lobby error:", err);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
disabled={isCreating || isJoining || !joinCode.trim()}
|
||||||
|
>
|
||||||
|
{isJoining ? "Joining..." : "Join Lobby"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
apps/web/src/routes/multiplayer/lobby.$code.tsx
Normal file
9
apps/web/src/routes/multiplayer/lobby.$code.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/multiplayer/lobby/$code')({
|
||||||
|
component: RouteComponent,
|
||||||
|
})
|
||||||
|
|
||||||
|
function RouteComponent() {
|
||||||
|
return <div>Hello "/multiplayer/lobby/$code"!</div>
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue