fix(chess): send game updates to move sender + responsive mobile redesign
Some checks failed
Deploy to Production / test (push) Failing after 32s

Bun's ws.publish() excludes the sender, so the player making a move never
received the GAME_UPDATE with the new FEN — causing pieces to snap back.
Added ctx.send() alongside ctx.publish() for GAME_UPDATE and GAME_ENDED.

Also redesigned the panel for mobile: hamburger drawer sidebar, responsive
chess board sizing via ResizeObserver, stacked layouts on small screens,
and touch-friendly modals/controls across lobby and game pages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
syntaxbullet
2026-04-02 14:58:40 +02:00
parent 24211dca14
commit 9c4da51cfb
5 changed files with 166 additions and 89 deletions

View File

@@ -77,15 +77,19 @@ export function handleGameMessage(msg: GameWsClientMessage, ctx: WsContext): voi
} }
const spectatorView = roomManager.getSpectatorView(msg.roomId); const spectatorView = roomManager.getSpectatorView(msg.roomId);
ctx.publish(`room:${msg.roomId}`, JSON.stringify({ type: "GAME_UPDATE", roomId: msg.roomId, state: spectatorView })); const updateMsg = JSON.stringify({ type: "GAME_UPDATE", roomId: msg.roomId, state: spectatorView });
ctx.publish(`room:${msg.roomId}`, updateMsg);
ctx.send(updateMsg);
if (result.gameOver) { if (result.gameOver) {
ctx.publish(`room:${msg.roomId}`, JSON.stringify({ const endedMsg = JSON.stringify({
type: "GAME_ENDED", type: "GAME_ENDED",
roomId: msg.roomId, roomId: msg.roomId,
winner: result.gameOver.winner, winner: result.gameOver.winner,
reason: result.gameOver.reason, reason: result.gameOver.reason,
})); });
ctx.publish(`room:${msg.roomId}`, endedMsg);
ctx.send(endedMsg);
ctx.publish("lobby", JSON.stringify({ type: "ROOM_LIST_UPDATE", rooms: roomManager.listRooms() })); ctx.publish("lobby", JSON.stringify({ type: "ROOM_LIST_UPDATE", rooms: roomManager.listRooms() }));
} }
break; break;

View File

