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:
2026-03-11 12:52:32 +01:00
parent cd993e032d
commit ff311b5fac
54 changed files with 3964 additions and 0 deletions

View 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"
}

View 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>

View 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"
}
}

View 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);
}
}

View 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>
)
}

View 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>
)
}

View 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 }

View 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 }

View 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 }

View 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 }

View 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 }

View 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 }
}

View 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))
}

View 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>,
)

View 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>()

View 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>
),
})

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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" }),
}))

View File

@@ -0,0 +1,14 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"jsx": "react-jsx",
"outDir": "dist",
"rootDir": "src",
"types": ["vite/client"],
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}

View 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,
},
},
},
})