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:
@@ -3,6 +3,7 @@
|
|||||||
import CreateGame from "@/components/home/CreateGame";
|
import CreateGame from "@/components/home/CreateGame";
|
||||||
import JoinGame from "@/components/home/JoinGame";
|
import JoinGame from "@/components/home/JoinGame";
|
||||||
import PublicGames from "@/components/home/PublicGames/PublicGames";
|
import PublicGames from "@/components/home/PublicGames/PublicGames";
|
||||||
|
import LiveGames from "@/components/home/LiveGames";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useSession } from "@/context/session";
|
import { useSession } from "@/context/session";
|
||||||
|
|
||||||
@@ -78,6 +79,9 @@ export default function Home() {
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Laufende Partien – Live-Kacheln */}
|
||||||
|
<LiveGames />
|
||||||
|
|
||||||
{/* Öffentliche Spiele + Beitreten */}
|
{/* Öffentliche Spiele + Beitreten */}
|
||||||
<div className={`grid gap-6 grid-cols-1 ${isLoggedIn ? "lg:grid-cols-3" : ""}`}>
|
<div className={`grid gap-6 grid-cols-1 ${isLoggedIn ? "lg:grid-cols-3" : ""}`}>
|
||||||
<div className={isLoggedIn ? "lg:col-span-2" : ""}>
|
<div className={isLoggedIn ? "lg:col-span-2" : ""}>
|
||||||
|
|||||||
@@ -242,6 +242,11 @@ export default function TournamentDetailPage() {
|
|||||||
Spielen
|
Spielen
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
{!isMyGame && g.gameCode && !g.result && (
|
||||||
|
<Link href={`/${g.gameCode}`} className="btn btn-xs btn-outline">
|
||||||
|
Zuschauen
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
{g.gameCode && g.result && (
|
{g.gameCode && g.result && (
|
||||||
<Link href={`/${g.gameCode}`} className="btn btn-xs btn-ghost">
|
<Link href={`/${g.gameCode}`} className="btn btn-xs btn-ghost">
|
||||||
Ansehen
|
Ansehen
|
||||||
|
|||||||
51
client/src/components/home/LiveGames.tsx
Normal file
51
client/src/components/home/LiveGames.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 () => {
|
export const fetchPublicGames = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_URL}/v1/games`, { cache: "no-store" });
|
const res = await fetch(`${API_URL}/v1/games`, { cache: "no-store" });
|
||||||
|
|||||||
@@ -1,9 +1,28 @@
|
|||||||
import type { Game, User } from "@michess/types";
|
import type { Game, User } from "@michess/types";
|
||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
|
import { Chess } from "chess.js";
|
||||||
|
|
||||||
import GameModel, { activeGames } from "../db/models/game.model.js";
|
import GameModel, { activeGames } from "../db/models/game.model.js";
|
||||||
|
|
||||||
|
export const getLiveGames = (_req: Request, res: Response) => {
|
||||||
|
const live = activeGames
|
||||||
|
.filter(g => g.white?.id && g.black?.id && !g.winner)
|
||||||
|
.map(g => {
|
||||||
|
const chess = new Chess();
|
||||||
|
if (g.pgn) chess.loadPgn(g.pgn);
|
||||||
|
return {
|
||||||
|
code: g.code,
|
||||||
|
white: { name: g.white?.name },
|
||||||
|
black: { name: g.black?.name },
|
||||||
|
fen: chess.fen(),
|
||||||
|
timeControl: g.timeControl,
|
||||||
|
tournamentCode: g.tournamentCode
|
||||||
|
};
|
||||||
|
});
|
||||||
|
res.status(200).json(live);
|
||||||
|
};
|
||||||
|
|
||||||
export const getGames = async (req: Request, res: Response) => {
|
export const getGames = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
if (!req.query.id && !req.query.userid) {
|
if (!req.query.id && !req.query.userid) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import * as controller from "../controllers/games.controller.js";
|
|||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
|
router.get("/live", controller.getLiveGames);
|
||||||
router.route("/").get(controller.getGames).post(controller.createGame);
|
router.route("/").get(controller.getGames).post(controller.createGame);
|
||||||
|
|
||||||
router.route("/:code").get(controller.getActiveGame);
|
router.route("/:code").get(controller.getActiveGame);
|
||||||
|
|||||||
Reference in New Issue
Block a user