diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index 2f75c46..0000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,22 +0,0 @@ -# To get started with Dependabot version updates, you'll need to specify which -# package ecosystems to update and where the package manifests are located. -# Please see the documentation for all configuration options: -# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates - -version: 2 -updates: - - package-ecosystem: "npm" - directory: "/" - schedule: - interval: "monthly" - - - package-ecosystem: "npm" - directory: "/client" - schedule: - interval: "monthly" - - - package-ecosystem: "npm" - directory: "/server" - schedule: - interval: "monthly" - diff --git a/README.md b/README.md index 6edbdd1..0281162 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,9 @@ Yet another Chess web app. Live demo at [ches.su](https://ches.su). - play against other users in real-time - spectate and chat in ongoing games with other users -- ~~_optional_ user accounts for tracking stats and game history~~ (wip) -- mobile-friendly (wip) +- _optional_ user accounts for tracking stats and game history +- ~~play solo against Stockfish~~ (wip) +- mobile-friendly - ... and more ([view roadmap](https://github.com/users/nizewn/projects/2)) Built with Next.js 13, Tailwind CSS + daisyUI, react-chessboard, chess.js, Express.js, socket.io and PostgreSQL. diff --git a/client/package.json b/client/package.json index f4e3fd8..390d167 100644 --- a/client/package.json +++ b/client/package.json @@ -9,9 +9,9 @@ "lint": "next lint" }, "dependencies": { - "@tabler/icons-react": "^2.11.0", + "@tabler/icons-react": "^2.14.0", "chess.js": "^1.0.0-beta.4", - "next": "13.2.4", + "next": "13.3.0", "react": "18.2.0", "react-chessboard": "^2.1.2", "react-dom": "18.2.0", @@ -19,14 +19,14 @@ }, "devDependencies": { "@chessu/types": "*", - "@types/node": "18.15.3", - "@types/react": "18.0.28", + "@types/node": "18.15.11", + "@types/react": "18.0.33", "@types/react-dom": "18.0.11", "autoprefixer": "^10.4.14", - "daisyui": "^2.51.4", + "daisyui": "^2.51.5", "postcss": "^8.4.21", - "tailwindcss": "^3.2.7", - "typescript": "5.0.2" + "tailwindcss": "^3.3.1", + "typescript": "5.0.3" }, "optionalDependencies": { "bufferutil": "^4.0.7", diff --git a/client/src/app/[code]/page.tsx b/client/src/app/[code]/page.tsx index 934f458..636f051 100644 --- a/client/src/app/[code]/page.tsx +++ b/client/src/app/[code]/page.tsx @@ -1,9 +1,9 @@ import GameAuthWrapper from "@/components/game/GameAuthWrapper"; -import { getGame } from "@/lib/game"; +import { fetchActiveGame } from "@/lib/game"; import { notFound } from "next/navigation"; export async function generateMetadata({ params }: { params: { code: string } }) { - const game = await getGame(params.code); + const game = await fetchActiveGame(params.code); if (!game) { return { description: "Game not found", @@ -35,7 +35,7 @@ export async function generateMetadata({ params }: { params: { code: string } }) } export default async function Game({ params }: { params: { code: string } }) { - const game = await getGame(params.code); + const game = await fetchActiveGame(params.code); if (!game) { notFound(); } 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..1c24e54 --- /dev/null +++ b/client/src/app/archive/[id]/not-found.tsx @@ -0,0 +1,8 @@ +export default function NotFound() { + return ( +
+
Error 404
+
Game 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..a1c5dde --- /dev/null +++ b/client/src/app/archive/[id]/page.tsx @@ -0,0 +1,45 @@ +import ArchivedGame from "@/components/archive/ArchivedGame"; +import { fetchArchivedGame } 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 fetchArchivedGame({ 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 Archive({ params }: { params: { id: number } }) { + const game = (await fetchArchivedGame({ id: params.id })) as Game | undefined; + if (!game) { + notFound(); + } + + return ; +} diff --git a/client/src/app/layout.tsx b/client/src/app/layout.tsx index 862a7df..10bbfae 100644 --- a/client/src/app/layout.tsx +++ b/client/src/app/layout.tsx @@ -1,6 +1,6 @@ import "@/styles/globals.css"; -import Navbar from "@/components/Navbar"; +import Header from "@/components/Header"; import Footer from "@/components/Footer"; import AuthModal from "@/components/auth/AuthModal"; @@ -30,7 +30,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) - +
{children} diff --git a/client/src/app/[code]/not-found.tsx b/client/src/app/not-found.tsx similarity index 100% rename from client/src/app/[code]/not-found.tsx rename to client/src/app/not-found.tsx diff --git a/client/src/app/settings/page.tsx b/client/src/app/settings/page.tsx new file mode 100644 index 0000000..220e540 --- /dev/null +++ b/client/src/app/settings/page.tsx @@ -0,0 +1,119 @@ +"use client"; + +import type { FormEvent } from "react"; +import { useRouter } from "next/navigation"; +import { SessionContext } from "@/context/session"; +import { useContext, useState } from "react"; +import { updateUser } from "@/lib/auth"; + +export default function Settings() { + const session = useContext(SessionContext); + const router = useRouter(); + + const [buttonLoading, setButtonLoading] = useState(false); + const [serverMessage, setServerMessage] = useState(null); + + if (!session || session.user === undefined) { + return ( +
+
Loading user...
+
+ ); + } + + if (session.user === null) { + router.replace("/"); + return; + } + + async function updateAccount(e: FormEvent) { + e.preventDefault(); + const target = e.target as HTMLFormElement; + const updateUsername = target.elements.namedItem("updateUsername") as HTMLInputElement; + const updateEmail = target.elements.namedItem("updateEmail") as HTMLInputElement; + const updatePassword = target.elements.namedItem("updatePassword") as HTMLInputElement; + + const newName = + !updateUsername.value || updateUsername.value === session?.user?.name + ? undefined + : updateUsername.value; + const newEmail = + !updateEmail.value || updateEmail.value === session?.user?.email + ? undefined + : updateEmail.value; + + if (!newName && !newEmail && !updatePassword.value) return; + + setButtonLoading(true); + const user = await updateUser(newName, newEmail, updatePassword.value || undefined); + + if (typeof user === "string") { + setServerMessage(user); + } else if (user?.id) { + session?.setUser(user); + setServerMessage("Account updated successfully"); + setTimeout(() => { + setServerMessage(null); + }, 5000); + } + + updatePassword.value = ""; + setButtonLoading(false); + } + return ( +
+

Account settings

+ +
+ + + + + + + {serverMessage && ( +
+ {serverMessage} +
+ )} +
+ +
+
+
+ ); +} diff --git a/client/src/app/user/[name]/not-found.tsx b/client/src/app/user/[name]/not-found.tsx new file mode 100644 index 0000000..e967d00 --- /dev/null +++ b/client/src/app/user/[name]/not-found.tsx @@ -0,0 +1,8 @@ +export default function NotFound() { + return ( +
+
Error 404
+
User not found
+
+ ); +} diff --git a/client/src/app/user/[name]/page.tsx b/client/src/app/user/[name]/page.tsx new file mode 100644 index 0000000..7a768b5 --- /dev/null +++ b/client/src/app/user/[name]/page.tsx @@ -0,0 +1,158 @@ +import CopyLink from "@/components/user/CopyLink"; +import { fetchProfileData } from "@/lib/user"; +import { notFound } from "next/navigation"; + +export async function generateMetadata({ params }: { params: { name: string } }) { + const data = await fetchProfileData(params.name); + if (!data) { + return { + description: "User not found", + robots: { + index: false, + follow: false, + nocache: true, + noarchive: true + } + }; + } + return { + title: `${data.name} | chessu`, + description: `${data.name}'s profile`, + openGraph: { + title: `${data.name} | chessu`, + description: `${data.name}'s profile on chessu`, + url: `https://ches.su/user/${data.name}`, + siteName: "chessu", + locale: "en_US", + type: "website" + }, + robots: { + index: true, + follow: false, + nocache: true + } + }; +} + +export default async function Profile({ params }: { params: { name: string } }) { + const data = await fetchProfileData(params.name); + if (!data) { + notFound(); + } + + return ( +
+
+

{data.name}

+
+
+ Wins + {data.wins} +
+
+ Draws + {data.draws} +
+
+ Losses + {data.losses} +
+
+ +
+ +
+

Recent games

+
    + {data.recentGames.map((game) => ( +
  • +
    +
    + + White + {game.winner === "white" && ( + winner + )} + + + {game.white?.name} + +
    +
    + + {game.winner === "black" && ( + winner + )} + Black + + + {game.black?.name} + +
    +
    + +
    {game.endReason}
    + +
    + + Started + + {" "} + {new Date(game.startedAt as number).toLocaleString()} + + + + Ended + + {" "} + {new Date(game.endedAt as number).toLocaleString()} + + +
    + + + Review game + +
  • + ))} +
+
+
+ ); +} diff --git a/client/src/components/Footer.tsx b/client/src/components/Footer.tsx index c36bec3..31d4855 100644 --- a/client/src/components/Footer.tsx +++ b/client/src/components/Footer.tsx @@ -6,7 +6,12 @@ export default function Footer() {

© 2023{" "} - + nize

@@ -15,7 +20,7 @@ export default function Footer() { diff --git a/client/src/components/Navbar.tsx b/client/src/components/Header.tsx similarity index 95% rename from client/src/components/Navbar.tsx rename to client/src/components/Header.tsx index 964b90a..6fb3a18 100644 --- a/client/src/components/Navbar.tsx +++ b/client/src/components/Header.tsx @@ -2,7 +2,7 @@ import { IconUser } from "@tabler/icons-react"; import Link from "next/link"; import ThemeToggle from "./ThemeToggle"; -export default function Navbar() { +export default function Header() { return (
@@ -26,7 +26,7 @@ export default function Navbar() { here diff --git a/client/src/components/archive/ArchivedGame.tsx b/client/src/components/archive/ArchivedGame.tsx new file mode 100644 index 0000000..b912181 --- /dev/null +++ b/client/src/components/archive/ArchivedGame.tsx @@ -0,0 +1,363 @@ +"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 = ( + + ); + const whiteHtml = ( + + ); + 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}).`} + +
+ +
+ +
+
+