fix(chess): send game updates to move sender + responsive mobile redesign
Some checks failed
Deploy to Production / test (push) Failing after 32s
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:
@@ -77,15 +77,19 @@ export function handleGameMessage(msg: GameWsClientMessage, ctx: WsContext): voi
|
||||
}
|
||||
|
||||
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) {
|
||||
ctx.publish(`room:${msg.roomId}`, JSON.stringify({
|
||||
const endedMsg = JSON.stringify({
|
||||
type: "GAME_ENDED",
|
||||
roomId: msg.roomId,
|
||||
winner: result.gameOver.winner,
|
||||
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() }));
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -13,8 +13,10 @@ import {
|
||||
ChevronRight,
|
||||
Gamepad2,
|
||||
Trophy,
|
||||
Menu,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { cn } from "../lib/utils";
|
||||
import type { AuthUser } from "../lib/useAuth";
|
||||
@@ -54,6 +56,7 @@ export default function Layout({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -63,6 +66,11 @@ export default function Layout({
|
||||
? `https://cdn.discordapp.com/avatars/${user.discordId}/${user.avatar}.png?size=64`
|
||||
: null;
|
||||
|
||||
// Close mobile drawer on route change
|
||||
useEffect(() => {
|
||||
setMobileOpen(false);
|
||||
}, [location.pathname]);
|
||||
|
||||
function isActive(path: string): boolean {
|
||||
if (path === "/admin" && location.pathname === "/admin") return true;
|
||||
if (path === "/dashboard" && location.pathname === "/dashboard") return true;
|
||||
@@ -70,25 +78,18 @@ export default function Layout({
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex">
|
||||
<aside
|
||||
className={cn(
|
||||
"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"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center h-16 px-4 border-b border-border">
|
||||
<div className="font-display text-xl font-bold tracking-tight">
|
||||
{collapsed ? "A" : "Aurora"}
|
||||
</div>
|
||||
</div>
|
||||
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={() => navigate(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)
|
||||
@@ -97,13 +98,13 @@ export default function Layout({
|
||||
)}
|
||||
>
|
||||
<Icon className={cn("w-5 h-5 shrink-0", isActive(path) && "text-primary")} />
|
||||
{!collapsed && <span>{label}</span>}
|
||||
{(!collapsed || mobileOpen) && <span>{label}</span>}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="border-t border-border p-3 space-y-2">
|
||||
{!collapsed && (
|
||||
{(!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" />
|
||||
@@ -117,28 +118,83 @@ export default function Layout({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={cn("flex", collapsed ? "flex-col items-center gap-2" : "items-center justify-between px-2")}>
|
||||
<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 && <span>Sign out</span>}
|
||||
{(!collapsed || mobileOpen) && <span>Sign out</span>}
|
||||
</button>
|
||||
{/* Collapse toggle only on desktop */}
|
||||
<button
|
||||
onClick={() => setCollapsed((c) => !c)}
|
||||
className="p-1.5 rounded-md text-text-tertiary hover:text-foreground hover:bg-primary/10 transition-colors"
|
||||
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 (
|
||||
<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
|
||||
className={cn(
|
||||
"fixed inset-y-0 left-0 z-50 flex flex-col bg-background border-r border-border transition-all duration-200",
|
||||
// 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 justify-between h-14 md:h-16 px-4 border-b border-border">
|
||||
<div className="font-display text-xl font-bold tracking-tight">
|
||||
{collapsed && !mobileOpen ? "A" : "Aurora"}
|
||||
</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>
|
||||
|
||||
{sidebarContent}
|
||||
</aside>
|
||||
|
||||
<main className={cn("flex-1 transition-all duration-200", collapsed ? "ml-16" : "ml-60")}>
|
||||
<div className="max-w-[1600px] mx-auto px-6 py-8">
|
||||
<main className={cn(
|
||||
"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}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -49,20 +49,20 @@ export function GameLobby() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-3 mb-4 md:mb-6">
|
||||
<div className="min-w-0">
|
||||
<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>
|
||||
<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"
|
||||
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
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mb-4">
|
||||
<div className="flex gap-2 mb-4 overflow-x-auto pb-1">
|
||||
<button
|
||||
onClick={() => setFilter(null)}
|
||||
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
|
||||
@@ -98,11 +98,11 @@ export function GameLobby() {
|
||||
{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 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 min-w-0">
|
||||
<span className="text-lg shrink-0">{plugin?.icon ?? "🎮"}</span>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium truncate">{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"
|
||||
@@ -118,7 +118,7 @@ export function GameLobby() {
|
||||
</div>
|
||||
<button
|
||||
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"
|
||||
? "bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
: "bg-card border border-border text-text-tertiary hover:text-foreground"
|
||||
@@ -134,8 +134,8 @@ export function GameLobby() {
|
||||
</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()}>
|
||||
<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-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>
|
||||
<div className="space-y-2">
|
||||
{gameTypes.map(g => (
|
||||
|
||||
@@ -50,11 +50,11 @@ export function GameRoom({ userId }: { userId: string }) {
|
||||
|
||||
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 justify-between gap-3 mb-4 md:mb-6">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<span className="text-xl shrink-0">{plugin.icon}</span>
|
||||
<div className="min-w-0">
|
||||
<h1 className="font-display text-base font-semibold truncate">{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"
|
||||
@@ -70,7 +70,7 @@ export function GameRoom({ userId }: { userId: string }) {
|
||||
</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"
|
||||
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
|
||||
</button>
|
||||
@@ -94,12 +94,13 @@ export function GameRoom({ userId }: { userId: string }) {
|
||||
)}
|
||||
|
||||
{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">
|
||||
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>
|
||||
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>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { useState, useMemo, useRef, useEffect } from "react";
|
||||
import { Chessboard } from "react-chessboard";
|
||||
import { Chess } from "chess.js";
|
||||
import type { GameUIProps } from "../registry";
|
||||
@@ -15,6 +15,22 @@ export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players }
|
||||
const chess = state as ChessState;
|
||||
const [promotionFrom, setPromotionFrom] = 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(() => {
|
||||
if (!chess?.fen) return null;
|
||||
@@ -89,7 +105,6 @@ export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players }
|
||||
// Highlight king in check
|
||||
const customSquareStyles: Record<string, React.CSSProperties> = {};
|
||||
if (game.inCheck()) {
|
||||
// Find the king square of the player in check
|
||||
const board = game.board();
|
||||
const kingColor = game.turn();
|
||||
for (let r = 0; r < 8; r++) {
|
||||
@@ -108,8 +123,9 @@ export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-4">
|
||||
<div>
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
{/* Board column */}
|
||||
<div ref={containerRef} className="w-full max-w-[400px]">
|
||||
{/* Opponent info */}
|
||||
<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">
|
||||
@@ -126,7 +142,7 @@ export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players }
|
||||
onPromotionPieceSelect={handlePromotion}
|
||||
boardOrientation={boardOrientation}
|
||||
isDraggablePiece={isDraggablePiece}
|
||||
boardWidth={400}
|
||||
boardWidth={boardWidth}
|
||||
showPromotionDialog={promotionFrom !== null}
|
||||
promotionToSquare={promotionTo as any}
|
||||
animationDuration={200}
|
||||
@@ -153,11 +169,11 @@ export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players }
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="flex flex-col gap-3 min-w-[180px]">
|
||||
{/* Sidebar - stacks below on mobile */}
|
||||
<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="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 ? (
|
||||
<div className="text-xs text-text-disabled">No moves yet</div>
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user