fix: bracket progression protection, cleaner uis
This commit is contained in:
6
connect4-ui/.eslintrc.json
Normal file
6
connect4-ui/.eslintrc.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"extends": [
|
||||||
|
"next/core-web-vitals",
|
||||||
|
"next/typescript"
|
||||||
|
]
|
||||||
|
}
|
||||||
4
connect4-ui/AGENTS.md
Normal file
4
connect4-ui/AGENTS.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# AGENTS
|
||||||
|
|
||||||
|
- Use the TypeScript/TSX lint checker for validation instead of `npm run build`.
|
||||||
|
- Prefer `npm run lint` after TS/TSX changes unless the user explicitly asks for a different verification step.
|
||||||
@@ -260,8 +260,18 @@ export default function PlayPage() {
|
|||||||
Connected as <span className="text-green-300">{username}</span>
|
Connected as <span className="text-green-300">{username}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{gamePhase === "connected" && (
|
||||||
|
<button
|
||||||
|
onClick={sendReady}
|
||||||
|
className="rounded-full border border-green-700 bg-green-900/60 px-3 py-1.5 text-sm font-medium text-green-300 transition-colors hover:bg-green-800/70"
|
||||||
|
>
|
||||||
|
Ready Up
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<PhaseIndicator phase={gamePhase} isMyTurn={isMyTurn} />
|
<PhaseIndicator phase={gamePhase} isMyTurn={isMyTurn} />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{status === "reconnecting" && (
|
{status === "reconnecting" && (
|
||||||
<div className="rounded-lg border border-yellow-700 bg-yellow-950/30 px-4 py-3 text-sm text-yellow-200">
|
<div className="rounded-lg border border-yellow-700 bg-yellow-950/30 px-4 py-3 text-sm text-yellow-200">
|
||||||
@@ -270,49 +280,26 @@ export default function PlayPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div className="bg-gray-900 border border-gray-700 rounded-xl p-4 flex flex-col gap-3">
|
|
||||||
<h2 className="text-sm font-semibold text-gray-300 uppercase tracking-wider">
|
|
||||||
Match
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
{tournamentMode && (
|
{tournamentMode && (
|
||||||
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-purple-950/50 border border-purple-700 text-purple-300 text-sm">
|
<div className="flex items-center gap-2 rounded-lg border border-purple-700 bg-purple-950/50 px-3 py-2 text-sm text-purple-300">
|
||||||
<span>🏆</span>
|
<span>🏆</span>
|
||||||
<span>Tournament mode active</span>
|
<span>Tournament mode active</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{gamePhase === "connected" && (
|
{(gamePhase === "playing" || gamePhase === "game-over") && myColor && (
|
||||||
<button
|
|
||||||
onClick={sendReady}
|
|
||||||
className="w-full py-2.5 bg-green-700 hover:bg-green-600 text-white text-sm font-semibold rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
✋ Ready to Play
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{gamePhase === "ready" && (
|
|
||||||
<div className="text-center py-3 text-yellow-300 text-sm animate-pulse">
|
|
||||||
⏳ Waiting for opponent...
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(gamePhase === "playing" || gamePhase === "game-over") &&
|
|
||||||
myColor && (
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<div className="flex items-center gap-3 px-3 py-2 rounded-lg bg-gray-800">
|
<div className="flex items-center gap-3 rounded-lg bg-gray-800 px-3 py-2">
|
||||||
<span className="text-white font-medium text-sm">
|
<span className="text-sm font-medium text-white">
|
||||||
You are {myColorLabel}
|
You are {myColorLabel}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{gamePhase === "playing" && (
|
{gamePhase === "playing" && (
|
||||||
<div
|
<div
|
||||||
className={`flex items-center justify-center gap-2 px-3 py-2 rounded-lg font-semibold text-sm ${
|
className={`flex items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm font-semibold ${
|
||||||
isMyTurn
|
isMyTurn
|
||||||
? "bg-green-900/50 border border-green-600 text-green-300 animate-pulse"
|
? "animate-pulse border border-green-600 bg-green-900/50 text-green-300"
|
||||||
: "bg-gray-800 text-gray-400"
|
: "bg-gray-800 text-gray-400"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -324,18 +311,7 @@ export default function PlayPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{gamePhase === "game-over" && gameResult && !tournamentMode && (
|
<div className="bg-gray-900 border border-gray-700 rounded-xl p-6 flex flex-col items-center justify-center gap-4 min-h-96">
|
||||||
<button
|
|
||||||
onClick={sendReady}
|
|
||||||
className="w-full py-2 bg-green-700 hover:bg-green-600 text-white text-sm font-semibold rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
Play Again
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="lg:col-span-2 bg-gray-900 border border-gray-700 rounded-xl p-6 flex flex-col items-center justify-center gap-4 min-h-96">
|
|
||||||
{gamePhase === "idle" ? (
|
{gamePhase === "idle" ? (
|
||||||
<div className="text-gray-500 text-center py-10">
|
<div className="text-gray-500 text-center py-10">
|
||||||
Connect from the connection page to start.
|
Connect from the connection page to start.
|
||||||
@@ -349,9 +325,9 @@ export default function PlayPage() {
|
|||||||
<p className="text-gray-500 text-sm max-w-sm">
|
<p className="text-gray-500 text-sm max-w-sm">
|
||||||
Click the{" "}
|
Click the{" "}
|
||||||
<span className="text-green-300 font-semibold">
|
<span className="text-green-300 font-semibold">
|
||||||
Ready to Play
|
Ready Up
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
button in the Match panel to enter the queue.
|
button beside your status to enter the queue.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : gamePhase === "ready" ? (
|
) : gamePhase === "ready" ? (
|
||||||
@@ -407,7 +383,6 @@ export default function PlayPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -106,6 +106,13 @@ export default function SpectatePage() {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const resetLiveGames = useCallback(() => {
|
||||||
|
const next = new Map<number, LiveGame>();
|
||||||
|
liveGamesRef.current = next;
|
||||||
|
setLiveGames(next);
|
||||||
|
setSelectedGame(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (status === "disconnected" && shouldRedirectToConnect) {
|
if (status === "disconnected" && shouldRedirectToConnect) {
|
||||||
clearRedirectFlag();
|
clearRedirectFlag();
|
||||||
@@ -156,6 +163,7 @@ export default function SpectatePage() {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case "TOURNAMENT_SCORES":
|
case "TOURNAMENT_SCORES":
|
||||||
|
resetLiveGames();
|
||||||
setScores(msg.scores);
|
setScores(msg.scores);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -199,10 +207,15 @@ export default function SpectatePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "GAME_MATCH_START":
|
case "GAME_MATCH_START":
|
||||||
|
updateGame(msg.matchId, {
|
||||||
|
player1: msg.player1,
|
||||||
|
player2: msg.player2,
|
||||||
|
});
|
||||||
addLog(
|
addLog(
|
||||||
`Match started: #${msg.matchId} ${msg.player1} vs ${msg.player2}`,
|
`Match started: #${msg.matchId} ${msg.player1} vs ${msg.player2}`,
|
||||||
);
|
);
|
||||||
send(cmd.gameList());
|
send(cmd.gameList());
|
||||||
|
send(cmd.gameWatch(msg.matchId));
|
||||||
send(cmd.playerList());
|
send(cmd.playerList());
|
||||||
if (tournamentType === "KnockoutBracket" && !knockoutRawData) {
|
if (tournamentType === "KnockoutBracket" && !knockoutRawData) {
|
||||||
send(cmd.getData("TOURNAMENT_DATA"));
|
send(cmd.getData("TOURNAMENT_DATA"));
|
||||||
@@ -271,6 +284,9 @@ export default function SpectatePage() {
|
|||||||
updateGame(msg.matchId, {
|
updateGame(msg.matchId, {
|
||||||
result: { kind: "win", winner: msg.winner },
|
result: { kind: "win", winner: msg.winner },
|
||||||
});
|
});
|
||||||
|
if (tournamentType === "KnockoutBracket" && !knockoutRawData) {
|
||||||
|
setSelectedGame((prev) => (prev === msg.matchId ? null : prev));
|
||||||
|
}
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
send(cmd.gameList());
|
send(cmd.gameList());
|
||||||
send(cmd.playerList());
|
send(cmd.playerList());
|
||||||
@@ -284,6 +300,9 @@ export default function SpectatePage() {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
updateGame(msg.matchId, { result: { kind: "draw" } });
|
updateGame(msg.matchId, { result: { kind: "draw" } });
|
||||||
|
if (tournamentType === "KnockoutBracket" && !knockoutRawData) {
|
||||||
|
setSelectedGame((prev) => (prev === msg.matchId ? null : prev));
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "GAME_TERMINATED":
|
case "GAME_TERMINATED":
|
||||||
@@ -292,6 +311,9 @@ export default function SpectatePage() {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
updateGame(msg.matchId, { result: { kind: "terminated" } });
|
updateGame(msg.matchId, { result: { kind: "terminated" } });
|
||||||
|
if (tournamentType === "KnockoutBracket" && !knockoutRawData) {
|
||||||
|
setSelectedGame((prev) => (prev === msg.matchId ? null : prev));
|
||||||
|
}
|
||||||
send(cmd.gameList());
|
send(cmd.gameList());
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -311,6 +333,7 @@ export default function SpectatePage() {
|
|||||||
send(cmd.getData("TOURNAMENT_DATA"));
|
send(cmd.getData("TOURNAMENT_DATA"));
|
||||||
}
|
}
|
||||||
} else if (msg.key === "TOURNAMENT_DATA" && msg.value) {
|
} else if (msg.key === "TOURNAMENT_DATA" && msg.value) {
|
||||||
|
resetLiveGames();
|
||||||
setKnockoutRawData(msg.value);
|
setKnockoutRawData(msg.value);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -325,7 +348,15 @@ export default function SpectatePage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return unsubscribe;
|
return unsubscribe;
|
||||||
}, [addLog, knockoutRawData, send, subscribe, tournamentType, updateGame]);
|
}, [
|
||||||
|
addLog,
|
||||||
|
knockoutRawData,
|
||||||
|
resetLiveGames,
|
||||||
|
send,
|
||||||
|
subscribe,
|
||||||
|
tournamentType,
|
||||||
|
updateGame,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (status !== "connected" || role !== "observer") return;
|
if (status !== "connected" || role !== "observer") return;
|
||||||
@@ -357,6 +388,49 @@ export default function SpectatePage() {
|
|||||||
() => buildKnockoutBracket(knockoutRounds, liveGames, tournamentWinner),
|
() => buildKnockoutBracket(knockoutRounds, liveGames, tournamentWinner),
|
||||||
[knockoutRounds, liveGames, tournamentWinner],
|
[knockoutRounds, liveGames, tournamentWinner],
|
||||||
);
|
);
|
||||||
|
const seedingMatches = useMemo(() => {
|
||||||
|
const seen = new Set<number>();
|
||||||
|
const matches: Array<{
|
||||||
|
id: number;
|
||||||
|
player1: string;
|
||||||
|
player2: string;
|
||||||
|
currentTurnColor: 1 | 2 | null;
|
||||||
|
resultKind: KnockoutMatchView["resultKind"];
|
||||||
|
status: "scheduled" | "live" | "completed";
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
for (const game of gameList) {
|
||||||
|
seen.add(game.id);
|
||||||
|
const live = liveGames.get(game.id);
|
||||||
|
const resultKind = live?.result?.kind ?? null;
|
||||||
|
if (resultKind) continue;
|
||||||
|
matches.push({
|
||||||
|
id: game.id,
|
||||||
|
player1: game.player1,
|
||||||
|
player2: game.player2,
|
||||||
|
currentTurnColor: resultKind ? null : (live?.currentTurnColor ?? null),
|
||||||
|
resultKind,
|
||||||
|
status: resultKind ? "completed" : live ? "live" : "scheduled",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const game of Array.from(liveGames.values()).sort(
|
||||||
|
(a, b) => b.id - a.id,
|
||||||
|
)) {
|
||||||
|
if (seen.has(game.id) || !game.player1 || !game.player2 || game.result)
|
||||||
|
continue;
|
||||||
|
matches.push({
|
||||||
|
id: game.id,
|
||||||
|
player1: game.player1,
|
||||||
|
player2: game.player2,
|
||||||
|
currentTurnColor: game.result ? null : game.currentTurnColor,
|
||||||
|
resultKind: game.result?.kind ?? null,
|
||||||
|
status: game.result ? "completed" : "live",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return matches;
|
||||||
|
}, [gameList, liveGames]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
@@ -366,20 +440,12 @@ export default function SpectatePage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{status === "connected" && (
|
{status === "connected" && tournamentActive && (
|
||||||
<div
|
<div className="rounded-xl border border-purple-600 bg-purple-950/40 p-4 flex items-center gap-4">
|
||||||
className={`rounded-xl p-4 border flex items-center gap-4 ${
|
<div className="text-3xl">🏆</div>
|
||||||
tournamentActive
|
|
||||||
? "bg-purple-950/40 border-purple-600"
|
|
||||||
: "bg-gray-900 border-gray-700"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="text-3xl">{tournamentActive ? "🏆" : "⏳"}</div>
|
|
||||||
<div>
|
<div>
|
||||||
<div className="font-semibold text-white">
|
<div className="font-semibold text-white">
|
||||||
{tournamentActive
|
{`Tournament Active - ${tournamentType ?? "Unknown Type"}`}
|
||||||
? `Tournament Active - ${tournamentType ?? "Unknown Type"}`
|
|
||||||
: "No Active Tournament"}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-400">
|
<div className="text-sm text-gray-400">
|
||||||
{gameList.length} match{gameList.length !== 1 ? "es" : ""} running
|
{gameList.length} match{gameList.length !== 1 ? "es" : ""} running
|
||||||
@@ -470,9 +536,31 @@ export default function SpectatePage() {
|
|||||||
</h2>
|
</h2>
|
||||||
{showKnockoutBracket ? (
|
{showKnockoutBracket ? (
|
||||||
knockoutBracket.length === 0 ? (
|
knockoutBracket.length === 0 ? (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
<div className="rounded-lg border border-dashed border-gray-700 bg-gray-950/60 px-4 py-6 text-center text-sm text-gray-500">
|
<div className="rounded-lg border border-dashed border-gray-700 bg-gray-950/60 px-4 py-6 text-center text-sm text-gray-500">
|
||||||
Knockout seeding is still in progress. The bracket will appear
|
Knockout seeding is still in progress. The bracket will
|
||||||
once tournament data is available.
|
appear once tournament data is available.
|
||||||
|
</div>
|
||||||
|
{seedingMatches.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{seedingMatches.map((match) => {
|
||||||
|
return (
|
||||||
|
<MatchSelectorCard
|
||||||
|
key={match.id}
|
||||||
|
matchId={match.id}
|
||||||
|
player1={match.player1}
|
||||||
|
player2={match.player2}
|
||||||
|
currentTurnColor={match.currentTurnColor}
|
||||||
|
status={match.status}
|
||||||
|
resultKind={match.resultKind}
|
||||||
|
selectedGame={selectedGame}
|
||||||
|
onSelect={setSelectedGame}
|
||||||
|
className="w-full sm:w-[calc(50%-0.375rem)] xl:w-[calc(33.333%-0.5rem)]"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto pb-2">
|
<div className="overflow-x-auto pb-2">
|
||||||
@@ -489,74 +577,18 @@ export default function SpectatePage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-1 flex-col justify-center gap-3">
|
<div className="flex flex-1 flex-col justify-center gap-3">
|
||||||
{round.matches.map((match) => {
|
{round.matches.map((match) => {
|
||||||
const isSelected =
|
|
||||||
match.matchId !== null &&
|
|
||||||
selectedGame === match.matchId;
|
|
||||||
const canSelect = match.matchId !== null;
|
|
||||||
const borderClass =
|
|
||||||
match.status === "completed"
|
|
||||||
? "border-green-700/80"
|
|
||||||
: "border-gray-700";
|
|
||||||
|
|
||||||
const Tag = canSelect ? "button" : "div";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tag
|
<MatchSelectorCard
|
||||||
key={match.key}
|
key={match.key}
|
||||||
{...(canSelect
|
matchId={match.matchId}
|
||||||
? {
|
player1={match.player1}
|
||||||
onClick: () =>
|
player2={match.player2}
|
||||||
setSelectedGame(
|
currentTurnColor={match.currentTurnColor}
|
||||||
isSelected ? null : match.matchId,
|
status={match.status}
|
||||||
),
|
resultKind={match.resultKind}
|
||||||
type: "button" as const,
|
selectedGame={selectedGame}
|
||||||
}
|
onSelect={setSelectedGame}
|
||||||
: {})}
|
|
||||||
className={`w-full rounded-xl border bg-gray-950/70 p-3 text-left transition-colors ${borderClass} ${
|
|
||||||
canSelect
|
|
||||||
? isSelected
|
|
||||||
? "ring-1 ring-blue-400"
|
|
||||||
: "hover:border-blue-700/80"
|
|
||||||
: ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<BracketPlayerRow
|
|
||||||
name={match.player1}
|
|
||||||
playerColor={1}
|
|
||||||
isActive={match.currentTurnColor === 1}
|
|
||||||
/>
|
/>
|
||||||
<BracketPlayerRow
|
|
||||||
name={match.player2}
|
|
||||||
playerColor={2}
|
|
||||||
isActive={match.currentTurnColor === 2}
|
|
||||||
/>
|
|
||||||
<div className="flex items-center justify-between pt-1 text-xs">
|
|
||||||
<span className="text-gray-500">
|
|
||||||
{match.matchId !== null
|
|
||||||
? `Match #${match.matchId}`
|
|
||||||
: "Awaiting match"}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className={`rounded-full px-2 py-0.5 ${
|
|
||||||
match.status === "completed"
|
|
||||||
? "bg-green-950/70 text-green-300"
|
|
||||||
: match.status === "live"
|
|
||||||
? "bg-blue-950/70 text-blue-300"
|
|
||||||
: "bg-gray-800 text-gray-400"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{match.status === "completed"
|
|
||||||
? match.resultKind === "draw"
|
|
||||||
? "Tie"
|
|
||||||
: "Complete"
|
|
||||||
: match.status === "live"
|
|
||||||
? "Live"
|
|
||||||
: "Pending"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Tag>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -570,54 +602,38 @@ export default function SpectatePage() {
|
|||||||
No active matches
|
No active matches
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-3">
|
||||||
{gameList.map((g) => {
|
{gameList.map((g) => {
|
||||||
const live = liveGames.get(g.id);
|
const live = liveGames.get(g.id);
|
||||||
|
const resultKind = live?.result?.kind ?? null;
|
||||||
|
const status = resultKind
|
||||||
|
? "completed"
|
||||||
|
: live
|
||||||
|
? "live"
|
||||||
|
: "scheduled";
|
||||||
return (
|
return (
|
||||||
<button
|
<MatchSelectorCard
|
||||||
key={g.id}
|
key={g.id}
|
||||||
onClick={() =>
|
matchId={g.id}
|
||||||
setSelectedGame(selectedGame === g.id ? null : g.id)
|
player1={g.player1}
|
||||||
|
player2={g.player2}
|
||||||
|
currentTurnColor={
|
||||||
|
resultKind ? null : (live?.currentTurnColor ?? null)
|
||||||
}
|
}
|
||||||
className={`px-3 py-2 rounded-lg border text-sm transition-colors ${
|
status={status}
|
||||||
selectedGame === g.id
|
resultKind={resultKind}
|
||||||
? "border-blue-500 bg-blue-950/50 text-blue-200"
|
selectedGame={selectedGame}
|
||||||
: "border-gray-700 bg-gray-800 hover:border-gray-500 text-gray-300"
|
onSelect={setSelectedGame}
|
||||||
}`}
|
className="w-full sm:w-[calc(50%-0.375rem)] xl:w-[calc(33.333%-0.5rem)]"
|
||||||
>
|
/>
|
||||||
<span className="text-gray-500 text-xs font-mono mr-1">
|
|
||||||
#{g.id}
|
|
||||||
</span>
|
|
||||||
<span className="text-red-400">{g.player1}</span>
|
|
||||||
<span className="text-gray-500 mx-1">vs</span>
|
|
||||||
<span className="text-yellow-400">{g.player2}</span>
|
|
||||||
{live?.result && (
|
|
||||||
<span className="ml-1 text-xs">
|
|
||||||
{live.result.kind === "win"
|
|
||||||
? ` 🏆 ${live.result.winner}`
|
|
||||||
: live.result.kind === "draw"
|
|
||||||
? " 🤝"
|
|
||||||
: " ⛔"}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{selectedGameData && (
|
||||||
<div className="bg-gray-900 border border-gray-700 rounded-xl p-6 flex flex-col items-center gap-4 min-h-64">
|
<div className="bg-gray-900 border border-gray-700 rounded-xl p-6 flex flex-col items-center gap-4 min-h-64">
|
||||||
{!selectedGameData ? (
|
|
||||||
<div className="flex flex-col items-center justify-center gap-3 py-12 text-center">
|
|
||||||
<span className="text-4xl text-gray-700">🎯</span>
|
|
||||||
<p className="text-gray-500 text-sm">
|
|
||||||
{gameList.length > 0
|
|
||||||
? "Click a match above to see the board"
|
|
||||||
: "Active matches will appear here"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
<>
|
||||||
{selectedGameData.result && (
|
{selectedGameData.result && (
|
||||||
<div
|
<div
|
||||||
@@ -649,8 +665,8 @@ export default function SpectatePage() {
|
|||||||
disabled
|
disabled
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -692,6 +708,87 @@ function BracketPlayerRow({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MatchSelectorCard({
|
||||||
|
matchId,
|
||||||
|
player1,
|
||||||
|
player2,
|
||||||
|
currentTurnColor,
|
||||||
|
status,
|
||||||
|
resultKind,
|
||||||
|
selectedGame,
|
||||||
|
onSelect,
|
||||||
|
className = "w-full",
|
||||||
|
}: {
|
||||||
|
matchId: number | null;
|
||||||
|
player1: string | null;
|
||||||
|
player2: string | null;
|
||||||
|
currentTurnColor: 1 | 2 | null;
|
||||||
|
status: "scheduled" | "live" | "completed";
|
||||||
|
resultKind: KnockoutMatchView["resultKind"];
|
||||||
|
selectedGame: number | null;
|
||||||
|
onSelect: (matchId: number | null) => void;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const isSelected = matchId !== null && selectedGame === matchId;
|
||||||
|
const canSelect = matchId !== null;
|
||||||
|
const borderClass =
|
||||||
|
status === "completed" ? "border-green-700/80" : "border-gray-700";
|
||||||
|
const Tag = canSelect ? "button" : "div";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tag
|
||||||
|
{...(canSelect
|
||||||
|
? {
|
||||||
|
onClick: () => onSelect(isSelected ? null : matchId),
|
||||||
|
type: "button" as const,
|
||||||
|
}
|
||||||
|
: {})}
|
||||||
|
className={`${className} rounded-xl border bg-gray-950/70 p-3 text-left transition-colors ${borderClass} ${
|
||||||
|
canSelect
|
||||||
|
? isSelected
|
||||||
|
? "ring-1 ring-blue-400"
|
||||||
|
: "hover:border-blue-700/80"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<BracketPlayerRow
|
||||||
|
name={player1}
|
||||||
|
playerColor={1}
|
||||||
|
isActive={currentTurnColor === 1}
|
||||||
|
/>
|
||||||
|
<BracketPlayerRow
|
||||||
|
name={player2}
|
||||||
|
playerColor={2}
|
||||||
|
isActive={currentTurnColor === 2}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-between pt-1 text-xs">
|
||||||
|
<span className="text-gray-500">
|
||||||
|
{matchId !== null ? `Match #${matchId}` : "Awaiting match"}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`rounded-full px-2 py-0.5 ${
|
||||||
|
status === "completed"
|
||||||
|
? "bg-green-950/70 text-green-300"
|
||||||
|
: status === "live"
|
||||||
|
? "bg-blue-950/70 text-blue-300"
|
||||||
|
: "bg-gray-800 text-gray-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{status === "completed"
|
||||||
|
? resultKind === "draw"
|
||||||
|
? "Tie"
|
||||||
|
: "Complete"
|
||||||
|
: status === "live"
|
||||||
|
? "Live"
|
||||||
|
: "Pending"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function isKnockoutDataMessage(raw: string) {
|
function isKnockoutDataMessage(raw: string) {
|
||||||
return !raw.includes(":") && (raw.includes(",") || raw.includes("|"));
|
return !raw.includes(":") && (raw.includes(",") || raw.includes("|"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import { cmd } from "@/lib/protocol";
|
|||||||
export default function Nav() {
|
export default function Nav() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { status, role, username, send, becomePlayer, disconnect } = useConnection();
|
const { status, role, username, send, becomePlayer, disconnect } =
|
||||||
|
useConnection();
|
||||||
const [showPlayerModal, setShowPlayerModal] = useState(false);
|
const [showPlayerModal, setShowPlayerModal] = useState(false);
|
||||||
const [nextUsername, setNextUsername] = useState(username);
|
const [nextUsername, setNextUsername] = useState(username);
|
||||||
const isConnectionPage = pathname === "/";
|
const isConnectionPage = pathname === "/";
|
||||||
@@ -47,21 +48,18 @@ export default function Nav() {
|
|||||||
<div className="ml-auto flex items-center gap-2">
|
<div className="ml-auto flex items-center gap-2">
|
||||||
{!isConnectionPage && (
|
{!isConnectionPage && (
|
||||||
<>
|
<>
|
||||||
|
{role !== "player" && (
|
||||||
<button
|
<button
|
||||||
onClick={
|
onClick={() => {
|
||||||
role === "player"
|
|
||||||
? handleBecomeObserver
|
|
||||||
: () => {
|
|
||||||
setNextUsername(username);
|
setNextUsername(username);
|
||||||
setShowPlayerModal(true);
|
setShowPlayerModal(true);
|
||||||
}
|
}}
|
||||||
}
|
|
||||||
disabled={disableRoleSwitch}
|
disabled={disableRoleSwitch}
|
||||||
className="px-4 py-2 rounded-lg text-sm font-semibold transition-colors bg-blue-600 hover:bg-blue-500 disabled:bg-gray-700 disabled:text-gray-500 text-white"
|
className="px-4 py-2 rounded-lg text-sm font-semibold transition-colors bg-blue-600 hover:bg-blue-500 disabled:bg-gray-700 disabled:text-gray-500 text-white"
|
||||||
>
|
>
|
||||||
{role === "player" ? "Become Observer" : "Become Player"}
|
Become Player
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={disconnect}
|
onClick={disconnect}
|
||||||
className="px-4 py-2 rounded-lg text-sm font-semibold transition-colors bg-gray-700 hover:bg-red-600 text-white"
|
className="px-4 py-2 rounded-lg text-sm font-semibold transition-colors bg-gray-700 hover:bg-red-600 text-white"
|
||||||
|
|||||||
1
connect4-ui/connect4-moderator-server
Symbolic link
1
connect4-ui/connect4-moderator-server
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../../connect4-moderator-server
|
||||||
Reference in New Issue
Block a user