33
.eslintrc
33
.eslintrc
@@ -1,34 +1,3 @@
|
|||||||
{
|
{
|
||||||
"settings": {
|
"extends": ["next", "prettier"]
|
||||||
"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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
72
README.md
72
README.md
@@ -1,32 +1,70 @@
|
|||||||
# chessu
|
# 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
|
- play against other users in real-time
|
||||||
- CSS Modules
|
- spectate and chat in ongoing games with other users
|
||||||
- [react-chessboard](https://github.com/Clariity/react-chessboard)
|
- ~~_optional_ user accounts for tracking stats and game history~~ (wip)
|
||||||
- [chess.js](https://github.com/jhlywa/chess.js)
|
- mobile-friendly (wip)
|
||||||
- Express.js
|
|
||||||
- socket.io
|
|
||||||
- PostgreSQL
|
|
||||||
|
|
||||||
## 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
|
```sh
|
||||||
npm install # install all dependencies
|
# install all dependencies, including eslint and prettier for development
|
||||||
|
npm install
|
||||||
|
|
||||||
npm run dev # concurrently run frontend and backend dev servers
|
# concurrently run frontend and backend development servers
|
||||||
|
npm run dev # -w client/server to run only one
|
||||||
|
|
||||||
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
|
### 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`
|
client:
|
||||||
(also see server cors config and session middleware for local development)
|
|
||||||
|
```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://...
|
||||||
|
```
|
||||||
|
|||||||
37
client/.gitignore
vendored
Normal file
37
client/.gitignore
vendored
Normal file
@@ -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
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/chessu.svg" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Chessu</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
9
client/next.config.js
Normal file
9
client/next.config.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
reactStrictMode: true,
|
||||||
|
experimental: {
|
||||||
|
appDir: true
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = nextConfig;
|
||||||
@@ -1,28 +1,35 @@
|
|||||||
{
|
{
|
||||||
"name": "chessu",
|
"name": "@chessu/client",
|
||||||
"private": true,
|
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "next dev",
|
||||||
"build": "tsc && vite build",
|
"build": "next build",
|
||||||
"preview": "vite preview"
|
"start": "next start",
|
||||||
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/colors": "^0.1.8",
|
"@tabler/icons-react": "^2.7.0",
|
||||||
"@radix-ui/react-icons": "^1.1.1",
|
|
||||||
"chess.js": "^1.0.0-beta.3",
|
"chess.js": "^1.0.0-beta.3",
|
||||||
"react": "^18.2.0",
|
"next": "13.2.3",
|
||||||
"react-chessboard": "^2.0.8",
|
"react": "18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-chessboard": "^2.1.0",
|
||||||
"react-router-dom": "^6.8.1",
|
"react-dom": "18.2.0",
|
||||||
"socket.io-client": "^4.6.0"
|
"socket.io-client": "^4.6.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.0.28",
|
"@chessu/types": "*",
|
||||||
"@types/react-dom": "^18.0.11",
|
"@types/node": "18.14.6",
|
||||||
"@vitejs/plugin-react-swc": "^3.1.0",
|
"@types/react": "18.0.28",
|
||||||
"typescript": "^4.9.5",
|
"@types/react-dom": "18.0.11",
|
||||||
"vite": "^4.1.1"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
6
client/postcss.config.js
Normal file
6
client/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {}
|
||||||
|
}
|
||||||
|
};
|
||||||
BIN
client/public/assets/default_avatar.png
Normal file
BIN
client/public/assets/default_avatar.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
@@ -1,104 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="iso-8859-1"?>
|
|
||||||
<!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
|
||||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
|
||||||
viewBox="0 0 512 512" style="enable-background:new 0 0 512 512;" xml:space="preserve">
|
|
||||||
<circle style="fill:#88C5CC;" cx="256" cy="256" r="256"/>
|
|
||||||
<path style="fill:#80B9BF;" d="M436.08,428.48c-0.508-1.388-1.052-2.752-1.656-4.084c-0.508-1.392-1.052-2.756-1.656-4.092
|
|
||||||
c-0.508-1.392-1.048-2.752-1.656-4.084c-0.508-1.388-1.048-2.752-1.656-4.084c-0.492-1.36-1.024-2.696-1.612-4
|
|
||||||
c-0.696-2.22-1.568-4.344-2.516-6.428c-0.504-1.388-1.052-2.752-1.656-4.084c-0.504-1.392-1.052-2.752-1.656-4.088
|
|
||||||
c-0.504-1.388-1.048-2.752-1.656-4.084c-0.504-1.392-1.048-2.756-1.656-4.092c-0.504-1.388-1.048-2.752-1.652-4.084
|
|
||||||
c-0.508-1.388-1.056-2.752-1.656-4.084c-0.5-1.376-1.044-2.736-1.64-4.056c-0.508-1.4-1.064-2.776-1.672-4.12
|
|
||||||
c-0.508-1.388-1.052-2.752-1.656-4.088c-0.508-1.388-1.052-2.752-1.656-4.084c-0.508-1.388-1.048-2.752-1.656-4.088
|
|
||||||
c-0.508-1.388-1.048-2.752-1.656-4.084c-0.508-1.392-1.048-2.752-1.656-4.084c-0.508-1.396-1.056-2.756-1.66-4.092
|
|
||||||
c-0.5-1.376-1.036-2.728-1.636-4.056c-0.504-1.384-1.048-2.744-1.648-4.072c-0.688-2.2-1.552-4.316-2.496-6.388
|
|
||||||
c-0.504-1.388-1.052-2.752-1.656-4.088c-0.504-1.388-1.052-2.752-1.66-4.084c-0.504-1.392-1.048-2.752-1.656-4.088
|
|
||||||
c-0.5-1.388-1.048-2.752-1.656-4.088c-0.5-1.376-1.04-2.732-1.64-4.056c-0.508-1.4-1.056-2.772-1.664-4.116
|
|
||||||
c-0.508-1.392-1.052-2.752-1.656-4.084c-0.508-1.392-1.056-2.756-1.656-4.092c-0.508-1.388-1.056-2.752-1.656-4.084
|
|
||||||
c-0.508-1.388-1.056-2.752-1.66-4.088c-0.504-1.388-1.052-2.752-1.656-4.084c-0.5-1.376-1.036-2.736-1.64-4.056
|
|
||||||
c-0.508-1.404-1.06-2.776-1.668-4.12c-0.504-1.388-1.048-2.752-1.656-4.088c-0.504-1.388-1.048-2.752-1.656-4.084
|
|
||||||
c-0.504-1.392-1.052-2.752-1.656-4.084c-0.5-1.372-1.036-2.72-1.632-4.04c-0.692-2.2-1.556-4.32-2.5-6.392
|
|
||||||
c-0.5-1.376-1.04-2.732-1.64-4.052c-0.508-1.4-1.056-2.776-1.664-4.124c-0.508-1.388-1.056-2.752-1.656-4.088
|
|
||||||
c-0.508-1.388-1.056-2.752-1.656-4.084c-0.508-1.388-1.056-2.756-1.664-4.088c-0.5-1.392-1.048-2.756-1.656-4.092
|
|
||||||
c-0.5-1.388-1.048-2.752-1.652-4.084c-0.504-1.392-1.052-2.756-1.656-4.088c-0.5-1.376-1.044-2.732-1.64-4.052
|
|
||||||
c-0.508-1.4-1.06-2.776-1.668-4.12c-0.504-1.392-1.056-2.756-1.656-4.088c-0.508-1.388-1.056-2.752-1.656-4.088
|
|
||||||
c-0.508-1.388-1.052-2.752-1.66-4.088c-0.504-1.388-1.048-2.752-1.656-4.084c-0.504-1.388-1.048-2.752-1.652-4.088
|
|
||||||
c-0.492-1.36-1.028-2.7-1.616-4.008c-0.696-2.212-1.568-4.336-2.516-6.42c-0.5-1.388-1.048-2.752-1.656-4.084
|
|
||||||
c-0.5-1.392-1.048-2.756-1.656-4.092c-0.5-1.388-1.048-2.752-1.656-4.084c-0.5-1.388-1.048-2.752-1.652-4.088
|
|
||||||
c-0.504-1.388-1.052-2.752-1.656-4.084c-0.504-1.388-1.052-2.752-1.656-4.088c-0.5-1.376-1.044-2.732-1.64-4.056
|
|
||||||
c-0.508-1.4-1.06-2.776-1.672-4.12c-0.504-1.392-1.052-2.756-1.656-4.092c-0.504-1.388-1.048-2.752-1.656-4.084
|
|
||||||
c-0.504-1.388-1.048-2.752-1.656-4.088c-0.504-1.388-1.052-2.752-1.656-4.084c-0.504-1.388-1.052-2.752-1.652-4.088
|
|
||||||
c-0.5-1.376-1.044-2.732-1.64-4.056c-0.508-1.4-1.06-2.776-1.672-4.12c-0.5-1.388-1.052-2.752-1.656-4.088
|
|
||||||
c-0.508-1.388-1.052-2.756-1.656-4.088C300.772,91.38,280.228,76,256,76c-1.504,0-2.984-0.228-4.452-0.092
|
|
||||||
C250.36,76,249.192,76,248.028,76C248.02,76,248,76,248,76v0.316c-28,3.892-48.004,27.308-48.004,55.524
|
|
||||||
c0,4.872,0.652,9.584,1.82,14.092c0.144,0.636,0.248,1.28,0.412,1.908c1.04,4.612,2.632,9.016,4.752,13.132
|
|
||||||
c1.028,3.616,2.416,7.088,4.116,10.372c1.016,3.556,2.388,6.968,4.064,10.2c1.02,3.544,2.384,6.952,4.056,10.176
|
|
||||||
c0.032,0.116,0.072,0.228,0.1,0.344C215.244,192.428,212,195.836,212,200c0,1.28,0.332,2.476,0.876,3.552
|
|
||||||
c-0.008,0.152-0.048,0.296-0.048,0.448c0,1.864,0.672,3.56,1.752,4.924c0.18,1.5,0.76,2.868,1.656,4
|
|
||||||
c0.18,1.5,0.756,2.864,1.656,3.996c0.172,1.504,0.756,2.872,1.656,4.004c0.172,1.5,0.756,2.868,1.652,4
|
|
||||||
c0.176,1.5,0.76,2.868,1.656,4c0.176,1.5,0.76,2.868,1.656,4c0.176,1.5,0.76,2.868,1.656,4c0.176,1.5,0.76,2.868,1.656,4
|
|
||||||
c0.048,0.404,0.112,0.792,0.224,1.176C222.928,286.508,211.18,340.436,184,388h-4c-4.4,0-8,3.6-8,8c0,1.288,0.336,2.492,0.884,3.572
|
|
||||||
c0.116,1.616,0.72,3.032,1.672,4.164c0.064,0.732,0.212,1.424,0.46,2.072c-1.82,1.468-3.016,3.688-3.016,6.2
|
|
||||||
c0,1.26,0.324,2.44,0.852,3.512c0.048,1.584,0.54,3.144,1.376,4.488H164c-4.4,0-8,3.596-8,8c0,1.276,0.332,2.476,0.876,3.552
|
|
||||||
c-0.008,0.152-0.048,0.296-0.048,0.448c0,1.864,0.672,3.56,1.752,4.924c0.18,1.5,0.76,2.868,1.656,4
|
|
||||||
c0.18,1.5,0.756,2.864,1.656,3.992c0.172,1.508,0.756,2.876,1.656,4.008c0.172,1.5,0.756,2.868,1.652,4
|
|
||||||
c0.176,1.5,0.76,2.868,1.656,4c0.176,1.5,0.76,2.868,1.656,4c0.176,1.5,0.76,2.868,1.656,4c0.176,1.5,0.76,2.868,1.656,4
|
|
||||||
c0.176,1.5,0.76,2.868,1.656,4c0.176,1.5,0.752,2.864,1.652,3.992c0.176,1.508,0.756,2.876,1.656,4.008
|
|
||||||
c0.176,1.5,0.756,2.868,1.656,4c0.172,1.5,0.756,2.868,1.656,4c0.172,1.5,0.756,2.868,1.656,4c0.172,1.5,0.756,2.868,1.656,4
|
|
||||||
c0.172,1.5,0.756,2.868,1.656,4c0.048,0.396,0.188,0.752,0.288,1.124C207.808,508.484,231.488,512,256,512
|
|
||||||
c71.608,0,136.32-29.436,182.78-76.832c-0.336-0.872-0.664-1.748-1.048-2.596C437.236,431.184,436.684,429.816,436.08,428.48z"/>
|
|
||||||
<path style="fill:#E6E6E6;" d="M280,164c-12,0-24,0-24,0s-12,0-24,0c0,36,0,140-48,224c36,0,72,0,72,0s36,0,72,0
|
|
||||||
C280,304,280,200,280,164z"/>
|
|
||||||
<path style="fill:#CCCCCC;" d="M280,164c-5.788,0-11.564,0-16,0c0,36,0,140,48,224c5.256,0,10.616,0,16,0C280,304,280,200,280,164z"
|
|
||||||
/>
|
|
||||||
<path style="fill:#E6E6E6;" d="M312,132c0,30.936-25.048,56-56,56c-30.92,0-56-25.064-56-56c0-30.94,25.08-56,56-56
|
|
||||||
C286.952,76,312,101.06,312,132z"/>
|
|
||||||
<path style="fill:#CCCCCC;" d="M256,76c-2.728,0-5.384,0.26-7.996,0.636C275.148,80.524,296,103.784,296,132
|
|
||||||
s-20.852,51.476-47.996,55.364C250.616,187.74,253.272,188,256,188c30.952,0,56-25.064,56-56C312,101.06,286.952,76,256,76z"/>
|
|
||||||
<path style="fill:#CC584C;" d="M292,184c0,4.4-3.6,8-8,8h-56c-4.4,0-8-3.6-8-8l0,0c0-4.4,3.6-8,8-8h56C288.4,176,292,179.6,292,184
|
|
||||||
L292,184z"/>
|
|
||||||
<g>
|
|
||||||
<path style="fill:#263740;" d="M300,200c0,4.4-3.6,8-8,8h-72c-4.4,0-8-3.6-8-8l0,0c0-4.4,3.6-8,8-8h72C296.4,192,300,195.6,300,200
|
|
||||||
L300,200z"/>
|
|
||||||
<path style="fill:#263740;" d="M340,396c0,4.4-3.6,8-8,8H180c-4.4,0-8-3.6-8-8l0,0c0-4.4,3.6-8,8-8h152
|
|
||||||
C336.4,388,340,391.6,340,396L340,396z"/>
|
|
||||||
</g>
|
|
||||||
<path style="fill:#1E2C33;" d="M332,388h-16c4.4,0,8,3.6,8,8s-3.6,8-8,8h16c4.4,0,8-3.6,8-8S336.4,388,332,388z"/>
|
|
||||||
<path style="fill:#CC584C;" d="M340,412c0,4.4-3.6,8-8,8H180c-4.4,0-8-3.6-8-8l0,0c0-4.4,3.6-8,8-8h152C336.4,404,340,407.6,340,412
|
|
||||||
L340,412z"/>
|
|
||||||
<path style="fill:#263740;" d="M356,428c0,4.4-3.6,8-8,8H164c-4.4,0-8-3.6-8-8l0,0c0-4.4,3.6-8,8-8h184C352.4,420,356,423.6,356,428
|
|
||||||
L356,428z"/>
|
|
||||||
<path style="fill:#1E2C33;" d="M348,420h-16c4.4,0,8,3.6,8,8s-3.6,8-8,8h16c4.4,0,8-3.6,8-8S352.4,420,348,420z"/>
|
|
||||||
<path style="fill:#B34D43;" d="M332,404h-16c4.4,0,8,3.6,8,8s-3.6,8-8,8h16c4.4,0,8-3.6,8-8S336.4,404,332,404z"/>
|
|
||||||
<path style="fill:#1E2C33;" d="M292,192h-16c4.4,0,8,3.6,8,8s-3.6,8-8,8h16c4.4,0,8-3.6,8-8S296.4,192,292,192z"/>
|
|
||||||
<path style="fill:#B34D43;" d="M284,176h-16c4.4,0,8,3.6,8,8s-3.6,8-8,8h16c4.4,0,8-3.6,8-8S288.4,176,284,176z"/>
|
|
||||||
<g>
|
|
||||||
</g>
|
|
||||||
<g>
|
|
||||||
</g>
|
|
||||||
<g>
|
|
||||||
</g>
|
|
||||||
<g>
|
|
||||||
</g>
|
|
||||||
<g>
|
|
||||||
</g>
|
|
||||||
<g>
|
|
||||||
</g>
|
|
||||||
<g>
|
|
||||||
</g>
|
|
||||||
<g>
|
|
||||||
</g>
|
|
||||||
<g>
|
|
||||||
</g>
|
|
||||||
<g>
|
|
||||||
</g>
|
|
||||||
<g>
|
|
||||||
</g>
|
|
||||||
<g>
|
|
||||||
</g>
|
|
||||||
<g>
|
|
||||||
</g>
|
|
||||||
<g>
|
|
||||||
</g>
|
|
||||||
<g>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 7.0 KiB |
@@ -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 (
|
|
||||||
<ContextProvider>
|
|
||||||
<Header />
|
|
||||||
<main>
|
|
||||||
<Routes>
|
|
||||||
<Route index element={<Home />} />
|
|
||||||
<Route element={<ProtectedRoutes />}>
|
|
||||||
<Route path="/game/:gameCode" element={<Game />} />
|
|
||||||
</Route>
|
|
||||||
<Route path="*" element={<NotFound />} />
|
|
||||||
</Routes>
|
|
||||||
</main>
|
|
||||||
<Footer />
|
|
||||||
</ContextProvider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default App;
|
|
||||||
8
client/src/app/game/[code]/not-found.tsx
Normal file
8
client/src/app/game/[code]/not-found.tsx
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export default function NotFound() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-4">
|
||||||
|
<div className="text-2xl font-bold">Error</div>
|
||||||
|
<div className="text-xl">Game not found</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
client/src/app/game/[code]/page.tsx
Normal file
44
client/src/app/game/[code]/page.tsx
Normal file
@@ -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 <GameAuthWrapper initialLobby={game} />;
|
||||||
|
}
|
||||||
5
client/src/app/game/page.tsx
Normal file
5
client/src/app/game/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export default function GameEmpty() {
|
||||||
|
redirect("/");
|
||||||
|
}
|
||||||
46
client/src/app/layout.tsx
Normal file
46
client/src/app/layout.tsx
Normal file
@@ -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 (
|
||||||
|
<html lang="en">
|
||||||
|
<body>
|
||||||
|
<ContextProvider>
|
||||||
|
<Navbar />
|
||||||
|
|
||||||
|
<main className="mx-1 flex min-h-[70vh] justify-center md:mx-16 lg:mx-40">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<AuthModal />
|
||||||
|
</ContextProvider>
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
client/src/app/page.tsx
Normal file
26
client/src/app/page.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="flex w-full flex-wrap items-center justify-center gap-8 px-4 py-10 lg:gap-16 ">
|
||||||
|
{/* @ts-expect-error Server Component */}
|
||||||
|
<PublicGames />
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<h2 className="mb-4 text-xl font-bold leading-tight">Join from invite</h2>
|
||||||
|
<JoinGame />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="divider divider-vertical">or</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<h2 className="mb-4 text-xl font-bold leading-tight">Create game</h2>
|
||||||
|
<CreateGame />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.8 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 3.9 KiB |
@@ -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%;
|
|
||||||
}
|
|
||||||
@@ -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<HTMLInputElement | null>(null);
|
|
||||||
const session = useContext(SessionContext);
|
|
||||||
|
|
||||||
async function handleGuestLogin(e: MouseEvent<HTMLButtonElement>) {
|
|
||||||
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 (
|
|
||||||
<div className={styles.authBox}>
|
|
||||||
<div className={`${styles.section} ${styles.sectionLeft}`}>
|
|
||||||
<h3>Guest login</h3>
|
|
||||||
<form>
|
|
||||||
<div className={styles.formGroup}>
|
|
||||||
<label className={styles.formLabel} htmlFor="guest-username">
|
|
||||||
Username
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="guest-username"
|
|
||||||
ref={guestNameRef}
|
|
||||||
className={styles.formInput}
|
|
||||||
pattern="[a-zA-Z0-9_-]+"
|
|
||||||
title="_ - and alphanumeric characters only"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button className={styles.formButton} type="submit" onClick={handleGuestLogin}>
|
|
||||||
Continue as Guest
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<div className={`${styles.section} ${styles.sectionRight}`}>
|
|
||||||
<div className={styles.sectionDisabled}>coming soon.</div>
|
|
||||||
<h3>
|
|
||||||
Sign In{" "}
|
|
||||||
<span className={styles.orRegister}>
|
|
||||||
or <a href="#">register</a>
|
|
||||||
</span>
|
|
||||||
</h3>
|
|
||||||
<form className={styles.form}>
|
|
||||||
<div className={styles.formGroup}>
|
|
||||||
<label className={styles.formLabel} htmlFor="email">
|
|
||||||
Username/Email
|
|
||||||
</label>
|
|
||||||
<input type="email" id="email" className={styles.formInput} />
|
|
||||||
</div>
|
|
||||||
<div className={styles.formGroup}>
|
|
||||||
<label className={styles.formLabel} htmlFor="password">
|
|
||||||
Password
|
|
||||||
</label>
|
|
||||||
<input type="password" id="password" className={styles.formInput} />
|
|
||||||
</div>
|
|
||||||
<button className={styles.formButton} disabled>
|
|
||||||
Sign In
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
<div className={styles.oauth}>
|
|
||||||
<button className={styles.oauthButton} disabled>
|
|
||||||
<img src={GoogleOAuth} alt="Google" />
|
|
||||||
</button>
|
|
||||||
<button className={styles.oauthButton} disabled>
|
|
||||||
<img src={FacebookOAuth} alt="Facebook" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Auth;
|
|
||||||
@@ -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<string | Square>("");
|
|
||||||
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 (
|
|
||||||
<Chessboard
|
|
||||||
position={game.fen()}
|
|
||||||
animationDuration={200}
|
|
||||||
isDraggablePiece={isDraggablePiece}
|
|
||||||
boardOrientation={side === "b" ? "black" : "white"}
|
|
||||||
boardWidth={size}
|
|
||||||
onPieceDragBegin={onPieceDragBegin}
|
|
||||||
onPieceDragEnd={onPieceDragEnd}
|
|
||||||
onPieceDrop={onDrop}
|
|
||||||
onSquareClick={onSquareClick}
|
|
||||||
onSquareRightClick={onSquareRightClick}
|
|
||||||
customSquareStyles={{
|
|
||||||
...optionSquares,
|
|
||||||
...rightClickedSquares
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Board;
|
|
||||||
27
client/src/components/Footer.tsx
Normal file
27
client/src/components/Footer.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { IconBrandGithub } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
export default function Footer() {
|
||||||
|
return (
|
||||||
|
<footer className="footer text-base-content mx-1 mt-4 w-auto grid-flow-col items-center justify-between p-4 md:mx-16 lg:mx-40">
|
||||||
|
<div className="items-center">
|
||||||
|
<p>
|
||||||
|
© 2023{" "}
|
||||||
|
<a href="https://nize.ph" target="_blank" rel="noreferrer" className="link-hover">
|
||||||
|
nize
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="items-center">
|
||||||
|
<a
|
||||||
|
href="https://github.com/nizewn/chessu"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="btn btn-ghost btn-sm gap-1 normal-case"
|
||||||
|
>
|
||||||
|
<IconBrandGithub className="inline-block" size={16} />
|
||||||
|
GitHub
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import styles from "./Footer.module.css";
|
|
||||||
|
|
||||||
const Footer = () => {
|
|
||||||
return (
|
|
||||||
<footer className={styles.footer}>
|
|
||||||
made with ♥ by{" "}
|
|
||||||
<a href="https://nize.ph" target="_blank" rel="noreferrer">
|
|
||||||
nize
|
|
||||||
</a>
|
|
||||||
<br />
|
|
||||||
© {new Date().getFullYear()} —{" "}
|
|
||||||
<a
|
|
||||||
href="https://github.com/nizewn/chessu"
|
|
||||||
className={styles.github}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
GitHub
|
|
||||||
</a>
|
|
||||||
</footer>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Footer;
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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 (
|
|
||||||
<>
|
|
||||||
<div className={styles.note}>
|
|
||||||
This project is currently undergoing a major refactor & redesign. (
|
|
||||||
<a href="https://github.com/nizewn/chessu/pull/4" target="_blank" rel="noreferrer">
|
|
||||||
#4
|
|
||||||
</a>
|
|
||||||
)
|
|
||||||
</div>
|
|
||||||
<header className={styles.header}>
|
|
||||||
<Link to="/" className={styles.title}>
|
|
||||||
chessu
|
|
||||||
</Link>
|
|
||||||
<button
|
|
||||||
className={styles.themeToggle}
|
|
||||||
type="button"
|
|
||||||
onClick={() => changeTheme(darkTheme ? "light" : "dark")}
|
|
||||||
>
|
|
||||||
{darkTheme ? <SunIcon /> : <MoonIcon />}
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Header;
|
|
||||||
50
client/src/components/Navbar.tsx
Normal file
50
client/src/components/Navbar.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { IconUser } from "@tabler/icons-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import ThemeToggle from "./ThemeToggle";
|
||||||
|
|
||||||
|
export default function Navbar() {
|
||||||
|
return (
|
||||||
|
<header className="navbar mx-1 w-auto justify-center drop-shadow-sm md:mx-16 lg:mx-40">
|
||||||
|
<div className="flex flex-1 items-center gap-1">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="btn btn-ghost no-animation p-0 text-xl normal-case hover:bg-transparent"
|
||||||
|
>
|
||||||
|
chessu
|
||||||
|
</Link>
|
||||||
|
<div className="dropdown dropdown-right hover:dropdown-open">
|
||||||
|
<label tabIndex={0} className="badge badge-sm cursor-help">
|
||||||
|
alpha
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
tabIndex={0}
|
||||||
|
className="dropdown-content card card-compact bg-primary text-primary-content w-64 shadow"
|
||||||
|
>
|
||||||
|
<div className="card-body cursor-default">
|
||||||
|
<p className="text-left text-xs">
|
||||||
|
This project is a work in progress. You can view the roadmap{" "}
|
||||||
|
<a
|
||||||
|
href="https://github.com/users/nizewn/projects/2"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="link"
|
||||||
|
>
|
||||||
|
here
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-none">
|
||||||
|
<ThemeToggle />
|
||||||
|
<label tabIndex={0} htmlFor="auth-modal" className="btn btn-ghost btn-circle avatar">
|
||||||
|
<div className="w-10 rounded-full">
|
||||||
|
<IconUser className="m-auto block h-full" />
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
client/src/components/ThemeToggle.tsx
Normal file
36
client/src/components/ThemeToggle.tsx
Normal file
@@ -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 (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => changeTheme(darkTheme ? "light" : "dark")}
|
||||||
|
className={"btn btn-ghost btn-circle swap swap-rotate" + (darkTheme ? " swap-active" : "")}
|
||||||
|
>
|
||||||
|
<IconSun className="swap-on m-auto block h-full" />
|
||||||
|
<IconMoon className="swap-off m-auto block h-full" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
87
client/src/components/auth/AuthModal.tsx
Normal file
87
client/src/components/auth/AuthModal.tsx
Normal file
@@ -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<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
async function updateGuestName(e: FormEvent<HTMLFormElement>) {
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<input type="checkbox" id="auth-modal" className="modal-toggle" ref={modalToggleRef} />
|
||||||
|
|
||||||
|
<label
|
||||||
|
htmlFor="auth-modal"
|
||||||
|
className={"modal" + (session?.user === null ? " modal-open" : "")}
|
||||||
|
>
|
||||||
|
<label className="modal-box flex flex-col gap-4 pt-2">
|
||||||
|
<div className="flex w-full gap-2">
|
||||||
|
<div className="tabs flex-grow">
|
||||||
|
<a className="tab tab-bordered tab-active flex-grow">Guest</a>
|
||||||
|
<a className="tab tab-bordered tab-disabled flex-grow">Login</a>
|
||||||
|
<a className="tab tab-bordered tab-disabled flex-grow">Register</a>
|
||||||
|
</div>
|
||||||
|
{session?.user !== null && (
|
||||||
|
<label htmlFor="auth-modal" className="btn btn-sm btn-circle btn-ghost">
|
||||||
|
✕
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form className="flex flex-col" onSubmit={updateGuestName}>
|
||||||
|
<div className="form-control">
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text">Hello, {session?.user?.name || "unknown guest"}!</span>
|
||||||
|
</label>
|
||||||
|
<label className="input-group">
|
||||||
|
<span>Name</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
pattern="[A-Za-z0-9_]+"
|
||||||
|
title="Alphanumeric characters and underscores only"
|
||||||
|
id="guestName"
|
||||||
|
name="guestName"
|
||||||
|
placeholder="Enter name here..."
|
||||||
|
className="input input-bordered flex-grow"
|
||||||
|
maxLength={16}
|
||||||
|
minLength={2}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="modal-action">
|
||||||
|
<button className={"btn" + (buttonLoading ? " loading" : "")} type="submit">
|
||||||
|
Update
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</label>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
client/src/components/game/GameAuthWrapper.tsx
Normal file
21
client/src/components/game/GameAuthWrapper.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-4">
|
||||||
|
<div className="text-2xl font-bold">Loading</div>
|
||||||
|
<div className="text-xl">Waiting for authentication...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <GamePage initialLobby={initialLobby} />;
|
||||||
|
}
|
||||||
520
client/src/components/game/GamePage.tsx
Normal file
520
client/src/components/game/GamePage.tsx
Normal file
@@ -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<string | Square | null>(null);
|
||||||
|
|
||||||
|
const [chatMessages, setChatMessages] = useState<Message[]>([]);
|
||||||
|
const [boardWidth, setBoardWidth] = useState(480);
|
||||||
|
|
||||||
|
const [playBtnLoading, setPlayBtnLoading] = useState(false);
|
||||||
|
const [copiedLink, setCopiedLink] = useState(false);
|
||||||
|
|
||||||
|
const chatlistRef = useRef<HTMLUListElement>(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<HTMLInputElement>) {
|
||||||
|
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<HTMLFormElement>) {
|
||||||
|
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<HTMLButtonElement>) {
|
||||||
|
setPlayBtnLoading(true);
|
||||||
|
e.preventDefault();
|
||||||
|
socket.emit("joinAsPlayer");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPlayerHtml(side: "top" | "bottom") {
|
||||||
|
const blackHtml = (
|
||||||
|
<div className="flex w-full flex-col justify-center">
|
||||||
|
<span className={lobby.black?.name ? "font-bold" : ""}>
|
||||||
|
{lobby.black?.name || "(no one)"}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1 text-xs">
|
||||||
|
black
|
||||||
|
{lobby.black?.connected === false && (
|
||||||
|
<span className="badge badge-xs badge-error">disconnected</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
const whiteHtml = (
|
||||||
|
<div className="flex w-full flex-col justify-center">
|
||||||
|
<span className={lobby.white?.name ? "font-bold" : ""}>
|
||||||
|
{lobby.white?.name || "(no one)"}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1 text-xs">
|
||||||
|
white
|
||||||
|
{lobby.white?.connected === false && (
|
||||||
|
<span className="badge badge-xs badge-error">disconnected</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="flex w-full flex-wrap justify-center gap-6 px-4 py-4 lg:gap-10 2xl:gap-16">
|
||||||
|
<div className="relative h-min">
|
||||||
|
{/* overlay */}
|
||||||
|
{(!lobby.white?.id || !lobby.black?.id) && (
|
||||||
|
<div className="absolute top-0 right-0 bottom-0 z-10 flex h-full w-full items-center justify-center bg-black bg-opacity-70">
|
||||||
|
<div className="bg-base-200 flex w-full items-center justify-center gap-4 py-4 px-2">
|
||||||
|
Waiting for opponent.
|
||||||
|
{session?.user?.id !== lobby.white?.id && session?.user?.id !== lobby.black?.id && (
|
||||||
|
<button
|
||||||
|
className={"btn btn-secondary" + (playBtnLoading ? " btn-disabled" : "")}
|
||||||
|
onClick={clickPlay}
|
||||||
|
>
|
||||||
|
Play as {lobby.white?.id ? "black" : "white"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Chessboard
|
||||||
|
boardWidth={boardWidth}
|
||||||
|
customDarkSquareStyle={{ backgroundColor: "#4b7399" }}
|
||||||
|
customLightSquareStyle={{ backgroundColor: "#eae9d2" }}
|
||||||
|
position={lobby.actualGame.fen()}
|
||||||
|
boardOrientation={lobby.side === "b" ? "black" : "white"}
|
||||||
|
isDraggablePiece={isDraggablePiece}
|
||||||
|
onPieceDragBegin={onPieceDragBegin}
|
||||||
|
onPieceDragEnd={onPieceDragEnd}
|
||||||
|
onPieceDrop={onDrop}
|
||||||
|
onSquareClick={onSquareClick}
|
||||||
|
onSquareRightClick={onSquareRightClick}
|
||||||
|
customSquareStyles={{
|
||||||
|
...customSquares.lastMove,
|
||||||
|
...customSquares.check,
|
||||||
|
...customSquares.rightClicked,
|
||||||
|
...customSquares.options
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex max-w-lg flex-1 flex-col items-center justify-center gap-4">
|
||||||
|
<div className="mb-auto flex w-full p-2">
|
||||||
|
<div className="flex flex-1 flex-col items-center justify-between">
|
||||||
|
{getPlayerHtml("top")}
|
||||||
|
<div className="my-auto w-full text-sm">vs</div>
|
||||||
|
{getPlayerHtml("bottom")}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-1 flex-col gap-1">
|
||||||
|
<div className="mb-2 flex w-full flex-col items-end gap-1">
|
||||||
|
Invite friends:
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
"dropdown dropdown-top dropdown-end" + (copiedLink ? " dropdown-open" : "")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
tabIndex={0}
|
||||||
|
className="badge badge-md bg-base-300 text-base-content h-8 gap-1 font-mono text-xs sm:h-5 sm:text-sm"
|
||||||
|
onClick={copyInvite}
|
||||||
|
>
|
||||||
|
<IconCopy size={16} />
|
||||||
|
ches.su/game/{initialLobby.code}
|
||||||
|
</label>
|
||||||
|
<div tabIndex={0} className="dropdown-content badge badge-md badge-primary shadow">
|
||||||
|
copied to clipboard
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="h-36 w-full overflow-y-scroll">
|
||||||
|
<table className="table-compact table w-full">
|
||||||
|
<tbody>
|
||||||
|
{(lobby.actualGame.pgn() || "")
|
||||||
|
.split(/\d+\./)
|
||||||
|
.filter((move) => move.trim() !== "")
|
||||||
|
.map((moveSet, i) => {
|
||||||
|
const moves = moveSet.trim().split(" ");
|
||||||
|
return (
|
||||||
|
<tr className="flex w-full items-center gap-1" key={i + 1}>
|
||||||
|
<td className="">{i + 1}.</td>
|
||||||
|
<td className="w-1/2 text-center">{moves[0]}</td>
|
||||||
|
<td className="w-1/2 text-center">{moves[1]}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-60 w-full min-w-fit">
|
||||||
|
<div className="bg-base-300 flex h-full w-full min-w-[64px] flex-col rounded-lg p-4 shadow-sm">
|
||||||
|
<ul
|
||||||
|
className="mb-4 flex h-full flex-col gap-1 overflow-y-scroll break-words"
|
||||||
|
ref={chatlistRef}
|
||||||
|
>
|
||||||
|
{chatMessages.map((m, i) => (
|
||||||
|
<li className="max-w-[30rem]" key={i}>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
!m.author.id && m.author.name === "server" ? "bg-base-content p-2" : ""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{m.author.id && (
|
||||||
|
<span>
|
||||||
|
<span className="font-bold">{m.author.name}</span>:{" "}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={!m.author.id && m.author.name === "server" ? "text-base-300" : ""}
|
||||||
|
>
|
||||||
|
{m.message}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<form className="input-group mt-auto" onSubmit={chatClickSend}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Chat here..."
|
||||||
|
className="input input-bordered flex-grow"
|
||||||
|
name="chatInput"
|
||||||
|
id="chatInput"
|
||||||
|
onKeyUp={chatKeyUp}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button className="btn btn-secondary ml-1" type="submit">
|
||||||
|
send
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{lobby.observers && lobby.observers.length > 0 && (
|
||||||
|
<div className="w-full px-2 text-xs md:px-0">
|
||||||
|
Spectators: {lobby.observers?.map((o) => o.name).join(", ")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
145
client/src/components/game/handlers.ts
Normal file
145
client/src/components/game/handlers.ts
Normal file
@@ -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<CustomSquares>) {
|
||||||
|
return { ...squares, ...action };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initSocket(
|
||||||
|
user: User,
|
||||||
|
socket: Socket,
|
||||||
|
lobby: Lobby,
|
||||||
|
actions: {
|
||||||
|
updateLobby: Dispatch<Action>;
|
||||||
|
addMessage: Function;
|
||||||
|
updateCustomSquares: Dispatch<Partial<CustomSquares>>;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
59
client/src/components/home/CreateGame.tsx
Normal file
59
client/src/components/home/CreateGame.tsx
Normal file
@@ -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<HTMLFormElement>) {
|
||||||
|
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 (
|
||||||
|
<form className="form-control" onSubmit={submitCreateGame}>
|
||||||
|
<label className="label cursor-pointer">
|
||||||
|
<span className="label-text">Unlisted/invite-only</span>
|
||||||
|
<input type="checkbox" className="checkbox" name="createUnlisted" id="createUnlisted" />
|
||||||
|
</label>
|
||||||
|
<label className="label" htmlFor="createStartingSide">
|
||||||
|
<span className="label-text">Select your side</span>
|
||||||
|
</label>
|
||||||
|
<div className="input-group">
|
||||||
|
<select
|
||||||
|
className="select select-bordered"
|
||||||
|
name="createStartingSide"
|
||||||
|
id="createStartingSide"
|
||||||
|
>
|
||||||
|
<option value="random">Random</option>
|
||||||
|
<option value="white">White</option>
|
||||||
|
<option value="black">Black</option>
|
||||||
|
</select>
|
||||||
|
<button className={"btn" + (buttonLoading ? " loading" : "")} type="submit">
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
client/src/components/home/JoinGame.tsx
Normal file
61
client/src/components/home/JoinGame.tsx
Normal file
@@ -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<HTMLFormElement>) {
|
||||||
|
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 (
|
||||||
|
<form
|
||||||
|
className={"input-group" + (notFound ? " tooltip tooltip-error tooltip-open" : "")}
|
||||||
|
data-tip="error: game not found"
|
||||||
|
onSubmit={submitJoinGame}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Invite link or code"
|
||||||
|
className="input input-bordered"
|
||||||
|
name="joinGameCode"
|
||||||
|
id="joinGameCode"
|
||||||
|
/>
|
||||||
|
<button className={"btn btn-square" + (buttonLoading ? " loading" : "")} type="submit">
|
||||||
|
Join
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
client/src/components/home/PublicGames/JoinButton.tsx
Normal file
27
client/src/components/home/PublicGames/JoinButton.tsx
Normal file
@@ -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 (
|
||||||
|
<button
|
||||||
|
className={
|
||||||
|
"btn btn-ghost btn-xs focus:opacity-100 lg:opacity-0 lg:group-hover:opacity-100" +
|
||||||
|
(isLoading ? " btn-disabled" : "")
|
||||||
|
}
|
||||||
|
onClick={handleJoin}
|
||||||
|
>
|
||||||
|
Join
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
client/src/components/home/PublicGames/PublicGames.tsx
Normal file
48
client/src/components/home/PublicGames/PublicGames.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<h2 className="mb-2 text-2xl font-bold leading-tight">
|
||||||
|
Public games <RefreshButton />
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="bg-base-200 h-80 max-h-80 overflow-y-auto rounded-xl">
|
||||||
|
<table className="table-compact lg:table-normal table-zebra table rounded-none">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="w-48">Host</th>
|
||||||
|
<th className="w-48">Opponent</th>
|
||||||
|
<th className="w-24"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{games && games.length > 0 ? (
|
||||||
|
games.map((game) => (
|
||||||
|
<tr key={game.code} className="group">
|
||||||
|
<td>{game.host?.name}</td>
|
||||||
|
<td>
|
||||||
|
{(game.host?.id === game.white?.id ? game.black?.name : game.white?.name) || ""}
|
||||||
|
</td>
|
||||||
|
<th>
|
||||||
|
<JoinButton code={game.code as string} />
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<tr>
|
||||||
|
<td>(empty)</td>
|
||||||
|
<td></td>
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
client/src/components/home/PublicGames/RefreshButton.tsx
Normal file
25
client/src/components/home/PublicGames/RefreshButton.tsx
Normal file
@@ -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 (
|
||||||
|
<button
|
||||||
|
className={"btn btn-sm btn-ghost" + (isLoading ? " loading" : "")}
|
||||||
|
onClick={handleRefresh}
|
||||||
|
>
|
||||||
|
<IconRefresh size={16} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
2
client/src/config.ts
Normal file
2
client/src/config.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
// back-end server url
|
||||||
|
export const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001";
|
||||||
@@ -1 +0,0 @@
|
|||||||
export const apiUrl = import.meta.env.APIURL || "https://api.ches.su";
|
|
||||||
@@ -1,29 +1,22 @@
|
|||||||
import { PropsWithChildren, useEffect, useState } from "react";
|
"use client";
|
||||||
import type { User } from "@types";
|
|
||||||
|
|
||||||
import { SocketContext, socket } from "./socket";
|
import type { User } from "@chessu/types";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
import { SessionContext } from "./session";
|
import { SessionContext } from "./session";
|
||||||
|
import { fetchSession } from "@/lib/auth";
|
||||||
|
|
||||||
import { fetchSession } from "../utils/auth";
|
export default function ContextProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [user, setUser] = useState<User | null>({});
|
||||||
const ContextProvider = (props: PropsWithChildren) => {
|
|
||||||
const [user, setUser] = useState<User>({});
|
|
||||||
|
|
||||||
async function getSession() {
|
async function getSession() {
|
||||||
const user = await fetchSession();
|
const user = await fetchSession();
|
||||||
if (user) {
|
setUser(user || null);
|
||||||
setUser(user);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getSession();
|
getSession();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return <SessionContext.Provider value={{ user, setUser }}>{children}</SessionContext.Provider>;
|
||||||
<SocketContext.Provider value={socket}>
|
}
|
||||||
<SessionContext.Provider value={{ user, setUser }}>{props.children}</SessionContext.Provider>
|
|
||||||
</SocketContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
export default ContextProvider;
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { User } from "@types";
|
import type { User } from "@chessu/types";
|
||||||
|
|
||||||
import { createContext, Dispatch, SetStateAction } from "react";
|
import { createContext, Dispatch, SetStateAction } from "react";
|
||||||
|
|
||||||
export const SessionContext = createContext<{
|
export const SessionContext = createContext<{
|
||||||
user: User;
|
user: User | null | undefined; // undefined = hasn't been checked yet, null = no user
|
||||||
setUser: Dispatch<SetStateAction<User>>;
|
setUser: Dispatch<SetStateAction<User | null>>;
|
||||||
} | null>(null);
|
} | null>(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<Socket | null>(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%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
36
client/src/lib/auth.ts
Normal file
36
client/src/lib/auth.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
49
client/src/lib/game.ts
Normal file
49
client/src/lib/game.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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(
|
|
||||||
<BrowserRouter>
|
|
||||||
<App />
|
|
||||||
</BrowserRouter>
|
|
||||||
);
|
|
||||||
14
client/src/pages/404.tsx
Normal file
14
client/src/pages/404.tsx
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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<Game>({});
|
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
|
||||||
const socket = useContext(SocketContext);
|
|
||||||
const session = useContext(SessionContext);
|
|
||||||
|
|
||||||
const chatlistRef = useRef<HTMLUListElement>(null);
|
|
||||||
|
|
||||||
function joinAsPlayer(e: MouseEvent<HTMLButtonElement>) {
|
|
||||||
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<HTMLInputElement>) {
|
|
||||||
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 (
|
|
||||||
<div className={styles.game}>
|
|
||||||
<div className={styles.boardContainer}>
|
|
||||||
{/* had no brain cells left when i was writing this, sorry */}
|
|
||||||
{game.black?.id === session?.user.id ? (
|
|
||||||
game.white?.name ? (
|
|
||||||
<div className={styles.playerNameTop}>
|
|
||||||
<PersonIcon /> {game.white?.name}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
""
|
|
||||||
)
|
|
||||||
) : game.white?.id === session?.user.id ? (
|
|
||||||
game.black?.name ? (
|
|
||||||
<div className={styles.playerNameTop}>
|
|
||||||
<PersonIcon /> {game.black?.name}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
""
|
|
||||||
)
|
|
||||||
) : game.black?.name ? (
|
|
||||||
<div className={styles.playerNameTop}>
|
|
||||||
<PersonIcon /> {game.black?.name}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button type="button" onClick={joinAsPlayer} className={styles.playButton}>
|
|
||||||
Play as black
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<Board />
|
|
||||||
{game.black?.id === session?.user.id ? (
|
|
||||||
<div className={styles.playerNameBottom}>
|
|
||||||
<PersonIcon /> {session?.user.name}
|
|
||||||
</div>
|
|
||||||
) : game.white?.name ? (
|
|
||||||
<div className={styles.playerNameBottom}>
|
|
||||||
<PersonIcon /> {game.white?.name}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button type="button" onClick={joinAsPlayer} className={styles.playButton}>
|
|
||||||
Play as white
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className={styles.sidebar}>
|
|
||||||
<div className={styles.invite}>
|
|
||||||
Invite friends:{" "}
|
|
||||||
<span className={styles.copy} onClick={() => handleCopy("link")}>
|
|
||||||
ches.su/game/{game.code} <CopyIcon />
|
|
||||||
</span>
|
|
||||||
<div className={styles.code}>
|
|
||||||
or code{" "}
|
|
||||||
<span className={styles.copy} onClick={() => handleCopy("code")}>
|
|
||||||
{game.code} <CopyIcon />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={styles.chatbox}>
|
|
||||||
<ul className={styles.chatList} ref={chatlistRef}>
|
|
||||||
{messages.map((m, i) => (
|
|
||||||
<li
|
|
||||||
key={i}
|
|
||||||
className={
|
|
||||||
!m.author.id && m.author.name === "server"
|
|
||||||
? styles.server
|
|
||||||
: !m.author.id && m.author.name === "game"
|
|
||||||
? styles.gameOver
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{m.author.id ? (
|
|
||||||
<span>
|
|
||||||
<span
|
|
||||||
className={
|
|
||||||
m.author.id === game?.white?.id || m.author.id === game?.black?.id
|
|
||||||
? styles.player
|
|
||||||
: styles.author
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{m.author.name}
|
|
||||||
</span>
|
|
||||||
{": "}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
""
|
|
||||||
)}
|
|
||||||
{m.message}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="chatbox"
|
|
||||||
id="chatbox"
|
|
||||||
className={styles.chatInput}
|
|
||||||
onKeyUp={chatKeyUp}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className={styles.lobby}>
|
|
||||||
{game.observers && game.observers.length > 0 ? "Spectators: " : ""}
|
|
||||||
<div className={styles.lobbyUsers}>{game.observers?.map((o) => o.name).join(", ")}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
export default Game;
|
|
||||||
@@ -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%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 (
|
|
||||||
<fieldset className={styles.tabContent}>
|
|
||||||
{notFound ? <span className={styles.gameNotFound}>Game not found.</span> : ""}
|
|
||||||
<label className={styles.label} htmlFor="code">
|
|
||||||
Invite code
|
|
||||||
</label>
|
|
||||||
<input className={styles.input} type="text" id="code" name="code" required />
|
|
||||||
<button type="submit" className={styles.submit}>
|
|
||||||
Join Game
|
|
||||||
</button>
|
|
||||||
</fieldset>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const CreateGame = () => {
|
|
||||||
return (
|
|
||||||
<fieldset className={styles.tabContent}>
|
|
||||||
<label className={styles.label} htmlFor="side">
|
|
||||||
Starting side
|
|
||||||
</label>
|
|
||||||
<select className={styles.select} name="side" id="side">
|
|
||||||
<option value="random">Random</option>
|
|
||||||
<option value="white">White</option>
|
|
||||||
<option value="black">Black</option>
|
|
||||||
</select>
|
|
||||||
<button type="submit" className={styles.submit}>
|
|
||||||
Create Game
|
|
||||||
</button>
|
|
||||||
</fieldset>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
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<HTMLFormElement>) {
|
|
||||||
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 (
|
|
||||||
<form className={styles.home} onSubmit={handleSubmit}>
|
|
||||||
<fieldset className={styles.name}>
|
|
||||||
<label className={styles.label} htmlFor="name">
|
|
||||||
Display name
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
className={styles.input}
|
|
||||||
type="text"
|
|
||||||
id="name"
|
|
||||||
name="name"
|
|
||||||
pattern="[a-zA-Z0-9_-]+"
|
|
||||||
title="_ - and alphanumeric characters only"
|
|
||||||
defaultValue={session?.user.name}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</fieldset>
|
|
||||||
<div className={styles.tabs}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`${styles.tab} ${styles.tabLeft} ${creatingGame ? "" : styles.tabActive}`}
|
|
||||||
onClick={() => {
|
|
||||||
setCreatingGame(false);
|
|
||||||
setGameNotFound(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Join
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`${styles.tab} ${styles.tabRight} ${creatingGame ? styles.tabActive : ""}`}
|
|
||||||
onClick={() => setCreatingGame(true)}
|
|
||||||
>
|
|
||||||
Create
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{creatingGame ? <CreateGame /> : <JoinGame notFound={gameNotFound} />}
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Home;
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
const NotFound = () => {
|
|
||||||
return <div>Error 404: page not found</div>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default NotFound;
|
|
||||||
@@ -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 ? <Outlet /> : <Auth />;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ProtectedRoutes;
|
|
||||||
17
client/src/styles/globals.css
Normal file
17
client/src/styles/globals.css
Normal file
@@ -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;
|
||||||
33
client/src/types.ts
Normal file
33
client/src/types.ts
Normal file
@@ -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<Lobby>;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "setSide";
|
||||||
|
payload: Lobby["side"];
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "setGame";
|
||||||
|
payload: Chess;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface Message {
|
||||||
|
author: User;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -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;
|
|
||||||
};
|
|
||||||
1
client/src/vite-env.d.ts
vendored
1
client/src/vite-env.d.ts
vendored
@@ -1 +0,0 @@
|
|||||||
/// <reference types="vite/client" />
|
|
||||||
40
client/tailwind.config.js
Normal file
40
client/tailwind.config.js
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,28 +1,29 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ESNext",
|
"target": "es5",
|
||||||
"useDefineForClassFields": true,
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
"allowJs": true,
|
||||||
"allowJs": false,
|
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"esModuleInterop": false,
|
|
||||||
"allowSyntheticDefaultImports": true,
|
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"module": "ESNext",
|
"noEmit": true,
|
||||||
"moduleResolution": "Node",
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "node",
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"noEmit": true,
|
"jsx": "preserve",
|
||||||
"jsx": "react-jsx",
|
"incremental": true,
|
||||||
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@types": ["../types/"]
|
"@/*": ["./src/*"]
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"include": ["src"],
|
"plugins": [
|
||||||
"references": [
|
|
||||||
{
|
{
|
||||||
"path": "./tsconfig.node.json"
|
"name": "next"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"composite": true,
|
|
||||||
"module": "ESNext",
|
|
||||||
"moduleResolution": "Node",
|
|
||||||
"allowSyntheticDefaultImports": true
|
|
||||||
},
|
|
||||||
"include": ["vite.config.ts"]
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"rewrites": [{ "source": "/(.*)", "destination": "/" }]
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
});
|
|
||||||
29
package.json
29
package.json
@@ -1,27 +1,28 @@
|
|||||||
{
|
{
|
||||||
"name": "chessu",
|
"name": "chessu",
|
||||||
|
"private": "true",
|
||||||
"author": "nizewn",
|
"author": "nizewn",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"client",
|
||||||
|
"server",
|
||||||
|
"types"
|
||||||
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently \"cd server && npm run dev\" \"cd client && npm run dev\"",
|
"dev": "concurrently \"npm run dev -w client\" \"npm run dev -w server\"",
|
||||||
"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",
|
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"lint:fix": "eslint --fix .",
|
"lint:fix": "eslint --fix .",
|
||||||
"format:check": "prettier --check .",
|
"format": "prettier --write ."
|
||||||
"format:write": "prettier --write ."
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@typescript-eslint/eslint-plugin": "^5.52.0",
|
|
||||||
"@typescript-eslint/parser": "^5.52.0",
|
|
||||||
"concurrently": "^7.6.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-config-prettier": "^8.6.0",
|
||||||
"eslint-plugin-prettier": "^4.2.1",
|
"prettier": "^2.8.4",
|
||||||
"eslint-plugin-react": "^7.32.2",
|
"prettier-plugin-tailwindcss": "^0.2.4"
|
||||||
"prettier": "^2.8.4"
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,40 @@
|
|||||||
{
|
{
|
||||||
"main": "./dist/server/src/server.js",
|
"name": "@chessu/server",
|
||||||
|
"private": true,
|
||||||
|
"main": "./dist/server.js",
|
||||||
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node ./dist/server/src/server.js",
|
"start": "node ./dist/server.js",
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"dev": "ts-node-dev src/server.ts"
|
"dev": "node --loader ts-node/esm --watch src/server.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"chess.js": "1.0.0-beta.2",
|
"chess.js": "^1.0.0-beta.3",
|
||||||
"connect-pg-simple": "^8.0.0",
|
"connect-pg-simple": "^8.0.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.0.3",
|
"dotenv": "^16.0.3",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"express-session": "^1.17.3",
|
"express-session": "^1.17.3",
|
||||||
"nanoid": "^3.3.4",
|
"nanoid": "^4.0.1",
|
||||||
"pg": "^8.9.0",
|
"pg": "^8.9.0",
|
||||||
"socket.io": "^4.6.0",
|
"socket.io": "^4.6.1",
|
||||||
"xss": "^1.0.14"
|
"xss": "^1.0.14"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@chessu/types": "*",
|
||||||
"@types/connect-pg-simple": "^7.0.0",
|
"@types/connect-pg-simple": "^7.0.0",
|
||||||
"@types/express": "^4.17.17",
|
"@types/express": "^4.17.17",
|
||||||
"@types/express-session": "^1.17.6",
|
"@types/express-session": "^1.17.6",
|
||||||
"@types/node": "^18.13.0",
|
"@types/node": "^18.14.6",
|
||||||
"@types/pg": "^8.6.6",
|
"@types/pg": "^8.6.6",
|
||||||
"ts-node-dev": "^2.0.0",
|
"ts-node": "^10.9.1",
|
||||||
"typescript": "^4.9.5"
|
"typescript": "^4.9.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"bufferutil": "^4.0.7",
|
||||||
|
"utf-8-validate": "^6.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
import type { User } from "@types";
|
import type { User } from "@chessu/types";
|
||||||
import xss from "xss";
|
import xss from "xss";
|
||||||
|
|
||||||
export const getCurrentSession = async (req: Request, res: Response) => {
|
export const getCurrentSession = async (req: Request, res: Response) => {
|
||||||
@@ -19,6 +19,13 @@ export const guestSession = async (req: Request, res: Response) => {
|
|||||||
try {
|
try {
|
||||||
const name = xss(req.body.name);
|
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) {
|
if (!req.session.user || !req.session.user?.id) {
|
||||||
// create guest session
|
// create guest session
|
||||||
const user: User = {
|
const user: User = {
|
||||||
|
|||||||
@@ -1,30 +1,31 @@
|
|||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
import { activeGames } from "../db/models/game.model";
|
import { activeGames } from "../db/models/game.model.js";
|
||||||
import type { Game, User } from "@types";
|
import type { Game, User } from "@chessu/types";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
|
|
||||||
export const getActiveGames = async (req: Request, res: Response) => {
|
export const getActivePublicGames = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
//if (!req.query || !req.query.code) {
|
res.status(200).json(activeGames.filter((g) => !g.unlisted && !g.winner));
|
||||||
res.status(200).json(activeGames);
|
} catch (err: unknown) {
|
||||||
//}
|
console.log(err);
|
||||||
|
res.status(500).end();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/*
|
export const getActiveGame = async (req: Request, res: Response) => {
|
||||||
// todo: if code query is URL, convert to code (or do it on client side?)
|
try {
|
||||||
const code =
|
if (!req.params || !req.params.code) {
|
||||||
(req.query.code as string).startsWith("http") ||
|
res.status(400).end();
|
||||||
(req.query.code as string).startsWith("ches.su")
|
return;
|
||||||
? path.posix.basename(url.parse(req.query.code as string).pathname as string)
|
}
|
||||||
: req.query.code;
|
|
||||||
|
|
||||||
console.log(code);
|
const game = activeGames.find((g) => g.code === req.params.code);
|
||||||
const game = activeGames.find((g) => g.code === code);
|
|
||||||
|
|
||||||
if (!game) {
|
if (!game) {
|
||||||
res.status(404).end();
|
res.status(404).end();
|
||||||
} else {
|
} else {
|
||||||
res.status(200).json(game);
|
res.status(200).json(game);
|
||||||
}*/
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.log(err);
|
console.log(err);
|
||||||
res.status(500).end();
|
res.status(500).end();
|
||||||
@@ -39,11 +40,16 @@ export const createGame = async (req: Request, res: Response) => {
|
|||||||
res.status(401).end();
|
res.status(401).end();
|
||||||
return;
|
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 = {
|
const game: Game = {
|
||||||
code: nanoid(6),
|
code: nanoid(6),
|
||||||
open: true,
|
unlisted,
|
||||||
host: user
|
host: user,
|
||||||
|
pgn: ""
|
||||||
};
|
};
|
||||||
if (req.body.side === "white") {
|
if (req.body.side === "white") {
|
||||||
game.white = user;
|
game.white = user;
|
||||||
@@ -65,10 +71,3 @@ export const createGame = async (req: Request, res: Response) => {
|
|||||||
res.status(500).end();
|
res.status(500).end();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// use sockets for joining games
|
|
||||||
/*
|
|
||||||
export const joinGame = async (req: Request, res: Response) => {
|
|
||||||
console.log("joining game!");
|
|
||||||
};
|
|
||||||
*/
|
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
import { Pool } from "pg";
|
import pg from "pg";
|
||||||
|
|
||||||
export const db = new Pool();
|
export const db = new pg.Pool();
|
||||||
|
|||||||
@@ -12,5 +12,4 @@ CREATE TABLE "game" (
|
|||||||
pgn TEXT,
|
pgn TEXT,
|
||||||
white_id INT REFERENCES "user",
|
white_id INT REFERENCES "user",
|
||||||
black_id INT REFERENCES "user",
|
black_id INT REFERENCES "user",
|
||||||
winner CHAR(5)
|
|
||||||
);
|
);
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { db } from "..";
|
import { db } from "../index.js";
|
||||||
import { Game } from "@types";
|
import type { Game } from "@chessu/types";
|
||||||
|
|
||||||
export const activeGames: Array<Game> = [];
|
export const activeGames: Array<Game> = [];
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { db } from "..";
|
import { db } from "../index.js";
|
||||||
import type { User } from "@types";
|
import type { User } from "@chessu/types";
|
||||||
|
|
||||||
const create = async (user: User, password: string) => {
|
const create = async (user: User, password: string) => {
|
||||||
if (user.name === "Guest" || user.email === undefined) {
|
if (user.name === "Guest" || user.email === undefined) {
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
|
import type { Session } from "express-session";
|
||||||
import session, { Session } from "express-session";
|
import session from "express-session";
|
||||||
import PGSimple from "connect-pg-simple";
|
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);
|
const PGSession = PGSimple(session);
|
||||||
|
|
||||||
import type { User } from "@types";
|
|
||||||
declare module "express-session" {
|
declare module "express-session" {
|
||||||
interface SessionData {
|
interface SessionData {
|
||||||
user: User;
|
user: User;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Router } from "express";
|
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);
|
router.route("/").get(controller.getCurrentSession);
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
|
import * as controller from "../controllers/games.controller.js";
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
import * as controller from "../controllers/games.controller";
|
router.route("/").get(controller.getActivePublicGames).post(controller.createGame);
|
||||||
|
|
||||||
router.route("/").get(controller.getActiveGames).post(controller.createGame);
|
router.route("/:code").get(controller.getActiveGame);
|
||||||
|
|
||||||
//router.route("/:id").put(controller.joinGame);
|
|
||||||
|
|
||||||
// todo: api for updating games/moves requiring authentication
|
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
const router = Router();
|
import games from "./games.route.js";
|
||||||
|
import auth from "./auth.route.js";
|
||||||
|
|
||||||
import games from "./games.route";
|
const router = Router();
|
||||||
import auth from "./auth.route";
|
|
||||||
|
|
||||||
router.use("/games", games);
|
router.use("/games", games);
|
||||||
router.use("/auth", auth);
|
router.use("/auth", auth);
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import "dotenv/config";
|
import "dotenv/config";
|
||||||
import cors from "cors";
|
import cors from "cors";
|
||||||
|
import type { Request, Response, NextFunction } from "express";
|
||||||
import express, { Request, Response, NextFunction } from "express";
|
import express from "express";
|
||||||
import { createServer } from "http";
|
import { createServer } from "http";
|
||||||
import session from "./middleware/session";
|
import session from "./middleware/session.js";
|
||||||
import { Server } from "socket.io";
|
import { Server } from "socket.io";
|
||||||
import { init as initSocket } from "./socket";
|
import { init as initSocket } from "./socket/index.js";
|
||||||
import { db } from "./db";
|
import { db } from "./db/index.js";
|
||||||
|
import routes from "./routes/index.js";
|
||||||
import routes from "./routes";
|
|
||||||
|
|
||||||
const corsConfig = {
|
const corsConfig = {
|
||||||
origin: process.env.CORS_ORIGIN || "http://localhost:3000",
|
origin: process.env.CORS_ORIGIN || "http://localhost:3000",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { activeGames } from "../db/models/game.model";
|
import { activeGames } from "../db/models/game.model.js";
|
||||||
import type { Socket } from "socket.io";
|
import type { DisconnectReason, Socket } from "socket.io";
|
||||||
import { Chess } from "chess.js";
|
import { Chess } from "chess.js";
|
||||||
import { io } from "../server";
|
import { io } from "../server.js";
|
||||||
|
|
||||||
export async function joinLobby(this: Socket, gameCode: string) {
|
export async function joinLobby(this: Socket, gameCode: string) {
|
||||||
const game = activeGames.find((g) => g.code === gameCode);
|
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.`);
|
console.log(`joinLobby: Game code ${gameCode} not found.`);
|
||||||
return;
|
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 = [];
|
if (game.observers === undefined) game.observers = [];
|
||||||
game.observers?.push(this.request.session.user);
|
game.observers?.push(this.request.session.user);
|
||||||
}
|
}
|
||||||
@@ -26,12 +29,10 @@ export async function joinLobby(this: Socket, gameCode: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await this.join(gameCode);
|
await this.join(gameCode);
|
||||||
this.emit("receivedLatestGame", game);
|
io.to(game.code as string).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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function leaveLobby(this: Socket, code?: string) {
|
export async function leaveLobby(this: Socket, reason?: DisconnectReason, code?: string) {
|
||||||
if (this.rooms.size >= 3 && !code) {
|
if (this.rooms.size >= 3 && !code) {
|
||||||
console.log(`[WARNING] leaveLobby: room size is ${this.rooms.size}, aborting...`);
|
console.log(`[WARNING] leaveLobby: room size is ${this.rooms.size}, aborting...`);
|
||||||
return;
|
return;
|
||||||
@@ -39,37 +40,34 @@ export async function leaveLobby(this: Socket, code?: string) {
|
|||||||
const game = activeGames.find(
|
const game = activeGames.find(
|
||||||
(g) =>
|
(g) =>
|
||||||
g.code === (code || this.rooms.size === 2 ? Array.from(this.rooms)[1] : 0) ||
|
g.code === (code || this.rooms.size === 2 ? Array.from(this.rooms)[1] : 0) ||
|
||||||
g.black?.id === this.request.session.id ||
|
(g.black?.connected && g.black?.id === this.request.session.user.id) ||
|
||||||
g.white?.id === this.request.session.id ||
|
(g.white?.connected && g.white?.id === this.request.session.user.id) ||
|
||||||
g.observers?.find((o) => this.request.session.id === o.id)
|
g.observers?.find((o) => this.request.session.user.id === o.id)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (game) {
|
if (game) {
|
||||||
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);
|
||||||
let name = "";
|
|
||||||
if (user) {
|
if (user) {
|
||||||
name = user.name as string;
|
|
||||||
game.observers?.splice(game.observers?.indexOf(user), 1);
|
game.observers?.splice(game.observers?.indexOf(user), 1);
|
||||||
}
|
}
|
||||||
if (game.black?.id === this.request.session.id) {
|
if (game.black && game.black?.id === this.request.session.user.id) {
|
||||||
name = game.black?.name as string;
|
game.black.connected = false;
|
||||||
game.black = undefined;
|
} else if (game.white && game.white?.id === this.request.session.user.id) {
|
||||||
}
|
game.white.connected = false;
|
||||||
if (game.white?.id === this.request.session.id) {
|
|
||||||
name = game.white?.name as string;
|
|
||||||
game.white = undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
if (game.timeout) clearTimeout(game.timeout);
|
||||||
game.timeout = Number(
|
game.timeout = Number(
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
activeGames.splice(activeGames.indexOf(game), 1);
|
activeGames.splice(activeGames.indexOf(game), 1);
|
||||||
}, 1000 * 60 * 30) // 30 minutes
|
}, 1000 * 60 * 15) // 15 minutes
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
this.to(game.code as string).emit("userLeft", name);
|
this.to(game.code as string).emit("receivedLatestGame", game);
|
||||||
this.to(game.code as string).emit("receivedLatestLobby", game);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await this.leave(code || Array.from(this.rooms)[1]);
|
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();
|
const prevTurn = chess.turn();
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(prevTurn === "b" && this.request.session.id !== game.black?.id) ||
|
(prevTurn === "b" && this.request.session.user.id !== game.black?.id) ||
|
||||||
(prevTurn === "w" && this.request.session.id !== game.white?.id)
|
(prevTurn === "w" && this.request.session.user.id !== game.white?.id)
|
||||||
) {
|
) {
|
||||||
throw new Error("not turn to move");
|
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) {
|
export async function joinAsPlayer(this: Socket) {
|
||||||
const game = activeGames.find((g) => g.code === Array.from(this.rooms)[1]);
|
const game = activeGames.find((g) => g.code === Array.from(this.rooms)[1]);
|
||||||
if (!game) return;
|
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) {
|
if (!game.white) {
|
||||||
game.white = this.request.session.user;
|
game.white = this.request.session.user;
|
||||||
|
game.white.connected = true;
|
||||||
if (user) game.observers?.splice(game.observers?.indexOf(user), 1);
|
if (user) game.observers?.splice(game.observers?.indexOf(user), 1);
|
||||||
io.to(game.code as string).emit("userJoinedAsPlayer", {
|
io.to(game.code as string).emit("userJoinedAsPlayer", {
|
||||||
name: this.request.session.user.name,
|
name: this.request.session.user.name,
|
||||||
@@ -147,6 +146,7 @@ export async function joinAsPlayer(this: Socket) {
|
|||||||
});
|
});
|
||||||
} else if (!game.black) {
|
} else if (!game.black) {
|
||||||
game.black = this.request.session.user;
|
game.black = this.request.session.user;
|
||||||
|
game.black.connected = true;
|
||||||
if (user) game.observers?.splice(game.observers?.indexOf(user), 1);
|
if (user) game.observers?.splice(game.observers?.indexOf(user), 1);
|
||||||
io.to(game.code as string).emit("userJoinedAsPlayer", {
|
io.to(game.code as string).emit("userJoinedAsPlayer", {
|
||||||
name: this.request.session.user.name,
|
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");
|
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("receivedLatestGame", game);
|
||||||
io.to(game.code as string).emit("receivedLatestLobby", game);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function chat(this: Socket, message: string) {
|
export async function chat(this: Socket, message: string) {
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import type { Socket } from "socket.io";
|
import type { Socket } from "socket.io";
|
||||||
import { io } from "../server";
|
import { io } from "../server.js";
|
||||||
import { joinLobby, leaveLobby, getLatestGame, sendMove, joinAsPlayer, chat } from "./game.socket";
|
import {
|
||||||
|
joinLobby,
|
||||||
|
leaveLobby,
|
||||||
|
getLatestGame,
|
||||||
|
sendMove,
|
||||||
|
joinAsPlayer,
|
||||||
|
chat
|
||||||
|
} from "./game.socket.js";
|
||||||
|
|
||||||
const socketConnect = (socket: Socket) => {
|
const socketConnect = (socket: Socket) => {
|
||||||
const req = socket.request;
|
const req = socket.request;
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2021",
|
"target": "ES2021",
|
||||||
"module": "commonjs",
|
"module": "NodeNext",
|
||||||
"paths": {
|
"moduleResolution": "NodeNext",
|
||||||
"@types": ["../types/"]
|
|
||||||
},
|
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
|||||||
3
types/index.ts → types/index.d.ts
vendored
3
types/index.ts → types/index.d.ts
vendored
@@ -6,7 +6,7 @@ export interface Game {
|
|||||||
winner?: "white" | "black" | "draw";
|
winner?: "white" | "black" | "draw";
|
||||||
host?: User;
|
host?: User;
|
||||||
code?: string;
|
code?: string;
|
||||||
open?: boolean;
|
unlisted?: boolean;
|
||||||
timeout?: number;
|
timeout?: number;
|
||||||
observers?: User[];
|
observers?: User[];
|
||||||
}
|
}
|
||||||
@@ -15,4 +15,5 @@ export interface User {
|
|||||||
id?: number | string; // string for guest IDs
|
id?: number | string; // string for guest IDs
|
||||||
name?: string;
|
name?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
|
connected?: boolean; // mainly for players, not spectators
|
||||||
}
|
}
|
||||||
5
types/package.json
Normal file
5
types/package.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"name": "@chessu/types",
|
||||||
|
"private": "true",
|
||||||
|
"version": "0.0.0"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user