move to public repo

This commit is contained in:
Nathaniel Tampus
2023-01-03 15:47:30 +08:00
commit 17ce3cf4c4
58 changed files with 2430 additions and 0 deletions

11
.editorconfig Normal file
View 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
View 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
View 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
View File

@@ -0,0 +1,7 @@
{
"semi": true,
"printWidth": 100,
"singleQuote": false,
"trailingComma": "none",
"endOfLine": "auto"
}

21
LICENSE Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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;

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

View 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%;
}

View 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;

View 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;

View 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;
}

View File

@@ -0,0 +1,24 @@
import styles from "./Footer.module.css";
const Footer = () => {
return (
<footer className={styles.footer}>
made with &hearts; by{" "}
<a href="https://nize.ph" target="_blank" rel="noreferrer">
nize
</a>
<br />
&copy; {new Date().getFullYear()} {" "}
<a
href="https://github.com/nizewn/chessu"
className={styles.github}
target="_blank"
rel="noreferrer"
>
GitHub
</a>
</footer>
);
};
export default Footer;

View 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);
}

View 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;

View File

@@ -0,0 +1 @@
export const apiUrl = import.meta.env.APIURL || "https://api.ches.su";

View 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;

View 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);

View 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
View 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
View 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
View File

@@ -0,0 +1,5 @@
const routes = () => {
return <div>routes</div>;
};
export default routes;

View 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);
}

View 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;

View 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%;
}
}

View 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;

View File

@@ -0,0 +1,5 @@
const NotFound = () => {
return <div>Error 404: page not found</div>;
};
export default NotFound;

View 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
View 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
View 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
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

28
client/tsconfig.json Normal file
View 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"
}
]
}

View File

@@ -0,0 +1,9 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Node",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

3
client/vercel.json Normal file
View File

@@ -0,0 +1,3 @@
{
"rewrites": [{ "source": "/(.*)", "destination": "/" }]
}

7
client/vite.config.ts Normal file
View 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
View 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
View 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"
}
}

View 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();
}
};

View 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
View File

@@ -0,0 +1,3 @@
import { Pool } from "pg";
export const db = new Pool();

16
server/src/db/init.sql Normal file
View 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)
);

View 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
};

View 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
};

View File

View 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;

View 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;

View 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;

View 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
View 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}`);
});

View 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
});
}

View 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
View 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
View 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;
}