feat(ui): simplified homepage, /play subpage, and fix bot-game PGN bug
Homepage now shows only a "Spielen" card (links to /play), public games, last 3 games as mini-board thumbnails for logged-in users, and live games. New /play page contains all game-mode cards (create, vs AI, friends, correspondence, tournament). Bug fix: /ai page was constructing Chess instances from FEN on every move, discarding the full PGN history. Switching to loadPgn() preserves all moves so the saved game can be fully reviewed in the archive. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -66,7 +66,10 @@ export default function AiGamePage() {
|
||||
const { move } = await res.json();
|
||||
if (move) {
|
||||
setGame((prev) => {
|
||||
const newGame = new Chess(prev.fen());
|
||||
// Load from PGN (not FEN) to preserve the full move history
|
||||
const newGame = new Chess();
|
||||
const prevPgn = prev.pgn();
|
||||
if (prevPgn) newGame.loadPgn(prevPgn);
|
||||
const result = newGame.move({ from: move.slice(0, 2), to: move.slice(2, 4), promotion: move[4] || "q" });
|
||||
if (result) setLastMove({ from: result.from, to: result.to });
|
||||
setStatus(getStatus(newGame));
|
||||
@@ -86,7 +89,10 @@ export default function AiGamePage() {
|
||||
const currentTurn = game.turn() === "w" ? "white" : "black";
|
||||
if (currentTurn !== playerColor) return false;
|
||||
|
||||
const newGame = new Chess(game.fen());
|
||||
// Load from PGN (not FEN) to preserve the full move history
|
||||
const newGame = new Chess();
|
||||
const currentPgn = game.pgn();
|
||||
if (currentPgn) newGame.loadPgn(currentPgn);
|
||||
let move = null;
|
||||
try {
|
||||
move = newGame.move({ from: sourceSquare, to: targetSquare, promotion: "q" });
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
"use client";
|
||||
|
||||
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 RecentGames from "@/components/home/RecentGames";
|
||||
import Link from "next/link";
|
||||
import { useSession } from "@/context/session";
|
||||
|
||||
@@ -14,77 +13,35 @@ export default function Home() {
|
||||
return (
|
||||
<div className="flex flex-col gap-8 w-full max-w-5xl px-4 py-8">
|
||||
|
||||
{/* Hauptaktionen */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
|
||||
<div className="card bg-base-200 border-2 border-primary shadow-lg">
|
||||
<div className="card-body gap-3">
|
||||
<h2 className="card-title text-primary">Spiel erstellen</h2>
|
||||
<p className="text-sm opacity-70">Erstelle eine Partie und teile den Einladungslink mit Freunden</p>
|
||||
<div className="mt-2">
|
||||
<CreateGame />
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{/* Spielen-Karte */}
|
||||
<Link
|
||||
href="/play"
|
||||
className="card bg-primary text-primary-content shadow-lg hover:shadow-xl transition-shadow cursor-pointer"
|
||||
>
|
||||
<div className="card-body items-center justify-center gap-4 py-12">
|
||||
<span className="text-6xl select-none">♟</span>
|
||||
<h2 className="card-title text-2xl">Spielen</h2>
|
||||
<p className="text-sm opacity-80 text-center">
|
||||
Partie erstellen, gegen KI, Turnier & mehr
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* Öffentliche Spiele */}
|
||||
<div className="md:col-span-2">
|
||||
<PublicGames />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card bg-base-200 shadow-lg">
|
||||
<div className="card-body gap-3">
|
||||
<h2 className="card-title">Gegen KI spielen</h2>
|
||||
<p className="text-sm opacity-70">Fordere Stockfish auf verschiedenen Stufen heraus — von Anfänger bis Meister</p>
|
||||
<div className="mt-auto pt-4">
|
||||
<Link href="/ai" className="btn btn-secondary w-full">Jetzt spielen</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card bg-base-200 shadow-lg">
|
||||
<div className="card-body gap-3">
|
||||
{isLoggedIn ? (
|
||||
<>
|
||||
<h2 className="card-title">Freunde</h2>
|
||||
<p className="text-sm opacity-70">Freunde hinzufügen, verwalten und zu einer Partie einladen</p>
|
||||
<div className="mt-auto pt-4">
|
||||
<Link href="/friends" className="btn btn-outline w-full">Freunde verwalten</Link>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h2 className="card-title">Einladung annehmen</h2>
|
||||
<p className="text-sm opacity-70 mb-2">Einladungslink oder Code eingeben um beizutreten</p>
|
||||
<JoinGame />
|
||||
</>
|
||||
{/* Letzte Spiele des eingeloggten Users */}
|
||||
{isLoggedIn && user.id && typeof user.id === "number" && (
|
||||
<RecentGames userId={user.id} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card bg-base-200 shadow-lg">
|
||||
<div className="card-body gap-3">
|
||||
<h2 className="card-title">Tagespartien</h2>
|
||||
<p className="text-sm opacity-70">Spiele in deinem eigenen Tempo — Züge bis zu 3 Tage Zeit</p>
|
||||
<div className="mt-auto pt-4">
|
||||
<Link href="/correspondence" className="btn btn-outline w-full">Tagespartien</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card bg-base-200 shadow-lg">
|
||||
<div className="card-body gap-3">
|
||||
<h2 className="card-title">Turnier</h2>
|
||||
<p className="text-sm opacity-70">Spiele Round-Robin Turniere gegen mehrere Gegner</p>
|
||||
<div className="mt-auto pt-4">
|
||||
<Link href="/tournament" className="btn btn-outline w-full">Turniere</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Laufende Partien – Live-Kacheln */}
|
||||
<LiveGames />
|
||||
|
||||
{/* Öffentliche Spiele */}
|
||||
<PublicGames />
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
81
client/src/app/play/page.tsx
Normal file
81
client/src/app/play/page.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import CreateGame from "@/components/home/CreateGame";
|
||||
import JoinGame from "@/components/home/JoinGame";
|
||||
import Link from "next/link";
|
||||
import { useSession } from "@/context/session";
|
||||
|
||||
export default function PlayPage() {
|
||||
const { user } = useSession();
|
||||
const isLoggedIn = user?.id && typeof user.id === "number";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8 w-full max-w-3xl px-4 py-8">
|
||||
<h1 className="text-3xl font-bold">Spielen</h1>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
|
||||
<div className="card bg-base-200 border-2 border-primary shadow-lg">
|
||||
<div className="card-body gap-3">
|
||||
<h2 className="card-title text-primary">Spiel erstellen</h2>
|
||||
<p className="text-sm opacity-70">Erstelle eine Partie und teile den Einladungslink mit Freunden</p>
|
||||
<div className="mt-2">
|
||||
<CreateGame />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card bg-base-200 shadow-lg">
|
||||
<div className="card-body gap-3">
|
||||
<h2 className="card-title">Gegen KI spielen</h2>
|
||||
<p className="text-sm opacity-70">Fordere Stockfish auf verschiedenen Stufen heraus — von Anfänger bis Meister</p>
|
||||
<div className="mt-auto pt-4">
|
||||
<Link href="/ai" className="btn btn-secondary w-full">Jetzt spielen</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoggedIn ? (
|
||||
<div className="card bg-base-200 shadow-lg">
|
||||
<div className="card-body gap-3">
|
||||
<h2 className="card-title">Freunde</h2>
|
||||
<p className="text-sm opacity-70">Freunde hinzufügen, verwalten und zu einer Partie einladen</p>
|
||||
<div className="mt-auto pt-4">
|
||||
<Link href="/friends" className="btn btn-outline w-full">Freunde verwalten</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="card bg-base-200 shadow-lg">
|
||||
<div className="card-body gap-3">
|
||||
<h2 className="card-title">Einladung annehmen</h2>
|
||||
<p className="text-sm opacity-70 mb-2">Einladungslink oder Code eingeben um beizutreten</p>
|
||||
<JoinGame />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card bg-base-200 shadow-lg">
|
||||
<div className="card-body gap-3">
|
||||
<h2 className="card-title">Tagespartien</h2>
|
||||
<p className="text-sm opacity-70">Spiele in deinem eigenen Tempo — Züge bis zu 3 Tage Zeit</p>
|
||||
<div className="mt-auto pt-4">
|
||||
<Link href="/correspondence" className="btn btn-outline w-full">Tagespartien</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card bg-base-200 shadow-lg md:col-span-2">
|
||||
<div className="card-body gap-3">
|
||||
<h2 className="card-title">Turnier</h2>
|
||||
<p className="text-sm opacity-70">Spiele Round-Robin Turniere gegen mehrere Gegner</p>
|
||||
<div className="mt-auto pt-4">
|
||||
<Link href="/tournament" className="btn btn-outline w-full">Turniere</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
82
client/src/components/home/RecentGames.tsx
Normal file
82
client/src/components/home/RecentGames.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Chessboard } from "react-chessboard";
|
||||
import { Chess } from "chess.js";
|
||||
import Link from "next/link";
|
||||
import { API_URL } from "@/config";
|
||||
import type { Game } from "@michess/types";
|
||||
|
||||
function getFen(pgn?: string): string {
|
||||
if (!pgn) return "start";
|
||||
try {
|
||||
const c = new Chess();
|
||||
c.loadPgn(pgn);
|
||||
return c.fen();
|
||||
} catch {
|
||||
return "start";
|
||||
}
|
||||
}
|
||||
|
||||
const BOARD_SIZE = 160;
|
||||
|
||||
export default function RecentGames({ userId }: { userId: number }) {
|
||||
const [games, setGames] = useState<Game[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${API_URL}/v1/games?userid=${userId}`, { cache: "no-store" })
|
||||
.then((r) => (r.ok ? r.json() : []))
|
||||
.then((data: Game[] | Game) => {
|
||||
if (Array.isArray(data)) setGames(data.slice(0, 3));
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [userId]);
|
||||
|
||||
if (!games.length) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-lg font-bold mb-3">Letzte Spiele</h2>
|
||||
<div className="flex gap-4 flex-wrap">
|
||||
{games.map((game) => {
|
||||
const fen = getFen(game.pgn);
|
||||
const myColor = game.white?.id === userId ? "white" : "black";
|
||||
const opponent = myColor === "white" ? game.black?.name : game.white?.name;
|
||||
const won = game.winner === myColor;
|
||||
const drew = game.winner === "draw";
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={game.id}
|
||||
href={`/archive/${game.id}`}
|
||||
className="card bg-base-200 shadow hover:shadow-lg transition-shadow overflow-hidden"
|
||||
style={{ width: BOARD_SIZE }}
|
||||
>
|
||||
<div className="pointer-events-none">
|
||||
<Chessboard
|
||||
boardWidth={BOARD_SIZE}
|
||||
position={fen}
|
||||
boardOrientation={myColor}
|
||||
isDraggablePiece={() => false}
|
||||
customDarkSquareStyle={{ backgroundColor: "#4b7399" }}
|
||||
customLightSquareStyle={{ backgroundColor: "#eae9d2" }}
|
||||
areArrowsAllowed={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="px-2 py-2 text-xs">
|
||||
<div className="font-semibold truncate">vs {opponent ?? "?"}</div>
|
||||
<div
|
||||
className={`mt-0.5 font-medium ${
|
||||
won ? "text-success" : drew ? "text-warning" : "text-error"
|
||||
}`}
|
||||
>
|
||||
{won ? "Gewonnen" : drew ? "Remis" : "Verloren"}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user