add RoomLayout, BottomNav components; update BingoCard (readonly + redraw), Leaderboard (lobbyMode)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,13 +1,16 @@
|
||||
import type { BingoCard as BingoCardType } from "@celebrate-esc/shared"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
interface BingoCardProps {
|
||||
card: BingoCardType
|
||||
onTap: (tropeId: string) => void
|
||||
readonly?: boolean
|
||||
onRequestNewCard?: () => void
|
||||
}
|
||||
|
||||
export function BingoCard({ card, onTap }: BingoCardProps) {
|
||||
export function BingoCard({ card, onTap, readonly, onRequestNewCard }: BingoCardProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
@@ -20,23 +23,31 @@ export function BingoCard({ card, onTap }: BingoCardProps) {
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<div className="grid grid-cols-4 gap-1.5">
|
||||
{card.squares.map((square) => (
|
||||
<button
|
||||
key={square.tropeId}
|
||||
type="button"
|
||||
onClick={() => onTap(square.tropeId)}
|
||||
onClick={() => !readonly && onTap(square.tropeId)}
|
||||
disabled={readonly}
|
||||
className={`flex aspect-square items-center justify-center rounded-md border p-1.5 text-center text-sm leading-snug transition-colors ${
|
||||
square.tapped
|
||||
? "border-primary bg-primary/20 font-medium text-primary"
|
||||
: "border-muted hover:bg-muted/50"
|
||||
}`}
|
||||
: readonly
|
||||
? "border-muted text-muted-foreground"
|
||||
: "border-muted hover:bg-muted/50"
|
||||
} ${readonly ? "cursor-default" : "cursor-pointer"}`}
|
||||
>
|
||||
{square.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{card.hasBingo && onRequestNewCard && (
|
||||
<Button onClick={onRequestNewCard} variant="outline" className="w-full">
|
||||
Draw New Card
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
|
||||
96
packages/client/src/components/bottom-nav.tsx
Normal file
96
packages/client/src/components/bottom-nav.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { Link, useMatchRoute } from "@tanstack/react-router"
|
||||
|
||||
interface BottomNavProps {
|
||||
basePath: "/play/$roomCode" | "/host/$roomCode"
|
||||
roomCode: string
|
||||
isHost: boolean
|
||||
}
|
||||
|
||||
function GameIcon({ active }: { active: boolean }) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={active ? 2.5 : 2} strokeLinecap="round" strokeLinejoin="round" className="h-5 w-5">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polygon points="10,8 16,12 10,16" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function BingoIcon({ active }: { active: boolean }) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={active ? 2.5 : 2} strokeLinecap="round" strokeLinejoin="round" className="h-5 w-5">
|
||||
<rect x="3" y="3" width="7" height="7" rx="1" />
|
||||
<rect x="14" y="3" width="7" height="7" rx="1" />
|
||||
<rect x="3" y="14" width="7" height="7" rx="1" />
|
||||
<rect x="14" y="14" width="7" height="7" rx="1" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function TrophyIcon({ active }: { active: boolean }) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={active ? 2.5 : 2} strokeLinecap="round" strokeLinejoin="round" className="h-5 w-5">
|
||||
<path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6" />
|
||||
<path d="M18 9h1.5a2.5 2.5 0 0 0 0-5H18" />
|
||||
<path d="M4 22h16" />
|
||||
<path d="M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20 7 22" />
|
||||
<path d="M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20 17 22" />
|
||||
<path d="M18 2H6v7a6 6 0 0 0 12 0V2Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function WrenchIcon({ active }: { active: boolean }) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={active ? 2.5 : 2} strokeLinecap="round" strokeLinejoin="round" className="h-5 w-5">
|
||||
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
interface TabConfig {
|
||||
to: string
|
||||
label: string
|
||||
icon: (props: { active: boolean }) => React.ReactNode
|
||||
}
|
||||
|
||||
export function BottomNav({ basePath, roomCode, isHost }: BottomNavProps) {
|
||||
const matchRoute = useMatchRoute()
|
||||
|
||||
const tabs: TabConfig[] = [
|
||||
{ to: `${basePath}/game`, label: "Game", icon: GameIcon },
|
||||
{ to: `${basePath}/bingo`, label: "Bingo", icon: BingoIcon },
|
||||
{ to: `${basePath}/board`, label: isHost ? "Board" : "Leaderboard", icon: TrophyIcon },
|
||||
]
|
||||
|
||||
if (isHost) {
|
||||
tabs.push({ to: `${basePath}/host`, label: "Host", icon: WrenchIcon })
|
||||
}
|
||||
|
||||
return (
|
||||
<nav
|
||||
className="fixed bottom-0 left-0 right-0 z-50 border-t bg-background"
|
||||
style={{ paddingBottom: "env(safe-area-inset-bottom)" }}
|
||||
>
|
||||
<div className="flex">
|
||||
{tabs.map((tab) => {
|
||||
const active = !!matchRoute({ to: tab.to, params: { roomCode } })
|
||||
return (
|
||||
<Link
|
||||
key={tab.to}
|
||||
to={tab.to}
|
||||
params={{ roomCode }}
|
||||
className={`flex flex-1 flex-col items-center gap-0.5 py-2 text-xs transition-colors ${
|
||||
active
|
||||
? "text-primary"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
<tab.icon active={active} />
|
||||
<span>{tab.label}</span>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -4,15 +4,16 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
interface LeaderboardProps {
|
||||
entries: LeaderboardEntry[]
|
||||
resultsEntered?: boolean
|
||||
lobbyMode?: boolean
|
||||
}
|
||||
|
||||
export function Leaderboard({ entries, resultsEntered }: LeaderboardProps) {
|
||||
export function Leaderboard({ entries, resultsEntered, lobbyMode }: LeaderboardProps) {
|
||||
if (entries.length === 0) return null
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Leaderboard</CardTitle>
|
||||
<CardTitle>{lobbyMode ? `Players (${entries.length})` : "Leaderboard"}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
@@ -24,25 +25,29 @@ export function Leaderboard({ entries, resultsEntered }: LeaderboardProps) {
|
||||
</span>
|
||||
<span className="text-sm font-medium">{entry.displayName}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span title="Prediction points">P:{resultsEntered ? entry.predictionPoints : "?"}</span>
|
||||
<span title="Jury points">J:{entry.juryPoints}</span>
|
||||
<span title="Bingo points">B:{entry.bingoPoints}</span>
|
||||
<span title="Quiz points">Q:{entry.quizPoints}</span>
|
||||
<span className="text-sm font-bold text-foreground">{entry.totalPoints}</span>
|
||||
</div>
|
||||
{!lobbyMode && (
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span title="Prediction points">P:{resultsEntered ? entry.predictionPoints : "?"}</span>
|
||||
<span title="Jury points">J:{entry.juryPoints}</span>
|
||||
<span title="Bingo points">B:{entry.bingoPoints}</span>
|
||||
<span title="Quiz points">Q:{entry.quizPoints}</span>
|
||||
<span className="text-sm font-bold text-foreground">{entry.totalPoints}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="rounded-md bg-muted/50 p-3 text-xs text-muted-foreground">
|
||||
<p className="mb-1 font-medium">How scoring works</p>
|
||||
<ul className="flex flex-col gap-0.5">
|
||||
<li><strong>P</strong> = Prediction points — 25 for correct winner, 10 each for 2nd/3rd, 15 for last place</li>
|
||||
<li><strong>J</strong> = Jury points — rate each act 1-12, earn up to 5 pts per round for matching the group consensus</li>
|
||||
<li><strong>B</strong> = Bingo points — 2 pts per tapped trope + 10 bonus for a full bingo line</li>
|
||||
<li><strong>Q</strong> = Quiz points — 5 easy, 10 medium, 15 hard</li>
|
||||
</ul>
|
||||
</div>
|
||||
{!lobbyMode && (
|
||||
<div className="rounded-md bg-muted/50 p-3 text-xs text-muted-foreground">
|
||||
<p className="mb-1 font-medium">How scoring works</p>
|
||||
<ul className="flex flex-col gap-0.5">
|
||||
<li><strong>P</strong> = Prediction points — 25 for correct winner, 10 each for 2nd/3rd, 15 for last place</li>
|
||||
<li><strong>J</strong> = Jury points — rate each act 1-12, earn up to 5 pts per round for matching the group consensus</li>
|
||||
<li><strong>B</strong> = Bingo points — 2 pts per tapped trope + 10 bonus for a full bingo line</li>
|
||||
<li><strong>Q</strong> = Quiz points — 5 easy, 10 medium, 15 hard</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
|
||||
32
packages/client/src/components/room-layout.tsx
Normal file
32
packages/client/src/components/room-layout.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Outlet } from "@tanstack/react-router"
|
||||
|
||||
interface RoomLayoutProps {
|
||||
roomCode: string
|
||||
connectionStatus: "disconnected" | "connecting" | "connected"
|
||||
}
|
||||
|
||||
export function RoomLayout({ roomCode, connectionStatus }: RoomLayoutProps) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<header className="sticky top-0 z-50 flex items-center justify-between border-b bg-background px-4 py-3" style={{ paddingTop: "max(0.75rem, env(safe-area-inset-top))" }}>
|
||||
<span className="text-lg font-bold">I❤️ESC</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-sm font-bold tracking-widest">{roomCode}</span>
|
||||
<span
|
||||
className={`h-2 w-2 rounded-full ${
|
||||
connectionStatus === "connected"
|
||||
? "bg-green-500"
|
||||
: connectionStatus === "connecting"
|
||||
? "bg-yellow-500"
|
||||
: "bg-red-500"
|
||||
}`}
|
||||
title={connectionStatus}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex-1 pb-20">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user