move to public repo
This commit is contained in:
11
.editorconfig
Normal file
11
.editorconfig
Normal file
@@ -0,0 +1,11 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
|
||||
[*.{js,ts,json}]
|
||||
indent_size = 4
|
||||
|
||||
[*.{jsx,tsx,html}]
|
||||
indent_size = 2
|
||||
34
.eslintrc
Normal file
34
.eslintrc
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"settings": {
|
||||
"react": {
|
||||
"version": "detect"
|
||||
}
|
||||
},
|
||||
"root": true,
|
||||
"env": {
|
||||
"es2021": true,
|
||||
"node": true
|
||||
},
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:react/recommended",
|
||||
"plugin:react/jsx-runtime",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"prettier"
|
||||
],
|
||||
"overrides": [],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"ecmaVersion": "latest",
|
||||
"sourceType": "module"
|
||||
},
|
||||
"plugins": ["react", "@typescript-eslint", "prettier"],
|
||||
"rules": {
|
||||
"prettier/prettier": [
|
||||
"error",
|
||||
{
|
||||
"endOfLine": "auto"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
33
.gitignore
vendored
Normal file
33
.gitignore
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
build
|
||||
dist-ssr
|
||||
*.local
|
||||
package-lock.json
|
||||
|
||||
.env
|
||||
.env.test
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
7
.prettierrc
Normal file
7
.prettierrc
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"semi": true,
|
||||
"printWidth": 100,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "none",
|
||||
"endOfLine": "auto"
|
||||
}
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2023-present, Nathaniel Tampus
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
37
README.md
Normal file
37
README.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# chessu
|
||||
|
||||
Online 2-player chess. Live demo at [ches.su](https://ches.su)
|
||||
|
||||
- React 18
|
||||
- CSS Modules
|
||||
- [react-chessboard](https://github.com/Clariity/react-chessboard)
|
||||
- [chess.js](https://github.com/jhlywa/chess.js)
|
||||
- Express.js
|
||||
- socket.io
|
||||
- PostgreSQL
|
||||
|
||||
## Development
|
||||
|
||||
Root directory
|
||||
|
||||
```sh
|
||||
npm install # install all dependencies
|
||||
|
||||
npm run dev # concurrently run frontend and backend test servers
|
||||
|
||||
npm run build # build frontend and backend for production (see dist/ folders)
|
||||
|
||||
npm run react-dev # run frontend server only
|
||||
```
|
||||
|
||||
### Environment variables
|
||||
|
||||
Client: `APIURL`
|
||||
|
||||
Server: `SESSION_SECRET`, `PGUSER`, `PGPASSWORD`, `PGHOST`, `PGDATABASE`, `PGPORT`
|
||||
|
||||
## To-do (move this to issues):
|
||||
|
||||
- [] real-time games with chat & observer support
|
||||
- [] user authentication with oauth2 support (optional)
|
||||
- [] RESTful API for non-real-time games like daily chess
|
||||
12
chessu.session.sql
Normal file
12
chessu.session.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
SELECT * FROM "user";
|
||||
|
||||
INSERT INTO "user"(name, email, password) VALUES('astwangw', 'tests@gmail.com', 'passwordd') RETURNING id, name, email;
|
||||
|
||||
UPDATE "user" SET name = 'nizepogii' WHERE id = 1 RETURNING id, name, email;
|
||||
|
||||
DELETE FROM "user" WHERE id = 4 RETURNING id, name, email;
|
||||
|
||||
DROP TABLE "game";
|
||||
DROP TABLE "user";
|
||||
|
||||
INSERT INTO "game"(white_id, black_id) VALUES(undefined, undefined) RETURNING *;
|
||||
13
client/index.html
Normal file
13
client/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!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>
|
||||
28
client/package.json
Normal file
28
client/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "chessu",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/colors": "^0.1.8",
|
||||
"@radix-ui/react-icons": "^1.1.1",
|
||||
"chess.js": "^1.0.0-alpha.0",
|
||||
"react": "^18.2.0",
|
||||
"react-chessboard": "^1.3.1",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.6.1",
|
||||
"socket.io-client": "^4.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.0.26",
|
||||
"@types/react-dom": "^18.0.10",
|
||||
"@vitejs/plugin-react-swc": "^3.0.1",
|
||||
"typescript": "^4.9.4",
|
||||
"vite": "^4.0.3"
|
||||
}
|
||||
}
|
||||
104
client/public/chessu.svg
Normal file
104
client/public/chessu.svg
Normal file
@@ -0,0 +1,104 @@
|
||||
<?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>
|
||||
|
After Width: | Height: | Size: 7.0 KiB |
32
client/src/App.tsx
Normal file
32
client/src/App.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
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;
|
||||
BIN
client/src/assets/oauth_facebook.png
Normal file
BIN
client/src/assets/oauth_facebook.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
BIN
client/src/assets/oauth_google.png
Normal file
BIN
client/src/assets/oauth_google.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.9 KiB |
125
client/src/components/Auth/Auth.module.css
Normal file
125
client/src/components/Auth/Auth.module.css
Normal file
@@ -0,0 +1,125 @@
|
||||
.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%;
|
||||
}
|
||||
90
client/src/components/Auth/Auth.tsx
Normal file
90
client/src/components/Auth/Auth.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
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;
|
||||
210
client/src/components/Board/Board.tsx
Normal file
210
client/src/components/Board/Board.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
import { useContext, useState, useEffect, useReducer } from "react";
|
||||
import { Chess, Move } from "chess.js";
|
||||
import { Chessboard, Square, Pieces } 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) {
|
||||
const loadSuccess = game.loadPgn(latestGame.pgn);
|
||||
if (loadSuccess) {
|
||||
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 }) {
|
||||
const result = game.move({ from: m.from, to: m.to });
|
||||
if (result) {
|
||||
setOptionSquares({
|
||||
[m.from]: { background: "rgba(255, 255, 0, 0.4)" },
|
||||
[m.to]: { background: "rgba(255, 255, 0, 0.4)" }
|
||||
});
|
||||
} else {
|
||||
setOptionSquares({});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function isDraggablePiece({ piece }: { piece: Pieces }) {
|
||||
if (side === "s") return true;
|
||||
return piece.startsWith(side);
|
||||
}
|
||||
|
||||
function onDrop(sourceSquare: Square, targetSquare: Square) {
|
||||
if (side !== game.turn()) return false;
|
||||
|
||||
const move = makeMove({
|
||||
from: sourceSquare,
|
||||
to: targetSquare,
|
||||
promotion: "q"
|
||||
});
|
||||
if (move === null) return false; // illegal move
|
||||
socket?.emit("sendMove", { from: move.from, to: move.to });
|
||||
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: Pieces, 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 move = makeMove({
|
||||
from: moveFrom as Square,
|
||||
to: square,
|
||||
promotion: "q"
|
||||
});
|
||||
if (move === null) {
|
||||
resetFirstMove(square);
|
||||
} else {
|
||||
setMoveFrom("");
|
||||
socket?.emit("sendMove", { from: move.from, to: move.to });
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
14
client/src/components/Footer/Footer.module.css
Normal file
14
client/src/components/Footer/Footer.module.css
Normal file
@@ -0,0 +1,14 @@
|
||||
.footer {
|
||||
margin-top: 4em;
|
||||
padding-bottom: 2em;
|
||||
width: 100%;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.footer a {
|
||||
color: var(--blue12);
|
||||
}
|
||||
|
||||
.github {
|
||||
text-decoration: underline;
|
||||
}
|
||||
24
client/src/components/Footer/Footer.tsx
Normal file
24
client/src/components/Footer/Footer.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
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;
|
||||
15
client/src/components/Header/Header.module.css
Normal file
15
client/src/components/Header/Header.module.css
Normal file
@@ -0,0 +1,15 @@
|
||||
.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);
|
||||
}
|
||||
47
client/src/components/Header/Header.tsx
Normal file
47
client/src/components/Header/Header.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
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 (
|
||||
<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;
|
||||
1
client/src/config/config.ts
Normal file
1
client/src/config/config.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const apiUrl = import.meta.env.APIURL || "https://api.ches.su";
|
||||
29
client/src/context/ContextProvider.tsx
Normal file
29
client/src/context/ContextProvider.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { PropsWithChildren, useEffect, useState } from "react";
|
||||
import type { User } from "@types";
|
||||
|
||||
import { SocketContext, socket } from "./socket";
|
||||
import { SessionContext } from "./session";
|
||||
|
||||
import { fetchSession } from "../utils/auth";
|
||||
|
||||
const ContextProvider = (props: PropsWithChildren) => {
|
||||
const [user, setUser] = useState<User>({});
|
||||
|
||||
async function getSession() {
|
||||
const user = await fetchSession();
|
||||
if (user) {
|
||||
setUser(user);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
getSession();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SocketContext.Provider value={socket}>
|
||||
<SessionContext.Provider value={{ user, setUser }}>{props.children}</SessionContext.Provider>
|
||||
</SocketContext.Provider>
|
||||
);
|
||||
};
|
||||
export default ContextProvider;
|
||||
7
client/src/context/session.ts
Normal file
7
client/src/context/session.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { User } from "@types";
|
||||
import { createContext, Dispatch, SetStateAction } from "react";
|
||||
|
||||
export const SessionContext = createContext<{
|
||||
user: User;
|
||||
setUser: Dispatch<SetStateAction<User>>;
|
||||
} | null>(null);
|
||||
18
client/src/context/socket.ts
Normal file
18
client/src/context/socket.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
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);
|
||||
39
client/src/global.css
Normal file
39
client/src/global.css
Normal file
@@ -0,0 +1,39 @@
|
||||
@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%;
|
||||
}
|
||||
}
|
||||
10
client/src/main.tsx
Normal file
10
client/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
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>
|
||||
);
|
||||
5
client/src/routes.tsx
Normal file
5
client/src/routes.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
const routes = () => {
|
||||
return <div>routes</div>;
|
||||
};
|
||||
|
||||
export default routes;
|
||||
121
client/src/routes/Game/Game.module.css
Normal file
121
client/src/routes/Game/Game.module.css
Normal file
@@ -0,0 +1,121 @@
|
||||
.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);
|
||||
}
|
||||
238
client/src/routes/Game/Game.tsx
Normal file
238
client/src/routes/Game/Game.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
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;
|
||||
117
client/src/routes/Home/Home.module.css
Normal file
117
client/src/routes/Home/Home.module.css
Normal file
@@ -0,0 +1,117 @@
|
||||
.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%;
|
||||
}
|
||||
}
|
||||
139
client/src/routes/Home/Home.tsx
Normal file
139
client/src/routes/Home/Home.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
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;
|
||||
5
client/src/routes/NotFound/NotFound.tsx
Normal file
5
client/src/routes/NotFound/NotFound.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
const NotFound = () => {
|
||||
return <div>Error 404: page not found</div>;
|
||||
};
|
||||
|
||||
export default NotFound;
|
||||
12
client/src/routes/ProtectedRoutes.tsx
Normal file
12
client/src/routes/ProtectedRoutes.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
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;
|
||||
29
client/src/utils/auth.ts
Normal file
29
client/src/utils/auth.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
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 })
|
||||
});
|
||||
console.log(res);
|
||||
if (res.status === 201) {
|
||||
const user: User = await res.json();
|
||||
return user;
|
||||
}
|
||||
};
|
||||
24
client/src/utils/games.ts
Normal file
24
client/src/utils/games.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
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
Normal file
1
client/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
28
client/tsconfig.json
Normal file
28
client/tsconfig.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": false,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"paths": {
|
||||
"@types": ["../types/"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
9
client/tsconfig.node.json
Normal file
9
client/tsconfig.node.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
3
client/vercel.json
Normal file
3
client/vercel.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"rewrites": [{ "source": "/(.*)", "destination": "/" }]
|
||||
}
|
||||
7
client/vite.config.ts
Normal file
7
client/vite.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react-swc";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()]
|
||||
});
|
||||
27
package.json
Normal file
27
package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "chessu",
|
||||
"author": "nizewn",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"dev": "concurrently \"cd server && npm run dev\" \"cd client && npm run dev\"",
|
||||
"react-dev": "cd client && npm run dev",
|
||||
"install": "(cd client && npm install) & (cd server && npm install)",
|
||||
"build:client": "cd client && npm run build",
|
||||
"build:server": "cd server && npm run build",
|
||||
"server": "cd server && npm start",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint --fix .",
|
||||
"format:check": "prettier --check .",
|
||||
"format:write": "prettier --write ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "^5.47.1",
|
||||
"@typescript-eslint/parser": "^5.47.1",
|
||||
"concurrently": "^7.6.0",
|
||||
"eslint": "^8.30.0",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"eslint-plugin-prettier": "^4.2.1",
|
||||
"eslint-plugin-react": "^7.31.11",
|
||||
"prettier": "^2.8.1"
|
||||
}
|
||||
}
|
||||
29
server/package.json
Normal file
29
server/package.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"main": "./dist/server/src/server.js",
|
||||
"scripts": {
|
||||
"start": "node ./dist/server/src/server.js",
|
||||
"build": "tsc",
|
||||
"dev": "ts-node-dev src/server.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"chess.js": "^1.0.0-alpha.0",
|
||||
"connect-pg-simple": "^8.0.0",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.0.3",
|
||||
"express": "^4.18.2",
|
||||
"express-session": "^1.17.3",
|
||||
"nanoid": "^3.3.4",
|
||||
"pg": "^8.8.0",
|
||||
"socket.io": "^4.5.4",
|
||||
"xss": "^1.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/connect-pg-simple": "^7.0.0",
|
||||
"@types/express": "^4.17.15",
|
||||
"@types/express-session": "^1.17.5",
|
||||
"@types/node": "^18.11.18",
|
||||
"@types/pg": "^8.6.6",
|
||||
"ts-node-dev": "^2.0.0",
|
||||
"typescript": "^4.9.4"
|
||||
}
|
||||
}
|
||||
51
server/src/controllers/auth.controller.ts
Normal file
51
server/src/controllers/auth.controller.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { Request, Response } from "express";
|
||||
import type { User } from "@types";
|
||||
import xss from "xss";
|
||||
|
||||
export const getCurrentSession = async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (req.session.user) {
|
||||
res.status(200).json(req.session.user);
|
||||
} else {
|
||||
res.status(404).end();
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
console.log(err);
|
||||
res.status(500).end();
|
||||
}
|
||||
};
|
||||
|
||||
export const guestSession = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const name = xss(req.body.name);
|
||||
|
||||
if (!req.session.user || !req.session.user?.id) {
|
||||
// create guest session
|
||||
const user: User = {
|
||||
id: req.session.id,
|
||||
name
|
||||
};
|
||||
req.session.user = user;
|
||||
} else if (typeof req.session.user.id === "string" && req.session.user.name !== name) {
|
||||
// update guest name
|
||||
req.session.user.name = name;
|
||||
}
|
||||
req.session.save(() => {
|
||||
res.status(201).json(req.session.user);
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
console.log(err);
|
||||
res.status(500).end();
|
||||
}
|
||||
};
|
||||
|
||||
export const logoutSession = async (req: Request, res: Response) => {
|
||||
try {
|
||||
req.session.destroy(() => {
|
||||
res.status(204).end();
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
console.log(err);
|
||||
res.status(500).end();
|
||||
}
|
||||
};
|
||||
74
server/src/controllers/games.controller.ts
Normal file
74
server/src/controllers/games.controller.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import type { Request, Response } from "express";
|
||||
import { activeGames } from "../db/models/game.model";
|
||||
import type { Game, User } from "@types";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
export const getActiveGames = async (req: Request, res: Response) => {
|
||||
try {
|
||||
//if (!req.query || !req.query.code) {
|
||||
res.status(200).json(activeGames);
|
||||
//}
|
||||
|
||||
/*
|
||||
// todo: if code query is URL, convert to code (or do it on client side?)
|
||||
const code =
|
||||
(req.query.code as string).startsWith("http") ||
|
||||
(req.query.code as string).startsWith("ches.su")
|
||||
? path.posix.basename(url.parse(req.query.code as string).pathname as string)
|
||||
: req.query.code;
|
||||
|
||||
console.log(code);
|
||||
const game = activeGames.find((g) => g.code === code);
|
||||
|
||||
if (!game) {
|
||||
res.status(404).end();
|
||||
} else {
|
||||
res.status(200).json(game);
|
||||
}*/
|
||||
} catch (err: unknown) {
|
||||
console.log(err);
|
||||
res.status(500).end();
|
||||
}
|
||||
};
|
||||
|
||||
export const createGame = async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!req.session.user) {
|
||||
console.log("unauthorized createGame:");
|
||||
console.log(req.session);
|
||||
res.status(401).end();
|
||||
return;
|
||||
}
|
||||
const user: User = req.session.user;
|
||||
const game: Game = {
|
||||
code: nanoid(6),
|
||||
open: true,
|
||||
host: user
|
||||
};
|
||||
if (req.body.side === "white") {
|
||||
game.white = user;
|
||||
} else if (req.body.side === "black") {
|
||||
game.black = user;
|
||||
} else {
|
||||
// random
|
||||
if (Math.floor(Math.random() * 2) === 0) {
|
||||
game.white = user;
|
||||
} else {
|
||||
game.black = user;
|
||||
}
|
||||
}
|
||||
activeGames.push(game);
|
||||
|
||||
res.status(201).json({ code: game.code });
|
||||
} catch (err: unknown) {
|
||||
console.log(err);
|
||||
res.status(500).end();
|
||||
}
|
||||
};
|
||||
|
||||
// use sockets for joining games
|
||||
/*
|
||||
export const joinGame = async (req: Request, res: Response) => {
|
||||
console.log("joining game!");
|
||||
};
|
||||
*/
|
||||
3
server/src/db/index.ts
Normal file
3
server/src/db/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { Pool } from "pg";
|
||||
|
||||
export const db = new Pool();
|
||||
16
server/src/db/init.sql
Normal file
16
server/src/db/init.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
-- users
|
||||
CREATE TABLE "user" (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(128) UNIQUE NOT NULL,
|
||||
email VARCHAR(128),
|
||||
password TEXT
|
||||
);
|
||||
|
||||
-- games
|
||||
CREATE TABLE "game" (
|
||||
id SERIAL PRIMARY KEY,
|
||||
pgn TEXT,
|
||||
white_id INT REFERENCES "user",
|
||||
black_id INT REFERENCES "user",
|
||||
winner CHAR(5)
|
||||
);
|
||||
89
server/src/db/models/game.model.ts
Normal file
89
server/src/db/models/game.model.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { db } from "..";
|
||||
import { Game } from "@types";
|
||||
|
||||
export const activeGames: Array<Game> = [];
|
||||
|
||||
// todo: join user and game relationship
|
||||
|
||||
const create = async (game: Game) => {
|
||||
try {
|
||||
const res = await db.query(
|
||||
`INSERT INTO "game"(pgn, white_id, black_id, winner) VALUES($1, $2, $3, $4) RETURNING *`,
|
||||
[game.pgn || null, game.white?.id || null, game.black?.id || null, game.winner || null]
|
||||
);
|
||||
return {
|
||||
id: res.rows[0].id,
|
||||
pgn: res.rows[0].pgn,
|
||||
white: { id: res.rows[0].white_id },
|
||||
black: { id: res.rows[0].black_id },
|
||||
winner: res.rows[0].winner
|
||||
} as Game;
|
||||
} catch (err: unknown) {
|
||||
console.log(err);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const find = async (where?: string, limit = 1) => {
|
||||
const query = where
|
||||
? `SELECT * FROM "game"`
|
||||
: {
|
||||
text: `SELECT * FROM "game" WHERE $1 LIMIT $2`,
|
||||
values: [where, limit]
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await db.query(query);
|
||||
return res.rows.map((r) => {
|
||||
return {
|
||||
id: r.id,
|
||||
pgn: r.pgn,
|
||||
white: { id: r.white_id },
|
||||
black: { id: r.black_id },
|
||||
winner: r.winner
|
||||
} as Game;
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
console.log(err);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const update = async (id: number, data: string) => {
|
||||
try {
|
||||
const res = await db.query(`UPDATE "game" SET $1 WHERE id = $2 RETURNING *`, [data, id]);
|
||||
return {
|
||||
id: res.rows[0].id,
|
||||
pgn: res.rows[0].pgn,
|
||||
white: { id: res.rows[0].white_id },
|
||||
black: { id: res.rows[0].black_id },
|
||||
winner: res.rows[0].winner
|
||||
} as Game;
|
||||
} catch (err: unknown) {
|
||||
console.log(err);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (id: number) => {
|
||||
try {
|
||||
const res = await db.query(`DELETE FROM "game" WHERE id = $1 RETURNING *`, [id]);
|
||||
return {
|
||||
id: res.rows[0].id,
|
||||
pgn: res.rows[0].pgn,
|
||||
white: { id: res.rows[0].white_id },
|
||||
black: { id: res.rows[0].black_id },
|
||||
winner: res.rows[0].winner
|
||||
} as Game;
|
||||
} catch (err: unknown) {
|
||||
console.log(err);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const GameModel = {
|
||||
create,
|
||||
find,
|
||||
update,
|
||||
remove
|
||||
};
|
||||
87
server/src/db/models/user.model.ts
Normal file
87
server/src/db/models/user.model.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { db } from "..";
|
||||
import type { User } from "@types";
|
||||
|
||||
const create = async (user: User, password: string) => {
|
||||
if (user.name === "Guest" || user.email === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await db.query(
|
||||
`INSERT INTO "user"(name, email, password) VALUES($1, $2, $3) RETURNING id, name, email`,
|
||||
[user.name, user.email ?? null, password]
|
||||
);
|
||||
return res.rows[0] as User;
|
||||
} catch (err: unknown) {
|
||||
console.log(err);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const find = async (where?: string, limit = 1) => {
|
||||
// if user is not specified, get all users
|
||||
if (!where) {
|
||||
try {
|
||||
const res = await db.query(`SELECT id, name, email FROM "user"`);
|
||||
return res.rows as Array<User>;
|
||||
} catch (err: unknown) {
|
||||
console.log(err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
/* const res = await db.query(
|
||||
`SELECT id, name, email FROM "user" WHERE ${typeof user.id === "number" ? "id" : typeof user.name === "string" ? "name" : "email" } = $1 LIMIT $2`,
|
||||
[typeof user.id === "number" ? user.id : typeof user.name === "string" ? user.name : user.email, limit]
|
||||
); */
|
||||
const res = await db.query(`SELECT id, name, email FROM "user" WHERE $1 LIMIT $2`, [
|
||||
where,
|
||||
limit
|
||||
]);
|
||||
return res.rows as Array<User>;
|
||||
} catch (err: unknown) {
|
||||
console.log(err);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const update = async (id: number, data: string) => {
|
||||
if (typeof id === "string" || id === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await db.query(`UPDATE "user" SET $1 WHERE id = $2 RETURNING id, name, email`, [
|
||||
data,
|
||||
id
|
||||
]);
|
||||
return res.rows[0] as User;
|
||||
} catch (err: unknown) {
|
||||
console.log(err);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (id: number) => {
|
||||
if (typeof id === "string" || id === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await db.query(`DELETE FROM "user" WHERE id = $1 RETURNING id, name, email`, [
|
||||
id
|
||||
]);
|
||||
return res.rows[0] as User;
|
||||
} catch (err: unknown) {
|
||||
console.log(err);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const UserModel = {
|
||||
create,
|
||||
find,
|
||||
update,
|
||||
remove
|
||||
};
|
||||
0
server/src/middleware/error.ts
Normal file
0
server/src/middleware/error.ts
Normal file
42
server/src/middleware/session.ts
Normal file
42
server/src/middleware/session.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
import session, { Session } from "express-session";
|
||||
import PGSimple from "connect-pg-simple";
|
||||
import { db } from "../db";
|
||||
const PGSession = PGSimple(session);
|
||||
|
||||
import type { User } from "@types";
|
||||
declare module "express-session" {
|
||||
interface SessionData {
|
||||
user: User;
|
||||
}
|
||||
}
|
||||
declare module "http" {
|
||||
interface IncomingMessage {
|
||||
session: Session & {
|
||||
user: User;
|
||||
};
|
||||
}
|
||||
}
|
||||
const sessionMiddleware = session({
|
||||
store: new PGSession({
|
||||
pool: db,
|
||||
createTableIfMissing: true
|
||||
}),
|
||||
secret: process.env.SESSION_SECRET || "whatever this is",
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
name: "chessu",
|
||||
proxy: true,
|
||||
cookie: {
|
||||
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
|
||||
secure: true,
|
||||
httpOnly: true,
|
||||
sameSite: "none"
|
||||
},
|
||||
genid: function () {
|
||||
return nanoid(21);
|
||||
}
|
||||
});
|
||||
|
||||
export default sessionMiddleware;
|
||||
16
server/src/routes/auth.route.ts
Normal file
16
server/src/routes/auth.route.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Router } from "express";
|
||||
const router = Router();
|
||||
|
||||
import * as controller from "../controllers/auth.controller";
|
||||
|
||||
router.route("/").get(controller.getCurrentSession);
|
||||
|
||||
// create or update guest sessions
|
||||
router.route("/guest").post(controller.guestSession);
|
||||
|
||||
router.route("/logout").post(controller.logoutSession);
|
||||
|
||||
//router.route("/register").post(controller.registerUser);
|
||||
//router.route("/login").post(controller.loginUser);
|
||||
|
||||
export default router;
|
||||
12
server/src/routes/games.route.ts
Normal file
12
server/src/routes/games.route.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Router } from "express";
|
||||
const router = Router();
|
||||
|
||||
import * as controller from "../controllers/games.controller";
|
||||
|
||||
router.route("/").get(controller.getActiveGames).post(controller.createGame);
|
||||
|
||||
//router.route("/:id").put(controller.joinGame);
|
||||
|
||||
// todo: api for updating games/moves requiring authentication
|
||||
|
||||
export default router;
|
||||
10
server/src/routes/index.ts
Normal file
10
server/src/routes/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Router } from "express";
|
||||
const router = Router();
|
||||
|
||||
import games from "./games.route";
|
||||
import auth from "./auth.route";
|
||||
|
||||
router.use("/games", games);
|
||||
router.use("/auth", auth);
|
||||
|
||||
export default router;
|
||||
46
server/src/server.ts
Normal file
46
server/src/server.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import "dotenv/config";
|
||||
import cors from "cors";
|
||||
const corsConfig = { origin: "https://ches.su", credentials: true };
|
||||
|
||||
import express, { Request, Response, NextFunction } from "express";
|
||||
import { createServer } from "http";
|
||||
import session from "./middleware/session";
|
||||
import { Server } from "socket.io";
|
||||
import { init as initSocket } from "./socket";
|
||||
import { db } from "./db";
|
||||
|
||||
import routes from "./routes";
|
||||
|
||||
const app = express();
|
||||
const server = createServer(app);
|
||||
|
||||
// database
|
||||
db.connect();
|
||||
|
||||
// middleware
|
||||
app.use(cors(corsConfig));
|
||||
app.use(express.json());
|
||||
app.set("trust proxy", 1);
|
||||
app.use(session);
|
||||
app.use("/v1", routes);
|
||||
|
||||
// socket.io
|
||||
export const io = new Server(server, { cors: corsConfig });
|
||||
io.use((socket, next) => {
|
||||
session(socket.request as Request, {} as Response, next as NextFunction);
|
||||
});
|
||||
io.use((socket, next) => {
|
||||
const session = socket.request.session;
|
||||
if (session && session.user) {
|
||||
next();
|
||||
} else {
|
||||
console.log("io.use: no session");
|
||||
socket.disconnect();
|
||||
}
|
||||
});
|
||||
initSocket();
|
||||
|
||||
const port = process.env.PORT || 5432;
|
||||
server.listen(port, () => {
|
||||
console.log(`listening on :${port}`);
|
||||
});
|
||||
164
server/src/socket/game.socket.ts
Normal file
164
server/src/socket/game.socket.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { activeGames } from "../db/models/game.model";
|
||||
import type { Socket } from "socket.io";
|
||||
import { Chess } from "chess.js";
|
||||
import { io } from "../server";
|
||||
|
||||
export async function joinLobby(this: Socket, gameCode: string) {
|
||||
const game = activeGames.find((g) => g.code === gameCode);
|
||||
if (!game) {
|
||||
console.log(`joinLobby: Game code ${gameCode} not found.`);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!(game.white?.id === this.request.session.id || game.black?.id === this.request.session.id)
|
||||
) {
|
||||
if (game.observers === undefined) game.observers = [];
|
||||
game.observers?.push(this.request.session.user);
|
||||
}
|
||||
|
||||
if (this.rooms.size >= 2) {
|
||||
await leaveLobby.call(this);
|
||||
}
|
||||
|
||||
await this.join(gameCode);
|
||||
this.emit("receivedLatestGame", game);
|
||||
io.to(game.code as string).emit("receivedLatestLobby", game);
|
||||
io.to(game.code as string).emit("userJoined", this.request.session.user.name);
|
||||
}
|
||||
|
||||
export async function leaveLobby(this: Socket, code?: string) {
|
||||
if (this.rooms.size === 2) {
|
||||
const game = activeGames.find((g) => g.code === (code || Array.from(this.rooms)[1]));
|
||||
if (game) {
|
||||
const user = game.observers?.find((o) => o.id === this.request.session.id);
|
||||
let name = "";
|
||||
if (user) {
|
||||
name = user.name as string;
|
||||
game.observers?.splice(game.observers?.indexOf(user), 1);
|
||||
}
|
||||
if (game.black?.id === this.request.session.id) {
|
||||
name = game.black?.name as string;
|
||||
game.black = undefined;
|
||||
}
|
||||
if (game.white?.id === this.request.session.id) {
|
||||
name = game.white?.name as string;
|
||||
game.white = undefined;
|
||||
}
|
||||
|
||||
if (!game.white && !game.black && !game.observers) {
|
||||
// TODO
|
||||
//activeGames.splice(activeGames.indexOf(game), 1); // remove game if empty
|
||||
} else {
|
||||
this.to(game.code as string).emit("userLeft", name);
|
||||
this.to(game.code as string).emit("receivedLatestLobby", game);
|
||||
}
|
||||
}
|
||||
await this.leave(code || Array.from(this.rooms)[1]);
|
||||
} else if (this.rooms.size >= 3) {
|
||||
console.log(`[WARNING] leaveLobby: room size is ${this.rooms.size}, aborting...`);
|
||||
} else {
|
||||
// try to find a game with this user
|
||||
const game = activeGames.find(
|
||||
(g) =>
|
||||
g.code === code ||
|
||||
g.black?.id === this.request.session.id ||
|
||||
g.white?.id === this.request.session.id ||
|
||||
g.observers?.find((o) => this.request.session.id === o.id)
|
||||
);
|
||||
if (game) {
|
||||
const user = game.observers?.find((o) => this.request.session.id === o.id);
|
||||
let name = "";
|
||||
if (user) {
|
||||
name = user.name as string;
|
||||
game.observers?.splice(game.observers?.indexOf(user), 1);
|
||||
}
|
||||
if (game.black?.id === this.request.session.id) {
|
||||
name = game.black?.name as string;
|
||||
game.black = undefined;
|
||||
}
|
||||
if (game.white?.id === this.request.session.id) {
|
||||
name = game.white?.name as string;
|
||||
game.white = undefined;
|
||||
}
|
||||
this.to(game.code as string).emit("userLeft", name);
|
||||
this.to(game.code as string).emit("receivedLatestLobby", game);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function getLatestGame(this: Socket) {
|
||||
const game = activeGames.find((g) => g.code === Array.from(this.rooms)[1]);
|
||||
if (game) this.emit("receivedLatestGame", game);
|
||||
}
|
||||
|
||||
export async function sendMove(this: Socket, m: { from: string; to: string; promotion?: string }) {
|
||||
const game = activeGames.find((g) => g.code === Array.from(this.rooms)[1]);
|
||||
if (!game) return;
|
||||
const chess = new Chess();
|
||||
if (game.pgn) {
|
||||
chess.loadPgn(game.pgn);
|
||||
}
|
||||
const prevTurn = chess.turn();
|
||||
const newMove = chess.move(m);
|
||||
if (chess.isGameOver()) {
|
||||
let reason = "";
|
||||
if (chess.isCheckmate()) reason = "checkmate";
|
||||
else if (chess.isStalemate()) reason = "stalemate";
|
||||
else if (chess.isThreefoldRepetition()) reason = "repetition";
|
||||
else if (chess.isInsufficientMaterial()) reason = "insufficient";
|
||||
else if (chess.isDraw()) reason = "draw";
|
||||
|
||||
const winnerSide =
|
||||
reason === "checkmate" ? (prevTurn === "w" ? "white" : "black") : undefined;
|
||||
const winnerName =
|
||||
reason === "checkmate"
|
||||
? winnerSide === "white"
|
||||
? game.white?.name
|
||||
: game.black?.name
|
||||
: undefined;
|
||||
if (reason === "checkmate") {
|
||||
game.winner = winnerSide;
|
||||
} else {
|
||||
game.winner = "draw";
|
||||
}
|
||||
io.to(game.code as string).emit("gameOver", { reason, winnerName, winnerSide });
|
||||
}
|
||||
if (newMove === null) {
|
||||
this.emit("receivedLatestGame", game);
|
||||
} else {
|
||||
game.pgn = chess.pgn();
|
||||
this.to(game.code as string).emit("receivedMove", { from: m.from, to: m.to });
|
||||
}
|
||||
}
|
||||
|
||||
export async function joinAsPlayer(this: Socket) {
|
||||
const game = activeGames.find((g) => g.code === Array.from(this.rooms)[1]);
|
||||
if (!game) return;
|
||||
const user = game.observers?.find((o) => o.id === this.request.session.id);
|
||||
if (!game.white) {
|
||||
game.white = this.request.session.user;
|
||||
if (user) game.observers?.splice(game.observers?.indexOf(user), 1);
|
||||
io.to(game.code as string).emit("userJoinedAsPlayer", {
|
||||
name: this.request.session.user.name,
|
||||
side: "white"
|
||||
});
|
||||
} else if (!game.black) {
|
||||
game.black = this.request.session.user;
|
||||
if (user) game.observers?.splice(game.observers?.indexOf(user), 1);
|
||||
io.to(game.code as string).emit("userJoinedAsPlayer", {
|
||||
name: this.request.session.user.name,
|
||||
side: "black"
|
||||
});
|
||||
} else {
|
||||
console.log("[WARNING] attempted to join a game with already 2 players");
|
||||
}
|
||||
io.to(game.code as string).emit("receivedLatestGame", game);
|
||||
io.to(game.code as string).emit("receivedLatestLobby", game);
|
||||
}
|
||||
|
||||
export async function chat(this: Socket, message: string) {
|
||||
this.to(Array.from(this.rooms)[1]).emit("chat", {
|
||||
author: this.request.session.user,
|
||||
message
|
||||
});
|
||||
}
|
||||
34
server/src/socket/index.ts
Normal file
34
server/src/socket/index.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { Socket } from "socket.io";
|
||||
import { io } from "../server";
|
||||
import { joinLobby, leaveLobby, getLatestGame, sendMove, joinAsPlayer, chat } from "./game.socket";
|
||||
|
||||
const socketConnect = (socket: Socket) => {
|
||||
const req = socket.request;
|
||||
|
||||
// re-analyze if this is necessary, or if io.use will handle logout
|
||||
socket.use((__, next) => {
|
||||
req.session.reload((err) => {
|
||||
if (err) {
|
||||
console.log("reload: disconnecting socket.");
|
||||
console.log(err);
|
||||
socket.disconnect();
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
socket.on("disconnect", leaveLobby);
|
||||
|
||||
socket.on("joinLobby", joinLobby);
|
||||
socket.on("leaveLobby", leaveLobby);
|
||||
|
||||
socket.on("getLatestGame", getLatestGame);
|
||||
socket.on("sendMove", sendMove);
|
||||
socket.on("joinAsPlayer", joinAsPlayer);
|
||||
socket.on("chat", chat);
|
||||
};
|
||||
|
||||
export const init = () => {
|
||||
io.on("connection", socketConnect);
|
||||
};
|
||||
14
server/tsconfig.json
Normal file
14
server/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2021",
|
||||
"module": "commonjs",
|
||||
"paths": {
|
||||
"@types": ["../types/"]
|
||||
},
|
||||
"outDir": "./dist",
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true
|
||||
}
|
||||
}
|
||||
17
types/index.ts
Normal file
17
types/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export interface Game {
|
||||
id?: number;
|
||||
pgn?: string;
|
||||
white?: User;
|
||||
black?: User;
|
||||
winner?: "white" | "black" | "draw";
|
||||
host?: User;
|
||||
code?: string;
|
||||
open?: boolean;
|
||||
observers?: User[];
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id?: number | string; // string for guest IDs
|
||||
name?: string;
|
||||
email?: string;
|
||||
}
|
||||
Reference in New Issue
Block a user