diff --git a/client/src/app/archive/[id]/not-found.tsx b/client/src/app/archive/[id]/not-found.tsx new file mode 100644 index 0000000..296afd6 --- /dev/null +++ b/client/src/app/archive/[id]/not-found.tsx @@ -0,0 +1,8 @@ +export default function NotFound() { + return ( +
+
Error 404
+
Not found
+
+ ); +} diff --git a/client/src/app/archive/[id]/page.tsx b/client/src/app/archive/[id]/page.tsx new file mode 100644 index 0000000..ea5556a --- /dev/null +++ b/client/src/app/archive/[id]/page.tsx @@ -0,0 +1,45 @@ +import ArchivedGame from "@/components/archive/ArchivedGame"; +import { getArchivedGame } from "@/lib/game"; +import type { Game } from "@chessu/types"; +import { notFound } from "next/navigation"; + +export async function generateMetadata({ params }: { params: { id: number } }) { + const game = (await getArchivedGame({ id: params.id })) as Game | undefined; + if (!game) { + return { + description: "Game not found", + robots: { + index: false, + follow: false, + nocache: true, + noarchive: true + } + }; + } + return { + description: `Archived game: ${game.white?.name} vs ${game.black?.name}`, + openGraph: { + title: "chessu", + description: `Archived game: ${game.white?.name} vs ${game.black?.name}`, + url: `https://ches.su/archive/${game.id}`, + siteName: "chessu", + locale: "en_US", + type: "website" + }, + robots: { + index: false, + follow: false, + nocache: true, + noarchive: true + } + }; +} + +export default async function Game({ params }: { params: { id: number } }) { + const game = (await getArchivedGame({ id: params.id })) as Game | undefined; + if (!game) { + notFound(); + } + + return ; +} diff --git a/client/src/components/archive/ArchivedGame.tsx b/client/src/components/archive/ArchivedGame.tsx new file mode 100644 index 0000000..692ff36 --- /dev/null +++ b/client/src/components/archive/ArchivedGame.tsx @@ -0,0 +1,343 @@ +"use client"; + +import { Game } from "@chessu/types"; +import { Chessboard } from "react-chessboard"; +import { useEffect, useReducer, useState, useRef } from "react"; +import { + IconChevronLeft, + IconChevronRight, + IconCopy, + IconPlayerSkipBack, + IconPlayerSkipForward +} from "@tabler/icons-react"; +import type { Square } from "chess.js"; +import { Chess } from "chess.js"; +import type { CustomSquares } from "@/types"; +import { IconRotateClockwise2 } from "@tabler/icons-react"; + +export default function ArchivedGame({ game }: { game: Game }) { + const [boardWidth, setBoardWidth] = useState(480); + const moveListRef = useRef(null); + const [navFen, setNavFen] = useState(null); + const [navIndex, setNavIndex] = useState(null); + const [flipBoard, setFlipBoard] = useState(false); + const [copiedLink, setCopiedLink] = useState(false); + const [showPgn, setShowPgn] = useState(true); + const actualGame = new Chess(); + actualGame.loadPgn(game.pgn as string); + + const [customSquares, updateCustomSquares] = useReducer( + (squares: CustomSquares, action: Partial) => { + return { ...squares, ...action }; + }, + { + options: {}, + lastMove: {}, + rightClicked: {}, + check: {} + } + ); + + function handleResize() { + if (window.innerWidth >= 1920) { + setBoardWidth(580); + } else if (window.innerWidth >= 1536) { + setBoardWidth(540); + } else if (window.innerWidth >= 768) { + setBoardWidth(480); + } else { + setBoardWidth(350); + } + } + + // auto scroll for moves + useEffect(() => { + const activeMoveEl = document.getElementById("activeNavMove"); + const moveList = moveListRef.current; + if (!activeMoveEl || !moveList) return; + moveList.scrollTop = activeMoveEl.offsetTop; + }); + + useEffect(() => { + handleResize(); + window.addEventListener("resize", handleResize); + + return () => { + window.removeEventListener("resize", handleResize); + }; + }, []); + + function copyLink() { + const text = `https://ches.su/archive/${game.id}`; + if ("clipboard" in navigator) { + navigator.clipboard.writeText(text); + } else { + document.execCommand("copy", true, text); + } + setCopiedLink(true); + setTimeout(() => { + setCopiedLink(false); + }, 5000); + } + + function onSquareRightClick(square: Square) { + const colour = "rgba(0, 0, 255, 0.4)"; + updateCustomSquares({ + rightClicked: { + ...customSquares.rightClicked, + [square]: + customSquares.rightClicked[square] && + customSquares.rightClicked[square]?.backgroundColor === colour + ? undefined + : { backgroundColor: colour } + } + }); + } + + function getMoveListHtml() { + const history = actualGame.history({ verbose: true }); + const movePairs = history + .slice(history.length / 2) + .map((_, i) => history.slice((i *= 2), i + 2)); + + return movePairs.map((moves, i) => { + return ( + + {i + 1}. + navigateMove(history.indexOf(moves[0]))} + > + {moves[0].san} + + {moves[1] && ( + navigateMove(history.indexOf(moves[1]))} + > + {moves[1].san} + + )} + + ); + }); + } + + function navigateMove(index: number | null | "prev") { + const history = actualGame.history({ verbose: true }); + + if (index === null || (index !== "prev" && index >= history.length - 1) || !history.length) { + // last move + setNavIndex(null); + setNavFen(null); + return; + } + + if (index === "prev") { + index = history.length - 2; + } else if (index < 0) { + index = 0; + } + + setNavIndex(index); + setNavFen(history[index].after); + } + + function getNavMoveSquares() { + const history = actualGame.history({ verbose: true }); + + if (!history.length) return; + + let index = navIndex ?? history.length - 1; + + return { + [history[index].from]: { background: "rgba(255, 255, 0, 0.4)" }, + [history[index].to]: { background: "rgba(255, 255, 0, 0.4)" } + }; + } + + function getPlayerHtml(side: "top" | "bottom") { + const blackHtml = ( +
+ {game.black?.name} + black +
+ ); + const whiteHtml = ( +
+ {game.white?.name} + white +
+ ); + if (flipBoard) { + return side === "top" ? whiteHtml : blackHtml; + } else { + return side === "top" ? blackHtml : whiteHtml; + } + } + + return ( +
+
+ false} + onSquareClick={() => updateCustomSquares({ rightClicked: {} })} + onSquareRightClick={onSquareRightClick} + customSquareStyles={{ + ...getNavMoveSquares(), + ...customSquares.rightClicked + }} + /> +
+ +
+
+
+ {getPlayerHtml("top")} +
+ +
+ {getPlayerHtml("bottom")} +
+ +
+
+ Archived link: +
+ +
+ copied to clipboard +
+
+
+
+ + {getMoveListHtml()} +
+
+
+ + + + +
+
+
+ +
+
+ {game.endReason === "abandoned" + ? game.winner === "draw" + ? "The game ended in a draw due to abandonment." + : `The game was won by ${game.winner} due to abandonment.` + : game.winner === "draw" + ? "The game ended in a draw." + : `The game was won by checkmate (${game.winner}).`} + +
+ +
+ +
+
+