diff --git a/.eslintrc b/.eslintrc
index 249cad4..c5d43a6 100644
--- a/.eslintrc
+++ b/.eslintrc
@@ -1,34 +1,3 @@
{
- "settings": {
- "react": {
- "version": "detect"
- }
- },
- "root": true,
- "env": {
- "es2021": true,
- "node": true
- },
- "extends": [
- "eslint:recommended",
- "plugin:react/recommended",
- "plugin:react/jsx-runtime",
- "plugin:@typescript-eslint/recommended",
- "prettier"
- ],
- "overrides": [],
- "parser": "@typescript-eslint/parser",
- "parserOptions": {
- "ecmaVersion": "latest",
- "sourceType": "module"
- },
- "plugins": ["react", "@typescript-eslint", "prettier"],
- "rules": {
- "prettier/prettier": [
- "error",
- {
- "endOfLine": "auto"
- }
- ]
- }
+ "extends": ["next", "prettier"]
}
diff --git a/README.md b/README.md
index e612117..8e13852 100644
--- a/README.md
+++ b/README.md
@@ -1,32 +1,70 @@
# chessu
-> ❗ This project is currently undergoing a major refactor in the `dev` branch. (see [#4](https://github.com/nizewn/chessu/pull/4))
+> ❗ This project is still in the early stages of development and should be considered unstable. Expect bugs and weird behavior.
-Online 2-player chess. Live demo at [ches.su](https://ches.su)
+Yet another Chess web app. Live demo at [ches.su](https://ches.su).
-- React 18
-- CSS Modules
-- [react-chessboard](https://github.com/Clariity/react-chessboard)
-- [chess.js](https://github.com/jhlywa/chess.js)
-- Express.js
-- socket.io
-- PostgreSQL
+- 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)
-## Development
+Built with Next.js 13, Tailwind CSS + daisyUI, react-chessboard, chess.js, Express.js, socket.io and PostgreSQL.
-This repository is used for production deployments. You will need to make changes to the configuration to get this running locally.
+## Configuration
+
+This project is structured as a monorepo using npm workspaces, separated into three packages:
+
+- `client` - Next.js application for the front-end, deployed to [ches.su](https://ches.su).
+- `server` - Node/Express.js application for the back-end, deployed to [api.ches.su](https://api.ches.su).
+- `types` - Shared type definitions for the client and server.
+
+### Scripts
```sh
-npm install # install all dependencies
+# install all dependencies, including eslint and prettier for development
+npm install
+
+# concurrently run frontend and backend development servers
+npm run dev # -w client/server to run only one
-npm run dev # concurrently run frontend and backend dev servers
-npm run react-dev # run frontend server only
+# for separate production deployments
+npm install -w client
+npm install -w server
+
+npm run build -w client
+npm run build -w server
+
+npm start -w client
+npm start -w server
```
+For separate deployments, you may exclude the `client` or `server` directory. However, you should include the `types` folder as it contains shared type definitions that are required by both packages.
+
### Environment variables
-Client: `APIURL` (or just change `apiUrl` in `/client/src/config/config.ts`)
+You may create a `.env` file in each package directory to set their environment variables.
-Server: `PORT`, `SESSION_SECRET`, `PGUSER`, `PGPASSWORD`, `PGHOST`, `PGDATABASE`, `PGPORT`
-(also see server cors config and session middleware for local development)
+client:
+
+```env
+NEXT_PUBLIC_API_URL=http://localhost:3001 # replace with backend URL
+```
+
+server:
+
+```env
+CORS_ORIGIN=http://localhost:3000 # replace with frontend URL
+PORT=3001
+SESSION_SECRET=randomstring # replace for security
+
+# PostgreSQL connection info
+PGHOST=db.example.com
+PGUSER=exampleuser
+PGPASSWORD=examplepassword
+PGDATABASE=chessu
+
+# or use a connection string instead
+DATABASE_URL=postgres://...
+```
diff --git a/client/.gitignore b/client/.gitignore
new file mode 100644
index 0000000..a097e18
--- /dev/null
+++ b/client/.gitignore
@@ -0,0 +1,37 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
+/.pnp
+.pnp.js
+
+# testing
+/coverage
+
+# next.js
+/.next/
+/out/
+
+# production
+/build
+
+# misc
+.DS_Store
+*.pem
+.vscode/
+
+# debug
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+.pnpm-debug.log*
+
+# local env files
+.env*.local
+
+# vercel
+.vercel
+
+# typescript
+*.tsbuildinfo
+next-env.d.ts
diff --git a/client/index.html b/client/index.html
deleted file mode 100644
index a546081..0000000
--- a/client/index.html
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-
-
-
-
- Chessu
-
-
-
-
-
-
diff --git a/client/next.config.js b/client/next.config.js
new file mode 100644
index 0000000..72ccbee
--- /dev/null
+++ b/client/next.config.js
@@ -0,0 +1,9 @@
+/** @type {import('next').NextConfig} */
+const nextConfig = {
+ reactStrictMode: true,
+ experimental: {
+ appDir: true
+ }
+};
+
+module.exports = nextConfig;
diff --git a/client/package.json b/client/package.json
index 27424d8..f2cd0ea 100644
--- a/client/package.json
+++ b/client/package.json
@@ -1,28 +1,35 @@
{
- "name": "chessu",
- "private": true,
+ "name": "@chessu/client",
"version": "0.0.0",
- "type": "module",
+ "private": true,
"scripts": {
- "dev": "vite",
- "build": "tsc && vite build",
- "preview": "vite preview"
+ "dev": "next dev",
+ "build": "next build",
+ "start": "next start",
+ "lint": "next lint"
},
"dependencies": {
- "@radix-ui/colors": "^0.1.8",
- "@radix-ui/react-icons": "^1.1.1",
+ "@tabler/icons-react": "^2.7.0",
"chess.js": "^1.0.0-beta.3",
- "react": "^18.2.0",
- "react-chessboard": "^2.0.8",
- "react-dom": "^18.2.0",
- "react-router-dom": "^6.8.1",
- "socket.io-client": "^4.6.0"
+ "next": "13.2.3",
+ "react": "18.2.0",
+ "react-chessboard": "^2.1.0",
+ "react-dom": "18.2.0",
+ "socket.io-client": "^4.6.1"
},
"devDependencies": {
- "@types/react": "^18.0.28",
- "@types/react-dom": "^18.0.11",
- "@vitejs/plugin-react-swc": "^3.1.0",
- "typescript": "^4.9.5",
- "vite": "^4.1.1"
+ "@chessu/types": "*",
+ "@types/node": "18.14.6",
+ "@types/react": "18.0.28",
+ "@types/react-dom": "18.0.11",
+ "autoprefixer": "^10.4.13",
+ "daisyui": "^2.51.3",
+ "postcss": "^8.4.21",
+ "tailwindcss": "^3.2.7",
+ "typescript": "4.9.5"
+ },
+ "optionalDependencies": {
+ "bufferutil": "^4.0.7",
+ "utf-8-validate": "^6.0.3"
}
}
diff --git a/client/postcss.config.js b/client/postcss.config.js
new file mode 100644
index 0000000..fe66dd6
--- /dev/null
+++ b/client/postcss.config.js
@@ -0,0 +1,6 @@
+module.exports = {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {}
+ }
+};
diff --git a/client/public/assets/default_avatar.png b/client/public/assets/default_avatar.png
new file mode 100644
index 0000000..0989209
Binary files /dev/null and b/client/public/assets/default_avatar.png differ
diff --git a/client/public/chessu.svg b/client/public/chessu.svg
deleted file mode 100644
index cabe48a..0000000
--- a/client/public/chessu.svg
+++ /dev/null
@@ -1,104 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/client/src/App.tsx b/client/src/App.tsx
deleted file mode 100644
index 18bbb0c..0000000
--- a/client/src/App.tsx
+++ /dev/null
@@ -1,32 +0,0 @@
-import { Route, Routes } from "react-router-dom";
-import ContextProvider from "./context/ContextProvider";
-
-import Header from "./components/Header/Header";
-import Footer from "./components/Footer/Footer";
-
-import ProtectedRoutes from "./routes/ProtectedRoutes";
-import Home from "./routes/Home/Home";
-import Game from "./routes/Game/Game";
-import NotFound from "./routes/NotFound/NotFound";
-
-import "./global.css";
-
-const App = (): JSX.Element => {
- return (
-
-
-
-
- } />
- }>
- } />
-
- } />
-
-
-
-
- );
-};
-
-export default App;
diff --git a/client/src/app/game/[code]/not-found.tsx b/client/src/app/game/[code]/not-found.tsx
new file mode 100644
index 0000000..d49494f
--- /dev/null
+++ b/client/src/app/game/[code]/not-found.tsx
@@ -0,0 +1,8 @@
+export default function NotFound() {
+ return (
+
+
Error
+
Game not found
+
+ );
+}
diff --git a/client/src/app/game/[code]/page.tsx b/client/src/app/game/[code]/page.tsx
new file mode 100644
index 0000000..b8d87c3
--- /dev/null
+++ b/client/src/app/game/[code]/page.tsx
@@ -0,0 +1,44 @@
+import GameAuthWrapper from "@/components/game/GameAuthWrapper";
+import { getGame } from "@/lib/game";
+import { notFound } from "next/navigation";
+
+export async function generateMetadata({ params }: { params: { code: string } }) {
+ const game = await getGame(params.code);
+ if (!game) {
+ return {
+ description: "Game not found",
+ robots: {
+ index: false,
+ follow: false,
+ nocache: true,
+ noarchive: true
+ }
+ };
+ }
+ return {
+ description: `Play or watch a game with ${game.host?.name}`,
+ openGraph: {
+ title: "chessu",
+ description: `Play or watch a game with ${game.host?.name}`,
+ url: `https://ches.su/game/${game.code}`,
+ siteName: "chessu",
+ locale: "en_US",
+ type: "website"
+ },
+ robots: {
+ index: false,
+ follow: false,
+ nocache: true,
+ noarchive: true
+ }
+ };
+}
+
+export default async function Game({ params }: { params: { code: string } }) {
+ const game = await getGame(params.code);
+ if (!game) {
+ notFound();
+ }
+
+ return ;
+}
diff --git a/client/src/app/game/page.tsx b/client/src/app/game/page.tsx
new file mode 100644
index 0000000..12560fb
--- /dev/null
+++ b/client/src/app/game/page.tsx
@@ -0,0 +1,5 @@
+import { redirect } from "next/navigation";
+
+export default function GameEmpty() {
+ redirect("/");
+}
diff --git a/client/src/app/layout.tsx b/client/src/app/layout.tsx
new file mode 100644
index 0000000..45d79fc
--- /dev/null
+++ b/client/src/app/layout.tsx
@@ -0,0 +1,46 @@
+import "@/styles/globals.css";
+
+import Navbar from "@/components/Navbar";
+import Footer from "@/components/Footer";
+import AuthModal from "@/components/auth/AuthModal";
+
+import ContextProvider from "@/context/ContextProvider";
+
+export const metadata = {
+ title: "chessu",
+ description: "Play Chess online.",
+ openGraph: {
+ title: "chessu",
+ description: "Play Chess online.",
+ url: "https://ches.su",
+ siteName: "chessu",
+ locale: "en_US",
+ type: "website"
+ },
+ robots: {
+ index: true,
+ follow: false,
+ nocache: true,
+ noarchive: true
+ }
+};
+
+export default function RootLayout({ children }: { children: React.ReactNode }) {
+ return (
+
+
+
+
+
+
+ {children}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/client/src/app/page.tsx b/client/src/app/page.tsx
new file mode 100644
index 0000000..e25fe55
--- /dev/null
+++ b/client/src/app/page.tsx
@@ -0,0 +1,26 @@
+import PublicGames from "@/components/home/PublicGames/PublicGames";
+import JoinGame from "@/components/home/JoinGame";
+import CreateGame from "@/components/home/CreateGame";
+
+export default function Home() {
+ return (
+
+ {/* @ts-expect-error Server Component */}
+
+
+
+
+
Join from invite
+
+
+
+
or
+
+
+
Create game
+
+
+
+
+ );
+}
diff --git a/client/src/assets/oauth_facebook.png b/client/src/assets/oauth_facebook.png
deleted file mode 100644
index 8d0d202..0000000
Binary files a/client/src/assets/oauth_facebook.png and /dev/null differ
diff --git a/client/src/assets/oauth_google.png b/client/src/assets/oauth_google.png
deleted file mode 100644
index b1327b4..0000000
Binary files a/client/src/assets/oauth_google.png and /dev/null differ
diff --git a/client/src/components/Auth/Auth.module.css b/client/src/components/Auth/Auth.module.css
deleted file mode 100644
index 713d7f9..0000000
--- a/client/src/components/Auth/Auth.module.css
+++ /dev/null
@@ -1,125 +0,0 @@
-.authBox {
- display: flex;
- flex-wrap: wrap;
- justify-content: space-between;
- align-items: center;
- border-radius: 4px;
- position: relative;
-}
-
-.orRegister {
- font-size: 70%;
-}
-
-.orRegister a {
- color: var(--blue11);
- text-decoration: underline;
-}
-
-.authBox h3 {
- margin-bottom: 1em;
-}
-
-.section {
- flex: 1;
- width: 50%;
- min-width: 250px;
- padding: 20px;
- position: relative;
-}
-
-.sectionDisabled {
- position: absolute;
- top: 0;
- right: 0;
- bottom: 0;
- width: 100%;
- background-color: rgba(0, 0, 0, 0.4);
- border-top-right-radius: 4px;
- border-bottom-right-radius: 4px;
- backdrop-filter: blur(1px);
- z-index: 10;
- display: flex;
- justify-content: center;
- align-items: center;
- pointer-events: none;
-}
-
-.sectionLeft {
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- text-align: center;
- /*border-right: 1px solid #eee;*/
-}
-
-.sectionRight {
- display: flex;
- flex-direction: column;
- pointer-events: none;
-}
-
-.form {
- display: flex;
- flex-direction: column;
- align-items: stretch;
-}
-
-.formGroup {
- display: flex;
- flex-direction: column;
- margin-bottom: 10px;
-}
-
-.formLabel {
- font-size: 14px;
- font-weight: 500;
- margin-bottom: 5px;
-}
-
-.formInput {
- background-color: var(--blue4);
- box-shadow: 0 0 0 1px var(--blue6);
- color: var(--blue12);
- border-radius: 4px;
- padding: 8px;
- font-size: 14px;
-}
-
-.formInput:focus {
- box-shadow: 0 0 0 1px var(--blue8);
-}
-
-.formButton {
- border-radius: 4px;
- background-color: var(--blue10);
- color: var(--blue1);
- font-size: 14px;
- font-weight: 500;
- padding: 8px 16px;
- cursor: pointer;
-}
-
-.formButton:focus,
-.formButton:hover {
- background-color: var(--blue11);
-}
-
-.oauth {
- display: flex;
- flex-wrap: wrap;
- align-items: center;
- margin-top: 10px;
-}
-
-.oauthButton {
- width: 50%;
- background-color: transparent;
- cursor: pointer;
- display: inline-flex;
-}
-
-.oauthButton img {
- width: 100%;
-}
diff --git a/client/src/components/Auth/Auth.tsx b/client/src/components/Auth/Auth.tsx
deleted file mode 100644
index 971bd8d..0000000
--- a/client/src/components/Auth/Auth.tsx
+++ /dev/null
@@ -1,90 +0,0 @@
-import { MouseEvent, useRef, useContext } from "react";
-import { SessionContext } from "../../context/session";
-import { setGuestSession } from "../../utils/auth";
-
-import styles from "./Auth.module.css";
-import GoogleOAuth from "../../assets/oauth_google.png";
-import FacebookOAuth from "../../assets/oauth_facebook.png";
-
-// TODO: clean up this component
-
-const Auth = () => {
- const guestNameRef = useRef(null);
- const session = useContext(SessionContext);
-
- async function handleGuestLogin(e: MouseEvent) {
- e.preventDefault();
- if (!guestNameRef.current || !guestNameRef.current.value) return;
-
- const user = await setGuestSession(guestNameRef.current.value);
-
- if (user) {
- session?.setUser(user);
- } else {
- console.log("guest auth failed");
- }
- }
-
- return (
-
-
-
-
coming soon.
-
- Sign In{" "}
-
- or register
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
-};
-
-export default Auth;
diff --git a/client/src/components/Board/Board.tsx b/client/src/components/Board/Board.tsx
deleted file mode 100644
index 38b767c..0000000
--- a/client/src/components/Board/Board.tsx
+++ /dev/null
@@ -1,217 +0,0 @@
-import { useContext, useState, useEffect, useReducer } from "react";
-import { Chess, Move, Square } from "chess.js";
-import { Chessboard } from "react-chessboard";
-import { SocketContext } from "../../context/socket";
-import { SessionContext } from "../../context/session";
-import type { Game } from "@types";
-
-/**
- * bug: always on initial position on page load, regardless of game.fen()
- but works fine in production without StrictMode rendering the component twice
- * */
-
-const Board = () => {
- const socket = useContext(SocketContext);
- const session = useContext(SessionContext);
-
- const [size, setSize] = useState(400);
-
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
- const [_, forceUpdate] = useReducer((x) => x + 1, 0);
-
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
- const [game, _setGame] = useState(new Chess());
- const [side, setSide] = useState<"b" | "w" | "s">("s");
-
- const [moveFrom, setMoveFrom] = useState("");
- const [rightClickedSquares, setRightClickedSquares] = useState<{
- [square: string]: { backgroundColor: string } | undefined;
- }>({});
- const [optionSquares, setOptionSquares] = useState<{
- [square: string]: { background: string; borderRadius?: string };
- }>({});
-
- useEffect(() => {
- if (socket === null) return;
-
- window.addEventListener("resize", handleResize);
- handleResize();
-
- socket.on("receivedLatestGame", (latestGame: Game) => {
- if (latestGame.pgn) {
- game.loadPgn(latestGame.pgn);
- forceUpdate();
- }
- if (latestGame.black?.id === session?.user.id) {
- if (side !== "b") setSide("b");
- } else if (latestGame.white?.id === session?.user.id) {
- if (side !== "w") setSide("w");
- } else if (side !== "s") {
- setSide("s");
- }
- });
-
- socket.on("receivedMove", (m: { from: string; to: string; promotion?: string }) => {
- const success = makeMove(m);
- if (!success) {
- socket.emit("getLatestGame");
- }
- });
-
- return () => {
- window.removeEventListener("resize", handleResize);
- socket.off("receivedMove");
- socket.off("receivedLatestGame");
- };
- }, []);
-
- function handleResize() {
- const container = document.getElementById("root");
- if (!container || !container.offsetWidth) return;
- if (container.offsetWidth > 1600) {
- setSize(container.offsetWidth * 0.25);
- } else if (container.offsetWidth > 700) {
- setSize(container.offsetWidth * 0.35);
- } else {
- setSize(container.offsetWidth - 100);
- }
- }
-
- function makeMove(m: { from: string; to: string; promotion?: string }) {
- try {
- const result = game.move(m);
- if (result) {
- setOptionSquares({
- [m.from]: { background: "rgba(255, 255, 0, 0.4)" },
- [m.to]: { background: "rgba(255, 255, 0, 0.4)" }
- });
- return result;
- } else {
- throw new Error("invalid move");
- }
- } catch (e) {
- setOptionSquares({});
- return false;
- }
- }
-
- function isDraggablePiece({ piece }: { piece: string }) {
- if (side === "s") return true;
- return piece.startsWith(side);
- }
-
- function onDrop(sourceSquare: Square, targetSquare: Square) {
- if (side !== game.turn()) return false;
-
- const moveDetails = {
- from: sourceSquare,
- to: targetSquare,
- promotion: "q"
- };
-
- const move = makeMove(moveDetails);
- if (!move) return false; // illegal move
- socket?.emit("sendMove", moveDetails);
- return true;
- }
-
- function getMoveOptions(square: Square) {
- const moves = game.moves({
- square,
- verbose: true
- }) as Move[];
- if (moves.length === 0) {
- return;
- }
-
- const newSquares: {
- [square: string]: { background: string; borderRadius?: string };
- } = {};
- moves.map((move) => {
- newSquares[move.to] = {
- background:
- game.get(move.to as Square) &&
- game.get(move.to as Square)?.color !== game.get(square)?.color
- ? "radial-gradient(circle, rgba(0,0,0,.1) 85%, transparent 85%)"
- : "radial-gradient(circle, rgba(0,0,0,.1) 25%, transparent 25%)",
- borderRadius: "50%"
- };
- return move;
- });
- newSquares[square] = {
- background: "rgba(255, 255, 0, 0.4)"
- };
- setOptionSquares(newSquares);
- }
-
- function onPieceDragBegin(_piece: string, sourceSquare: Square) {
- if (side !== game.turn()) return;
-
- getMoveOptions(sourceSquare);
- }
- function onPieceDragEnd() {
- setOptionSquares({});
- }
-
- function onSquareClick(square: Square) {
- setRightClickedSquares({});
- if (side !== game.turn()) return;
-
- function resetFirstMove(square: Square) {
- setMoveFrom(square);
- getMoveOptions(square);
- }
-
- // from square
- if (!moveFrom) {
- resetFirstMove(square);
- return;
- }
-
- const moveDetails = {
- from: moveFrom as Square,
- to: square,
- promotion: "q"
- };
-
- const move = makeMove(moveDetails);
- if (!move) {
- resetFirstMove(square);
- } else {
- setMoveFrom("");
- socket?.emit("sendMove", moveDetails);
- }
- }
-
- function onSquareRightClick(square: Square) {
- const colour = "rgba(0, 0, 255, 0.4)";
- setRightClickedSquares({
- ...rightClickedSquares,
- [square]:
- rightClickedSquares[square] && rightClickedSquares[square]?.backgroundColor === colour
- ? undefined
- : { backgroundColor: colour }
- });
- }
-
- return (
-
- );
-};
-
-export default Board;
diff --git a/client/src/components/Footer.tsx b/client/src/components/Footer.tsx
new file mode 100644
index 0000000..c36bec3
--- /dev/null
+++ b/client/src/components/Footer.tsx
@@ -0,0 +1,27 @@
+import { IconBrandGithub } from "@tabler/icons-react";
+
+export default function Footer() {
+ return (
+
+ );
+}
diff --git a/client/src/components/Footer/Footer.module.css b/client/src/components/Footer/Footer.module.css
deleted file mode 100644
index 630f669..0000000
--- a/client/src/components/Footer/Footer.module.css
+++ /dev/null
@@ -1,14 +0,0 @@
-.footer {
- margin-top: 4em;
- padding-bottom: 2em;
- width: 100%;
- font-size: 0.8em;
-}
-
-.footer a {
- color: var(--blue12);
-}
-
-.github {
- text-decoration: underline;
-}
diff --git a/client/src/components/Footer/Footer.tsx b/client/src/components/Footer/Footer.tsx
deleted file mode 100644
index ad93f6d..0000000
--- a/client/src/components/Footer/Footer.tsx
+++ /dev/null
@@ -1,24 +0,0 @@
-import styles from "./Footer.module.css";
-
-const Footer = () => {
- return (
-
- );
-};
-
-export default Footer;
diff --git a/client/src/components/Header/Header.module.css b/client/src/components/Header/Header.module.css
deleted file mode 100644
index 6149e85..0000000
--- a/client/src/components/Header/Header.module.css
+++ /dev/null
@@ -1,29 +0,0 @@
-.header {
- padding: 1.5em;
- font-size: 2.3em;
- text-align: center;
-}
-
-.title {
- color: var(--blue12);
-}
-
-.themeToggle {
- padding: 0 0.5em;
- background: transparent;
- color: var(--blue12);
-}
-
-.note {
- text-align: center;
- background-color: var(--blue2);
- padding: 0.9em 0;
- font-size: 0.7em;
- width: 100%;
- color: var(--blue12);
-}
-
-.note a {
- color: var(--blue11);
- text-decoration: underline;
-}
diff --git a/client/src/components/Header/Header.tsx b/client/src/components/Header/Header.tsx
deleted file mode 100644
index 092cbd9..0000000
--- a/client/src/components/Header/Header.tsx
+++ /dev/null
@@ -1,56 +0,0 @@
-import { useEffect, useState } from "react";
-import { Link } from "react-router-dom";
-
-import styles from "./Header.module.css";
-import { SunIcon, MoonIcon } from "@radix-ui/react-icons";
-
-const Header = () => {
- const [darkTheme, setDarkTheme] = useState(false);
- useEffect(() => {
- window
- .matchMedia("(prefers-color-scheme: dark)")
- .addEventListener("change", (e) => changeTheme(e.matches ? "dark" : "light"));
-
- changeTheme(window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
- }, []);
-
- function changeTheme(theme: "dark" | "light") {
- if (theme === "dark") {
- if (!document.body.classList.contains("dark-theme")) {
- document.body.classList.add("dark-theme");
- }
- setDarkTheme(true);
- } else {
- if (document.body.classList.contains("dark-theme")) {
- document.body.classList.remove("dark-theme");
- }
- setDarkTheme(false);
- }
- }
-
- return (
- <>
-
- This project is currently undergoing a major refactor & redesign. (
-
- #4
-
- )
-
-
-
- chessu
-
- changeTheme(darkTheme ? "light" : "dark")}
- >
- {darkTheme ? : }
-
-
- >
- );
-};
-
-export default Header;
diff --git a/client/src/components/Navbar.tsx b/client/src/components/Navbar.tsx
new file mode 100644
index 0000000..964b90a
--- /dev/null
+++ b/client/src/components/Navbar.tsx
@@ -0,0 +1,50 @@
+import { IconUser } from "@tabler/icons-react";
+import Link from "next/link";
+import ThemeToggle from "./ThemeToggle";
+
+export default function Navbar() {
+ return (
+
+
+
+ chessu
+
+
+
+ alpha
+
+
+
+
+ This project is a work in progress. You can view the roadmap{" "}
+
+ here
+
+ .
+
+
+
+
+
+
+
+ );
+}
diff --git a/client/src/components/ThemeToggle.tsx b/client/src/components/ThemeToggle.tsx
new file mode 100644
index 0000000..8942f52
--- /dev/null
+++ b/client/src/components/ThemeToggle.tsx
@@ -0,0 +1,36 @@
+"use client";
+
+import { IconSun, IconMoon } from "@tabler/icons-react";
+import { useState, useEffect } from "react";
+
+export default function ThemeToggle() {
+ const [darkTheme, setDarkTheme] = useState(false);
+
+ useEffect(() => {
+ window
+ .matchMedia("(prefers-color-scheme: dark)")
+ .addEventListener("change", (e) => changeTheme(e.matches ? "dark" : "light"));
+
+ changeTheme(window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
+ }, []);
+
+ function changeTheme(theme: "dark" | "light") {
+ if (theme === "dark") {
+ document.documentElement.setAttribute("data-theme", "chessuDark");
+ setDarkTheme(true);
+ } else {
+ document.documentElement.setAttribute("data-theme", "chessuLight");
+ setDarkTheme(false);
+ }
+ }
+ return (
+ changeTheme(darkTheme ? "light" : "dark")}
+ className={"btn btn-ghost btn-circle swap swap-rotate" + (darkTheme ? " swap-active" : "")}
+ >
+
+
+
+ );
+}
diff --git a/client/src/components/auth/AuthModal.tsx b/client/src/components/auth/AuthModal.tsx
new file mode 100644
index 0000000..35edeae
--- /dev/null
+++ b/client/src/components/auth/AuthModal.tsx
@@ -0,0 +1,87 @@
+"use client";
+
+import type { FormEvent } from "react";
+import { useRef, useState, useContext } from "react";
+import { SessionContext } from "@/context/session";
+import { setGuestSession } from "@/lib/auth";
+
+// TODO: add login and register views
+
+export default function AuthModal() {
+ const session = useContext(SessionContext);
+ const [buttonLoading, setButtonLoading] = useState(false);
+ const modalToggleRef = useRef(null);
+
+ async function updateGuestName(e: FormEvent) {
+ e.preventDefault();
+
+ const target = e.target as HTMLFormElement;
+ const guestName = target.elements.namedItem("guestName") as HTMLInputElement;
+ if (!guestName || !guestName.value) return;
+
+ setButtonLoading(true);
+ const user = await setGuestSession(guestName.value);
+ if (user) {
+ session?.setUser(user);
+ if (modalToggleRef.current?.checked) {
+ modalToggleRef.current.checked = false;
+ }
+ }
+ guestName.value = "";
+ setButtonLoading(false);
+ }
+
+ return (
+ <>
+
+
+
+
+
+
+ {session?.user !== null && (
+
+ ✕
+
+ )}
+
+
+
+
+
+ >
+ );
+}
diff --git a/client/src/components/game/GameAuthWrapper.tsx b/client/src/components/game/GameAuthWrapper.tsx
new file mode 100644
index 0000000..c868ce6
--- /dev/null
+++ b/client/src/components/game/GameAuthWrapper.tsx
@@ -0,0 +1,21 @@
+"use client";
+
+import { useContext } from "react";
+import { SessionContext } from "@/context/session";
+import GamePage from "./GamePage";
+import type { Game } from "@chessu/types";
+
+export default function GameAuthWrapper({ initialLobby }: { initialLobby: Game }) {
+ const session = useContext(SessionContext);
+
+ if (!session?.user || !session.user?.id) {
+ return (
+
+
Loading
+
Waiting for authentication...
+
+ );
+ }
+
+ return ;
+}
diff --git a/client/src/components/game/GamePage.tsx b/client/src/components/game/GamePage.tsx
new file mode 100644
index 0000000..1e3a118
--- /dev/null
+++ b/client/src/components/game/GamePage.tsx
@@ -0,0 +1,520 @@
+"use client";
+// TODO: restructure
+
+import { Chessboard } from "react-chessboard";
+import { IconCopy } from "@tabler/icons-react";
+import { useState, useEffect, useContext, useReducer, useRef } from "react";
+import type { KeyboardEvent, FormEvent } from "react";
+//import Image from "next/image";
+import type { Game } from "@chessu/types";
+import type { Message } from "@/types";
+
+import { io } from "socket.io-client";
+import { API_URL } from "@/config";
+import { SessionContext } from "@/context/session";
+import { Chess } from "chess.js";
+import type { Square, Move } from "chess.js";
+import { initSocket, lobbyReducer, squareReducer } from "./handlers";
+
+const socket = io(API_URL, { withCredentials: true, autoConnect: false });
+
+export default function GamePage({ initialLobby }: { initialLobby: Game }) {
+ const session = useContext(SessionContext);
+
+ const [lobby, updateLobby] = useReducer(lobbyReducer, {
+ ...initialLobby,
+ actualGame: new Chess(),
+ side: "s"
+ });
+
+ const [customSquares, updateCustomSquares] = useReducer(squareReducer, {
+ options: {},
+ lastMove: {},
+ rightClicked: {},
+ check: {}
+ });
+
+ const [moveFrom, setMoveFrom] = useState(null);
+
+ const [chatMessages, setChatMessages] = useState([]);
+ const [boardWidth, setBoardWidth] = useState(480);
+
+ const [playBtnLoading, setPlayBtnLoading] = useState(false);
+ const [copiedLink, setCopiedLink] = useState(false);
+
+ const chatlistRef = useRef(null);
+
+ useEffect(() => {
+ if (!session?.user || !session.user?.id) return;
+ socket.connect();
+
+ window.addEventListener("resize", handleResize);
+ handleResize();
+
+ if (lobby.pgn && lobby.actualGame.pgn() !== lobby.pgn) {
+ lobby.actualGame.loadPgn(lobby.pgn as string);
+
+ const lastMove = lobby.actualGame.history({ verbose: true }).pop();
+
+ let lastMoveSquares = undefined;
+ let kingSquare = undefined;
+ if (lastMove) {
+ lastMoveSquares = {
+ [lastMove.from]: { background: "rgba(255, 255, 0, 0.4)" },
+ [lastMove.to]: { background: "rgba(255, 255, 0, 0.4)" }
+ };
+ }
+ if (lobby.actualGame.inCheck()) {
+ const kingPos = lobby.actualGame.board().reduce((acc, row, index) => {
+ const squareIndex = row.findIndex(
+ (square) => square && square.type === "k" && square.color === lobby.actualGame.turn()
+ );
+ return squareIndex >= 0 ? `${String.fromCharCode(squareIndex + 97)}${8 - index}` : acc;
+ }, "");
+ kingSquare = {
+ [kingPos]: {
+ background: "radial-gradient(red, rgba(255,0,0,.4), transparent 70%)",
+ borderRadius: "50%"
+ }
+ };
+ }
+ updateCustomSquares({
+ lastMove: lastMoveSquares,
+ check: kingSquare
+ });
+ }
+
+ if (lobby.black?.id === session?.user?.id) {
+ if (lobby.side !== "b") updateLobby({ type: "setSide", payload: "b" });
+ } else if (lobby.white?.id === session?.user?.id) {
+ if (lobby.side !== "w") updateLobby({ type: "setSide", payload: "w" });
+ } else if (lobby.side !== "s") {
+ updateLobby({ type: "setSide", payload: "s" });
+ }
+
+ initSocket(session.user, socket, lobby, {
+ updateLobby,
+ addMessage,
+ updateCustomSquares,
+ makeMove
+ });
+
+ return () => {
+ window.removeEventListener("resize", handleResize);
+ socket.removeAllListeners();
+ socket.disconnect();
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ // auto scroll down when new message is added
+ useEffect(() => {
+ const chatlist = chatlistRef.current;
+ if (!chatlist) return;
+ chatlist.scrollTop = chatlist.scrollHeight;
+ }, [chatMessages]);
+
+ useEffect(() => {
+ updateTurnTitle();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [lobby]);
+
+ function updateTurnTitle() {
+ if (lobby.side === "s" || !lobby.white?.id || !lobby.black?.id) return;
+
+ if (lobby.side === lobby.actualGame.turn()) {
+ document.title = "(your turn) chessu";
+ } else {
+ document.title = "chessu";
+ }
+ }
+
+ 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);
+ }
+ }
+
+ function addMessage(message: Message) {
+ setChatMessages((prev) => [...prev, message]);
+ }
+
+ function sendChat(message: string) {
+ if (!session?.user) return;
+
+ socket.emit("chat", message);
+ addMessage({ author: session.user, message });
+ }
+
+ function chatKeyUp(e: KeyboardEvent) {
+ e.preventDefault();
+ if (e.key === "Enter") {
+ const input = e.target as HTMLInputElement;
+ if (!input.value || input.value.length == 0) return;
+ sendChat(input.value);
+ input.value = "";
+ }
+ }
+
+ function chatClickSend(e: FormEvent) {
+ e.preventDefault();
+
+ const target = e.target as HTMLFormElement;
+ const input = target.elements.namedItem("chatInput") as HTMLInputElement;
+ if (!input.value || input.value.length == 0) return;
+ sendChat(input.value);
+ input.value = "";
+ }
+
+ function makeMove(m: { from: string; to: string; promotion?: string }) {
+ try {
+ const result = lobby.actualGame.move(m);
+
+ if (result) {
+ updateLobby({
+ type: "updateLobby",
+ payload: { pgn: lobby.actualGame.pgn() }
+ });
+ updateTurnTitle();
+ let kingSquare = undefined;
+ if (lobby.actualGame.inCheck()) {
+ const kingPos = lobby.actualGame.board().reduce((acc, row, index) => {
+ const squareIndex = row.findIndex(
+ (square) => square && square.type === "k" && square.color === lobby.actualGame.turn()
+ );
+ return squareIndex >= 0 ? `${String.fromCharCode(squareIndex + 97)}${8 - index}` : acc;
+ }, "");
+ kingSquare = {
+ [kingPos]: {
+ background: "radial-gradient(red, rgba(255,0,0,.4), transparent 70%)",
+ borderRadius: "50%"
+ }
+ };
+ }
+ updateCustomSquares({
+ lastMove: {
+ [result.from]: { background: "rgba(255, 255, 0, 0.4)" },
+ [result.to]: { background: "rgba(255, 255, 0, 0.4)" }
+ },
+ options: {},
+ check: kingSquare
+ });
+ return true;
+ } else {
+ throw new Error("Invalid move");
+ }
+ } catch (err) {
+ updateCustomSquares({
+ options: {}
+ });
+ return false;
+ }
+ }
+
+ function isDraggablePiece({ piece }: { piece: string }) {
+ if (lobby.side === "s") return true;
+ return piece.startsWith(lobby.side);
+ }
+
+ function onDrop(sourceSquare: Square, targetSquare: Square) {
+ if (lobby.side !== lobby.actualGame.turn()) return false;
+
+ const moveDetails = {
+ from: sourceSquare,
+ to: targetSquare,
+ promotion: "q"
+ };
+
+ const move = makeMove(moveDetails);
+ if (!move) return false; // illegal move
+ socket.emit("sendMove", moveDetails);
+ return true;
+ }
+
+ function getMoveOptions(square: Square) {
+ const moves = lobby.actualGame.moves({
+ square,
+ verbose: true
+ }) as Move[];
+ if (moves.length === 0) {
+ return;
+ }
+
+ const newSquares: {
+ [square: string]: { background: string; borderRadius?: string };
+ } = {};
+ moves.map((move) => {
+ newSquares[move.to] = {
+ background:
+ lobby.actualGame.get(move.to as Square) &&
+ lobby.actualGame.get(move.to as Square)?.color !== lobby.actualGame.get(square)?.color
+ ? "radial-gradient(circle, rgba(0,0,0,.1) 85%, transparent 85%)"
+ : "radial-gradient(circle, rgba(0,0,0,.1) 25%, transparent 25%)",
+ borderRadius: "50%"
+ };
+ return move;
+ });
+ newSquares[square] = {
+ background: "rgba(255, 255, 0, 0.4)"
+ };
+ updateCustomSquares({ options: newSquares });
+ }
+
+ function onPieceDragBegin(_piece: string, sourceSquare: Square) {
+ if (lobby.side !== lobby.actualGame.turn()) return;
+
+ getMoveOptions(sourceSquare);
+ }
+
+ function onPieceDragEnd() {
+ updateCustomSquares({ options: {} });
+ }
+
+ function onSquareClick(square: Square) {
+ updateCustomSquares({ rightClicked: {} });
+ if (lobby.side !== lobby.actualGame.turn()) return;
+
+ function resetFirstMove(square: Square) {
+ setMoveFrom(square);
+ getMoveOptions(square);
+ }
+
+ // from square
+ if (moveFrom === null) {
+ resetFirstMove(square);
+ return;
+ }
+
+ const moveDetails = {
+ from: moveFrom,
+ to: square,
+ promotion: "q"
+ };
+
+ const move = makeMove(moveDetails);
+ if (!move) {
+ resetFirstMove(square);
+ } else {
+ setMoveFrom(null);
+ socket.emit("sendMove", moveDetails);
+ }
+ }
+
+ 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 clickPlay(e: FormEvent) {
+ setPlayBtnLoading(true);
+ e.preventDefault();
+ socket.emit("joinAsPlayer");
+ }
+
+ function getPlayerHtml(side: "top" | "bottom") {
+ const blackHtml = (
+
+
+ {lobby.black?.name || "(no one)"}
+
+
+ black
+ {lobby.black?.connected === false && (
+ disconnected
+ )}
+
+
+ );
+ const whiteHtml = (
+
+
+ {lobby.white?.name || "(no one)"}
+
+
+ white
+ {lobby.white?.connected === false && (
+ disconnected
+ )}
+
+
+ );
+
+ if (lobby.black?.id === session?.user?.id) {
+ return side === "top" ? whiteHtml : blackHtml;
+ } else {
+ return side === "top" ? blackHtml : whiteHtml;
+ }
+ }
+
+ function copyInvite() {
+ const text = `https://ches.su/game/${initialLobby.code}`;
+ if ("clipboard" in navigator) {
+ navigator.clipboard.writeText(text);
+ } else {
+ document.execCommand("copy", true, text);
+ }
+ setCopiedLink(true);
+ setTimeout(() => {
+ setCopiedLink(false);
+ }, 5000);
+ }
+
+ return (
+
+
+ {/* overlay */}
+ {(!lobby.white?.id || !lobby.black?.id) && (
+
+
+ Waiting for opponent.
+ {session?.user?.id !== lobby.white?.id && session?.user?.id !== lobby.black?.id && (
+
+ Play as {lobby.white?.id ? "black" : "white"}
+
+ )}
+
+
+ )}
+
+
+
+
+
+
+
+ {getPlayerHtml("top")}
+
vs
+ {getPlayerHtml("bottom")}
+
+
+
+
+ Invite friends:
+
+
+
+ ches.su/game/{initialLobby.code}
+
+
+ copied to clipboard
+
+
+
+
+
+
+ {(lobby.actualGame.pgn() || "")
+ .split(/\d+\./)
+ .filter((move) => move.trim() !== "")
+ .map((moveSet, i) => {
+ const moves = moveSet.trim().split(" ");
+ return (
+
+ {i + 1}.
+ {moves[0]}
+ {moves[1]}
+
+ );
+ })}
+
+
+
+
+
+
+
+
+
+ {chatMessages.map((m, i) => (
+
+
+ {m.author.id && (
+
+ {m.author.name} :{" "}
+
+ )}
+
+ {m.message}
+
+
+
+ ))}
+
+
+
+
+ {lobby.observers && lobby.observers.length > 0 && (
+
+ Spectators: {lobby.observers?.map((o) => o.name).join(", ")}
+
+ )}
+
+
+ );
+}
diff --git a/client/src/components/game/handlers.ts b/client/src/components/game/handlers.ts
new file mode 100644
index 0000000..82632be
--- /dev/null
+++ b/client/src/components/game/handlers.ts
@@ -0,0 +1,145 @@
+import type { Dispatch } from "react";
+import type { Action, Lobby, Message, CustomSquares } from "@/types";
+import { Chess } from "chess.js";
+import type { Game, User } from "@chessu/types";
+import type { Socket } from "socket.io-client";
+
+export function lobbyReducer(lobby: Lobby, action: Action): Lobby {
+ switch (action.type) {
+ case "updateLobby":
+ return { ...lobby, ...action.payload };
+
+ case "setSide":
+ return { ...lobby, side: action.payload };
+
+ case "setGame":
+ return { ...lobby, actualGame: action.payload };
+
+ default:
+ throw new Error("Invalid action type");
+ }
+}
+
+export function squareReducer(squares: CustomSquares, action: Partial) {
+ return { ...squares, ...action };
+}
+
+export function initSocket(
+ user: User,
+ socket: Socket,
+ lobby: Lobby,
+ actions: {
+ updateLobby: Dispatch;
+ addMessage: Function;
+ updateCustomSquares: Dispatch>;
+ makeMove: Function;
+ }
+) {
+ socket.on("connect", () => {
+ console.log("connected!");
+ socket.emit("joinLobby", lobby.code);
+ });
+ socket.on("disconnect", () => {
+ console.log("disconnected!");
+ });
+ // TODO: handle disconnect
+
+ socket.on("chat", (message: Message) => {
+ actions.addMessage(message);
+ });
+
+ socket.on("receivedLatestGame", (latestGame: Game) => {
+ if (latestGame.pgn && latestGame.pgn !== lobby.actualGame.pgn()) {
+ lobby.actualGame.loadPgn(latestGame.pgn as string);
+
+ const lastMove = lobby.actualGame.history({ verbose: true }).pop();
+
+ let lastMoveSquares = undefined;
+ let kingSquare = undefined;
+ if (lastMove) {
+ lastMoveSquares = {
+ [lastMove.from]: { background: "rgba(255, 255, 0, 0.4)" },
+ [lastMove.to]: { background: "rgba(255, 255, 0, 0.4)" }
+ };
+ }
+ if (lobby.actualGame.inCheck()) {
+ const kingPos = lobby.actualGame.board().reduce((acc, row, index) => {
+ const squareIndex = row.findIndex(
+ (square) =>
+ square &&
+ square.type === "k" &&
+ square.color === lobby.actualGame.turn()
+ );
+ return squareIndex >= 0
+ ? `${String.fromCharCode(squareIndex + 97)}${8 - index}`
+ : acc;
+ }, "");
+ kingSquare = {
+ [kingPos]: {
+ background: "radial-gradient(red, rgba(255,0,0,.4), transparent 70%)",
+ borderRadius: "50%"
+ }
+ };
+ }
+ actions.updateCustomSquares({
+ lastMove: lastMoveSquares,
+ check: kingSquare
+ });
+ }
+ actions.updateLobby({ type: "updateLobby", payload: latestGame });
+
+ if (latestGame.black?.id === user?.id) {
+ if (lobby.side !== "b") actions.updateLobby({ type: "setSide", payload: "b" });
+ } else if (latestGame.white?.id === user?.id) {
+ if (lobby.side !== "w") actions.updateLobby({ type: "setSide", payload: "w" });
+ } else if (lobby.side !== "s") {
+ actions.updateLobby({ type: "setSide", payload: "s" });
+ }
+ });
+
+ socket.on("receivedMove", (m: { from: string; to: string; promotion?: string }) => {
+ const success = actions.makeMove(m);
+ if (!success) {
+ socket.emit("getLatestGame");
+ }
+ });
+
+ socket.on("userJoinedAsPlayer", ({ name, side }: { name: string; side: "white" | "black" }) => {
+ actions.addMessage({
+ author: { name: "server" },
+ message: `${name} is now playing as ${side}.`
+ });
+ });
+
+ socket.on(
+ "gameOver",
+ ({
+ reason,
+ winnerName,
+ winnerSide
+ }: {
+ reason: string;
+ winnerName?: string;
+ winnerSide?: string;
+ }) => {
+ const m = {
+ author: { name: "server" }
+ } as Message;
+
+ if (reason === "checkmate") {
+ m.message = `${winnerName} (${winnerSide}) has won by checkmate.`;
+ } else {
+ let message = "The game has ended in a draw";
+ if (reason === "repetition") {
+ message = message.concat(" due to threefold repetition");
+ } else if (reason === "insufficient") {
+ message = message.concat(" due to insufficient material");
+ } else if (reason === "stalemate") {
+ message = "The game has been drawn due to stalemate";
+ }
+ m.message = message.concat(".");
+ }
+ actions.addMessage(m);
+ }
+ );
+}
diff --git a/client/src/components/home/CreateGame.tsx b/client/src/components/home/CreateGame.tsx
new file mode 100644
index 0000000..41dd306
--- /dev/null
+++ b/client/src/components/home/CreateGame.tsx
@@ -0,0 +1,59 @@
+"use client";
+
+import type { FormEvent } from "react";
+import { useState, useContext } from "react";
+import { SessionContext } from "@/context/session";
+import { createGame } from "@/lib/game";
+import { useRouter } from "next/navigation";
+
+export default function CreateGame() {
+ const session = useContext(SessionContext);
+ const [buttonLoading, setButtonLoading] = useState(false);
+ const router = useRouter();
+
+ async function submitCreateGame(e: FormEvent) {
+ e.preventDefault();
+ if (!session?.user?.id) return;
+ setButtonLoading(true);
+
+ const target = e.target as HTMLFormElement;
+ const unlisted = target.elements.namedItem("createUnlisted") as HTMLInputElement;
+ const startingSide = (target.elements.namedItem("createStartingSide") as HTMLSelectElement)
+ .value;
+
+ const game = await createGame(startingSide, unlisted.checked);
+
+ if (game) {
+ router.push(`/game/${game.code}`);
+ } else {
+ setButtonLoading(false);
+ // TODO: Show error message
+ }
+ }
+
+ return (
+
+ );
+}
diff --git a/client/src/components/home/JoinGame.tsx b/client/src/components/home/JoinGame.tsx
new file mode 100644
index 0000000..824caab
--- /dev/null
+++ b/client/src/components/home/JoinGame.tsx
@@ -0,0 +1,61 @@
+"use client";
+
+import type { FormEvent } from "react";
+import { useState, useContext } from "react";
+import { SessionContext } from "@/context/session";
+import { getGame } from "@/lib/game";
+import { useRouter } from "next/navigation";
+
+export default function JoinGame() {
+ const session = useContext(SessionContext);
+ const [buttonLoading, setButtonLoading] = useState(false);
+ const [notFound, setNotFound] = useState(false);
+ const router = useRouter();
+
+ async function submitJoinGame(e: FormEvent) {
+ e.preventDefault();
+ if (!session?.user?.id) return;
+
+ const target = e.target as HTMLFormElement;
+ const codeEl = target.elements.namedItem("joinGameCode") as HTMLInputElement;
+
+ let code = codeEl.value;
+ if (!code) return;
+
+ setButtonLoading(true);
+
+ if (code.startsWith("http") || code.startsWith("ches.su")) {
+ code = new URL(code).pathname.split("/")[2];
+ }
+
+ const game = await getGame(code);
+
+ if (game && game.code) {
+ router.push(`/game/${game.code}`);
+ } else {
+ setButtonLoading(false);
+ setNotFound(true);
+ setTimeout(() => setNotFound(false), 5000);
+ codeEl.value = "";
+ }
+ }
+
+ return (
+
+ );
+}
diff --git a/client/src/components/home/PublicGames/JoinButton.tsx b/client/src/components/home/PublicGames/JoinButton.tsx
new file mode 100644
index 0000000..1f3a866
--- /dev/null
+++ b/client/src/components/home/PublicGames/JoinButton.tsx
@@ -0,0 +1,27 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+import { useTransition } from "react";
+
+export default function JoinButton({ code }: { code: string }) {
+ const router = useRouter();
+ const [isLoading, startTransition] = useTransition();
+
+ function handleJoin() {
+ startTransition(() => {
+ router.push(`/game/${code}`);
+ });
+ }
+
+ return (
+
+ Join
+
+ );
+}
diff --git a/client/src/components/home/PublicGames/PublicGames.tsx b/client/src/components/home/PublicGames/PublicGames.tsx
new file mode 100644
index 0000000..2984294
--- /dev/null
+++ b/client/src/components/home/PublicGames/PublicGames.tsx
@@ -0,0 +1,48 @@
+import { getPublicGames } from "@/lib/game";
+import JoinButton from "./JoinButton";
+import RefreshButton from "./RefreshButton";
+
+export default async function PublicGames() {
+ const games = await getPublicGames();
+
+ return (
+
+
+ Public games
+
+
+
+
+
+
+ Host
+ Opponent
+
+
+
+
+ {games && games.length > 0 ? (
+ games.map((game) => (
+
+ {game.host?.name}
+
+ {(game.host?.id === game.white?.id ? game.black?.name : game.white?.name) || ""}
+
+
+
+
+
+ ))
+ ) : (
+
+ (empty)
+
+
+
+ )}
+
+
+
+
+ );
+}
diff --git a/client/src/components/home/PublicGames/RefreshButton.tsx b/client/src/components/home/PublicGames/RefreshButton.tsx
new file mode 100644
index 0000000..bc87b79
--- /dev/null
+++ b/client/src/components/home/PublicGames/RefreshButton.tsx
@@ -0,0 +1,25 @@
+"use client";
+
+import { IconRefresh } from "@tabler/icons-react";
+import { useRouter } from "next/navigation";
+import { useTransition } from "react";
+
+export default function RefreshButton() {
+ const router = useRouter();
+ const [isLoading, startTransition] = useTransition();
+
+ function handleRefresh() {
+ startTransition(() => {
+ router.refresh();
+ });
+ }
+
+ return (
+
+
+
+ );
+}
diff --git a/client/src/config.ts b/client/src/config.ts
new file mode 100644
index 0000000..397995f
--- /dev/null
+++ b/client/src/config.ts
@@ -0,0 +1,2 @@
+// back-end server url
+export const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001";
diff --git a/client/src/config/config.ts b/client/src/config/config.ts
deleted file mode 100644
index 60b0c45..0000000
--- a/client/src/config/config.ts
+++ /dev/null
@@ -1 +0,0 @@
-export const apiUrl = import.meta.env.APIURL || "https://api.ches.su";
diff --git a/client/src/context/ContextProvider.tsx b/client/src/context/ContextProvider.tsx
index 1775e0a..91eb386 100644
--- a/client/src/context/ContextProvider.tsx
+++ b/client/src/context/ContextProvider.tsx
@@ -1,29 +1,22 @@
-import { PropsWithChildren, useEffect, useState } from "react";
-import type { User } from "@types";
+"use client";
-import { SocketContext, socket } from "./socket";
-import { SessionContext } from "./session";
+import type { User } from "@chessu/types";
-import { fetchSession } from "../utils/auth";
+import { useState, useEffect } from "react";
+import { SessionContext } from "./session";
+import { fetchSession } from "@/lib/auth";
-const ContextProvider = (props: PropsWithChildren) => {
- const [user, setUser] = useState({});
+export default function ContextProvider({ children }: { children: React.ReactNode }) {
+ const [user, setUser] = useState({});
async function getSession() {
const user = await fetchSession();
- if (user) {
- setUser(user);
- }
+ setUser(user || null);
}
useEffect(() => {
getSession();
}, []);
- return (
-
- {props.children}
-
- );
-};
-export default ContextProvider;
+ return {children} ;
+}
diff --git a/client/src/context/session.ts b/client/src/context/session.ts
index 629856a..de6f15d 100644
--- a/client/src/context/session.ts
+++ b/client/src/context/session.ts
@@ -1,7 +1,8 @@
-import { User } from "@types";
+import type { User } from "@chessu/types";
+
import { createContext, Dispatch, SetStateAction } from "react";
export const SessionContext = createContext<{
- user: User;
- setUser: Dispatch>;
+ user: User | null | undefined; // undefined = hasn't been checked yet, null = no user
+ setUser: Dispatch>;
} | null>(null);
diff --git a/client/src/context/socket.ts b/client/src/context/socket.ts
deleted file mode 100644
index 833c1d3..0000000
--- a/client/src/context/socket.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-import { createContext } from "react";
-import { io, Socket } from "socket.io-client";
-import { apiUrl } from "../config/config";
-
-export const socket: Socket = io(apiUrl, {
- withCredentials: true,
- autoConnect: false
-});
-
-socket.on("connect", () => {
- console.log("socket connected");
-});
-
-socket.on("disconnect", () => {
- console.log("socket disconnected");
-});
-
-export const SocketContext = createContext(null);
diff --git a/client/src/global.css b/client/src/global.css
deleted file mode 100644
index 7ad79a5..0000000
--- a/client/src/global.css
+++ /dev/null
@@ -1,39 +0,0 @@
-@import url("https://fonts.googleapis.com/css2?family=Poppins&display=swap");
-@import "@radix-ui/colors/blue.css";
-@import "@radix-ui/colors/blueDark.css";
-
-* {
- margin: 0;
- padding: 0;
- border: 0;
- outline: 0;
- box-sizing: border-box;
- list-style: none;
- text-decoration: none;
-}
-
-body {
- text-align: center;
- font-family: "Poppins", sans-serif;
- background-color: var(--blue1);
- color: var(--blue12);
-}
-
-main {
- margin-top: 1em;
- min-height: 300px;
- padding: 1.5em;
- border-radius: 0.5em;
- background-color: var(--blue2);
- margin: auto;
- display: inline-block;
- min-width: 450px;
- max-width: 900px;
- box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
-}
-
-@media only screen and (max-width: 550px) {
- main {
- min-width: 90%;
- }
-}
diff --git a/client/src/lib/auth.ts b/client/src/lib/auth.ts
new file mode 100644
index 0000000..0c99c9c
--- /dev/null
+++ b/client/src/lib/auth.ts
@@ -0,0 +1,36 @@
+import type { User } from "@chessu/types";
+import { API_URL } from "@/config";
+
+export const fetchSession = async () => {
+ try {
+ const res = await fetch(`${API_URL}/v1/auth`, {
+ credentials: "include"
+ });
+
+ if (res && res.status === 200) {
+ const user: User = await res.json();
+ return user;
+ }
+ } catch (err) {
+ console.error(err);
+ }
+};
+
+export const setGuestSession = async (name: string) => {
+ try {
+ const res = await fetch(`${API_URL}/v1/auth/guest`, {
+ method: "POST",
+ credentials: "include",
+ headers: {
+ "Content-Type": "application/json"
+ },
+ body: JSON.stringify({ name })
+ });
+ if (res.status === 201) {
+ const user: User = await res.json();
+ return user;
+ }
+ } catch (err) {
+ console.error(err);
+ }
+};
diff --git a/client/src/lib/game.ts b/client/src/lib/game.ts
new file mode 100644
index 0000000..93fd532
--- /dev/null
+++ b/client/src/lib/game.ts
@@ -0,0 +1,49 @@
+import type { Game } from "@chessu/types";
+import { API_URL } from "@/config";
+
+export const createGame = async (side: string, unlisted: boolean) => {
+ try {
+ const res = await fetch(`${API_URL}/v1/games`, {
+ method: "POST",
+ credentials: "include",
+ headers: {
+ "Content-Type": "application/json"
+ },
+ body: JSON.stringify({ side, unlisted }),
+ cache: "no-store"
+ });
+
+ if (res && res.status === 201) {
+ const game: Game = await res.json();
+ return game;
+ }
+ } catch (err) {
+ console.error(err);
+ }
+};
+
+export const getGame = async (code: string) => {
+ try {
+ const res = await fetch(`${API_URL}/v1/games/${code}`, { cache: "no-store" });
+
+ if (res && res.status === 200) {
+ const game: Game = await res.json();
+ return game;
+ }
+ } catch (err) {
+ console.error(err);
+ }
+};
+
+export const getPublicGames = async () => {
+ try {
+ const res = await fetch(`${API_URL}/v1/games`, { cache: "no-store" });
+
+ if (res && res.status === 200) {
+ const games: Game[] = await res.json();
+ return games;
+ }
+ } catch (err) {
+ console.error(err);
+ }
+};
diff --git a/client/src/main.tsx b/client/src/main.tsx
deleted file mode 100644
index 111d9d8..0000000
--- a/client/src/main.tsx
+++ /dev/null
@@ -1,10 +0,0 @@
-import { createRoot } from "react-dom/client";
-import { BrowserRouter } from "react-router-dom";
-
-import App from "./App";
-
-createRoot(document.getElementById("root") as HTMLElement).render(
-
-
-
-);
diff --git a/client/src/pages/404.tsx b/client/src/pages/404.tsx
new file mode 100644
index 0000000..4250fff
--- /dev/null
+++ b/client/src/pages/404.tsx
@@ -0,0 +1,14 @@
+import { useRouter } from "next/router";
+import { useEffect } from "react";
+
+// Temporary. Move to app/ directory when it's supported
+
+export default function NotFound() {
+ const router = useRouter();
+
+ useEffect(() => {
+ router.replace("/");
+ });
+
+ return null;
+}
diff --git a/client/src/routes/Game/Game.module.css b/client/src/routes/Game/Game.module.css
deleted file mode 100644
index fa9ffad..0000000
--- a/client/src/routes/Game/Game.module.css
+++ /dev/null
@@ -1,121 +0,0 @@
-.game {
- display: flex;
- justify-content: center;
- flex-wrap: wrap;
- gap: 1.5em;
-}
-
-.boardContainer {
- text-align: left;
-}
-
-.playerNameTop {
- margin-bottom: 0.5em;
-}
-.playerNameBottom {
- margin-top: 0.5em;
-}
-.playButton {
- padding: 0.5em;
- margin: 0.5em 0;
- cursor: pointer;
- background-color: var(--blue10);
- color: var(--blue1);
- font-weight: bold;
- font-size: 1em;
-}
-.playButton:hover,
-.playButton:focus {
- background-color: var(--blue11);
-}
-
-.sidebar {
- text-align: left;
- font-size: 0.9em;
- display: flex;
- flex-wrap: wrap;
- flex-direction: column;
- justify-content: space-between;
- gap: 2em;
-}
-
-.invite {
- text-align: right;
-}
-
-.copy {
- font-size: 0.8em;
- cursor: pointer;
- padding: 6px;
- border-radius: 8px;
- background-color: var(--blue4);
-}
-
-.lobby {
- height: 6em;
- width: 260px;
-}
-.lobbyUsers {
- font-size: 0.8em;
- overflow: wrap;
-}
-
-.chatbox {
- display: flex;
- flex-direction: column;
- gap: 1em;
- padding: 1em;
- background-color: var(--blue3);
- border-radius: 10px;
- width: 260px;
- height: 280px;
-}
-
-.chatList {
- overflow-y: scroll;
- overflow-x: hidden;
- scrollbar-width: thin;
- height: 100%;
-}
-.chatList::-webkit-scrollbar {
- width: 0.4em;
-}
-.chatList::-webkit-scrollbar-thumb {
- background-color: var(--blue6);
- border-radius: 2px;
-}
-
-.author {
- font-weight: bold;
-}
-.player {
- font-weight: bold;
- color: var(--blue9);
-}
-.server {
- color: var(--blue11);
-}
-.gameOver {
- background-color: var(--blue12);
- color: var(--blue1);
- font-weight: bold;
- padding: 0.4em;
-}
-
-.chatInput {
- display: block;
- height: 2em;
- padding: 0 0.5em;
- border-radius: 6px;
- width: 228px;
- margin-top: auto;
- flex-shrink: 0;
- background-color: var(--blue5);
- box-shadow: 0 0 0 1px var(--blue6);
- color: var(--blue12);
-}
-
-.chatInput:hover,
-.chatInput:focus {
- box-shadow: 0 0 0 1px var(--blue7);
-}
diff --git a/client/src/routes/Game/Game.tsx b/client/src/routes/Game/Game.tsx
deleted file mode 100644
index c2749be..0000000
--- a/client/src/routes/Game/Game.tsx
+++ /dev/null
@@ -1,238 +0,0 @@
-import { MouseEvent, KeyboardEvent, useRef } from "react";
-import { useParams } from "react-router-dom";
-import Board from "../../components/Board/Board";
-import { useEffect, useContext, useState } from "react";
-import { SocketContext } from "../../context/socket";
-import { SessionContext } from "../../context/session";
-import type { Game, User } from "@types";
-
-import styles from "./Game.module.css";
-import { CopyIcon, PersonIcon } from "@radix-ui/react-icons";
-
-interface Message {
- author: User;
- message: string;
-}
-
-const Game = () => {
- const { gameCode } = useParams();
- const [game, setGame] = useState({});
- const [messages, setMessages] = useState([]);
- const socket = useContext(SocketContext);
- const session = useContext(SessionContext);
-
- const chatlistRef = useRef(null);
-
- function joinAsPlayer(e: MouseEvent) {
- e.preventDefault();
- socket?.emit("joinAsPlayer");
- }
-
- function handleCopy(type: "link" | "code") {
- const text = type === "code" ? game.code : `https://ches.su/game/${game.code}`;
- if (!text) return;
- if ("clipboard" in navigator) {
- navigator.clipboard.writeText(text);
- } else {
- document.execCommand("copy", true, text);
- }
- }
-
- function addMessage(m: Message) {
- setMessages((msgs) => [...msgs, m]);
- }
-
- function chatKeyUp(e: KeyboardEvent) {
- e.preventDefault();
- if (e.key === "Enter") {
- const value = (e.target as HTMLInputElement).value;
- if (!value || value.length === 0) return;
- socket?.emit("chat", value);
- addMessage({ author: session?.user as User, message: value });
- (e.target as HTMLInputElement).value = "";
- }
- }
-
- useEffect(() => {
- // auto scroll down when new message is added
- const box = chatlistRef.current;
- if (!box) return;
- box.scrollTop = box.scrollHeight;
- }, [messages]);
-
- useEffect(() => {
- if (socket === null) {
- console.log("socket is null");
- return;
- }
-
- socket.on("receivedLatestLobby", (g: Game) => {
- setGame(g);
- });
-
- socket.on("userJoined", (name: string) => {
- addMessage({ author: { name: "server" }, message: `${name} has joined the lobby.` });
- });
- socket.on("userLeft", (name: string) => {
- addMessage({ author: { name: "server" }, message: `${name} has left the lobby.` });
- });
- socket.on("userJoinedAsPlayer", ({ name, side }: { name: string; side: string }) => {
- addMessage({ author: { name: "server" }, message: `${name} is now playing ${side}.` });
- });
- socket.on("chat", (m: Message) => {
- addMessage(m);
- });
- socket.on(
- "gameOver",
- ({
- reason,
- winnerName,
- winnerSide
- }: {
- reason: string;
- winnerName?: string;
- winnerSide?: string;
- }) => {
- const m = {
- author: { name: "game" }
- } as Message;
-
- if (reason === "checkmate") {
- m.message = `${winnerName}(${winnerSide}) has won by checkmate.`;
- } else {
- let message = "The game has ended in a draw";
- if (reason === "repetition") {
- message = message.concat(" due to threefold repetition");
- } else if (reason === "insufficient") {
- message = message.concat(" due to insufficient material");
- } else if (reason === "stalemate") {
- message = "The game has been drawn due to stalemate";
- }
- m.message = message.concat(".");
- }
- addMessage(m);
- }
- );
-
- socket.connect();
- socket.emit("joinLobby", gameCode);
- return () => {
- socket.off("receivedLatestLobby");
- socket.off("userJoined");
- socket.off("userLeft");
- socket.off("userJoinedAsPlayer");
- socket.off("chat");
- socket.off("gameOver");
- socket.disconnect();
- };
- }, []);
-
- return (
-
-
- {/* had no brain cells left when i was writing this, sorry */}
- {game.black?.id === session?.user.id ? (
- game.white?.name ? (
-
- ) : (
- ""
- )
- ) : game.white?.id === session?.user.id ? (
- game.black?.name ? (
-
- ) : (
- ""
- )
- ) : game.black?.name ? (
-
- ) : (
-
- Play as black
-
- )}
-
- {game.black?.id === session?.user.id ? (
-
- ) : game.white?.name ? (
-
- ) : (
-
- Play as white
-
- )}
-
-
-
- Invite friends:{" "}
-
handleCopy("link")}>
- ches.su/game/{game.code}
-
-
- or code{" "}
- handleCopy("code")}>
- {game.code}
-
-
-
-
-
- {messages.map((m, i) => (
-
- {m.author.id ? (
-
-
- {m.author.name}
-
- {": "}
-
- ) : (
- ""
- )}
- {m.message}
-
- ))}
-
-
-
-
-
- {game.observers && game.observers.length > 0 ? "Spectators: " : ""}
-
{game.observers?.map((o) => o.name).join(", ")}
-
-
-
- );
-};
-export default Game;
diff --git a/client/src/routes/Home/Home.module.css b/client/src/routes/Home/Home.module.css
deleted file mode 100644
index 48fadcf..0000000
--- a/client/src/routes/Home/Home.module.css
+++ /dev/null
@@ -1,117 +0,0 @@
-.home {
- display: flex;
- justify-content: center;
- align-items: center;
- flex-direction: column;
-}
-.gameNotFound {
- position: absolute;
- color: var(--blue11);
-}
-.name {
- display: flex;
- flex-wrap: wrap;
- align-items: center;
- justify-content: center;
- gap: 14px;
- margin-bottom: 2em;
-}
-
-.label {
- line-height: 2em;
- user-select: none;
-}
-
-.input {
- height: 2em;
- width: 50%;
- flex-grow: 0;
- padding: 0 0.5em;
- background-color: var(--blue3);
- border-radius: 6px;
- box-shadow: 0 0 0 1px var(--blue6);
- color: var(--blue12);
-}
-
-.input:focus,
-.select:focus {
- box-shadow: 0 0 0 1px var(--blue8);
-}
-
-.tabs,
-.tabContent {
- width: 70%;
-}
-
-.tabContent {
- height: 10em;
- padding: 1.5em 0.5em 0.5em;
- display: flex;
- flex-wrap: wrap;
- align-items: center;
- justify-content: center;
- gap: 10px;
-}
-
-.select {
- width: 50%;
- height: 2em;
- padding: 0 0.5em;
- background-color: var(--blue3);
- box-shadow: 0 0 0 1px var(--blue6);
- color: var(--blue12);
-}
-
-.submit {
- cursor: pointer;
- margin-top: 1em;
- font-weight: bold;
- border-radius: 8px;
- padding: 0.4em 0;
- flex-basis: 50%;
- color: var(--blue1);
- background-color: var(--blue10);
-}
-
-.submit:hover,
-.submit:focus {
- background-color: var(--blue11);
-}
-
-.tabContent .input {
- width: 45%;
- background-color: var(--blue4);
-}
-
-.tabContent {
- font-size: 0.9em;
- border-bottom-left-radius: 8px;
- border-bottom-right-radius: 8px;
-}
-
-.tabLeft {
- border-top-left-radius: 8px;
-}
-
-.tabRight {
- border-top-right-radius: 8px;
-}
-
-.tab {
- border: 2px solid var(--blue3);
- padding: 4px 0;
- background-color: var(--blue3);
- color: var(--blue12);
- width: 50%;
-}
-
-.tabActive,
-.tabContent {
- background-color: var(--blue5);
-}
-@media only screen and (max-width: 550px) {
- .tabs,
- .tabContent {
- width: 75%;
- }
-}
diff --git a/client/src/routes/Home/Home.tsx b/client/src/routes/Home/Home.tsx
deleted file mode 100644
index 4a45eb2..0000000
--- a/client/src/routes/Home/Home.tsx
+++ /dev/null
@@ -1,139 +0,0 @@
-import { FormEvent, useContext, useEffect, useState } from "react";
-import { useNavigate } from "react-router-dom";
-
-import styles from "./Home.module.css";
-
-import { setGuestSession } from "../../utils/auth";
-import { SessionContext } from "../../context/session";
-import { createGame, findGame } from "../../utils/games";
-
-const JoinGame = ({ notFound }: { notFound: boolean }) => {
- return (
-
- {notFound ? Game not found. : ""}
-
- Invite code
-
-
-
- Join Game
-
-
- );
-};
-
-const CreateGame = () => {
- return (
-
-
- Starting side
-
-
- Random
- White
- Black
-
-
- Create Game
-
-
- );
-};
-
-const Home = () => {
- const [creatingGame, setCreatingGame] = useState(false);
- const [gameNotFound, setGameNotFound] = useState(false);
- const session = useContext(SessionContext);
- const navigate = useNavigate();
-
- async function handleCreateGame(name: string, side: string) {
- const user = await setGuestSession(name);
- if (user) {
- session?.setUser(user);
- const game = await createGame(side);
- if (game) {
- navigate(`/game/${game.code}`);
- } else {
- // TODO error handling
- console.log("handleCreateGame unsuccessful");
- }
- }
- }
-
- async function handleJoinGame(name: string, code: string) {
- const user = await setGuestSession(name);
- if (user) {
- session?.setUser(user);
- if (code.startsWith("http") || code.startsWith("ches.su")) {
- code = new URL(code).pathname.split("/")[2];
- }
- const game = await findGame(code);
- if (game) {
- navigate(`/game/${game.code}`);
- } else {
- // TODO error handling
- setGameNotFound(true);
- }
- }
- }
-
- function handleSubmit(e: FormEvent) {
- e.preventDefault();
- const target = e.target as HTMLFormElement;
-
- const playerName = (target.elements.namedItem("name") as HTMLInputElement).value;
-
- if (!playerName) return;
-
- if (creatingGame) {
- const startingSide = (target.elements.namedItem("side") as HTMLSelectElement).value;
- handleCreateGame(playerName, startingSide);
- } else {
- const gameCode = (target.elements.namedItem("code") as HTMLInputElement).value;
- if (!gameCode) return;
- handleJoinGame(playerName, gameCode);
- }
- }
-
- return (
-
- );
-};
-
-export default Home;
diff --git a/client/src/routes/NotFound/NotFound.tsx b/client/src/routes/NotFound/NotFound.tsx
deleted file mode 100644
index 3bc685b..0000000
--- a/client/src/routes/NotFound/NotFound.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-const NotFound = () => {
- return Error 404: page not found
;
-};
-
-export default NotFound;
diff --git a/client/src/routes/ProtectedRoutes.tsx b/client/src/routes/ProtectedRoutes.tsx
deleted file mode 100644
index 6ca914f..0000000
--- a/client/src/routes/ProtectedRoutes.tsx
+++ /dev/null
@@ -1,12 +0,0 @@
-import { useContext } from "react";
-import { Outlet } from "react-router-dom";
-import Auth from "../components/Auth/Auth";
-import { SessionContext } from "../context/session";
-
-const ProtectedRoutes = () => {
- const session = useContext(SessionContext);
-
- return session && session?.user.id ? : ;
-};
-
-export default ProtectedRoutes;
diff --git a/client/src/styles/globals.css b/client/src/styles/globals.css
new file mode 100644
index 0000000..995bb75
--- /dev/null
+++ b/client/src/styles/globals.css
@@ -0,0 +1,17 @@
+@tailwind base;
+
+* {
+ scrollbar-width: thin;
+}
+
+*::-webkit-scrollbar {
+ width: 4px;
+}
+
+*::-webkit-scrollbar-thumb {
+ @apply bg-slate-500;
+ border-radius: 2px;
+}
+
+@tailwind components;
+@tailwind utilities;
diff --git a/client/src/types.ts b/client/src/types.ts
new file mode 100644
index 0000000..aafc632
--- /dev/null
+++ b/client/src/types.ts
@@ -0,0 +1,33 @@
+import type { Game, User } from "@chessu/types";
+import type { Chess } from "chess.js";
+
+export interface Lobby extends Game {
+ actualGame: Chess;
+ side: "b" | "w" | "s";
+}
+
+export interface CustomSquares {
+ options: { [square: string]: { background: string; borderRadius?: string } };
+ lastMove: { [square: string]: { background: string } };
+ rightClicked: { [square: string]: { backgroundColor: string } | undefined };
+ check: { [square: string]: { background: string; borderRadius?: string } };
+}
+
+export type Action =
+ | {
+ type: "updateLobby";
+ payload: Partial;
+ }
+ | {
+ type: "setSide";
+ payload: Lobby["side"];
+ }
+ | {
+ type: "setGame";
+ payload: Chess;
+ };
+
+export interface Message {
+ author: User;
+ message: string;
+}
diff --git a/client/src/utils/auth.ts b/client/src/utils/auth.ts
deleted file mode 100644
index d66188a..0000000
--- a/client/src/utils/auth.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-import type { User } from "@types";
-import { apiUrl } from "../config/config";
-
-export const fetchSession = async () => {
- const res = await fetch(`${apiUrl}/v1/auth`, {
- credentials: "include"
- });
-
- if (res.status === 200) {
- const user: User = await res.json();
- return user;
- }
-};
-
-export const setGuestSession = async (name: string) => {
- const res = await fetch(`${apiUrl}/v1/auth/guest`, {
- method: "POST",
- credentials: "include",
- headers: {
- "Content-Type": "application/json"
- },
- body: JSON.stringify({ name })
- });
- if (res.status === 201) {
- const user: User = await res.json();
- return user;
- }
-};
diff --git a/client/src/utils/games.ts b/client/src/utils/games.ts
deleted file mode 100644
index 25292d3..0000000
--- a/client/src/utils/games.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import type { Game } from "@types";
-import { apiUrl } from "../config/config";
-
-export const createGame = async (side: string) => {
- const res = await fetch(`${apiUrl}/v1/games`, {
- method: "POST",
- credentials: "include",
- headers: {
- "Content-Type": "application/json"
- },
- body: JSON.stringify({ side })
- });
-
- const game: Game | undefined = await res.json();
- return game;
-};
-
-export const findGame = async (code: string) => {
- const res = await fetch(`${apiUrl}/v1/games`);
- const games = await res.json();
- const game: Game | undefined = games.find((g: Game) => g.code === code);
-
- return game;
-};
diff --git a/client/src/vite-env.d.ts b/client/src/vite-env.d.ts
deleted file mode 100644
index 11f02fe..0000000
--- a/client/src/vite-env.d.ts
+++ /dev/null
@@ -1 +0,0 @@
-///
diff --git a/client/tailwind.config.js b/client/tailwind.config.js
new file mode 100644
index 0000000..23d3fd8
--- /dev/null
+++ b/client/tailwind.config.js
@@ -0,0 +1,40 @@
+/** @type {import('tailwindcss').Config} */
+module.exports = {
+ content: ["./src/**/*.{js,ts,jsx,tsx}"],
+ theme: {
+ extend: {}
+ },
+ plugins: [require("daisyui")],
+ daisyui: {
+ // based on daisyUI night and winter themes
+ themes: [
+ {
+ chessuLight: {
+ primary: "#047AFF",
+ secondary: "#6370d6",
+ accent: "#C148AC",
+ neutral: "#9ab3d9",
+ "base-100": "#FFFFFF",
+ "base-200": "#F2F7FF",
+ "base-300": "#E3E9F4",
+ "base-content": "#394E6A",
+ info: "#93E7FB",
+ success: "#81CFD1",
+ warning: "#EFD7BB",
+ error: "#E58B8B"
+ },
+ chessuDark: {
+ primary: "#38BDF8",
+ secondary: "#818CF8",
+ accent: "#1d4ed8",
+ neutral: "#1E293B",
+ "base-100": "#0F172A",
+ info: "#0CA5E9",
+ success: "#2DD4BF",
+ warning: "#F4BF50",
+ error: "#FB7085"
+ }
+ }
+ ]
+ }
+};
diff --git a/client/tsconfig.json b/client/tsconfig.json
index 5e88004..eb2ca4d 100644
--- a/client/tsconfig.json
+++ b/client/tsconfig.json
@@ -1,28 +1,29 @@
{
"compilerOptions": {
- "target": "ESNext",
- "useDefineForClassFields": true,
- "lib": ["DOM", "DOM.Iterable", "ESNext"],
- "allowJs": false,
+ "target": "es5",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
"skipLibCheck": true,
- "esModuleInterop": false,
- "allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
- "module": "ESNext",
- "moduleResolution": "Node",
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
- "noEmit": true,
- "jsx": "react-jsx",
+ "jsx": "preserve",
+ "incremental": true,
+ "baseUrl": ".",
"paths": {
- "@types": ["../types/"]
- }
+ "@/*": ["./src/*"]
+ },
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ]
},
- "include": ["src"],
- "references": [
- {
- "path": "./tsconfig.node.json"
- }
- ]
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+ "exclude": ["node_modules"]
}
diff --git a/client/tsconfig.node.json b/client/tsconfig.node.json
deleted file mode 100644
index 13b35d0..0000000
--- a/client/tsconfig.node.json
+++ /dev/null
@@ -1,9 +0,0 @@
-{
- "compilerOptions": {
- "composite": true,
- "module": "ESNext",
- "moduleResolution": "Node",
- "allowSyntheticDefaultImports": true
- },
- "include": ["vite.config.ts"]
-}
diff --git a/client/vercel.json b/client/vercel.json
deleted file mode 100644
index 9e940b3..0000000
--- a/client/vercel.json
+++ /dev/null
@@ -1,3 +0,0 @@
-{
- "rewrites": [{ "source": "/(.*)", "destination": "/" }]
-}
diff --git a/client/vite.config.ts b/client/vite.config.ts
deleted file mode 100644
index cb46aea..0000000
--- a/client/vite.config.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import { defineConfig } from "vite";
-import react from "@vitejs/plugin-react-swc";
-
-// https://vitejs.dev/config/
-export default defineConfig({
- plugins: [react()],
- server: {
- port: 3000
- }
-});
diff --git a/package.json b/package.json
index ddef049..f51bccd 100644
--- a/package.json
+++ b/package.json
@@ -1,27 +1,28 @@
{
"name": "chessu",
+ "private": "true",
"author": "nizewn",
"license": "MIT",
+ "workspaces": [
+ "client",
+ "server",
+ "types"
+ ],
"scripts": {
- "dev": "concurrently \"cd server && npm run dev\" \"cd client && npm run dev\"",
- "react-dev": "cd client && npm run dev",
- "install": "(cd client && npm install) & (cd server && npm install)",
- "build:client": "cd client && npm run build",
- "build:server": "cd server && npm run build",
- "server": "cd server && npm start",
+ "dev": "concurrently \"npm run dev -w client\" \"npm run dev -w server\"",
"lint": "eslint .",
"lint:fix": "eslint --fix .",
- "format:check": "prettier --check .",
- "format:write": "prettier --write ."
+ "format": "prettier --write ."
},
"devDependencies": {
- "@typescript-eslint/eslint-plugin": "^5.52.0",
- "@typescript-eslint/parser": "^5.52.0",
"concurrently": "^7.6.0",
- "eslint": "^8.34.0",
+ "eslint": "^8.35.0",
+ "eslint-config-next": "13.2.3",
"eslint-config-prettier": "^8.6.0",
- "eslint-plugin-prettier": "^4.2.1",
- "eslint-plugin-react": "^7.32.2",
- "prettier": "^2.8.4"
+ "prettier": "^2.8.4",
+ "prettier-plugin-tailwindcss": "^0.2.4"
+ },
+ "engines": {
+ "node": ">=18"
}
}
diff --git a/server/package.json b/server/package.json
index 8127b39..15ab29f 100644
--- a/server/package.json
+++ b/server/package.json
@@ -1,29 +1,40 @@
{
- "main": "./dist/server/src/server.js",
+ "name": "@chessu/server",
+ "private": true,
+ "main": "./dist/server.js",
+ "type": "module",
"scripts": {
- "start": "node ./dist/server/src/server.js",
+ "start": "node ./dist/server.js",
"build": "tsc",
- "dev": "ts-node-dev src/server.ts"
+ "dev": "node --loader ts-node/esm --watch src/server.ts"
},
"dependencies": {
- "chess.js": "1.0.0-beta.2",
+ "chess.js": "^1.0.0-beta.3",
"connect-pg-simple": "^8.0.0",
"cors": "^2.8.5",
"dotenv": "^16.0.3",
"express": "^4.18.2",
"express-session": "^1.17.3",
- "nanoid": "^3.3.4",
+ "nanoid": "^4.0.1",
"pg": "^8.9.0",
- "socket.io": "^4.6.0",
+ "socket.io": "^4.6.1",
"xss": "^1.0.14"
},
"devDependencies": {
+ "@chessu/types": "*",
"@types/connect-pg-simple": "^7.0.0",
"@types/express": "^4.17.17",
"@types/express-session": "^1.17.6",
- "@types/node": "^18.13.0",
+ "@types/node": "^18.14.6",
"@types/pg": "^8.6.6",
- "ts-node-dev": "^2.0.0",
+ "ts-node": "^10.9.1",
"typescript": "^4.9.5"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "bufferutil": "^4.0.7",
+ "utf-8-validate": "^6.0.3"
}
}
diff --git a/server/src/controllers/auth.controller.ts b/server/src/controllers/auth.controller.ts
index cbab5b8..2e2b067 100644
--- a/server/src/controllers/auth.controller.ts
+++ b/server/src/controllers/auth.controller.ts
@@ -1,5 +1,5 @@
import type { Request, Response } from "express";
-import type { User } from "@types";
+import type { User } from "@chessu/types";
import xss from "xss";
export const getCurrentSession = async (req: Request, res: Response) => {
@@ -19,6 +19,13 @@ export const guestSession = async (req: Request, res: Response) => {
try {
const name = xss(req.body.name);
+ const pattern = /^[A-Za-z0-9_]+$/;
+
+ if (!pattern.test(name)) {
+ res.status(400).end();
+ return;
+ }
+
if (!req.session.user || !req.session.user?.id) {
// create guest session
const user: User = {
diff --git a/server/src/controllers/games.controller.ts b/server/src/controllers/games.controller.ts
index 2549bda..3c57013 100644
--- a/server/src/controllers/games.controller.ts
+++ b/server/src/controllers/games.controller.ts
@@ -1,30 +1,31 @@
import type { Request, Response } from "express";
-import { activeGames } from "../db/models/game.model";
-import type { Game, User } from "@types";
+import { activeGames } from "../db/models/game.model.js";
+import type { Game, User } from "@chessu/types";
import { nanoid } from "nanoid";
-export const getActiveGames = async (req: Request, res: Response) => {
+export const getActivePublicGames = async (req: Request, res: Response) => {
try {
- //if (!req.query || !req.query.code) {
- res.status(200).json(activeGames);
- //}
+ res.status(200).json(activeGames.filter((g) => !g.unlisted && !g.winner));
+ } catch (err: unknown) {
+ console.log(err);
+ res.status(500).end();
+ }
+};
- /*
- // todo: if code query is URL, convert to code (or do it on client side?)
- const code =
- (req.query.code as string).startsWith("http") ||
- (req.query.code as string).startsWith("ches.su")
- ? path.posix.basename(url.parse(req.query.code as string).pathname as string)
- : req.query.code;
+export const getActiveGame = async (req: Request, res: Response) => {
+ try {
+ if (!req.params || !req.params.code) {
+ res.status(400).end();
+ return;
+ }
- console.log(code);
- const game = activeGames.find((g) => g.code === code);
+ const game = activeGames.find((g) => g.code === req.params.code);
if (!game) {
res.status(404).end();
} else {
res.status(200).json(game);
- }*/
+ }
} catch (err: unknown) {
console.log(err);
res.status(500).end();
@@ -39,11 +40,16 @@ export const createGame = async (req: Request, res: Response) => {
res.status(401).end();
return;
}
- const user: User = req.session.user;
+ const user: User = {
+ ...req.session.user,
+ connected: false
+ };
+ const unlisted: boolean = req.body.unlisted ?? false;
const game: Game = {
code: nanoid(6),
- open: true,
- host: user
+ unlisted,
+ host: user,
+ pgn: ""
};
if (req.body.side === "white") {
game.white = user;
@@ -65,10 +71,3 @@ export const createGame = async (req: Request, res: Response) => {
res.status(500).end();
}
};
-
-// use sockets for joining games
-/*
-export const joinGame = async (req: Request, res: Response) => {
- console.log("joining game!");
-};
-*/
diff --git a/server/src/db/index.ts b/server/src/db/index.ts
index d41a907..40f6898 100644
--- a/server/src/db/index.ts
+++ b/server/src/db/index.ts
@@ -1,3 +1,3 @@
-import { Pool } from "pg";
+import pg from "pg";
-export const db = new Pool();
+export const db = new pg.Pool();
diff --git a/server/src/db/init.sql b/server/src/db/init.sql
index 63ae404..a4c2c80 100644
--- a/server/src/db/init.sql
+++ b/server/src/db/init.sql
@@ -12,5 +12,4 @@ CREATE TABLE "game" (
pgn TEXT,
white_id INT REFERENCES "user",
black_id INT REFERENCES "user",
- winner CHAR(5)
);
\ No newline at end of file
diff --git a/server/src/db/models/game.model.ts b/server/src/db/models/game.model.ts
index f04799e..1aabe77 100644
--- a/server/src/db/models/game.model.ts
+++ b/server/src/db/models/game.model.ts
@@ -1,5 +1,5 @@
-import { db } from "..";
-import { Game } from "@types";
+import { db } from "../index.js";
+import type { Game } from "@chessu/types";
export const activeGames: Array = [];
diff --git a/server/src/db/models/user.model.ts b/server/src/db/models/user.model.ts
index 8b96a04..a7ec173 100644
--- a/server/src/db/models/user.model.ts
+++ b/server/src/db/models/user.model.ts
@@ -1,5 +1,5 @@
-import { db } from "..";
-import type { User } from "@types";
+import { db } from "../index.js";
+import type { User } from "@chessu/types";
const create = async (user: User, password: string) => {
if (user.name === "Guest" || user.email === undefined) {
diff --git a/server/src/middleware/session.ts b/server/src/middleware/session.ts
index f20d8c4..d24a394 100644
--- a/server/src/middleware/session.ts
+++ b/server/src/middleware/session.ts
@@ -1,11 +1,12 @@
import { nanoid } from "nanoid";
-
-import session, { Session } from "express-session";
+import type { Session } from "express-session";
+import session from "express-session";
import PGSimple from "connect-pg-simple";
-import { db } from "../db";
+import { db } from "../db/index.js";
+import type { User } from "@chessu/types";
+
const PGSession = PGSimple(session);
-import type { User } from "@types";
declare module "express-session" {
interface SessionData {
user: User;
diff --git a/server/src/routes/auth.route.ts b/server/src/routes/auth.route.ts
index b0d4df7..11aa1dc 100644
--- a/server/src/routes/auth.route.ts
+++ b/server/src/routes/auth.route.ts
@@ -1,7 +1,7 @@
import { Router } from "express";
-const router = Router();
+import * as controller from "../controllers/auth.controller.js";
-import * as controller from "../controllers/auth.controller";
+const router = Router();
router.route("/").get(controller.getCurrentSession);
diff --git a/server/src/routes/games.route.ts b/server/src/routes/games.route.ts
index d9d1211..699a6d2 100644
--- a/server/src/routes/games.route.ts
+++ b/server/src/routes/games.route.ts
@@ -1,12 +1,10 @@
import { Router } from "express";
-const router = Router();
-
-import * as controller from "../controllers/games.controller";
+import * as controller from "../controllers/games.controller.js";
-router.route("/").get(controller.getActiveGames).post(controller.createGame);
+const router = Router();
-//router.route("/:id").put(controller.joinGame);
+router.route("/").get(controller.getActivePublicGames).post(controller.createGame);
-// todo: api for updating games/moves requiring authentication
+router.route("/:code").get(controller.getActiveGame);
export default router;
diff --git a/server/src/routes/index.ts b/server/src/routes/index.ts
index 833701a..51ebefc 100644
--- a/server/src/routes/index.ts
+++ b/server/src/routes/index.ts
@@ -1,8 +1,8 @@
import { Router } from "express";
-const router = Router();
+import games from "./games.route.js";
+import auth from "./auth.route.js";
-import games from "./games.route";
-import auth from "./auth.route";
+const router = Router();
router.use("/games", games);
router.use("/auth", auth);
diff --git a/server/src/server.ts b/server/src/server.ts
index 66e427b..7316302 100644
--- a/server/src/server.ts
+++ b/server/src/server.ts
@@ -1,14 +1,13 @@
import "dotenv/config";
import cors from "cors";
-
-import express, { Request, Response, NextFunction } from "express";
+import type { Request, Response, NextFunction } from "express";
+import express from "express";
import { createServer } from "http";
-import session from "./middleware/session";
+import session from "./middleware/session.js";
import { Server } from "socket.io";
-import { init as initSocket } from "./socket";
-import { db } from "./db";
-
-import routes from "./routes";
+import { init as initSocket } from "./socket/index.js";
+import { db } from "./db/index.js";
+import routes from "./routes/index.js";
const corsConfig = {
origin: process.env.CORS_ORIGIN || "http://localhost:3000",
diff --git a/server/src/socket/game.socket.ts b/server/src/socket/game.socket.ts
index 428aa30..530cb20 100644
--- a/server/src/socket/game.socket.ts
+++ b/server/src/socket/game.socket.ts
@@ -1,7 +1,7 @@
-import { activeGames } from "../db/models/game.model";
-import type { Socket } from "socket.io";
+import { activeGames } from "../db/models/game.model.js";
+import type { DisconnectReason, Socket } from "socket.io";
import { Chess } from "chess.js";
-import { io } from "../server";
+import { io } from "../server.js";
export async function joinLobby(this: Socket, gameCode: string) {
const game = activeGames.find((g) => g.code === gameCode);
@@ -9,9 +9,12 @@ export async function joinLobby(this: Socket, gameCode: string) {
console.log(`joinLobby: Game code ${gameCode} not found.`);
return;
}
- if (
- !(game.white?.id === this.request.session.id || game.black?.id === this.request.session.id)
- ) {
+
+ if (game.white && game.white?.id === this.request.session.user.id) {
+ game.white.connected = true;
+ } else if (game.black && game.black?.id === this.request.session.user.id) {
+ game.black.connected = true;
+ } else {
if (game.observers === undefined) game.observers = [];
game.observers?.push(this.request.session.user);
}
@@ -26,12 +29,10 @@ export async function joinLobby(this: Socket, gameCode: string) {
}
await this.join(gameCode);
- this.emit("receivedLatestGame", game);
- io.to(game.code as string).emit("receivedLatestLobby", game);
- io.to(game.code as string).emit("userJoined", this.request.session.user.name);
+ io.to(game.code as string).emit("receivedLatestGame", game);
}
-export async function leaveLobby(this: Socket, code?: string) {
+export async function leaveLobby(this: Socket, reason?: DisconnectReason, code?: string) {
if (this.rooms.size >= 3 && !code) {
console.log(`[WARNING] leaveLobby: room size is ${this.rooms.size}, aborting...`);
return;
@@ -39,37 +40,34 @@ export async function leaveLobby(this: Socket, code?: string) {
const game = activeGames.find(
(g) =>
g.code === (code || this.rooms.size === 2 ? Array.from(this.rooms)[1] : 0) ||
- g.black?.id === this.request.session.id ||
- g.white?.id === this.request.session.id ||
- g.observers?.find((o) => this.request.session.id === o.id)
+ (g.black?.connected && g.black?.id === this.request.session.user.id) ||
+ (g.white?.connected && g.white?.id === this.request.session.user.id) ||
+ g.observers?.find((o) => this.request.session.user.id === o.id)
);
if (game) {
- const user = game.observers?.find((o) => o.id === this.request.session.id);
- let name = "";
+ const user = game.observers?.find((o) => o.id === this.request.session.user.id);
if (user) {
- name = user.name as string;
game.observers?.splice(game.observers?.indexOf(user), 1);
}
- if (game.black?.id === this.request.session.id) {
- name = game.black?.name as string;
- game.black = undefined;
- }
- if (game.white?.id === this.request.session.id) {
- name = game.white?.name as string;
- game.white = undefined;
+ if (game.black && game.black?.id === this.request.session.user.id) {
+ game.black.connected = false;
+ } else if (game.white && game.white?.id === this.request.session.user.id) {
+ game.white.connected = false;
}
- if (!game.white && !game.black && (!game.observers || game.observers.length === 0)) {
+ // count sockets
+ const sockets = await io.in(game.code as string).fetchSockets();
+
+ if (sockets.length <= 0 || (reason === undefined && sockets.length <= 1)) {
if (game.timeout) clearTimeout(game.timeout);
game.timeout = Number(
setTimeout(() => {
activeGames.splice(activeGames.indexOf(game), 1);
- }, 1000 * 60 * 30) // 30 minutes
+ }, 1000 * 60 * 15) // 15 minutes
);
} else {
- this.to(game.code as string).emit("userLeft", name);
- this.to(game.code as string).emit("receivedLatestLobby", game);
+ this.to(game.code as string).emit("receivedLatestGame", game);
}
}
await this.leave(code || Array.from(this.rooms)[1]);
@@ -92,8 +90,8 @@ export async function sendMove(this: Socket, m: { from: string; to: string; prom
const prevTurn = chess.turn();
if (
- (prevTurn === "b" && this.request.session.id !== game.black?.id) ||
- (prevTurn === "w" && this.request.session.id !== game.white?.id)
+ (prevTurn === "b" && this.request.session.user.id !== game.black?.id) ||
+ (prevTurn === "w" && this.request.session.user.id !== game.white?.id)
) {
throw new Error("not turn to move");
}
@@ -137,9 +135,10 @@ export async function sendMove(this: Socket, m: { from: string; to: string; prom
export async function joinAsPlayer(this: Socket) {
const game = activeGames.find((g) => g.code === Array.from(this.rooms)[1]);
if (!game) return;
- const user = game.observers?.find((o) => o.id === this.request.session.id);
+ const user = game.observers?.find((o) => o.id === this.request.session.user.id);
if (!game.white) {
game.white = this.request.session.user;
+ game.white.connected = true;
if (user) game.observers?.splice(game.observers?.indexOf(user), 1);
io.to(game.code as string).emit("userJoinedAsPlayer", {
name: this.request.session.user.name,
@@ -147,6 +146,7 @@ export async function joinAsPlayer(this: Socket) {
});
} else if (!game.black) {
game.black = this.request.session.user;
+ game.black.connected = true;
if (user) game.observers?.splice(game.observers?.indexOf(user), 1);
io.to(game.code as string).emit("userJoinedAsPlayer", {
name: this.request.session.user.name,
@@ -156,7 +156,6 @@ export async function joinAsPlayer(this: Socket) {
console.log("[WARNING] attempted to join a game with already 2 players");
}
io.to(game.code as string).emit("receivedLatestGame", game);
- io.to(game.code as string).emit("receivedLatestLobby", game);
}
export async function chat(this: Socket, message: string) {
diff --git a/server/src/socket/index.ts b/server/src/socket/index.ts
index 2e449fd..81ef55e 100644
--- a/server/src/socket/index.ts
+++ b/server/src/socket/index.ts
@@ -1,6 +1,13 @@
import type { Socket } from "socket.io";
-import { io } from "../server";
-import { joinLobby, leaveLobby, getLatestGame, sendMove, joinAsPlayer, chat } from "./game.socket";
+import { io } from "../server.js";
+import {
+ joinLobby,
+ leaveLobby,
+ getLatestGame,
+ sendMove,
+ joinAsPlayer,
+ chat
+} from "./game.socket.js";
const socketConnect = (socket: Socket) => {
const req = socket.request;
diff --git a/server/tsconfig.json b/server/tsconfig.json
index 90ae472..8dd616e 100644
--- a/server/tsconfig.json
+++ b/server/tsconfig.json
@@ -1,10 +1,8 @@
{
"compilerOptions": {
"target": "ES2021",
- "module": "commonjs",
- "paths": {
- "@types": ["../types/"]
- },
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
"outDir": "./dist",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
diff --git a/types/index.ts b/types/index.d.ts
similarity index 79%
rename from types/index.ts
rename to types/index.d.ts
index 022cf0e..e2452ec 100644
--- a/types/index.ts
+++ b/types/index.d.ts
@@ -6,7 +6,7 @@ export interface Game {
winner?: "white" | "black" | "draw";
host?: User;
code?: string;
- open?: boolean;
+ unlisted?: boolean;
timeout?: number;
observers?: User[];
}
@@ -15,4 +15,5 @@ export interface User {
id?: number | string; // string for guest IDs
name?: string;
email?: string;
+ connected?: boolean; // mainly for players, not spectators
}
diff --git a/types/package.json b/types/package.json
new file mode 100644
index 0000000..e26cbb5
--- /dev/null
+++ b/types/package.json
@@ -0,0 +1,5 @@
+{
+ "name": "@chessu/types",
+ "private": "true",
+ "version": "0.0.0"
+}