feat(panel): implement GameLobby and GameRoom pages
Some checks failed
Deploy to Production / test (push) Failing after 34s
Some checks failed
Deploy to Production / test (push) Failing after 34s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,163 @@
|
|||||||
export function GameLobby() {
|
import { useState, useEffect } from "react";
|
||||||
return <div className="text-text-tertiary">Game Lobby — loading...</div>;
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useWebSocket } from "../lib/useWebSocket";
|
||||||
|
import { gameUIRegistry } from "./registry";
|
||||||
|
import "./chess";
|
||||||
|
|
||||||
|
interface RoomSummary {
|
||||||
|
id: string;
|
||||||
|
gameSlug: string;
|
||||||
|
gameName: string;
|
||||||
|
host: string;
|
||||||
|
playerCount: number;
|
||||||
|
maxPlayers: number;
|
||||||
|
spectatorCount: number;
|
||||||
|
status: "waiting" | "playing" | "finished";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GameLobby() {
|
||||||
|
const { send, subscribe, connected } = useWebSocket();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [rooms, setRooms] = useState<RoomSummary[]>([]);
|
||||||
|
const [filter, setFilter] = useState<string | null>(null);
|
||||||
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
|
||||||
|
const gameTypes = gameUIRegistry.list();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!connected) return;
|
||||||
|
|
||||||
|
const unsubscribe = subscribe((msg: any) => {
|
||||||
|
if (msg.type === "ROOM_LIST_UPDATE") {
|
||||||
|
setRooms(msg.rooms);
|
||||||
|
}
|
||||||
|
if (msg.type === "ROOM_CREATED") {
|
||||||
|
navigate(`/${msg.gameSlug}/${msg.roomId}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return unsubscribe;
|
||||||
|
}, [connected, subscribe, navigate]);
|
||||||
|
|
||||||
|
const filteredRooms = filter ? rooms.filter(r => r.gameSlug === filter) : rooms;
|
||||||
|
const activeRooms = filteredRooms.filter(r => r.status !== "finished");
|
||||||
|
|
||||||
|
function createRoom(gameSlug: string) {
|
||||||
|
send({ type: "CREATE_ROOM", gameType: gameSlug });
|
||||||
|
setShowCreate(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="font-display text-lg font-semibold">Games</h1>
|
||||||
|
<p className="text-sm text-text-tertiary">Browse and create game rooms</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreate(true)}
|
||||||
|
className="rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium hover:bg-primary/90 transition-colors"
|
||||||
|
>
|
||||||
|
+ Create Room
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 mb-4">
|
||||||
|
<button
|
||||||
|
onClick={() => setFilter(null)}
|
||||||
|
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
|
||||||
|
filter === null ? "bg-primary/15 text-primary" : "bg-card text-text-tertiary hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
All Games
|
||||||
|
</button>
|
||||||
|
{gameTypes.map(g => (
|
||||||
|
<button
|
||||||
|
key={g.slug}
|
||||||
|
onClick={() => setFilter(g.slug)}
|
||||||
|
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
|
||||||
|
filter === g.slug ? "bg-primary/15 text-primary" : "bg-card text-text-tertiary hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{g.icon} {g.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-card rounded-lg border border-border">
|
||||||
|
<div className="flex items-center gap-2 px-5 py-3 border-b border-border">
|
||||||
|
<span className="text-sm font-semibold">Active Rooms</span>
|
||||||
|
<span className="text-xs text-text-disabled">({activeRooms.length})</span>
|
||||||
|
</div>
|
||||||
|
{activeRooms.length === 0 ? (
|
||||||
|
<div className="px-5 py-8 text-center text-sm text-text-tertiary">
|
||||||
|
No active rooms. Create one to get started!
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
{activeRooms.map(room => {
|
||||||
|
const plugin = gameUIRegistry.get(room.gameSlug);
|
||||||
|
return (
|
||||||
|
<div key={room.id} className="flex items-center justify-between px-5 py-3 hover:bg-raised/40 transition-colors">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-lg">{plugin?.icon ?? "🎮"}</span>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium">{room.gameName}</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-text-tertiary">
|
||||||
|
<span className={`inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold ${
|
||||||
|
room.status === "waiting"
|
||||||
|
? "bg-warning/15 text-warning"
|
||||||
|
: "bg-success/15 text-success"
|
||||||
|
}`}>
|
||||||
|
{room.status === "waiting" ? "Waiting" : "Playing"}
|
||||||
|
</span>
|
||||||
|
<span>{room.playerCount}/{room.maxPlayers} players</span>
|
||||||
|
{room.spectatorCount > 0 && <span>· 👁 {room.spectatorCount}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/${room.gameSlug}/${room.id}`)}
|
||||||
|
className={`rounded-md px-3 py-1.5 text-xs font-semibold transition-colors ${
|
||||||
|
room.status === "waiting"
|
||||||
|
? "bg-primary text-primary-foreground hover:bg-primary/90"
|
||||||
|
: "bg-card border border-border text-text-tertiary hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{room.status === "waiting" ? "Join" : "Spectate"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showCreate && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => setShowCreate(false)}>
|
||||||
|
<div className="bg-card border border-border rounded-lg p-6 w-full max-w-sm" onClick={e => e.stopPropagation()}>
|
||||||
|
<h2 className="font-display text-base font-semibold mb-4">Create a Room</h2>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{gameTypes.map(g => (
|
||||||
|
<button
|
||||||
|
key={g.slug}
|
||||||
|
onClick={() => createRoom(g.slug)}
|
||||||
|
className="w-full flex items-center gap-3 rounded-md border border-border px-4 py-3 text-sm font-medium hover:bg-raised/40 transition-colors"
|
||||||
|
>
|
||||||
|
<span className="text-lg">{g.icon}</span>
|
||||||
|
<span>{g.name}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreate(false)}
|
||||||
|
className="mt-4 w-full text-center text-sm text-text-tertiary hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,118 @@
|
|||||||
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
|
import { useGameRoom } from "../lib/useGameRoom";
|
||||||
|
import { gameUIRegistry } from "./registry";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import "./chess";
|
||||||
|
|
||||||
export function GameRoom({ userId }: { userId: string }) {
|
export function GameRoom({ userId }: { userId: string }) {
|
||||||
return <div className="text-text-tertiary">Game Room — loading...</div>;
|
const { gameSlug, roomId } = useParams<{ gameSlug: string; roomId: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const {
|
||||||
|
gameState, players, spectators, roomStatus,
|
||||||
|
isSpectator, gameOver, error, sendAction, leaveRoom,
|
||||||
|
} = useGameRoom(roomId!, userId);
|
||||||
|
|
||||||
|
const plugin = gameSlug ? gameUIRegistry.get(gameSlug) : undefined;
|
||||||
|
|
||||||
|
if (!plugin) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-16">
|
||||||
|
<div className="text-lg font-display font-semibold mb-2">Unknown Game</div>
|
||||||
|
<p className="text-sm text-text-tertiary mb-4">The game type "{gameSlug}" doesn't exist.</p>
|
||||||
|
<button onClick={() => navigate("/games")} className="text-sm text-primary hover:underline">
|
||||||
|
Back to Games
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (roomStatus === "not_found") {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-16">
|
||||||
|
<div className="text-lg font-display font-semibold mb-2">Room Not Found</div>
|
||||||
|
<p className="text-sm text-text-tertiary mb-4">This room no longer exists or has expired.</p>
|
||||||
|
<button onClick={() => navigate("/games")} className="text-sm text-primary hover:underline">
|
||||||
|
Back to Games
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (roomStatus === "connecting") {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-16">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin text-text-tertiary" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const GameComponent = plugin.component;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-xl">{plugin.icon}</span>
|
||||||
|
<div>
|
||||||
|
<h1 className="font-display text-base font-semibold">{plugin.name}</h1>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-text-tertiary">
|
||||||
|
<span className={`inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold ${
|
||||||
|
roomStatus === "waiting" ? "bg-warning/15 text-warning"
|
||||||
|
: roomStatus === "playing" ? "bg-success/15 text-success"
|
||||||
|
: "bg-card text-text-tertiary"
|
||||||
|
}`}>
|
||||||
|
{roomStatus === "waiting" ? "Waiting" : roomStatus === "playing" ? "Playing" : "Finished"}
|
||||||
|
</span>
|
||||||
|
{isSpectator && <span className="text-text-disabled">Spectating</span>}
|
||||||
|
<span>👁 {spectators.length}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => { leaveRoom(); navigate("/games"); }}
|
||||||
|
className="rounded-md px-3 py-1.5 text-sm font-medium bg-card border border-border text-text-tertiary hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
Leave
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-2 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{gameOver && (
|
||||||
|
<div className="mb-4 rounded-lg border border-primary/30 bg-primary/10 px-4 py-3">
|
||||||
|
<div className="text-sm font-semibold text-primary">
|
||||||
|
{gameOver.winner
|
||||||
|
? `Winner: ${players.find(p => p.discordId === gameOver.winner)?.username ?? gameOver.winner}`
|
||||||
|
: "Draw!"}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-text-tertiary mt-1">Reason: {gameOver.reason}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{roomStatus === "waiting" && (
|
||||||
|
<div className="bg-card rounded-lg border border-border p-8 text-center">
|
||||||
|
<div className="text-sm text-text-tertiary mb-2">
|
||||||
|
Waiting for players ({players.length}/2)
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-text-disabled">
|
||||||
|
Share this URL to invite: <span className="font-mono bg-surface px-2 py-0.5 rounded select-all">{window.location.href}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(roomStatus === "playing" || roomStatus === "finished") && gameState && (
|
||||||
|
<GameComponent
|
||||||
|
state={gameState}
|
||||||
|
myPlayerId={userId}
|
||||||
|
isSpectator={isSpectator}
|
||||||
|
onAction={sendAction}
|
||||||
|
players={players}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user