diff --git a/.commitlintrc.json b/.commitlintrc.json new file mode 100644 index 00000000..2c46208d --- /dev/null +++ b/.commitlintrc.json @@ -0,0 +1,6 @@ +{ + "extends": ["@commitlint/config-conventional"], + "rules": { + "type-enum": [2, "always", ["ci", "chore", "docs", "feat", "fix", "perf", "refactor", "revert", "style", "assets"]] + } +} diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 00000000..2ff0d6d8 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,13 @@ +// .eslintrc.js +module.exports = { + extends: ["@repo/eslint-config/library.js"], + parser: "@typescript-eslint/parser", + parserOptions: { + project: true, + }, + rules: { + "no-unused-vars": "off", + "no-redeclare": "off", + "turbo/no-undeclared-env-vars": "off", + } +}; diff --git a/frontend/src/App.css b/.eslintrc.json similarity index 100% rename from frontend/src/App.css rename to .eslintrc.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..96fab4fe --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# Dependencies +node_modules +.pnp +.pnp.js + +# Local env files +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Testing +coverage + +# Turbo +.turbo + +# Vercel +.vercel + +# Build Outputs +.next/ +out/ +build +dist + + +# Debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Misc +.DS_Store +*.pem diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 00000000..a99004d2 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,5 @@ +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" + +yarn lint-staged --allow-empty && +npm run lint && npm run format \ No newline at end of file diff --git a/.lintstagedrc b/.lintstagedrc new file mode 100644 index 00000000..7fc15cd8 --- /dev/null +++ b/.lintstagedrc @@ -0,0 +1,3 @@ +{ + "**/*.{ts,tsx,json}": ["prettier --write"] +} \ No newline at end of file diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..e872e038 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,9 @@ +node_modules + +build + +dist + +.turbo + + diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 00000000..03d5e4a6 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,10 @@ +{ + "singleQuote": true, + "printWidth":120, + "bracketSpacing": true, + "tabWidth": 2, + "trailingComma": "es5", + "semi": true + +} + diff --git a/README.md b/README.md new file mode 100644 index 00000000..26288a74 --- /dev/null +++ b/README.md @@ -0,0 +1,37 @@ +## Chess + +Building a platform where people can + +1. Sign up +2. Create a new match/get connected to an existing match +3. During the match, let users play moves +4. Have a rating system that goes up and down similar to standard chess rating + +## Tech stack + +Let's keep it simple + +1. React for Frontend +2. Node.js for Backend +3. Typescript as the language +4. Separate Websocket servers for handling real time games +5. Redis for storing all moves of a game in a queue + +## Setting it up locally + + - Clone the repo + - Copy over .env.example over to .env everywhere + - Update .env + - Postgres DB Credentials + - Github/Google Auth credentials + - npm install + - Start ws server + - cd apps/ws + - npm run dev + - Start Backend + - cd apps/backend + - npm run dev + - Start frontend + - cd apps/frontend + - npm run dev + diff --git a/apps/backend/.env.example b/apps/backend/.env.example new file mode 100644 index 00000000..b27c09c7 --- /dev/null +++ b/apps/backend/.env.example @@ -0,0 +1,8 @@ +GOOGLE_CLIENT_ID=your_google_client_id +GOOGLE_CLIENT_SECRET=your_google_client_secret +GITHUB_CLIENT_ID=your_github_client_id +GITHUB_CLIENT_SECRET=your_github_client_secret + +ALLOWED_HOSTS="http://localhost:5173,https://example.com" + +AUTH_REDIRECT_URL="http://localhost:5173/game/random" \ No newline at end of file diff --git a/apps/backend/.eslintrc.js b/apps/backend/.eslintrc.js new file mode 100644 index 00000000..b0075cf8 --- /dev/null +++ b/apps/backend/.eslintrc.js @@ -0,0 +1,6 @@ +module.exports = { + extends: ["../../.eslintrc.js"], + parserOptions: { + project: "./tsconfig.json", + }, +}; diff --git a/backend1/.gitignore b/apps/backend/.gitignore similarity index 100% rename from backend1/.gitignore rename to apps/backend/.gitignore index f06235c4..de4d1f00 100644 --- a/backend1/.gitignore +++ b/apps/backend/.gitignore @@ -1,2 +1,2 @@ -node_modules dist +node_modules diff --git a/apps/backend/package-lock.json b/apps/backend/package-lock.json new file mode 100644 index 00000000..486215ea --- /dev/null +++ b/apps/backend/package-lock.json @@ -0,0 +1,786 @@ +{ + "name": "backend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "backend", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "@types/express": "^4.17.21", + "express": "^4.19.2" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.5", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", + "integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", + "integrity": "sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "*" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.0", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.0.tgz", + "integrity": "sha512-bGyep3JqPCRry1wq+O5n7oiBgGWmeIJXPjXXCo8EK0u8duZGSYar7cGqd3ML2JUsLGeB7fmc06KYo9fLGWqPvQ==", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", + "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==" + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==" + }, + "node_modules/@types/node": { + "version": "20.12.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.7.tgz", + "integrity": "sha512-wq0cICSkRLVaf3UGLMGItu/PtdY7oaXaI/RVU+xliKVOtRna3PRY57ZDfztpDL0n11vfymMUnXv8QwYCO7L1wg==", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/qs": { + "version": "6.9.15", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.15.tgz", + "integrity": "sha512-uXHQKES6DQKKCLh441Xv/dwxOq1TVS3JPUMlEqoEglvlhR6Mxnlew/Xq/LRVHpLyk7iK3zODe1qYHIMltO7XGg==" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==" + }, + "node_modules/@types/send": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", + "integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.7", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.7.tgz", + "integrity": "sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "*" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" + }, + "node_modules/body-parser": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", + "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.11.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", + "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" + }, + "node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", + "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", + "dependencies": { + "get-intrinsic": "^1.2.4" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.19.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz", + "integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.2", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.6.0", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.2.0", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.1", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.7", + "proxy-addr": "~2.0.7", + "qs": "6.11.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.18.0", + "serve-static": "1.15.0", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/finalhandler": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", + "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", + "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "hasown": "^2.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", + "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "dependencies": { + "get-intrinsic": "^1.1.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", + "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", + "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", + "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==" + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", + "integrity": "sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", + "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", + "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", + "dependencies": { + "side-channel": "^1.0.4" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "node_modules/send": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", + "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/serve-static": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", + "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", + "dependencies": { + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.18.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" + }, + "node_modules/side-channel": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", + "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", + "dependencies": { + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4", + "object-inspect": "^1.13.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "engines": { + "node": ">= 0.8" + } + } + } +} diff --git a/apps/backend/package.json b/apps/backend/package.json new file mode 100644 index 00000000..1364cd37 --- /dev/null +++ b/apps/backend/package.json @@ -0,0 +1,36 @@ +{ + "name": "backend", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "build": "npx esbuild src/index.ts --bundle --platform=node --outfile=dist/index.js", + "start": "node dist/index.js", + "dev": "npm run build && npm run start", + "lint": "eslint . --ext .ts --max-warnings 0", + "lint:fix": "eslint --fix . --ext .ts" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "@repo/db": "*", + "@types/express-session": "^1.18.0", + "cookie-session": "^2.1.0", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.19.2", + "express-session": "^1.18.0", + "jsonwebtoken": "^9.0.2", + "passport": "^0.7.0", + "passport-github2": "^0.1.12", + "passport-google-oauth20": "^2.0.0" + }, + "devDependencies": { + "@types/cookie-session": "^2.0.49", + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/jsonwebtoken": "^9.0.6", + "@types/passport": "^1.0.16" + } +} diff --git a/apps/backend/src/db/index.ts b/apps/backend/src/db/index.ts new file mode 100644 index 00000000..92cbaf97 --- /dev/null +++ b/apps/backend/src/db/index.ts @@ -0,0 +1,5 @@ +import { PrismaClient } from '@prisma/client'; + +const client = new PrismaClient(); + +export const db = client; diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts new file mode 100644 index 00000000..474ac707 --- /dev/null +++ b/apps/backend/src/index.ts @@ -0,0 +1,44 @@ +import express from 'express'; +import v1Router from './router/v1'; +import cors from 'cors'; +import { initPassport } from './passport'; +import authRoute from './router/auth'; +import dotenv from 'dotenv'; +import session from 'express-session'; +import passport from 'passport'; + +const app = express(); + +dotenv.config(); +app.use( + session({ + secret: process.env.COOKIE_SECRET || 'keyboard cat', + resave: false, + saveUninitialized: false, + cookie: { secure: false, maxAge: 24 * 60 * 60 * 1000 }, + }), +); + +initPassport(); +app.use(passport.initialize()); +app.use(passport.authenticate('session')); + +const allowedHosts = process.env.ALLOWED_HOSTS + ? process.env.ALLOWED_HOSTS.split(',') + : []; + +app.use( + cors({ + origin: allowedHosts, + methods: 'GET,POST,PUT,DELETE', + credentials: true, + }), +); + +app.use('/auth', authRoute); +app.use('/v1', v1Router); + +const PORT = process.env.PORT || 3000; +app.listen(PORT, () => { + console.log(`Server is running on port ${PORT}`); +}); diff --git a/apps/backend/src/passport.ts b/apps/backend/src/passport.ts new file mode 100644 index 00000000..bcbe7d74 --- /dev/null +++ b/apps/backend/src/passport.ts @@ -0,0 +1,123 @@ +const GoogleStrategy = require('passport-google-oauth20').Strategy; +const GithubStrategy = require('passport-github2').Strategy; +import passport from 'passport'; +import dotenv from 'dotenv'; +import { db } from './db'; + +interface GithubEmailRes { + email: string; + primary: boolean; + verified: boolean; + visibility: 'private' | 'public'; +} + +dotenv.config(); +const GOOGLE_CLIENT_ID = + process.env.GOOGLE_CLIENT_ID || 'your_google_client_id'; +const GOOGLE_CLIENT_SECRET = + process.env.GOOGLE_CLIENT_SECRET || 'your_google_client_secret'; +const GITHUB_CLIENT_ID = + process.env.GITHUB_CLIENT_ID || 'your_github_client_id'; +const GITHUB_CLIENT_SECRET = + process.env.GITHUB_CLIENT_SECRET || 'your_github_client_secret'; + +export function initPassport() { + if ( + !GOOGLE_CLIENT_ID || + !GOOGLE_CLIENT_SECRET || + !GITHUB_CLIENT_ID || + !GITHUB_CLIENT_SECRET + ) { + throw new Error( + 'Missing environment variables for authentication providers', + ); + } + + passport.use( + new GoogleStrategy( + { + clientID: GOOGLE_CLIENT_ID, + clientSecret: GOOGLE_CLIENT_SECRET, + callbackURL: '/auth/google/callback', + }, + async function ( + accessToken: string, + refreshToken: string, + profile: any, + done: (error: any, user?: any) => void, + ) { + const user = await db.user.upsert({ + create: { + email: profile.emails[0].value, + name: profile.displayName, + provider: 'GOOGLE', + }, + update: { + name: profile.displayName, + }, + where: { + email: profile.emails[0].value, + }, + }); + + done(null, user); + }, + ), + ); + + passport.use( + new GithubStrategy( + { + clientID: GITHUB_CLIENT_ID, + clientSecret: GITHUB_CLIENT_SECRET, + callbackURL: '/auth/github/callback', + }, + async function ( + accessToken: string, + refreshToken: string, + profile: any, + done: (error: any, user?: any) => void, + ) { + const res = await fetch('https://api.github.com/user/emails', { + headers: { + Authorization: `token ${accessToken}`, + }, + }); + const data: GithubEmailRes[] = await res.json(); + const primaryEmail = data.find((item) => item.primary === true); + + const user = await db.user.upsert({ + create: { + email: primaryEmail!.email, + name: profile.displayName, + provider: 'GITHUB', + }, + update: { + name: profile.displayName, + }, + where: { + email: primaryEmail?.email, + }, + }); + + done(null, user); + }, + ), + ); + + passport.serializeUser(function (user: any, cb) { + process.nextTick(function () { + return cb(null, { + id: user.id, + username: user.username, + picture: user.picture, + }); + }); + }); + + passport.deserializeUser(function (user: any, cb) { + process.nextTick(function () { + return cb(null, user); + }); + }); +} diff --git a/apps/backend/src/router/auth.ts b/apps/backend/src/router/auth.ts new file mode 100644 index 00000000..d84eb85b --- /dev/null +++ b/apps/backend/src/router/auth.ts @@ -0,0 +1,81 @@ +import { Request, Response, Router } from 'express'; +import passport from 'passport'; +import jwt from 'jsonwebtoken'; +import { db } from '../db'; +const router = Router(); + +const CLIENT_URL = + process.env.AUTH_REDIRECT_URL ?? 'http://localhost:5173/game/random'; +const JWT_SECRET = process.env.JWT_SECRET || 'your_secret_key'; + +interface User { + id: string; +} + +router.get('/refresh', async (req: Request, res: Response) => { + if (req.user) { + const user = req.user as User; + + // Token is issued so it can be shared b/w HTTP and ws server + // Todo: Make this temporary and add refresh logic here + + const userDb = await db.user.findFirst({ + where: { + id: user.id, + }, + }); + + const token = jwt.sign({ userId: user.id }, JWT_SECRET); + res.json({ + token, + id: user.id, + name: userDb?.name, + }); + } else { + res.status(401).json({ success: false, message: 'Unauthorized' }); + } +}); + +router.get('/login/failed', (req: Request, res: Response) => { + res.status(401).json({ success: false, message: 'failure' }); +}); + +router.get('/logout', (req: Request, res: Response) => { + req.logout((err) => { + if (err) { + console.error('Error logging out:', err); + res.status(500).json({ error: 'Failed to log out' }); + } else { + res.clearCookie('jwt'); + res.redirect('http://localhost:5173/'); + } + }); +}); + +router.get( + '/google', + passport.authenticate('google', { scope: ['profile', 'email'] }), +); + +router.get( + '/google/callback', + passport.authenticate('google', { + successRedirect: CLIENT_URL, + failureRedirect: '/login/failed', + }), +); + +router.get( + '/github', + passport.authenticate('github', { scope: ['read:user', 'user:email'] }), +); + +router.get( + '/github/callback', + passport.authenticate('github', { + successRedirect: CLIENT_URL, + failureRedirect: '/login/failed', + }), +); + +export default router; diff --git a/apps/backend/src/router/v1.ts b/apps/backend/src/router/v1.ts new file mode 100644 index 00000000..4ac069e8 --- /dev/null +++ b/apps/backend/src/router/v1.ts @@ -0,0 +1,9 @@ +import { Router } from 'express'; + +const v1Router = Router(); + +v1Router.get('/', (req, res) => { + res.send('Hello, World!'); +}); + +export default v1Router; diff --git a/apps/backend/tsconfig.json b/apps/backend/tsconfig.json new file mode 100644 index 00000000..c52a098f --- /dev/null +++ b/apps/backend/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "es2016", + "module": "commonjs", + "rootDir": "./src", + "outDir": "./dist", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true + + }, + "include": [ + "src/**/*", + "routes/**/*", + "**/*.js" +], +"outDir": "dist" +} diff --git a/apps/frontend/.env.example b/apps/frontend/.env.example new file mode 100644 index 00000000..ec8a0eb4 --- /dev/null +++ b/apps/frontend/.env.example @@ -0,0 +1,2 @@ +VITE_APP_WS_URL="ws://localhost:8080" +VITE_APP_BACKEND_URL="http://localhost:3000" \ No newline at end of file diff --git a/frontend/.eslintrc.cjs b/apps/frontend/.eslintrc.cjs similarity index 100% rename from frontend/.eslintrc.cjs rename to apps/frontend/.eslintrc.cjs diff --git a/frontend/.gitignore b/apps/frontend/.gitignore similarity index 97% rename from frontend/.gitignore rename to apps/frontend/.gitignore index a547bf36..d7de12f3 100644 --- a/frontend/.gitignore +++ b/apps/frontend/.gitignore @@ -12,6 +12,8 @@ dist dist-ssr *.local +.env + # Editor directories and files .vscode/* !.vscode/extensions.json diff --git a/frontend/README.md b/apps/frontend/README.md similarity index 99% rename from frontend/README.md rename to apps/frontend/README.md index 0d6babed..9d0b4bcd 100644 --- a/frontend/README.md +++ b/apps/frontend/README.md @@ -22,7 +22,7 @@ export default { project: ['./tsconfig.json', './tsconfig.node.json'], tsconfigRootDir: __dirname, }, -} +}; ``` - Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked` diff --git a/apps/frontend/components.json b/apps/frontend/components.json new file mode 100644 index 00000000..8588ed16 --- /dev/null +++ b/apps/frontend/components.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "src/index.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils" + } +} \ No newline at end of file diff --git a/apps/frontend/index.html b/apps/frontend/index.html new file mode 100644 index 00000000..d5f8e13b --- /dev/null +++ b/apps/frontend/index.html @@ -0,0 +1,17 @@ + + + + + + + Chess - Play Chess Online for Free + + +
+ + + diff --git a/frontend/package-lock.json b/apps/frontend/package-lock.json similarity index 100% rename from frontend/package-lock.json rename to apps/frontend/package-lock.json diff --git a/frontend/package.json b/apps/frontend/package.json similarity index 60% rename from frontend/package.json rename to apps/frontend/package.json index b7b320f7..832f8dc4 100644 --- a/frontend/package.json +++ b/apps/frontend/package.json @@ -10,10 +10,26 @@ "preview": "vite preview" }, "dependencies": { + "@radix-ui/react-accordion": "^1.1.2", + "@radix-ui/react-alert-dialog": "^1.0.5", + "@radix-ui/react-dialog": "^1.0.5", + "@radix-ui/react-icons": "^1.3.0", + "@radix-ui/react-slot": "^1.0.2", + "@repo/store": "*", + "@repo/ui": "*", "chess.js": "^1.0.0-beta.8", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.0", + "history": "^5.3.0", + "lucide-react": "^0.372.0", "react": "^18.2.0", + "react-confetti": "^6.1.0", "react-dom": "^18.2.0", - "react-router-dom": "^6.22.3" + "react-router-dom": "^6.22.3", + "recoil": "^0.7.7", + "tailwind-merge": "^2.3.0", + "tailwindcss-animate": "^1.0.7", + "zustand": "^4.5.2" }, "devDependencies": { "@types/react": "^18.2.66", diff --git a/frontend/postcss.config.js b/apps/frontend/postcss.config.js similarity index 100% rename from frontend/postcss.config.js rename to apps/frontend/postcss.config.js diff --git a/apps/frontend/public/bb.png b/apps/frontend/public/bb.png new file mode 100644 index 00000000..ad1074e7 Binary files /dev/null and b/apps/frontend/public/bb.png differ diff --git a/apps/frontend/public/bk.png b/apps/frontend/public/bk.png new file mode 100644 index 00000000..4317d71c Binary files /dev/null and b/apps/frontend/public/bk.png differ diff --git a/apps/frontend/public/bn.png b/apps/frontend/public/bn.png new file mode 100644 index 00000000..f028dfca Binary files /dev/null and b/apps/frontend/public/bn.png differ diff --git a/apps/frontend/public/bp.png b/apps/frontend/public/bp.png new file mode 100644 index 00000000..84e57bd0 Binary files /dev/null and b/apps/frontend/public/bp.png differ diff --git a/apps/frontend/public/bq.png b/apps/frontend/public/bq.png new file mode 100644 index 00000000..a9e70998 Binary files /dev/null and b/apps/frontend/public/bq.png differ diff --git a/apps/frontend/public/br.png b/apps/frontend/public/br.png new file mode 100644 index 00000000..d54910e0 Binary files /dev/null and b/apps/frontend/public/br.png differ diff --git a/apps/frontend/public/capture.wav b/apps/frontend/public/capture.wav new file mode 100644 index 00000000..0be36ae8 Binary files /dev/null and b/apps/frontend/public/capture.wav differ diff --git a/apps/frontend/public/chess.png b/apps/frontend/public/chess.png new file mode 100644 index 00000000..3ac9fd3d Binary files /dev/null and b/apps/frontend/public/chess.png differ diff --git a/frontend/public/chessboard.jpeg b/apps/frontend/public/chessboard.jpeg similarity index 100% rename from frontend/public/chessboard.jpeg rename to apps/frontend/public/chessboard.jpeg diff --git a/apps/frontend/public/computer.png b/apps/frontend/public/computer.png new file mode 100644 index 00000000..ff63d23b Binary files /dev/null and b/apps/frontend/public/computer.png differ diff --git a/apps/frontend/public/facebook.png b/apps/frontend/public/facebook.png new file mode 100644 index 00000000..63e240cb Binary files /dev/null and b/apps/frontend/public/facebook.png differ diff --git a/apps/frontend/public/friendship.png b/apps/frontend/public/friendship.png new file mode 100644 index 00000000..c2205a77 Binary files /dev/null and b/apps/frontend/public/friendship.png differ diff --git a/apps/frontend/public/github.svg b/apps/frontend/public/github.svg new file mode 100644 index 00000000..9c0bd586 --- /dev/null +++ b/apps/frontend/public/github.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/frontend/public/google.svg b/apps/frontend/public/google.svg new file mode 100644 index 00000000..c599462c --- /dev/null +++ b/apps/frontend/public/google.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/frontend/public/lightning-bolt.png b/apps/frontend/public/lightning-bolt.png new file mode 100644 index 00000000..3ec138b7 Binary files /dev/null and b/apps/frontend/public/lightning-bolt.png differ diff --git a/apps/frontend/public/move.wav b/apps/frontend/public/move.wav new file mode 100644 index 00000000..87106ac8 Binary files /dev/null and b/apps/frontend/public/move.wav differ diff --git a/apps/frontend/public/strategy.png b/apps/frontend/public/strategy.png new file mode 100644 index 00000000..d67ad66d Binary files /dev/null and b/apps/frontend/public/strategy.png differ diff --git a/apps/frontend/public/theme.svg b/apps/frontend/public/theme.svg new file mode 100644 index 00000000..2ae0b3e6 --- /dev/null +++ b/apps/frontend/public/theme.svg @@ -0,0 +1 @@ + image/svg+xml \ No newline at end of file diff --git a/apps/frontend/public/trophy.png b/apps/frontend/public/trophy.png new file mode 100644 index 00000000..d9aca358 Binary files /dev/null and b/apps/frontend/public/trophy.png differ diff --git a/frontend/public/vite.svg b/apps/frontend/public/vite.svg similarity index 100% rename from frontend/public/vite.svg rename to apps/frontend/public/vite.svg diff --git a/apps/frontend/public/wb.png b/apps/frontend/public/wb.png new file mode 100644 index 00000000..a201318c Binary files /dev/null and b/apps/frontend/public/wb.png differ diff --git a/apps/frontend/public/wk.png b/apps/frontend/public/wk.png new file mode 100644 index 00000000..b9f9a460 Binary files /dev/null and b/apps/frontend/public/wk.png differ diff --git a/apps/frontend/public/wn.png b/apps/frontend/public/wn.png new file mode 100644 index 00000000..f780d9ef Binary files /dev/null and b/apps/frontend/public/wn.png differ diff --git a/apps/frontend/public/wp.png b/apps/frontend/public/wp.png new file mode 100644 index 00000000..cd73bf66 Binary files /dev/null and b/apps/frontend/public/wp.png differ diff --git a/apps/frontend/public/wq.png b/apps/frontend/public/wq.png new file mode 100644 index 00000000..d31ac462 Binary files /dev/null and b/apps/frontend/public/wq.png differ diff --git a/apps/frontend/public/wr.png b/apps/frontend/public/wr.png new file mode 100644 index 00000000..0c181455 Binary files /dev/null and b/apps/frontend/public/wr.png differ diff --git a/apps/frontend/src/App.css b/apps/frontend/src/App.css new file mode 100644 index 00000000..08b4b959 --- /dev/null +++ b/apps/frontend/src/App.css @@ -0,0 +1,45 @@ +.loader { + width: 100%; + height: 100vh; +} + +.loader__main { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + gap: 50px; +} + +.loader__dot { + width: 20px; + height: 20px; + background-color: white; + border-radius: 20px; + transform: translate(0px, -40px); + animation: bounce 1s infinite; +} + +.loader__dot:nth-child(2) { + animation-delay: 0.2s; +} + +.loader__dot:nth-child(3) { + animation-delay: 0.4s; +} + +@keyframes bounce { + 0% { + transform: translate(0px, -10px); + } + 40% { + width: 0px; + height: 2px; + transform: translate(0px, 40px) scale(1.7); + } + 100% { + height: 20px; + transform: translate(0px, -20px); + } +} \ No newline at end of file diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx new file mode 100644 index 00000000..8d020fc9 --- /dev/null +++ b/apps/frontend/src/App.tsx @@ -0,0 +1,60 @@ + +/* eslint-disable @typescript-eslint/no-unused-vars */ + +import "./App.css"; +import "./themes.css"; + +import { BrowserRouter, Route, Routes } from 'react-router-dom'; +import { Landing } from './screens/Landing'; +import { Game } from './screens/Game'; +import Login from './screens/Login'; +import { Suspense } from 'react'; +import { RecoilRoot } from 'recoil'; +import { Loader } from './components/Loader'; +import { Layout } from './layout'; +import { Settings } from './screens/Settings'; +import { Themes } from "./components/themes"; +import { ThemesProvider } from "./context/themeContext"; + +function App() { + return ( +
+ + }> + + + + + +
+ ); +} + +function AuthApp() { + return ( + + + } + /> + } + /> + } + /> + } + > + } /> + + + + ); +} + +export default App; diff --git a/apps/frontend/src/components/BackgroundSvg.tsx b/apps/frontend/src/components/BackgroundSvg.tsx new file mode 100644 index 00000000..af311253 --- /dev/null +++ b/apps/frontend/src/components/BackgroundSvg.tsx @@ -0,0 +1,113 @@ +export default function BackgroundSvg() { + return ( + + + + + + + + + + + + + + + + + + + ); +} diff --git a/apps/frontend/src/components/Button.tsx b/apps/frontend/src/components/Button.tsx new file mode 100644 index 00000000..610f426a --- /dev/null +++ b/apps/frontend/src/components/Button.tsx @@ -0,0 +1,18 @@ +export const Button = ({ + onClick, + children, + className, +}: { + onClick: () => void; + children: React.ReactNode; + className?: string; +}) => { + return ( + + ); +}; diff --git a/apps/frontend/src/components/Card.tsx b/apps/frontend/src/components/Card.tsx new file mode 100644 index 00000000..caeebe03 --- /dev/null +++ b/apps/frontend/src/components/Card.tsx @@ -0,0 +1,103 @@ +import { useNavigate } from 'react-router-dom'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import chessIcon from '../../public/chess.png'; +import computerIcon from '../../public/computer.png'; +import lightningIcon from '../../public/lightning-bolt.png'; +import friendIcon from '../../public/friendship.png'; +import tournamentIcon from '../../public/trophy.png'; +import variantsIcon from '../../public/strategy.png'; +import GameModeComponent from './GameModeComponent'; + +export function PlayCard() { + const gameModeData = [ + { + icon: ( + online + ), + title: 'Play Online', + description: 'Play vs a Person of Similar Skill', + onClick: () => { + navigate('/game/random'); + }, + disabled: false, + }, + { + icon: ( + computer + ), + title: 'Computer', + description: 'Challenge a bot from easy to master', + disabled: true, + }, + { + icon: ( + friend + ), + title: 'Play a Friend', + description: 'Invite a Friend to a game of Chess', + disabled: true, + }, + { + icon: ( + tournament + ), + title: 'Tournaments', + description: 'Join an Arena where anyone can Win', + disabled: true, + }, + { + icon: ( + variants + ), + title: 'Chess Variants', + description: 'Find Fun New ways to play chess', + disabled: true, + }, + ]; + + const navigate = useNavigate(); + return ( + + + +

+ Play Chess +

+ chess +
+ +
+ + {gameModeData.map((data) => { + return ; + })} + +
+ ); +} diff --git a/apps/frontend/src/components/ChessBoard.tsx b/apps/frontend/src/components/ChessBoard.tsx new file mode 100644 index 00000000..179de87a --- /dev/null +++ b/apps/frontend/src/components/ChessBoard.tsx @@ -0,0 +1,399 @@ +import { Chess, Color, Move, PieceSymbol, Square } from 'chess.js'; +import { MouseEvent, memo, useEffect, useState } from 'react'; +import { MOVE } from '../screens/Game'; +import LetterNotation from './chess-board/LetterNotation'; +import LegalMoveIndicator from './chess-board/LegalMoveIndicator'; +import ChessSquare from './chess-board/ChessSquare'; +import NumberNotation from './chess-board/NumberNotation'; +import { drawArrow } from '../utils/canvas'; +import useWindowSize from '../hooks/useWindowSize'; +import Confetti from 'react-confetti'; +import MoveSound from '/move.wav'; +import CaptureSound from '/capture.wav'; + +import { useRecoilState } from 'recoil'; + +import { + isBoardFlippedAtom, + movesAtom, + userSelectedMoveIndexAtom, +} from '@repo/store/chessBoard'; + +export function isPromoting(chess: Chess, from: Square, to: Square) { + if (!from) { + return false; + } + + const piece = chess.get(from); + + if (piece?.type !== 'p') { + return false; + } + + if (piece.color !== chess.turn()) { + return false; + } + + if (!['1', '8'].some((it) => to.endsWith(it))) { + return false; + } + + return chess + .history({ verbose: true }) + .map((it) => it.to) + .includes(to); +} + +export const ChessBoard = memo(({ + gameId, + started, + myColor, + chess, + board, + socket, + setBoard, +}: { + myColor: Color; + gameId: string; + started: boolean; + chess: Chess; + setBoard: React.Dispatch< + React.SetStateAction< + ({ + square: Square; + type: PieceSymbol; + color: Color; + } | null)[][] + > + >; + board: ({ + square: Square; + type: PieceSymbol; + color: Color; + } | null)[][]; + socket: WebSocket; +}) => { + console.log("chessboard reloaded") + + const [isFlipped, setIsFlipped] = useRecoilState(isBoardFlippedAtom); + const [userSelectedMoveIndex, setUserSelectedMoveIndex] = useRecoilState( + userSelectedMoveIndexAtom, + ); + const [moves, setMoves] = useRecoilState(movesAtom); + const [lastMove, setLastMove] = useState<{ from: string; to: string } | null>( + null, + ); + const [rightClickedSquares, setRightClickedSquares] = useState([]); + const [arrowStart, setArrowStart] = useState(null); + + const [from, setFrom] = useState(null); + const isMyTurn = myColor === chess.turn(); + const [legalMoves, setLegalMoves] = useState([]); + + const labels = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']; + const [canvas, setCanvas] = useState(null); + const boxSize = 80; + const [gameOver, setGameOver] = useState(false); + const moveAudio = new Audio(MoveSound); + const captureAudio = new Audio(CaptureSound); + + const handleMouseDown = ( + e: MouseEvent, + squareRep: string, + ) => { + e.preventDefault(); + if (e.button === 2) { + setArrowStart(squareRep); + } + }; + + useEffect(() => { + if (myColor === 'b') { + setIsFlipped(true); + } + }, [myColor]); + + const clearCanvas = () => { + setRightClickedSquares([]); + if (canvas) { + const ctx = canvas.getContext('2d'); + ctx?.clearRect(0, 0, canvas.width, canvas.height); + } + }; + + const handleRightClick = (squareRep: string) => { + if (rightClickedSquares.includes(squareRep)) { + setRightClickedSquares((prev) => prev.filter((sq) => sq !== squareRep)); + } else { + setRightClickedSquares((prev) => [...prev, squareRep]); + } + }; + + const handleDrawArrow = (squareRep: string) => { + if (arrowStart) { + const stoppedAtSquare = squareRep; + if (canvas) { + const ctx = canvas.getContext('2d'); + if (ctx) { + drawArrow({ + ctx, + start: arrowStart, + end: stoppedAtSquare, + isFlipped, + squareSize: boxSize, + }); + } + } + setArrowStart(null); + } + }; + + const handleMouseUp = (e: MouseEvent, squareRep: string) => { + e.preventDefault(); + if (!started) { + return; + } + if (e.button === 2) { + if (arrowStart === squareRep) { + handleRightClick(squareRep); + } else { + handleDrawArrow(squareRep); + } + } else { + clearCanvas(); + } + }; + + useEffect(() => { + clearCanvas(); + const lMove = moves.at(-1); + if (lMove) { + setLastMove({ + from: lMove.from, + to: lMove.to, + }); + } else { + setLastMove(null); + } + }, [moves]); + + useEffect(() => { + if (userSelectedMoveIndex !== null) { + const move = moves[userSelectedMoveIndex]; + setLastMove({ + from: move.from, + to: move.to, + }); + chess.load(move.after); + setBoard(chess.board()); + return; + } + }, [userSelectedMoveIndex]); + + useEffect(() => { + if (userSelectedMoveIndex !== null) { + chess.reset(); + moves.forEach((move) => { + chess.move({ from: move.from, to: move.to }); + }); + setBoard(chess.board()); + setUserSelectedMoveIndex(null); + } else { + setBoard(chess.board()); + } + }, [moves]); + + return ( + <> + {gameOver && } +
+
+ {(isFlipped ? board.slice().reverse() : board).map((row, i) => { + i = isFlipped ? i + 1 : 8 - i; + return ( +
+ + {(isFlipped ? row.slice().reverse() : row).map((square, j) => { + j = isFlipped ? 7 - (j % 8) : j % 8; + + const isMainBoxColor = (i + j) % 2 !== 0; + const isPiece: boolean = !!square; + const squareRepresentation = (String.fromCharCode(97 + j) + + '' + + i) as Square; + const isHighlightedSquare = + from === squareRepresentation || + squareRepresentation === lastMove?.from || + squareRepresentation === lastMove?.to; + const isRightClickedSquare = + rightClickedSquares.includes(squareRepresentation); + + const piece = square && square.type; + const isKingInCheckSquare = + piece === 'k' && + square?.color === chess.turn() && + chess.inCheck(); + + return ( +
{ + if (!started) { + return; + } + if (userSelectedMoveIndex !== null) { + chess.reset(); + moves.forEach((move) => { + chess.move({ from: move.from, to: move.to }); + }); + setBoard(chess.board()); + setUserSelectedMoveIndex(null); + return; + } + if (!from && square?.color !== chess.turn()) return; + if (!isMyTurn) return; + if (from != squareRepresentation) { + setFrom(squareRepresentation); + if (isPiece) { + setLegalMoves( + chess + .moves({ + verbose: true, + square: square?.square, + }) + .map((move) => move.to), + ); + } + } else { + setFrom(null); + } + if (!isPiece) { + setLegalMoves([]); + } + + if (!from) { + setFrom(squareRepresentation); + setLegalMoves( + chess + .moves({ + verbose: true, + square: square?.square, + }) + .map((move) => move.to), + ); + } else { + try { + let moveResult: Move; + if ( + isPromoting(chess, from, squareRepresentation) + ) { + moveResult = chess.move({ + from, + to: squareRepresentation, + promotion: 'q', + }); + } else { + moveResult = chess.move({ + from, + to: squareRepresentation, + }); + } + if (moveResult) { + moveAudio.play(); + + if (moveResult?.captured) { + captureAudio.play(); + } + setMoves((prev) => [...prev, moveResult]); + setFrom(null); + setLegalMoves([]); + if (moveResult.san.includes('#')) { + setGameOver(true); + } + socket.send( + JSON.stringify({ + type: MOVE, + payload: { + gameId, + move: moveResult, + }, + }), + ); + } + } catch (e) { + console.log('e', e); + } + } + }} + style={{ + width: boxSize, + height: boxSize, + }} + key={j} + className={`${isRightClickedSquare ? (isMainBoxColor ? 'bg-[#CF664E]' : 'bg-[#E87764]') : isKingInCheckSquare ? 'bg-[#FF6347]' : isHighlightedSquare ? `${isMainBoxColor ? 'bg-[#BBCB45]' : 'bg-[#F4F687]'}` : isMainBoxColor ? 'bg-boardDark' : 'bg-boardLight'} ${''}`} + onContextMenu={(e) => { + e.preventDefault(); + }} + onMouseDown={(e) => { + handleMouseDown(e, squareRepresentation); + }} + onMouseUp={(e) => { + handleMouseUp(e, squareRepresentation); + }} + > +
+ {square && } + {isFlipped + ? i === 8 && ( + + ) + : i === 1 && ( + + )} + {!!from && + legalMoves.includes(squareRepresentation) && ( + + )} +
+
+ ); + })} +
+ ); + })} +
+ + setCanvas(ref)} + width={boxSize * 8} + height={boxSize * 8} + style={{ + position: 'absolute', + top: 0, + left: 0, + pointerEvents: 'none', + }} + onContextMenu={(e) => e.preventDefault()} + onMouseDown={(e) => { + e.preventDefault(); + }} + onMouseUp={(e) => e.preventDefault()} + > +
+ + ); + +}; + + diff --git a/apps/frontend/src/components/ExitGameModel.tsx b/apps/frontend/src/components/ExitGameModel.tsx new file mode 100644 index 00000000..2a5d22e6 --- /dev/null +++ b/apps/frontend/src/components/ExitGameModel.tsx @@ -0,0 +1,39 @@ +import { + AlertDialogAction, + AlertDialogCancel, + AlertDialogTitle, + AlertDialogHeader, + AlertDialogFooter, + AlertDialog, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogDescription, +} from './ui/alert-dialog'; + +const ExitGameModel = ({ onClick } : {onClick : () => void}) => { + + return ( + + Exit + + + Are you absolutely sure? + + This action cannot be undone. This will be considered as a loss. + + + + Continue + + Exit + + + + + ); +}; + +export default ExitGameModel; diff --git a/apps/frontend/src/components/Footer.tsx b/apps/frontend/src/components/Footer.tsx new file mode 100644 index 00000000..d7240c21 --- /dev/null +++ b/apps/frontend/src/components/Footer.tsx @@ -0,0 +1,34 @@ +import { + GitHubLogoIcon, + VideoIcon, + TwitterLogoIcon, +} from '@radix-ui/react-icons'; +import { Link } from 'react-router-dom'; + +export const Footer = () => { + return ( +
+
+
+ Home | + Settings | + Login | + Play +
+
+ +
+
+
+ ); +}; diff --git a/apps/frontend/src/components/GameEndModal.tsx b/apps/frontend/src/components/GameEndModal.tsx new file mode 100644 index 00000000..428caade --- /dev/null +++ b/apps/frontend/src/components/GameEndModal.tsx @@ -0,0 +1,107 @@ +import React, { useState } from 'react'; +import WhiteKing from '../../public/wk.png'; +import BlackKing from '../../public/bk.png'; +import { GameResult, Result } from '@/screens/Game'; + +interface ModalProps { + blackPlayer?: { id: string; name: string }; + whitePlayer?: { id: string; name: string }; + gameResult: GameResult; +} + +const GameEndModal: React.FC = ({ + blackPlayer, + whitePlayer, + gameResult, +}) => { + const [isOpen, setIsOpen] = useState(true); + + const closeModal = () => { + setIsOpen(false); + }; + + const PlayerDisplay = ({ + player, + gameResult, + isWhite, + }: { + player?: { id: string; name: string }; + gameResult: Result; + isWhite: boolean; + }) => { + const imageSrc = isWhite ? WhiteKing : BlackKing; + const borderColor = + gameResult === (isWhite ? Result.WHITE_WINS : Result.BLACK_WINS) + ? 'border-green-400' + : 'border-red-400'; + + return ( +
+
+ {`${isWhite +
+
+

+ {getPlayerName(player)} +

+
+
+ ); + }; + + const getWinnerMessage = (result: Result) => { + switch (result) { + case Result.BLACK_WINS: + return 'Black Wins!'; + case Result.WHITE_WINS: + return 'White Wins!'; + default: + return "It's a Draw"; + } + }; + + const getPlayerName = (player: { id: string; name: string } | undefined) => { + return player ? player.name : 'Unknown'; + }; + + return ( +
+ {isOpen && ( +
+
+
+
+
+

+ {getWinnerMessage(gameResult.result)} +

+
+
+

by {gameResult.by}

+
+
+ +
vs
+ +
+
+
+ +
+
+
+ )} +
+ ); +}; + +export default GameEndModal; diff --git a/apps/frontend/src/components/GameModeComponent.tsx b/apps/frontend/src/components/GameModeComponent.tsx new file mode 100644 index 00000000..d0fb9c5b --- /dev/null +++ b/apps/frontend/src/components/GameModeComponent.tsx @@ -0,0 +1,36 @@ +import { ReactNode, MouseEventHandler } from 'react'; + +interface GameModeComponent { + icon: ReactNode; + title: string; + description: string; + onClick?: MouseEventHandler; + disabled: boolean; +} + +const GameModeComponent = ({ + icon, + title, + description, + onClick, + disabled, +}: GameModeComponent) => ( +
+ {icon} + +
+

+ {title} +

+

{description}

+ {disabled && ( +

Coming Soon ...

+ )} +
+
+); + +export default GameModeComponent; diff --git a/apps/frontend/src/components/Loader.tsx b/apps/frontend/src/components/Loader.tsx new file mode 100644 index 00000000..7c7dd82a --- /dev/null +++ b/apps/frontend/src/components/Loader.tsx @@ -0,0 +1,11 @@ +export const Loader = () => { + return ( +
+
+
+
+
+
+
+ ) +}; diff --git a/apps/frontend/src/components/MovesTable.tsx b/apps/frontend/src/components/MovesTable.tsx new file mode 100644 index 00000000..a657a127 --- /dev/null +++ b/apps/frontend/src/components/MovesTable.tsx @@ -0,0 +1,158 @@ +import { + isBoardFlippedAtom, + movesAtom, + userSelectedMoveIndexAtom, +} from '@repo/store/chessBoard'; +import { Move } from 'chess.js'; +import { useEffect, useRef } from 'react'; +import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil'; +import { + HandshakeIcon, + FlagIcon, + ChevronFirst, + ChevronLast, + ChevronLeft, + ChevronRight, + RefreshCw, +} from 'lucide-react'; + +const MovesTable = () => { + const [userSelectedMoveIndex, setUserSelectedMoveIndex] = useRecoilState( + userSelectedMoveIndexAtom, + ); + const setIsFlipped = useSetRecoilState(isBoardFlippedAtom); + const moves = useRecoilValue(movesAtom); + const movesTableRef = useRef(null); + const movesArray = moves.reduce((result, _, index: number, array: Move[]) => { + if (index % 2 === 0) { + result.push(array.slice(index, index + 2)); + } + return result; + }, [] as Move[][]); + + useEffect(() => { + if (movesTableRef && movesTableRef.current) { + movesTableRef.current.scrollTo({ + top: movesTableRef.current.scrollHeight, + behavior: 'smooth', + }); + } + }, [moves]); + return ( +
+
+ {movesArray.map((movePairs, index) => { + return ( +
+
+ {`${index + 1}.`} + + {movePairs.map((move, movePairIndex) => { + const isLastIndex = + movePairIndex === movePairs.length - 1 && + movesArray.length - 1 === index; + const isHighlighted = + userSelectedMoveIndex !== null + ? userSelectedMoveIndex === index * 2 + movePairIndex + : isLastIndex; + const { san } = move; + + return ( +
{ + setUserSelectedMoveIndex(index * 2 + movePairIndex); + }} + > + {san} +
+ ); + })} +
+
+ ); + })} +
+ {moves.length ? ( +
+
+ + +
+
+ + + + + + +
+
+ ) : null} +
+ ); +}; + +export default MovesTable; diff --git a/apps/frontend/src/components/Navbar.tsx b/apps/frontend/src/components/Navbar.tsx new file mode 100644 index 00000000..ce3a3950 --- /dev/null +++ b/apps/frontend/src/components/Navbar.tsx @@ -0,0 +1,27 @@ +import { MobileSidebar } from '@/components/mobile-sidebar'; +import { Button } from './ui/button'; +import { useNavigate } from 'react-router-dom'; + +export default function Navbar() { + const navigate = useNavigate(); + return ( +
+ +
+ ); +} diff --git a/apps/frontend/src/components/ShareGame.tsx b/apps/frontend/src/components/ShareGame.tsx new file mode 100644 index 00000000..8c13a69a --- /dev/null +++ b/apps/frontend/src/components/ShareGame.tsx @@ -0,0 +1,43 @@ +import { useState } from "react" +import { Button } from "./Button"; + +export const ShareGame = ({className,gameId}:{className?:string,gameId:string}) => { + + const url = window.origin+"/game/"+gameId; + const [copied,setCopied] = useState(false); + + const handleCopy = ()=>{ + window.navigator.clipboard.writeText(url); + setCopied((p)=>true); + } + + return ( +
+ +

+ Play with Friends +

+ +
+ + + +
+ {url} +
+
+ + +
+ ) +} + +const LinkSvg = () => { + return ( + + + + ) +} \ No newline at end of file diff --git a/apps/frontend/src/components/UserAvatar.tsx b/apps/frontend/src/components/UserAvatar.tsx new file mode 100644 index 00000000..1da59d78 --- /dev/null +++ b/apps/frontend/src/components/UserAvatar.tsx @@ -0,0 +1,3 @@ +export const UserAvatar = ({ name }: { name: string }) => { + return
{name}
; +}; diff --git a/apps/frontend/src/components/chess-board/ChessSquare.tsx b/apps/frontend/src/components/chess-board/ChessSquare.tsx new file mode 100644 index 00000000..5556708b --- /dev/null +++ b/apps/frontend/src/components/chess-board/ChessSquare.tsx @@ -0,0 +1,24 @@ +import { Color, PieceSymbol, Square } from 'chess.js'; + +const ChessSquare = ({ + square, +}: { + square: { + square: Square; + type: PieceSymbol; + color: Color; + }; +}) => { + return ( +
+ {square ? ( + + ) : null} +
+ ); +}; + +export default ChessSquare; \ No newline at end of file diff --git a/apps/frontend/src/components/chess-board/LegalMoveIndicator.tsx b/apps/frontend/src/components/chess-board/LegalMoveIndicator.tsx new file mode 100644 index 00000000..2cde48e0 --- /dev/null +++ b/apps/frontend/src/components/chess-board/LegalMoveIndicator.tsx @@ -0,0 +1,23 @@ +const LegalMoveIndicator = ({ + isPiece, + isMainBoxColor, +}: { + isPiece: boolean; + isMainBoxColor: boolean; +}) => { + return ( +
+ {isPiece ? ( +
+ ) : ( +
+ )} +
+ ); +}; + +export default LegalMoveIndicator; diff --git a/apps/frontend/src/components/chess-board/LetterNotation.tsx b/apps/frontend/src/components/chess-board/LetterNotation.tsx new file mode 100644 index 00000000..d34c7c35 --- /dev/null +++ b/apps/frontend/src/components/chess-board/LetterNotation.tsx @@ -0,0 +1,17 @@ +const LetterNotation = ({ + label, + isMainBoxColor, +}: { + label: string; + isMainBoxColor: boolean; +}) => { + return ( +
+ {label} +
+ ); +}; + +export default LetterNotation; diff --git a/apps/frontend/src/components/chess-board/NumberNotation.tsx b/apps/frontend/src/components/chess-board/NumberNotation.tsx new file mode 100644 index 00000000..7fb4149e --- /dev/null +++ b/apps/frontend/src/components/chess-board/NumberNotation.tsx @@ -0,0 +1,17 @@ +const NumberNotation = ({ + label, + isMainBoxColor, +}: { + label: string; + isMainBoxColor: boolean; +}) => { + return ( +
+ {label} +
+ ); +}; + +export default NumberNotation; diff --git a/apps/frontend/src/components/constants/side-nav.tsx b/apps/frontend/src/components/constants/side-nav.tsx new file mode 100644 index 00000000..39c09ccd --- /dev/null +++ b/apps/frontend/src/components/constants/side-nav.tsx @@ -0,0 +1,45 @@ +import { PuzzleIcon, LogInIcon, LogOutIcon, SettingsIcon } from 'lucide-react'; +const BACKEND_URL = + import.meta.env.VITE_APP_BACKEND_URL ?? 'http://localhost:3000'; +export const UpperNavItems = [ + { + title: 'Play', + icon: PuzzleIcon, + href: '/game/random', + color: 'text-green-500', + }, + // + // { + // title: 'Puzzles', + // icon: PuzzleIcon, + // href: '/', + // color: 'text-sky-500', + // }, + // { + // title: 'Learn', + // icon: PuzzleIcon, + // href: '/', + // color: 'text-sky-500', + // }, +]; + +export const LowerNavItems = [ + { + title: 'Login', + icon: LogInIcon, + href: '/login', + color: 'text-green-500', + }, + { + title: 'Logout', + icon: LogOutIcon, + href: `${BACKEND_URL}/auth/logout`, + color: 'text-green-500', + }, + { + title: 'Settings', + icon: SettingsIcon, + href: '/settings', + color: 'text-green-500', + }, +]; diff --git a/apps/frontend/src/components/mobile-sidebar.tsx b/apps/frontend/src/components/mobile-sidebar.tsx new file mode 100644 index 00000000..d131ca57 --- /dev/null +++ b/apps/frontend/src/components/mobile-sidebar.tsx @@ -0,0 +1,45 @@ +import { useState, useEffect } from 'react'; +import { MenuIcon } from 'lucide-react'; +import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet'; +import { SideNav } from '@/components/side-nav'; +import { UpperNavItems, LowerNavItems } from '@/components/constants/side-nav'; + +export const MobileSidebar = () => { + const [open, setOpen] = useState(false); + const [isMounted, setIsMounted] = useState(false); + + useEffect(() => { + setIsMounted(true); + }, []); + + if (!isMounted) { + return null; + } + + return ( + <> + + +
+ +

100xchess

+
+
+ +
+

+ 100xchess +

+ +
+
+ +
+
+
+ + ); +}; diff --git a/apps/frontend/src/components/side-nav.tsx b/apps/frontend/src/components/side-nav.tsx new file mode 100644 index 00000000..70abf2d0 --- /dev/null +++ b/apps/frontend/src/components/side-nav.tsx @@ -0,0 +1,147 @@ +import { cn } from '@/lib/utils'; +import { useSidebar } from '@/hooks/useSidebar'; +import { buttonVariants } from '@/components/ui/button'; +import { useLocation } from 'react-router-dom'; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from '@/components/subnav-accordian'; +import { useEffect, useState } from 'react'; +import { ChevronDownIcon } from '@radix-ui/react-icons'; +import { type LucideIcon } from 'lucide-react'; +import { useUser } from '@repo/store/useUser'; + +export interface NavItem { + title: string; + href: string; + icon: LucideIcon; + color?: string; + isChidren?: boolean; + children?: NavItem[]; +} + +interface SideNavProps { + items: NavItem[]; + setOpen?: (open: boolean) => void; + className?: string; +} + +export function SideNav({ items, setOpen, className }: SideNavProps) { + const user = useUser(); + const location = useLocation(); + const { isOpen } = useSidebar(); + const [openItem, setOpenItem] = useState(''); + const [lastOpenItem, setLastOpenItem] = useState(''); + + useEffect(() => { + if (isOpen) { + setOpenItem(lastOpenItem); + } else { + setLastOpenItem(openItem); + setOpenItem(''); + } + }, [isOpen]); + + return ( + + ); +} diff --git a/apps/frontend/src/components/sidebar.tsx b/apps/frontend/src/components/sidebar.tsx new file mode 100644 index 00000000..28bbcc29 --- /dev/null +++ b/apps/frontend/src/components/sidebar.tsx @@ -0,0 +1,65 @@ +import { useEffect } from 'react'; +import { SideNav } from '@/components/side-nav'; +import { UpperNavItems, LowerNavItems } from '@/components/constants/side-nav'; + +import { cn } from '@/lib/utils'; +import { useSidebar } from '@/hooks/useSidebar'; +interface SidebarProps { + className?: string; +} + +export default function Sidebar({ className }: SidebarProps) { + const { isOpen, toggle } = useSidebar(); + useEffect(() => { + const handleResize = () => { + const screenWidth = window.innerWidth; + const isBetweenMDAndLG = screenWidth >= 768 && screenWidth < 1024; + if (isBetweenMDAndLG) { + if (isOpen) { + toggle(); + } + } else { + if (!isOpen) { + toggle(); + } + } + }; + + handleResize(); + window.addEventListener('resize', handleResize); + + return () => { + window.removeEventListener('resize', handleResize); + }; + }, [isOpen, toggle]); + return ( + + ); +} diff --git a/apps/frontend/src/components/subnav-accordian.tsx b/apps/frontend/src/components/subnav-accordian.tsx new file mode 100644 index 00000000..723b37bf --- /dev/null +++ b/apps/frontend/src/components/subnav-accordian.tsx @@ -0,0 +1,53 @@ +import * as React from 'react'; +import * as AccordionPrimitive from '@radix-ui/react-accordion'; + +import { cn } from '@/lib/utils'; + +const Accordion = AccordionPrimitive.Root; + +const AccordionItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AccordionItem.displayName = 'AccordionItem'; + +const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + svg]:rotate-180', + className, + )} + {...props} + > + {children} + + +)); +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName; + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
{children}
+
+)); +AccordionContent.displayName = AccordionPrimitive.Content.displayName; + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; diff --git a/apps/frontend/src/components/themes.tsx b/apps/frontend/src/components/themes.tsx new file mode 100644 index 00000000..3ec0ed8f --- /dev/null +++ b/apps/frontend/src/components/themes.tsx @@ -0,0 +1,56 @@ +import { THEMES_DATA } from "@/constants/themes"; +import { useThemeContext } from "@/hooks/useThemes"; + +export function Themes() { + const { updateTheme } = useThemeContext(); + return ( +
+
+ { + THEMES_DATA.map(theme => { + return ( +
{ + updateTheme(theme.name); + }} + > +
+

{theme.name}

+
+
+ chess-piece + chess-piece + chess-piece + chess-piece +
+
+ ) + }) + } +
+
+ ) +} \ No newline at end of file diff --git a/apps/frontend/src/components/ui/alert-dialog.tsx b/apps/frontend/src/components/ui/alert-dialog.tsx new file mode 100644 index 00000000..82ed0ab9 --- /dev/null +++ b/apps/frontend/src/components/ui/alert-dialog.tsx @@ -0,0 +1,139 @@ +import * as React from 'react'; +import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'; + +import { cn } from '@/lib/utils'; +import { buttonVariants } from '@/components/ui/button'; + +const AlertDialog = AlertDialogPrimitive.Root; + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger; + +const AlertDialogPortal = AlertDialogPrimitive.Portal; + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName; + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)); +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName; + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +AlertDialogHeader.displayName = 'AlertDialogHeader'; + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +AlertDialogFooter.displayName = 'AlertDialogFooter'; + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName; + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName; + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName; + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName; + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +}; diff --git a/apps/frontend/src/components/ui/button.tsx b/apps/frontend/src/components/ui/button.tsx new file mode 100644 index 00000000..0ba42773 --- /dev/null +++ b/apps/frontend/src/components/ui/button.tsx @@ -0,0 +1,56 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground hover:bg-destructive/90", + outline: + "border border-input bg-background hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-10 px-4 py-2", + sm: "h-9 rounded-md px-3", + lg: "h-11 rounded-md px-8", + icon: "h-10 w-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button" + return ( + + ) + } +) +Button.displayName = "Button" + +export { Button, buttonVariants } diff --git a/apps/frontend/src/components/ui/card.tsx b/apps/frontend/src/components/ui/card.tsx new file mode 100644 index 00000000..afa13ecf --- /dev/null +++ b/apps/frontend/src/components/ui/card.tsx @@ -0,0 +1,79 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +Card.displayName = "Card" + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardHeader.displayName = "CardHeader" + +const CardTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardTitle.displayName = "CardTitle" + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardDescription.displayName = "CardDescription" + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardContent.displayName = "CardContent" + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardFooter.displayName = "CardFooter" + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } diff --git a/apps/frontend/src/components/ui/sheet.tsx b/apps/frontend/src/components/ui/sheet.tsx new file mode 100644 index 00000000..eeb6f5eb --- /dev/null +++ b/apps/frontend/src/components/ui/sheet.tsx @@ -0,0 +1,138 @@ +import * as React from 'react'; +import * as SheetPrimitive from '@radix-ui/react-dialog'; +import { cva, type VariantProps } from 'class-variance-authority'; +import { X } from 'lucide-react'; + +import { cn } from '@/lib/utils'; + +const Sheet = SheetPrimitive.Root; + +const SheetTrigger = SheetPrimitive.Trigger; + +const SheetClose = SheetPrimitive.Close; + +const SheetPortal = SheetPrimitive.Portal; + +const SheetOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SheetOverlay.displayName = SheetPrimitive.Overlay.displayName; + +const sheetVariants = cva( + 'fixed z-50 gap-4 bg-stone-800 pt-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500', + { + variants: { + side: { + top: 'inset-x-0 top-0 data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top', + bottom: + 'inset-x-0 bottom-0 data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom', + left: 'inset-y-0 left-0 h-full w-3/4 data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm', + right: + 'inset-y-0 right-0 h-full w-3/4 data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm', + }, + }, + defaultVariants: { + side: 'right', + }, + }, +); + +interface SheetContentProps + extends React.ComponentPropsWithoutRef, + VariantProps {} + +const SheetContent = React.forwardRef< + React.ElementRef, + SheetContentProps +>(({ side = 'right', className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)); +SheetContent.displayName = SheetPrimitive.Content.displayName; + +const SheetHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +SheetHeader.displayName = 'SheetHeader'; + +const SheetFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +SheetFooter.displayName = 'SheetFooter'; + +const SheetTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SheetTitle.displayName = SheetPrimitive.Title.displayName; + +const SheetDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SheetDescription.displayName = SheetPrimitive.Description.displayName; + +export { + Sheet, + SheetPortal, + SheetOverlay, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, +}; diff --git a/apps/frontend/src/components/ui/waitopponent.tsx b/apps/frontend/src/components/ui/waitopponent.tsx new file mode 100644 index 00000000..00cd4e73 --- /dev/null +++ b/apps/frontend/src/components/ui/waitopponent.tsx @@ -0,0 +1,31 @@ +export function Waitopponent() { + return ( +
+
+ Wait opponent will join soon... +
+
+ + Loading... +
+
+ ); +} diff --git a/apps/frontend/src/constants/themes.ts b/apps/frontend/src/constants/themes.ts new file mode 100644 index 00000000..66f2b0d2 --- /dev/null +++ b/apps/frontend/src/constants/themes.ts @@ -0,0 +1,29 @@ +import { THEME } from "@/context/themeContext" + +type THEME_DATA = { + id: number, + name: THEME, + background: string, + "board-light": string, + "board-dark": string, + "board-image": string +} + +export const THEMES_DATA: THEME_DATA[] = [ + { + id: 1, + name: "default", + background: "#302E2B", + "board-light": "#EBECD0", + "board-dark": "#739552", + "board-image": "https://www.chess.com/bundles/web/images/offline-play/standardboard.1d6f9426.png", + }, + { + id: 2, + name: "bubblegum", + background: "#E6455E", + "board-light": "#FEFFFE", + "board-dark": "#FBD9E1", + "board-image": "https://res.cloudinary.com/dcugqfvvg/image/upload/e_improve,e_sharpen/v1718047051/screenshot-localhost_5173-2024.06.11-00_44_01_pxwr43.png", + } +] \ No newline at end of file diff --git a/apps/frontend/src/context/themeContext.tsx b/apps/frontend/src/context/themeContext.tsx new file mode 100644 index 00000000..3ceec3bf --- /dev/null +++ b/apps/frontend/src/context/themeContext.tsx @@ -0,0 +1,41 @@ +import { createContext, useEffect, useState } from "react"; + +export type THEME = "default" | "bubblegum"; + +export type THEME_CONTEXT = { + theme: THEME, + updateTheme: (theme: THEME) => void +} + +const AVAILABLE_THEMES: THEME[] = ["default", "bubblegum"]; + +export const ThemeContext = createContext(null); + +export function ThemesProvider({ children }: { children: React.ReactNode }) { + const [theme, setTheme] = useState("default"); + + function updateTheme(theme: THEME) { + setTheme(theme); + localStorage.setItem("theme", theme); + document.querySelector("html")?.setAttribute("data-theme", theme); + } + + useEffect(() => { + const currentTheme = localStorage.getItem("theme") as THEME | null; + + if(currentTheme && AVAILABLE_THEMES.includes(currentTheme)) { + setTheme(currentTheme); + document.querySelector("html")?.setAttribute("data-theme", currentTheme); + } + }, []); + + return ( + + {children} + + ) +} + diff --git a/apps/frontend/src/hooks/useSidebar.ts b/apps/frontend/src/hooks/useSidebar.ts new file mode 100644 index 00000000..fa99ddd1 --- /dev/null +++ b/apps/frontend/src/hooks/useSidebar.ts @@ -0,0 +1,11 @@ +import { create } from 'zustand'; + +interface SidebarStore { + isOpen: boolean; + toggle: () => void; +} + +export const useSidebar = create((set: any) => ({ + isOpen: true, + toggle: () => set((state: any) => ({ isOpen: !state.isOpen })), +})); diff --git a/apps/frontend/src/hooks/useSocket.ts b/apps/frontend/src/hooks/useSocket.ts new file mode 100644 index 00000000..4abb5ba7 --- /dev/null +++ b/apps/frontend/src/hooks/useSocket.ts @@ -0,0 +1,28 @@ +import { useEffect, useState } from 'react'; +import { useUser } from '@repo/store/useUser'; + +const WS_URL = import.meta.env.VITE_APP_WS_URL ?? 'ws://localhost:8080'; + +export const useSocket = () => { + const [socket, setSocket] = useState(null); + const user = useUser(); + + useEffect(() => { + if (!user) return; + const ws = new WebSocket(`${WS_URL}?token=${user.token}`); + + ws.onopen = () => { + setSocket(ws); + }; + + ws.onclose = () => { + setSocket(null); + }; + + return () => { + ws.close(); + }; + }, [user]); + + return socket; +}; diff --git a/apps/frontend/src/hooks/useThemes.ts b/apps/frontend/src/hooks/useThemes.ts new file mode 100644 index 00000000..d19ec791 --- /dev/null +++ b/apps/frontend/src/hooks/useThemes.ts @@ -0,0 +1,7 @@ +import { useContext } from "react"; +import { THEME_CONTEXT, ThemeContext } from "@/context/themeContext"; + +export function useThemeContext() { + const data = useContext(ThemeContext) as THEME_CONTEXT; + return data; +} \ No newline at end of file diff --git a/apps/frontend/src/hooks/useWindowSize.ts b/apps/frontend/src/hooks/useWindowSize.ts new file mode 100644 index 00000000..78398429 --- /dev/null +++ b/apps/frontend/src/hooks/useWindowSize.ts @@ -0,0 +1,24 @@ +import { useLayoutEffect, useState } from 'react'; + +const useWindowSize = () => { + const [windowSize, setWindowSize] = useState({ width: 0, height: 0 }); + + const handleSize = () => { + setWindowSize({ + width: window.innerWidth, + height: window.innerHeight, + }); + }; + + useLayoutEffect(() => { + handleSize(); + + window.addEventListener('resize', handleSize); + + return () => window.removeEventListener('resize', handleSize); + }, []); + + return windowSize; +}; + +export default useWindowSize; diff --git a/apps/frontend/src/index.css b/apps/frontend/src/index.css new file mode 100644 index 00000000..c7300174 --- /dev/null +++ b/apps/frontend/src/index.css @@ -0,0 +1,92 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 0 0% 3.9%; + + --card: 0 0% 100%; + --card-foreground: 0 0% 3.9%; + + --popover: 0 0% 100%; + --popover-foreground: 0 0% 3.9%; + + --primary: 0 0% 9%; + --primary-foreground: 0 0% 98%; + + --secondary: 0 0% 96.1%; + --secondary-foreground: 0 0% 9%; + + --muted: 0 0% 96.1%; + --muted-foreground: 0 0% 45.1%; + + --accent: 0 0% 96.1%; + --accent-foreground: 0 0% 9%; + + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 98%; + + --border: 0 0% 89.8%; + --input: 0 0% 89.8%; + --ring: 0 0% 3.9%; + + --radius: 0.5rem; + } + + .dark { + --background: 0 0% 3.9%; + --foreground: 0 0% 98%; + + --card: 0 0% 3.9%; + --card-foreground: 0 0% 98%; + + --popover: 0 0% 3.9%; + --popover-foreground: 0 0% 98%; + + --primary: 0 0% 98%; + --primary-foreground: 0 0% 9%; + + --secondary: 0 0% 14.9%; + --secondary-foreground: 0 0% 98%; + + --muted: 0 0% 14.9%; + --muted-foreground: 0 0% 63.9%; + + --accent: 0 0% 14.9%; + --accent-foreground: 0 0% 98%; + + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 0% 98%; + + --border: 0 0% 14.9%; + --input: 0 0% 14.9%; + --ring: 0 0% 83.1%; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} + +.chess-board { + background-color: #302e2b; +} + +@layer utilities { + /* Hide scrollbar for Chrome, Safari and Opera */ + .no-scrollbar::-webkit-scrollbar { + display: none; + } + /* Hide scrollbar for IE, Edge and Firefox */ + .no-scrollbar { + -ms-overflow-style: none; /* IE and Edge */ + scrollbar-width: none; /* Firefox */ +} +} diff --git a/apps/frontend/src/layout/index.tsx b/apps/frontend/src/layout/index.tsx new file mode 100644 index 00000000..94c94249 --- /dev/null +++ b/apps/frontend/src/layout/index.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import Sidebar from '@/components/sidebar'; + +export const Layout = ({ children }: { children: React.ReactNode }) => { + return ( +
+ +
+ {children} +
+
+ ); +}; diff --git a/apps/frontend/src/lib/utils.ts b/apps/frontend/src/lib/utils.ts new file mode 100644 index 00000000..d084ccad --- /dev/null +++ b/apps/frontend/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { type ClassValue, clsx } from "clsx" +import { twMerge } from "tailwind-merge" + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} diff --git a/apps/frontend/src/main.tsx b/apps/frontend/src/main.tsx new file mode 100644 index 00000000..0fe89954 --- /dev/null +++ b/apps/frontend/src/main.tsx @@ -0,0 +1,5 @@ +import ReactDOM from 'react-dom/client'; +import App from './App.tsx'; +import './index.css'; + +ReactDOM.createRoot(document.getElementById('root')!).render(); diff --git a/apps/frontend/src/screens/Game.tsx b/apps/frontend/src/screens/Game.tsx new file mode 100644 index 00000000..69deaf5c --- /dev/null +++ b/apps/frontend/src/screens/Game.tsx @@ -0,0 +1,358 @@ +/* eslint-disable no-case-declarations */ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { useEffect, useRef, useState } from 'react'; +import MoveSound from '/move.wav'; +import { Button } from '../components/Button'; +import { ChessBoard, isPromoting } from '../components/ChessBoard'; +import { useSocket } from '../hooks/useSocket'; +import { Chess, Move } from 'chess.js'; +import { useNavigate, useParams } from 'react-router-dom'; +import MovesTable from '../components/MovesTable'; +import { useUser } from '@repo/store/useUser'; +import { UserAvatar } from '../components/UserAvatar'; + +// TODO: Move together, there's code repetition here +export const INIT_GAME = 'init_game'; +export const MOVE = 'move'; +export const OPPONENT_DISCONNECTED = 'opponent_disconnected'; +export const GAME_OVER = 'game_over'; +export const JOIN_ROOM = 'join_room'; +export const GAME_JOINED = 'game_joined'; +export const GAME_ALERT = 'game_alert'; +export const GAME_ADDED = 'game_added'; +export const USER_TIMEOUT = 'user_timeout'; +export const GAME_TIME = 'game_time'; +export const GAME_ENDED = 'game_ended'; +export const EXIT_GAME = 'exit_game'; +export enum Result { + WHITE_WINS = 'WHITE_WINS', + BLACK_WINS = 'BLACK_WINS', + DRAW = 'DRAW', +} +export interface GameResult { + result: Result; + by: string; +} + +const GAME_TIME_MS = 10 * 60 * 1000; + +import { useRecoilValue, useSetRecoilState } from 'recoil'; + +import { movesAtom, userSelectedMoveIndexAtom } from '@repo/store/chessBoard'; +import GameEndModal from '@/components/GameEndModal'; +import { Waitopponent } from '@/components/ui/waitopponent'; +import { ShareGame } from '../components/ShareGame'; +import ExitGameModel from '@/components/ExitGameModel'; + +const moveAudio = new Audio(MoveSound); + +interface Metadata { + blackPlayer: { id: string; name: string }; + whitePlayer: { id: string; name: string }; +} + +export const Game = () => { + const socket = useSocket(); + const { gameId } = useParams(); + const user = useUser(); + + const navigate = useNavigate(); + // Todo move to store/context + const [chess, _setChess] = useState(new Chess()); + const [board, setBoard] = useState(chess.board()); + const [added, setAdded] = useState(false); + const [started, setStarted] = useState(false); + const [gameMetadata, setGameMetadata] = useState(null); + const [result, setResult] = useState(null); + const [player1TimeConsumed, setPlayer1TimeConsumed] = useState(0); + const [player2TimeConsumed, setPlayer2TimeConsumed] = useState(0); + const [gameID,setGameID] = useState(""); + const setMoves = useSetRecoilState(movesAtom); + const userSelectedMoveIndex = useRecoilValue(userSelectedMoveIndexAtom); + const userSelectedMoveIndexRef = useRef(userSelectedMoveIndex); + + useEffect(() => { + userSelectedMoveIndexRef.current = userSelectedMoveIndex; + }, [userSelectedMoveIndex]); + + useEffect(() => { + if (!user) { + window.location.href = '/login'; + } + }, [user]); + + useEffect(() => { + if (!socket) { + return; + } + socket.onmessage = function (event) { + const message = JSON.parse(event.data); + switch (message.type) { + case GAME_ADDED: + setAdded(true); + setGameID((p)=>message.gameId); + break; + case INIT_GAME: + setBoard(chess.board()); + setStarted(true); + navigate(`/game/${message.payload.gameId}`); + setGameMetadata({ + blackPlayer: message.payload.blackPlayer, + whitePlayer: message.payload.whitePlayer, + }); + break; + case MOVE: + const { move, player1TimeConsumed, player2TimeConsumed } = + message.payload; + setPlayer1TimeConsumed(player1TimeConsumed); + setPlayer2TimeConsumed(player2TimeConsumed); + if (userSelectedMoveIndexRef.current !== null) { + setMoves((moves) => [...moves, move]); + return; + } + try { + if (isPromoting(chess, move.from, move.to)) { + chess.move({ + from: move.from, + to: move.to, + promotion: 'q', + }); + } else { + chess.move({ from: move.from, to: move.to }); + } + setMoves((moves) => [...moves, move]); + moveAudio.play(); + } catch (error) { + console.log('Error', error); + } + break; + case GAME_OVER: + setResult(message.payload.result); + break; + + case GAME_ENDED: + let wonBy; + switch (message.payload.status) { + case 'COMPLETED': + wonBy = message.payload.result !== 'DRAW' ? 'CheckMate' : 'Draw'; + break; + case 'PLAYER_EXIT': + wonBy = 'Player Exit'; + break; + default: + wonBy = 'Timeout'; + } + setResult({ + result: message.payload.result, + by: wonBy, + }); + chess.reset(); + setStarted(false); + setAdded(false); + + break; + + case USER_TIMEOUT: + setResult(message.payload.win); + break; + + case GAME_JOINED: + setGameMetadata({ + blackPlayer: message.payload.blackPlayer, + whitePlayer: message.payload.whitePlayer, + }); + setPlayer1TimeConsumed(message.payload.player1TimeConsumed); + setPlayer2TimeConsumed(message.payload.player2TimeConsumed); + console.error(message.payload); + setStarted(true); + + message.payload.moves.map((x: Move) => { + if (isPromoting(chess, x.from, x.to)) { + chess.move({ ...x, promotion: 'q' }); + } else { + chess.move(x); + } + }); + setMoves(message.payload.moves); + break; + + case GAME_TIME: + setPlayer1TimeConsumed(message.payload.player1Time); + setPlayer2TimeConsumed(message.payload.player2Time); + break; + + default: + alert(message.payload.message); + break; + } + }; + + if (gameId !== 'random') { + socket.send( + JSON.stringify({ + type: JOIN_ROOM, + payload: { + gameId, + }, + }), + ); + } + }, [chess, socket]); + + useEffect(() => { + if (started) { + const interval = setInterval(() => { + if (chess.turn() === 'w') { + setPlayer1TimeConsumed((p) => p + 100); + } else { + setPlayer2TimeConsumed((p) => p + 100); + } + }, 100); + return () => clearInterval(interval); + } + }, [started, gameMetadata, user]); + + const getTimer = (timeConsumed: number) => { + const timeLeftMs = GAME_TIME_MS - timeConsumed; + const minutes = Math.floor(timeLeftMs / (1000 * 60)); + const remainingSeconds = Math.floor((timeLeftMs % (1000 * 60)) / 1000); + + return ( +
+ Time Left: {minutes < 10 ? '0' : ''} + {minutes}:{remainingSeconds < 10 ? '0' : ''} + {remainingSeconds} +
+ ); + }; + + const handleExit = () => { + socket?.send( + JSON.stringify({ + type: EXIT_GAME, + payload: { + gameId, + }, + }), + ); + setMoves([]); + navigate('/'); + }; + + if (!socket) return
Connecting...
; + + return ( +
+ {result && ( + + )} + {started && ( +
+ {(user.id === gameMetadata?.blackPlayer?.id ? 'b' : 'w') === + chess.turn() + ? 'Your turn' + : "Opponent's turn"} +
+ )} +
+
+
+
+
+
+ {started && ( +
+
+ + {getTimer( + user.id === gameMetadata?.whitePlayer?.id + ? player2TimeConsumed + : player1TimeConsumed, + )} +
+
+ )} +
+
+ +
+
+ {started && ( +
+ + {getTimer( + user.id === gameMetadata?.blackPlayer?.id + ? player2TimeConsumed + : player1TimeConsumed, + )} +
+ )} +
+
+
+
+ {!started ? ( +
+ {added ? ( +
+
+ +
+ ) : ( + gameId === 'random' && ( + + ) + )} +
+ ) : ( +
+ handleExit()} /> +
+ )} +
+ +
+
+
+
+
+
+ ); +}; + diff --git a/apps/frontend/src/screens/Landing.tsx b/apps/frontend/src/screens/Landing.tsx new file mode 100644 index 00000000..ff053aef --- /dev/null +++ b/apps/frontend/src/screens/Landing.tsx @@ -0,0 +1,52 @@ +import { PlayCard } from '@/components/Card'; +import { Footer } from '@/components/Footer'; +import { useThemeContext } from '@/hooks/useThemes'; +import { THEMES_DATA } from '@/constants/themes'; + +export const Landing = () => { + const { theme } = useThemeContext(); + const currentTheme = THEMES_DATA.find(data => data.name === theme); + return ( + <> +
+
+ { + currentTheme ? ( + chess-board + ) : ( + chess-board + )} + +
+
+
+
+
+ chess-board +
+
+

Found an Issue!

+

Please create an issue in our github website below. You are also invited to contribute on the project.

+ + icon +

Github

+
+
+
+
+