@@ -13,8 +13,10 @@ import {
ChevronRight, ChevronRight,
Gamepad2, Gamepad2,
Trophy, Trophy,
Menu,
X,
} from "lucide-react"; } from "lucide-react";
import { useState } from "react"; import { useState, useEffect } from "react";
import { useLocation, useNavigate } from "react-router-dom"; import { useLocation, useNavigate } from "react-router-dom";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import type { AuthUser } from "../lib/useAuth"; import type { AuthUser } from "../lib/useAuth";
@@ -54,6 +56,7 @@ export default function Layout({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -63,6 +66,11 @@ export default function Layout({
? `https://cdn.discordapp.com/avatars/${user.discordId}/${user.avatar}.png?size=64` ? `https://cdn.discordapp.com/avatars/${user.discordId}/${user.avatar}.png?size=64`
: null; : null;
// Close mobile drawer on route change
useEffect(() => {
setMobileOpen(false);
}, [location.pathname]);
function isActive(path: string): boolean { function isActive(path: string): boolean {
if (path === "/admin" && location.pathname === "/admin") return true; if (path === "/admin" && location.pathname === "/admin") return true;
if (path === "/dashboard" && location.pathname === "/dashboard") return true; if (path === "/dashboard" && location.pathname === "/dashboard") return true;
@@ -70,75 +78,123 @@ export default function Layout({
return false; return false;
} }
function handleNav(path: string) {
navigate(path);
setMobileOpen(false);
}
const sidebarContent = (
<>
<nav className="flex-1 py-3 px-2 space-y-1 overflow-y-auto">
{navItems.map(({ path, label, icon: Icon }) => (
<button
key={path}
onClick={() => handleNav(path)}
className={cn(
"w-full flex items-center gap-3 rounded-md px-3 py-2.5 text-sm font-medium transition-colors",
isActive(path)
? "bg-primary/15 text-primary border-l-4 border-primary"
: "text-text-tertiary hover:bg-primary/8 hover:text-foreground"
)}
>
<Icon className={cn("w-5 h-5 shrink-0", isActive(path) && "text-primary")} />
{(!collapsed || mobileOpen) && <span>{label}</span>}
</button>
))}
</nav>
<div className="border-t border-border p-3 space-y-2">
{(!collapsed || mobileOpen) && (
<div className="flex items-center gap-3 px-2 py-1.5">
{avatarUrl ? (
<img src={avatarUrl} alt={user.username} className="w-8 h-8 rounded-full" />
) : (
<div className="w-8 h-8 rounded-full bg-surface flex items-center justify-center text-xs font-medium">
{user.username[0]?.toUpperCase()}
</div>
)}
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{user.username}</div>
</div>
</div>
)}
<div className={cn("flex", collapsed && !mobileOpen ? "flex-col items-center gap-2" : "items-center justify-between px-2")}>
<button
onClick={logout}
className="inline-flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-text-tertiary hover:text-destructive hover:bg-destructive/10 transition-colors"
title="Sign out"
>
<LogOut className="w-4 h-4" />
{(!collapsed || mobileOpen) && <span>Sign out</span>}
</button>
{/* Collapse toggle only on desktop */}
<button
onClick={() => setCollapsed((c) => !c)}
className="hidden md:block p-1.5 rounded-md text-text-tertiary hover:text-foreground hover:bg-primary/10 transition-colors"
title={collapsed ? "Expand sidebar" : "Collapse sidebar"}
>
{collapsed ? <ChevronRight className="w-4 h-4" /> : <ChevronLeft className="w-4 h-4" />}
</button>
</div>
</div>
</>
);
return ( return (
<div className="min-h-screen flex"> <div className="min-h-screen flex">
{/* Mobile header bar */}
<div className="fixed top-0 left-0 right-0 z-40 flex items-center h-14 px-4 bg-background border-b border-border md:hidden">
<button
onClick={() => setMobileOpen(true)}
className="p-2 -ml-2 rounded-md text-text-tertiary hover:text-foreground hover:bg-primary/10 transition-colors"
>
<Menu className="w-5 h-5" />
</button>
<div className="font-display text-lg font-bold tracking-tight ml-3">Aurora</div>
</div>
{/* Mobile overlay */}
{mobileOpen && (
<div
className="fixed inset-0 z-50 bg-black/50 md:hidden"
onClick={() => setMobileOpen(false)}
/>
)}
{/* Sidebar - mobile drawer + desktop fixed */}
<aside <aside
className={cn( className={cn(
"fixed inset-y-0 left-0 z-50 flex flex-col bg-background border-r border-border transition-all duration-200", "fixed inset-y-0 left-0 z-50 flex flex-col bg-background border-r border-border transition-all duration-200",
collapsed ? "w-16" : "w-60" // Mobile: off-screen drawer, shown when mobileOpen
"w-60 -translate-x-full md:translate-x-0",
mobileOpen && "translate-x-0",
// Desktop: respect collapsed state
!mobileOpen && collapsed && "md:w-16"
)} )}
> >
<div className="flex items-center h-16 px-4 border-b border-border"> <div className="flex items-center justify-between h-14 md:h-16 px-4 border-b border-border">
<div className="font-display text-xl font-bold tracking-tight"> <div className="font-display text-xl font-bold tracking-tight">
{collapsed ? "A" : "Aurora"} {collapsed && !mobileOpen ? "A" : "Aurora"}
</div> </div>
{/* Close button on mobile */}
<button
onClick={() => setMobileOpen(false)}
className="p-1.5 rounded-md text-text-tertiary hover:text-foreground md:hidden"
>
<X className="w-5 h-5" />
</button>
</div> </div>
<nav className="flex-1 py-3 px-2 space-y-1 overflow-y-auto"> {sidebarContent}
{navItems.map(({ path, label, icon: Icon }) => (
<button
key={path}
onClick={() => navigate(path)}
className={cn(
"w-full flex items-center gap-3 rounded-md px-3 py-2.5 text-sm font-medium transition-colors",
isActive(path)
? "bg-primary/15 text-primary border-l-4 border-primary"
: "text-text-tertiary hover:bg-primary/8 hover:text-foreground"
)}
>
<Icon className={cn("w-5 h-5 shrink-0", isActive(path) && "text-primary")} />
{!collapsed && <span>{label}</span>}
</button>
))}
</nav>
<div className="border-t border-border p-3 space-y-2">
{!collapsed && (
<div className="flex items-center gap-3 px-2 py-1.5">
{avatarUrl ? (
<img src={avatarUrl} alt={user.username} className="w-8 h-8 rounded-full" />
) : (
<div className="w-8 h-8 rounded-full bg-surface flex items-center justify-center text-xs font-medium">
{user.username[0]?.toUpperCase()}
</div>
)}
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{user.username}</div>
</div>
</div>
)}
<div className={cn("flex", collapsed ? "flex-col items-center gap-2" : "items-center justify-between px-2")}>
<button
onClick={logout}
className="inline-flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-text-tertiary hover:text-destructive hover:bg-destructive/10 transition-colors"
title="Sign out"
>
<LogOut className="w-4 h-4" />
{!collapsed && <span>Sign out</span>}
</button>
<button
onClick={() => setCollapsed((c) => !c)}
className="p-1.5 rounded-md text-text-tertiary hover:text-foreground hover:bg-primary/10 transition-colors"
title={collapsed ? "Expand sidebar" : "Collapse sidebar"}
>
{collapsed ? <ChevronRight className="w-4 h-4" /> : <ChevronLeft className="w-4 h-4" />}
</button>
</div>
</div>
</aside> </aside>
<main className={cn("flex-1 transition-all duration-200", collapsed ? "ml-16" : "ml-60")}> <main className={cn(
<div className="max-w-[1600px] mx-auto px-6 py-8"> "flex-1 transition-all duration-200",
// Mobile: no margin, pad for top header bar
"mt-14 md:mt-0",
collapsed ? "md:ml-16" : "md:ml-60"
)}>
<div className="max-w-[1600px] mx-auto px-4 py-6 md:px-6 md:py-8">
{children} {children}
</div> </div>
</main> </main>

View File

@@ -49,20 +49,20 @@ export function GameLobby() {
return ( return (
<div> <div>
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between gap-3 mb-4 md:mb-6">
<div> <div className="min-w-0">
<h1 className="font-display text-lg font-semibold">Games</h1> <h1 className="font-display text-lg font-semibold">Games</h1>
<p className="text-sm text-text-tertiary">Browse and create game rooms</p> <p className="text-sm text-text-tertiary hidden sm:block">Browse and create game rooms</p>
</div> </div>
<button <button
onClick={() => setShowCreate(true)} 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" className="rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium hover:bg-primary/90 transition-colors shrink-0"
> >
+ Create Room + Create Room
</button> </button>
</div> </div>
<div className="flex gap-2 mb-4"> <div className="flex gap-2 mb-4 overflow-x-auto pb-1">
<button <button
onClick={() => setFilter(null)} onClick={() => setFilter(null)}
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${ className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
@@ -98,11 +98,11 @@ export function GameLobby() {
{activeRooms.map(room => { {activeRooms.map(room => {
const plugin = gameUIRegistry.get(room.gameSlug); const plugin = gameUIRegistry.get(room.gameSlug);
return ( return (
<div key={room.id} className="flex items-center justify-between px-5 py-3 hover:bg-raised/40 transition-colors"> <div key={room.id} className="flex items-center justify-between gap-3 px-4 py-3 md:px-5 hover:bg-raised/40 transition-colors">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3 min-w-0">
<span className="text-lg">{plugin?.icon ?? "🎮"}</span> <span className="text-lg shrink-0">{plugin?.icon ?? "🎮"}</span>
<div> <div className="min-w-0">
<div className="text-sm font-medium">{room.gameName}</div> <div className="text-sm font-medium truncate">{room.gameName}</div>
<div className="flex items-center gap-2 text-xs text-text-tertiary"> <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 ${ <span className={`inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold ${
room.status === "waiting" room.status === "waiting"
@@ -118,7 +118,7 @@ export function GameLobby() {
</div> </div>
<button <button
onClick={() => navigate(`/${room.gameSlug}/${room.id}`)} onClick={() => navigate(`/${room.gameSlug}/${room.id}`)}
className={`rounded-md px-3 py-1.5 text-xs font-semibold transition-colors ${ className={`rounded-md px-3 py-1.5 text-xs font-semibold transition-colors shrink-0 ${
room.status === "waiting" room.status === "waiting"
? "bg-primary text-primary-foreground hover:bg-primary/90" ? "bg-primary text-primary-foreground hover:bg-primary/90"
: "bg-card border border-border text-text-tertiary hover:text-foreground" : "bg-card border border-border text-text-tertiary hover:text-foreground"
@@ -134,8 +134,8 @@ export function GameLobby() {
</div> </div>
{showCreate && ( {showCreate && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => setShowCreate(false)}> <div className="fixed inset-0 z-50 flex items-end sm: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()}> <div className="bg-card border border-border rounded-t-xl sm:rounded-lg p-6 w-full sm:max-w-sm" onClick={e => e.stopPropagation()}>
<h2 className="font-display text-base font-semibold mb-4">Create a Room</h2> <h2 className="font-display text-base font-semibold mb-4">Create a Room</h2>
<div className="space-y-2"> <div className="space-y-2">
{gameTypes.map(g => ( {gameTypes.map(g => (

View File

@@ -50,11 +50,11 @@ export function GameRoom({ userId }: { userId: string }) {
return ( return (
<div> <div>
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between gap-3 mb-4 md:mb-6">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3 min-w-0">
<span className="text-xl">{plugin.icon}</span> <span className="text-xl shrink-0">{plugin.icon}</span>
<div> <div className="min-w-0">
<h1 className="font-display text-base font-semibold">{plugin.name}</h1> <h1 className="font-display text-base font-semibold truncate">{plugin.name}</h1>
<div className="flex items-center gap-2 text-xs text-text-tertiary"> <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 ${ <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 === "waiting" ? "bg-warning/15 text-warning"
@@ -70,7 +70,7 @@ export function GameRoom({ userId }: { userId: string }) {
</div> </div>
<button <button
onClick={() => { leaveRoom(); navigate("/games"); }} 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" 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 shrink-0"
> >
Leave Leave
</button> </button>
@@ -94,12 +94,13 @@ export function GameRoom({ userId }: { userId: string }) {
)} )}
{roomStatus === "waiting" && ( {roomStatus === "waiting" && (
<div className="bg-card rounded-lg border border-border p-8 text-center"> <div className="bg-card rounded-lg border border-border p-5 md:p-8 text-center">
<div className="text-sm text-text-tertiary mb-2"> <div className="text-sm text-text-tertiary mb-2">
Waiting for players ({players.length}/2) Waiting for players ({players.length}/2)
</div> </div>
<div className="text-xs text-text-disabled"> <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> Share this URL to invite:
<span className="block mt-1 font-mono bg-surface px-2 py-1 rounded select-all text-[11px] break-all">{window.location.href}</span>
</div> </div>
</div> </div>
)} )}

View File

@@ -1,4 +1,4 @@
import { useState, useMemo } from "react"; import { useState, useMemo, useRef, useEffect } from "react";
import { Chessboard } from "react-chessboard"; import { Chessboard } from "react-chessboard";
import { Chess } from "chess.js"; import { Chess } from "chess.js";
import type { GameUIProps } from "../registry"; import type { GameUIProps } from "../registry";
@@ -15,6 +15,22 @@ export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players }
const chess = state as ChessState; const chess = state as ChessState;
const [promotionFrom, setPromotionFrom] = useState<string | null>(null); const [promotionFrom, setPromotionFrom] = useState<string | null>(null);
const [promotionTo, setPromotionTo] = useState<string | null>(null); const [promotionTo, setPromotionTo] = useState<string | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [boardWidth, setBoardWidth] = useState(400);
// Responsive board sizing
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const observer = new ResizeObserver((entries) => {
const width = entries[0]?.contentRect.width ?? 400;
// Cap board at 400px, floor at 280px
setBoardWidth(Math.max(280, Math.min(400, width)));
});
observer.observe(container);
return () => observer.disconnect();
}, []);
const game = useMemo(() => { const game = useMemo(() => {
if (!chess?.fen) return null; if (!chess?.fen) return null;
@@ -89,7 +105,6 @@ export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players }
// Highlight king in check // Highlight king in check
const customSquareStyles: Record<string, React.CSSProperties> = {}; const customSquareStyles: Record<string, React.CSSProperties> = {};
if (game.inCheck()) { if (game.inCheck()) {
// Find the king square of the player in check
const board = game.board(); const board = game.board();
const kingColor = game.turn(); const kingColor = game.turn();
for (let r = 0; r < 8; r++) { for (let r = 0; r < 8; r++) {
@@ -108,8 +123,9 @@ export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players }
} }
return ( return (
<div className="flex gap-4"> <div className="flex flex-col md:flex-row gap-4">
<div> {/* Board column */}
<div ref={containerRef} className="w-full max-w-[400px]">
{/* Opponent info */} {/* Opponent info */}
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<div className="w-6 h-6 rounded-full bg-surface flex items-center justify-center text-xs font-medium"> <div className="w-6 h-6 rounded-full bg-surface flex items-center justify-center text-xs font-medium">
@@ -126,7 +142,7 @@ export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players }
onPromotionPieceSelect={handlePromotion} onPromotionPieceSelect={handlePromotion}
boardOrientation={boardOrientation} boardOrientation={boardOrientation}
isDraggablePiece={isDraggablePiece} isDraggablePiece={isDraggablePiece}
boardWidth={400} boardWidth={boardWidth}
showPromotionDialog={promotionFrom !== null} showPromotionDialog={promotionFrom !== null}
promotionToSquare={promotionTo as any} promotionToSquare={promotionTo as any}
animationDuration={200} animationDuration={200}
@@ -153,11 +169,11 @@ export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players }
</div> </div>
</div> </div>
{/* Sidebar */} {/* Sidebar - stacks below on mobile */}
<div className="flex flex-col gap-3 min-w-[180px]"> <div className="flex flex-col gap-3 w-full md:w-auto md:min-w-[180px]">
<div className="bg-card rounded-lg border border-border"> <div className="bg-card rounded-lg border border-border">
<div className="px-4 py-2.5 border-b border-border text-xs font-semibold">Move History</div> <div className="px-4 py-2.5 border-b border-border text-xs font-semibold">Move History</div>
<div className="px-4 py-2 max-h-64 overflow-y-auto"> <div className="px-4 py-2 max-h-48 md:max-h-64 overflow-y-auto">
{chess.moveHistory.length === 0 ? ( {chess.moveHistory.length === 0 ? (
<div className="text-xs text-text-disabled">No moves yet</div> <div className="text-xs text-text-disabled">No moves yet</div>
) : ( ) : (