feat: live game tiles on homepage + bot spectating

- GET /v1/games/live returns all active games with both players assigned,
  including current FEN (computed server-side via chess.js)
- LiveGames component on homepage: mini chessboard tiles (160px) that
  auto-refresh every 3s, clickable to join as spectator
- Tournament page: 'Zuschauen' button for non-players on ongoing rounds
  (bot vs bot, human vs human, human vs bot all spectatable)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Michess
2026-04-15 08:03:26 +02:00
parent 86fbdd39ed
commit 0b3228c9d7
6 changed files with 99 additions and 0 deletions

View File

@@ -3,6 +3,7 @@
import CreateGame from "@/components/home/CreateGame";
import JoinGame from "@/components/home/JoinGame";
import PublicGames from "@/components/home/PublicGames/PublicGames";
import LiveGames from "@/components/home/LiveGames";
import Link from "next/link";
import { useSession } from "@/context/session";
@@ -78,6 +79,9 @@ export default function Home() {
</div>
{/* Laufende Partien Live-Kacheln */}
<LiveGames />
{/* Öffentliche Spiele + Beitreten */}
<div className={`grid gap-6 grid-cols-1 ${isLoggedIn ? "lg:grid-cols-3" : ""}`}>
<div className={isLoggedIn ? "lg:col-span-2" : ""}>

View File

@@ -242,6 +242,11 @@ export default function TournamentDetailPage() {
Spielen
</Link>
)}
{!isMyGame && g.gameCode && !g.result && (
<Link href={`/${g.gameCode}`} className="btn btn-xs btn-outline">
Zuschauen
</Link>
)}
{g.gameCode && g.result && (
<Link href={`/${g.gameCode}`} className="btn btn-xs btn-ghost">
Ansehen

View File

@@ -0,0 +1,51 @@
"use client";
import { useEffect, useState } from "react";
import { Chessboard } from "react-chessboard";
import Link from "next/link";
import { fetchLiveGames, type LiveGameEntry } from "@/lib/game";
export default function LiveGames() {
const [games, setGames] = useState<LiveGameEntry[]>([]);
useEffect(() => {
let mounted = true;
const load = async () => {
const data = await fetchLiveGames();
if (mounted) setGames(data);
};
load();
const interval = setInterval(load, 3000);
return () => { mounted = false; clearInterval(interval); };
}, []);
if (!games.length) return null;
return (
<div>
<h2 className="text-xl font-bold mb-3">Laufende Partien</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3">
{games.map((game) => (
<Link
key={game.code}
href={`/${game.code}`}
className="card bg-base-200 hover:bg-base-300 transition-colors overflow-hidden"
>
<Chessboard
position={game.fen}
boardWidth={160}
arePiecesDraggable={false}
customDarkSquareStyle={{ backgroundColor: "#4b7399" }}
customLightSquareStyle={{ backgroundColor: "#eae9d2" }}
/>
<div className="px-2 py-1.5 flex flex-col gap-0.5">
<span className="text-xs font-semibold truncate">{game.black?.name}</span>
<span className="text-xs opacity-40 text-center leading-none">vs</span>
<span className="text-xs font-semibold truncate">{game.white?.name}</span>
</div>
</Link>
))}
</div>
</div>
);
}

View File

@@ -35,6 +35,25 @@ export const fetchActiveGame = async (code: string) => {
}
};
export type LiveGameEntry = {
code: string;
white: { name?: string };
black: { name?: string };
fen: string;
timeControl?: number;
tournamentCode?: string;
};
export const fetchLiveGames = async (): Promise<LiveGameEntry[]> => {
try {
const res = await fetch(`${API_URL}/v1/games/live`, { cache: "no-store" });
if (res.ok) return res.json();
} catch (err) {
console.error(err);
}
return [];
};
export const fetchPublicGames = async () => {
try {
const res = await fetch(`${API_URL}/v1/games`, { cache: "no-store" });