implement foundation + room system (Plan 1 of 5)
Bun workspace monorepo with shared, server, client packages. Server: Hono + @hono/node-ws, Drizzle + PostgreSQL, in-memory room manager with WebSocket broadcasting, HTTP room creation, DB persistence layer. Client: React 19 + Vite + Tailwind v4 + shadcn/ui, TanStack Router with landing/display/host/player routes, Zustand store, WebSocket connection hook. 20 tests passing (room manager unit + WS integration). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
20
packages/client/components.json
Normal file
20
packages/client/components.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/app.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
12
packages/client/index.html
Normal file
12
packages/client/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>ESC Party</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
35
packages/client/package.json
Normal file
35
packages/client/package.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@celebrate-esc/client",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@celebrate-esc/shared": "workspace:*",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@tanstack/react-router": "latest",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.577.0",
|
||||
"react": "latest",
|
||||
"react-dom": "latest",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"zustand": "latest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "latest",
|
||||
"@tanstack/router-plugin": "latest",
|
||||
"@types/react": "latest",
|
||||
"@types/react-dom": "latest",
|
||||
"@vitejs/plugin-react": "latest",
|
||||
"tailwindcss": "latest",
|
||||
"typescript": "latest",
|
||||
"vite": "latest"
|
||||
}
|
||||
}
|
||||
40
packages/client/src/app.css
Normal file
40
packages/client/src/app.css
Normal file
@@ -0,0 +1,40 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme {
|
||||
--color-background: hsl(0 0% 100%);
|
||||
--color-foreground: hsl(0 0% 3.9%);
|
||||
--color-card: hsl(0 0% 100%);
|
||||
--color-card-foreground: hsl(0 0% 3.9%);
|
||||
--color-popover: hsl(0 0% 100%);
|
||||
--color-popover-foreground: hsl(0 0% 3.9%);
|
||||
--color-primary: hsl(0 0% 9%);
|
||||
--color-primary-foreground: hsl(0 0% 98%);
|
||||
--color-secondary: hsl(0 0% 96.1%);
|
||||
--color-secondary-foreground: hsl(0 0% 9%);
|
||||
--color-muted: hsl(0 0% 96.1%);
|
||||
--color-muted-foreground: hsl(0 0% 45.1%);
|
||||
--color-accent: hsl(0 0% 96.1%);
|
||||
--color-accent-foreground: hsl(0 0% 9%);
|
||||
--color-destructive: hsl(0 84.2% 60.2%);
|
||||
--color-destructive-foreground: hsl(0 0% 98%);
|
||||
--color-border: hsl(0 0% 89.8%);
|
||||
--color-input: hsl(0 0% 89.8%);
|
||||
--color-ring: hsl(0 0% 3.9%);
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
*,
|
||||
::after,
|
||||
::before,
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
body {
|
||||
background-color: var(--color-background);
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
}
|
||||
35
packages/client/src/components/player-list.tsx
Normal file
35
packages/client/src/components/player-list.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import type { Player } from "@celebrate-esc/shared"
|
||||
|
||||
interface PlayerListProps {
|
||||
players: Player[]
|
||||
mySessionId: string | null
|
||||
}
|
||||
|
||||
export function PlayerList({ players, mySessionId }: PlayerListProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">Players ({players.length})</h3>
|
||||
<ul className="flex flex-col gap-1">
|
||||
{players.map((player) => (
|
||||
<li key={player.id} className="flex items-center gap-2">
|
||||
<span
|
||||
className={`h-2 w-2 rounded-full ${player.connected ? "bg-green-500" : "bg-muted"}`}
|
||||
/>
|
||||
<span className={player.sessionId === mySessionId ? "font-bold" : ""}>
|
||||
{player.displayName}
|
||||
</span>
|
||||
{player.isHost && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Host
|
||||
</Badge>
|
||||
)}
|
||||
{player.sessionId === mySessionId && (
|
||||
<span className="text-xs text-muted-foreground">(you)</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
37
packages/client/src/components/room-header.tsx
Normal file
37
packages/client/src/components/room-header.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import type { Act } from "@celebrate-esc/shared"
|
||||
|
||||
interface RoomHeaderProps {
|
||||
roomCode: string
|
||||
currentAct: Act
|
||||
connectionStatus: "disconnected" | "connecting" | "connected"
|
||||
}
|
||||
|
||||
const actLabels: Record<Act, string> = {
|
||||
lobby: "Lobby",
|
||||
act1: "Act 1",
|
||||
act2: "Act 2",
|
||||
act3: "Act 3",
|
||||
ended: "Ended",
|
||||
}
|
||||
|
||||
export function RoomHeader({ roomCode, currentAct, connectionStatus }: RoomHeaderProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between border-b p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-mono text-2xl font-bold tracking-widest">{roomCode}</span>
|
||||
<Badge variant="outline">{actLabels[currentAct]}</Badge>
|
||||
</div>
|
||||
<span
|
||||
className={`h-2 w-2 rounded-full ${
|
||||
connectionStatus === "connected"
|
||||
? "bg-green-500"
|
||||
: connectionStatus === "connecting"
|
||||
? "bg-yellow-500"
|
||||
: "bg-red-500"
|
||||
}`}
|
||||
title={connectionStatus}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
36
packages/client/src/components/ui/badge.tsx
Normal file
36
packages/client/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
56
packages/client/src/components/ui/button.tsx
Normal file
56
packages/client/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
79
packages/client/src/components/ui/card.tsx
Normal file
79
packages/client/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Card.displayName = "Card"
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-2xl font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = "CardDescription"
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = "CardFooter"
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
22
packages/client/src/components/ui/input.tsx
Normal file
22
packages/client/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
53
packages/client/src/components/ui/tabs.tsx
Normal file
53
packages/client/src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
103
packages/client/src/hooks/use-websocket.ts
Normal file
103
packages/client/src/hooks/use-websocket.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { useEffect, useRef, useCallback } from "react"
|
||||
import type { ClientMessage, ServerMessage } from "@celebrate-esc/shared"
|
||||
import { useRoomStore } from "@/stores/room-store"
|
||||
|
||||
const SESSION_KEY = "esc-party-session"
|
||||
|
||||
function getStoredSession(): { roomCode: string; sessionId: string } | null {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(SESSION_KEY)
|
||||
if (!raw) return null
|
||||
return JSON.parse(raw)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function storeSession(roomCode: string, sessionId: string) {
|
||||
sessionStorage.setItem(SESSION_KEY, JSON.stringify({ roomCode, sessionId }))
|
||||
}
|
||||
|
||||
export function useWebSocket(roomCode: string) {
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
const {
|
||||
setRoom,
|
||||
setMySessionId,
|
||||
setConnectionStatus,
|
||||
updatePlayerConnected,
|
||||
addPlayer,
|
||||
setAct,
|
||||
reset,
|
||||
} = useRoomStore()
|
||||
|
||||
const send = useCallback((message: ClientMessage) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify(message))
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const stored = getStoredSession()
|
||||
const sessionId = stored?.roomCode === roomCode ? stored.sessionId : null
|
||||
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"
|
||||
const wsUrl = sessionId
|
||||
? `${protocol}//${window.location.host}/api/ws/${roomCode}?sessionId=${sessionId}`
|
||||
: `${protocol}//${window.location.host}/api/ws/${roomCode}`
|
||||
|
||||
setConnectionStatus("connecting")
|
||||
const ws = new WebSocket(wsUrl)
|
||||
wsRef.current = ws
|
||||
|
||||
ws.onopen = () => {
|
||||
setConnectionStatus("connected")
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data) as ServerMessage
|
||||
|
||||
switch (msg.type) {
|
||||
case "room_state": {
|
||||
setRoom(msg.room)
|
||||
if (msg.sessionId) {
|
||||
setMySessionId(msg.sessionId)
|
||||
storeSession(roomCode, msg.sessionId)
|
||||
} else if (sessionId) {
|
||||
// Reconnected with stored session
|
||||
setMySessionId(sessionId)
|
||||
}
|
||||
break
|
||||
}
|
||||
case "player_joined":
|
||||
addPlayer(msg.player)
|
||||
break
|
||||
case "player_disconnected":
|
||||
updatePlayerConnected(msg.playerId, false)
|
||||
break
|
||||
case "player_reconnected":
|
||||
updatePlayerConnected(msg.playerId, true)
|
||||
break
|
||||
case "act_changed":
|
||||
setAct(msg.newAct)
|
||||
break
|
||||
case "room_ended":
|
||||
setAct("ended")
|
||||
break
|
||||
case "error":
|
||||
console.error("Server error:", msg.message)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
setConnectionStatus("disconnected")
|
||||
}
|
||||
|
||||
return () => {
|
||||
ws.close()
|
||||
reset()
|
||||
}
|
||||
}, [roomCode, setRoom, setMySessionId, setConnectionStatus, updatePlayerConnected, addPlayer, setAct, reset])
|
||||
|
||||
return { send }
|
||||
}
|
||||
6
packages/client/src/lib/utils.ts
Normal file
6
packages/client/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
19
packages/client/src/main.tsx
Normal file
19
packages/client/src/main.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import "./app.css"
|
||||
import { StrictMode } from "react"
|
||||
import { createRoot } from "react-dom/client"
|
||||
import { RouterProvider, createRouter } from "@tanstack/react-router"
|
||||
import { routeTree } from "./routeTree.gen"
|
||||
|
||||
const router = createRouter({ routeTree })
|
||||
|
||||
declare module "@tanstack/react-router" {
|
||||
interface Register {
|
||||
router: typeof router
|
||||
}
|
||||
}
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<RouterProvider router={router} />
|
||||
</StrictMode>,
|
||||
)
|
||||
118
packages/client/src/routeTree.gen.ts
Normal file
118
packages/client/src/routeTree.gen.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
/* eslint-disable */
|
||||
|
||||
// @ts-nocheck
|
||||
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
|
||||
// This file was automatically generated by TanStack Router.
|
||||
// You should NOT make any changes in this file as it will be overwritten.
|
||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as PlayRoomCodeRouteImport } from './routes/play.$roomCode'
|
||||
import { Route as HostRoomCodeRouteImport } from './routes/host.$roomCode'
|
||||
import { Route as DisplayRoomCodeRouteImport } from './routes/display.$roomCode'
|
||||
|
||||
const IndexRoute = IndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const PlayRoomCodeRoute = PlayRoomCodeRouteImport.update({
|
||||
id: '/play/$roomCode',
|
||||
path: '/play/$roomCode',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const HostRoomCodeRoute = HostRoomCodeRouteImport.update({
|
||||
id: '/host/$roomCode',
|
||||
path: '/host/$roomCode',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const DisplayRoomCodeRoute = DisplayRoomCodeRouteImport.update({
|
||||
id: '/display/$roomCode',
|
||||
path: '/display/$roomCode',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/display/$roomCode': typeof DisplayRoomCodeRoute
|
||||
'/host/$roomCode': typeof HostRoomCodeRoute
|
||||
'/play/$roomCode': typeof PlayRoomCodeRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/display/$roomCode': typeof DisplayRoomCodeRoute
|
||||
'/host/$roomCode': typeof HostRoomCodeRoute
|
||||
'/play/$roomCode': typeof PlayRoomCodeRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/display/$roomCode': typeof DisplayRoomCodeRoute
|
||||
'/host/$roomCode': typeof HostRoomCodeRoute
|
||||
'/play/$roomCode': typeof PlayRoomCodeRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths: '/' | '/display/$roomCode' | '/host/$roomCode' | '/play/$roomCode'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to: '/' | '/display/$roomCode' | '/host/$roomCode' | '/play/$roomCode'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
| '/display/$roomCode'
|
||||
| '/host/$roomCode'
|
||||
| '/play/$roomCode'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
DisplayRoomCodeRoute: typeof DisplayRoomCodeRoute
|
||||
HostRoomCodeRoute: typeof HostRoomCodeRoute
|
||||
PlayRoomCodeRoute: typeof PlayRoomCodeRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/': {
|
||||
id: '/'
|
||||
path: '/'
|
||||
fullPath: '/'
|
||||
preLoaderRoute: typeof IndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/play/$roomCode': {
|
||||
id: '/play/$roomCode'
|
||||
path: '/play/$roomCode'
|
||||
fullPath: '/play/$roomCode'
|
||||
preLoaderRoute: typeof PlayRoomCodeRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/host/$roomCode': {
|
||||
id: '/host/$roomCode'
|
||||
path: '/host/$roomCode'
|
||||
fullPath: '/host/$roomCode'
|
||||
preLoaderRoute: typeof HostRoomCodeRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/display/$roomCode': {
|
||||
id: '/display/$roomCode'
|
||||
path: '/display/$roomCode'
|
||||
fullPath: '/display/$roomCode'
|
||||
preLoaderRoute: typeof DisplayRoomCodeRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
DisplayRoomCodeRoute: DisplayRoomCodeRoute,
|
||||
HostRoomCodeRoute: HostRoomCodeRoute,
|
||||
PlayRoomCodeRoute: PlayRoomCodeRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
._addFileTypes<FileRouteTypes>()
|
||||
9
packages/client/src/routes/__root.tsx
Normal file
9
packages/client/src/routes/__root.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { createRootRoute, Outlet } from "@tanstack/react-router"
|
||||
|
||||
export const Route = createRootRoute({
|
||||
component: () => (
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
<Outlet />
|
||||
</div>
|
||||
),
|
||||
})
|
||||
56
packages/client/src/routes/display.$roomCode.tsx
Normal file
56
packages/client/src/routes/display.$roomCode.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { useWebSocket } from "@/hooks/use-websocket"
|
||||
import { useRoomStore } from "@/stores/room-store"
|
||||
import { PlayerList } from "@/components/player-list"
|
||||
import { RoomHeader } from "@/components/room-header"
|
||||
|
||||
export const Route = createFileRoute("/display/$roomCode")({
|
||||
component: DisplayView,
|
||||
})
|
||||
|
||||
function DisplayView() {
|
||||
const { roomCode } = Route.useParams()
|
||||
useWebSocket(roomCode)
|
||||
const { room, connectionStatus } = useRoomStore()
|
||||
|
||||
if (!room) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<p className="text-muted-foreground">
|
||||
{connectionStatus === "connecting" ? "Connecting..." : "Room not found"}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<RoomHeader roomCode={roomCode} currentAct={room.currentAct} connectionStatus={connectionStatus} />
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-8 p-8">
|
||||
{room.currentAct === "lobby" && <LobbyDisplay roomCode={roomCode} />}
|
||||
<PlayerList players={room.players} mySessionId={null} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LobbyDisplay({ roomCode }: { roomCode: string }) {
|
||||
const joinUrl = `${window.location.origin}/play/${roomCode}`
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
<h2 className="text-2xl text-muted-foreground">Join the party!</h2>
|
||||
<div className="rounded-lg border-4 border-dashed border-muted p-8">
|
||||
<span className="font-mono text-8xl font-bold tracking-[0.3em]">{roomCode}</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
Go to <span className="font-mono font-medium">{joinUrl}</span>
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">or scan the QR code</p>
|
||||
{/* QR code will be added in Plan 5 (polish) */}
|
||||
<div className="flex h-48 w-48 items-center justify-center rounded-lg border-2 border-dashed border-muted">
|
||||
<span className="text-sm text-muted-foreground">QR code</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
87
packages/client/src/routes/host.$roomCode.tsx
Normal file
87
packages/client/src/routes/host.$roomCode.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { useWebSocket } from "@/hooks/use-websocket"
|
||||
import { useRoomStore } from "@/stores/room-store"
|
||||
import { PlayerList } from "@/components/player-list"
|
||||
import { RoomHeader } from "@/components/room-header"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import type { Act } from "@celebrate-esc/shared"
|
||||
|
||||
export const Route = createFileRoute("/host/$roomCode")({
|
||||
component: HostView,
|
||||
})
|
||||
|
||||
const nextActLabels: Partial<Record<Act, string>> = {
|
||||
lobby: "Start Act 1",
|
||||
act1: "Start Act 2",
|
||||
act2: "Start Act 3",
|
||||
act3: "End Party",
|
||||
}
|
||||
|
||||
function HostView() {
|
||||
const { roomCode } = Route.useParams()
|
||||
const { send } = useWebSocket(roomCode)
|
||||
const { room, mySessionId, connectionStatus } = useRoomStore()
|
||||
|
||||
if (!room) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<p className="text-muted-foreground">
|
||||
{connectionStatus === "connecting" ? "Connecting..." : "Room not found"}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<RoomHeader roomCode={roomCode} currentAct={room.currentAct} connectionStatus={connectionStatus} />
|
||||
<Tabs defaultValue="host" className="flex-1">
|
||||
<TabsList className="w-full rounded-none">
|
||||
<TabsTrigger value="play" className="flex-1">
|
||||
Play
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="host" className="flex-1">
|
||||
Host
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="play" className="p-4">
|
||||
<PlayerList players={room.players} mySessionId={mySessionId} />
|
||||
{/* Game UI will be added in later plans */}
|
||||
</TabsContent>
|
||||
<TabsContent value="host" className="p-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Room Controls</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
{room.currentAct !== "ended" && (
|
||||
<Button onClick={() => send({ type: "advance_act" })} className="w-full">
|
||||
{nextActLabels[room.currentAct] ?? "Next"}
|
||||
</Button>
|
||||
)}
|
||||
{room.currentAct !== "ended" && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => send({ type: "end_room" })}
|
||||
className="w-full"
|
||||
>
|
||||
End Party
|
||||
</Button>
|
||||
)}
|
||||
{room.currentAct === "ended" && (
|
||||
<p className="text-center text-muted-foreground">
|
||||
The party has ended. Thanks for playing!
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<PlayerList players={room.players} mySessionId={mySessionId} />
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
123
packages/client/src/routes/index.tsx
Normal file
123
packages/client/src/routes/index.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { useState } from "react"
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { useRoomStore } from "@/stores/room-store"
|
||||
|
||||
export const Route = createFileRoute("/")({
|
||||
component: LandingPage,
|
||||
})
|
||||
|
||||
function LandingPage() {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center gap-8 p-4">
|
||||
<h1 className="text-5xl font-bold tracking-tight">ESC Party</h1>
|
||||
<p className="text-muted-foreground text-lg">Eurovision Song Contest — Party Companion</p>
|
||||
<div className="flex flex-col gap-6 sm:flex-row">
|
||||
<CreateRoomCard />
|
||||
<JoinRoomCard />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CreateRoomCard() {
|
||||
const [displayName, setDisplayName] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
const navigate = useNavigate()
|
||||
const setMySessionId = useRoomStore((s) => s.setMySessionId)
|
||||
|
||||
async function handleCreate() {
|
||||
if (!displayName.trim()) return
|
||||
setLoading(true)
|
||||
setError("")
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/rooms", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ displayName: displayName.trim() }),
|
||||
})
|
||||
const { data } = (await res.json()) as { data: { code: string; sessionId: string } }
|
||||
|
||||
// Store session for reconnection
|
||||
sessionStorage.setItem(
|
||||
"esc-party-session",
|
||||
JSON.stringify({
|
||||
roomCode: data.code,
|
||||
sessionId: data.sessionId,
|
||||
}),
|
||||
)
|
||||
setMySessionId(data.sessionId)
|
||||
|
||||
navigate({ to: "/host/$roomCode", params: { roomCode: data.code } })
|
||||
} catch {
|
||||
setError("Failed to create room")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="w-80">
|
||||
<CardHeader>
|
||||
<CardTitle>Host a Party</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<Input
|
||||
placeholder="Your name"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
maxLength={20}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
|
||||
/>
|
||||
<Button onClick={handleCreate} disabled={!displayName.trim() || loading}>
|
||||
{loading ? "Creating..." : "Create Room"}
|
||||
</Button>
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function JoinRoomCard() {
|
||||
const [roomCode, setRoomCode] = useState("")
|
||||
const [displayName, setDisplayName] = useState("")
|
||||
const navigate = useNavigate()
|
||||
|
||||
function handleJoin() {
|
||||
if (!roomCode.trim() || !displayName.trim()) return
|
||||
// Store display name temporarily — will be sent via WebSocket on connect
|
||||
sessionStorage.setItem("esc-party-join-name", displayName.trim())
|
||||
navigate({ to: "/play/$roomCode", params: { roomCode: roomCode.trim().toUpperCase() } })
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="w-80">
|
||||
<CardHeader>
|
||||
<CardTitle>Join a Party</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<Input
|
||||
placeholder="Room code"
|
||||
value={roomCode}
|
||||
onChange={(e) => setRoomCode(e.target.value.toUpperCase())}
|
||||
maxLength={4}
|
||||
className="text-center text-2xl tracking-widest"
|
||||
/>
|
||||
<Input
|
||||
placeholder="Your name"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
maxLength={20}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleJoin()}
|
||||
/>
|
||||
<Button onClick={handleJoin} disabled={roomCode.length !== 4 || !displayName.trim()}>
|
||||
Join Room
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
103
packages/client/src/routes/play.$roomCode.tsx
Normal file
103
packages/client/src/routes/play.$roomCode.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { useWebSocket } from "@/hooks/use-websocket"
|
||||
import { useRoomStore } from "@/stores/room-store"
|
||||
import { PlayerList } from "@/components/player-list"
|
||||
import { RoomHeader } from "@/components/room-header"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
|
||||
export const Route = createFileRoute("/play/$roomCode")({
|
||||
component: PlayerView,
|
||||
})
|
||||
|
||||
function PlayerView() {
|
||||
const { roomCode } = Route.useParams()
|
||||
const { send } = useWebSocket(roomCode)
|
||||
const { room, mySessionId, connectionStatus } = useRoomStore()
|
||||
const joinSentRef = useRef(false)
|
||||
const [manualName, setManualName] = useState("")
|
||||
|
||||
// Auto-send join_room when connected for the first time (no existing session)
|
||||
useEffect(() => {
|
||||
if (connectionStatus !== "connected" || mySessionId || joinSentRef.current) return
|
||||
|
||||
const displayName = sessionStorage.getItem("esc-party-join-name")
|
||||
if (displayName) {
|
||||
joinSentRef.current = true
|
||||
sessionStorage.removeItem("esc-party-join-name")
|
||||
send({ type: "join_room", displayName })
|
||||
}
|
||||
}, [connectionStatus, mySessionId, send])
|
||||
|
||||
if (!room) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<p className="text-muted-foreground">
|
||||
{connectionStatus === "connecting" ? "Connecting..." : "Room not found"}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Fallback: if no stored display name and no session (e.g., direct URL access),
|
||||
// show a name input form
|
||||
if (!mySessionId && connectionStatus === "connected" && !joinSentRef.current) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center gap-4 p-4">
|
||||
<h2 className="text-xl font-bold">Join Room {roomCode}</h2>
|
||||
<Input
|
||||
placeholder="Your name"
|
||||
value={manualName}
|
||||
onChange={(e) => setManualName(e.target.value)}
|
||||
maxLength={20}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && manualName.trim()) {
|
||||
joinSentRef.current = true
|
||||
send({ type: "join_room", displayName: manualName.trim() })
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (manualName.trim()) {
|
||||
joinSentRef.current = true
|
||||
send({ type: "join_room", displayName: manualName.trim() })
|
||||
}
|
||||
}}
|
||||
disabled={!manualName.trim()}
|
||||
>
|
||||
Join
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!mySessionId) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<p className="text-muted-foreground">Joining room...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<RoomHeader roomCode={roomCode} currentAct={room.currentAct} connectionStatus={connectionStatus} />
|
||||
<div className="flex-1 p-4">
|
||||
{room.currentAct === "lobby" && (
|
||||
<div className="flex flex-col items-center gap-4 py-8">
|
||||
<p className="text-lg text-muted-foreground">Waiting for the host to start...</p>
|
||||
</div>
|
||||
)}
|
||||
{room.currentAct === "ended" && (
|
||||
<div className="flex flex-col items-center gap-4 py-8">
|
||||
<p className="text-lg text-muted-foreground">The party has ended. Thanks for playing!</p>
|
||||
</div>
|
||||
)}
|
||||
{/* Game UI will be added in later plans */}
|
||||
<PlayerList players={room.players} mySessionId={mySessionId} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
58
packages/client/src/stores/room-store.ts
Normal file
58
packages/client/src/stores/room-store.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { create } from "zustand"
|
||||
import type { RoomState, Player } from "@celebrate-esc/shared"
|
||||
|
||||
interface RoomStore {
|
||||
room: RoomState | null
|
||||
mySessionId: string | null
|
||||
connectionStatus: "disconnected" | "connecting" | "connected"
|
||||
|
||||
setRoom: (room: RoomState) => void
|
||||
setMySessionId: (sessionId: string) => void
|
||||
setConnectionStatus: (status: "disconnected" | "connecting" | "connected") => void
|
||||
updatePlayerConnected: (playerId: string, connected: boolean) => void
|
||||
addPlayer: (player: Player) => void
|
||||
setAct: (act: RoomState["currentAct"]) => void
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
export const useRoomStore = create<RoomStore>((set) => ({
|
||||
room: null,
|
||||
mySessionId: null,
|
||||
connectionStatus: "disconnected",
|
||||
|
||||
setRoom: (room) => set({ room }),
|
||||
setMySessionId: (sessionId) => set({ mySessionId: sessionId }),
|
||||
setConnectionStatus: (status) => set({ connectionStatus: status }),
|
||||
|
||||
updatePlayerConnected: (playerId, connected) =>
|
||||
set((state) => {
|
||||
if (!state.room) return state
|
||||
return {
|
||||
room: {
|
||||
...state.room,
|
||||
players: state.room.players.map((p) => (p.id === playerId ? { ...p, connected } : p)),
|
||||
},
|
||||
}
|
||||
}),
|
||||
|
||||
addPlayer: (player) =>
|
||||
set((state) => {
|
||||
if (!state.room) return state
|
||||
// Avoid duplicates
|
||||
if (state.room.players.some((p) => p.id === player.id)) return state
|
||||
return {
|
||||
room: {
|
||||
...state.room,
|
||||
players: [...state.room.players, player],
|
||||
},
|
||||
}
|
||||
}),
|
||||
|
||||
setAct: (act) =>
|
||||
set((state) => {
|
||||
if (!state.room) return state
|
||||
return { room: { ...state.room, currentAct: act } }
|
||||
}),
|
||||
|
||||
reset: () => set({ room: null, mySessionId: null, connectionStatus: "disconnected" }),
|
||||
}))
|
||||
14
packages/client/tsconfig.json
Normal file
14
packages/client/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"types": ["vite/client"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
23
packages/client/vite.config.ts
Normal file
23
packages/client/vite.config.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import path from "node:path"
|
||||
import tailwindcss from "@tailwindcss/vite"
|
||||
import { TanStackRouterVite } from "@tanstack/router-plugin/vite"
|
||||
import react from "@vitejs/plugin-react"
|
||||
import { defineConfig } from "vite"
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [TanStackRouterVite(), react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://localhost:3001",
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user