├── app ├── .npmrc ├── jest.setup.js ├── .husky │ ├── post-merge │ ├── pre-commit │ └── commit-msg ├── public │ ├── images │ │ ├── new-tab.png │ │ └── large-og.png │ ├── favicon │ │ ├── favicon.ico │ │ ├── apple-icon.png │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── favicon-96x96.png │ │ ├── ms-icon-70x70.png │ │ ├── apple-icon-57x57.png │ │ ├── apple-icon-60x60.png │ │ ├── apple-icon-72x72.png │ │ ├── apple-icon-76x76.png │ │ ├── ms-icon-144x144.png │ │ ├── ms-icon-150x150.png │ │ ├── ms-icon-310x310.png │ │ ├── android-icon-36x36.png │ │ ├── android-icon-48x48.png │ │ ├── android-icon-72x72.png │ │ ├── android-icon-96x96.png │ │ ├── apple-icon-114x114.png │ │ ├── apple-icon-120x120.png │ │ ├── apple-icon-144x144.png │ │ ├── apple-icon-152x152.png │ │ ├── apple-icon-180x180.png │ │ ├── android-icon-144x144.png │ │ ├── android-icon-192x192.png │ │ ├── apple-icon-precomposed.png │ │ ├── browserconfig.xml │ │ └── manifest.json │ ├── fonts │ │ └── inter-var-latin.woff2 │ └── svg │ │ └── Vercel.svg ├── postcss.config.js ├── .prettierrc.js ├── prisma │ ├── migrations │ │ ├── migration_lock.toml │ │ ├── 20230111134242_add_created_at_field_to_user │ │ │ └── migration.sql │ │ └── 20230111112222_init │ │ │ └── migration.sql │ └── schema.prisma ├── next-env.d.ts ├── .vscode │ ├── extensions.json │ ├── css.code-snippets │ ├── settings.json │ └── typescriptreact.code-snippets ├── src │ ├── lib │ │ ├── clsxm.ts │ │ ├── socket │ │ │ └── roomHandler.ts │ │ ├── prisma.ts │ │ ├── __tests__ │ │ │ └── helper.test.ts │ │ └── helper.ts │ ├── components │ │ ├── Kbd.tsx │ │ ├── Layout │ │ │ ├── AnimateFade.tsx │ │ │ ├── Layout.tsx │ │ │ └── Footer.tsx │ │ ├── Multiplayer │ │ │ ├── Skeleton.tsx │ │ │ ├── RoomCode.tsx │ │ │ ├── Multiplayer.tsx │ │ │ └── Players.tsx │ │ ├── Game │ │ │ ├── functions.ts │ │ │ └── Box.tsx │ │ ├── Account │ │ │ └── Login.tsx │ │ ├── Link │ │ │ ├── PrimaryLink.tsx │ │ │ ├── UnderlineLink.tsx │ │ │ ├── UnstyledLink.tsx │ │ │ ├── ArrowLink.tsx │ │ │ └── ButtonLink.tsx │ │ ├── Tooltip.tsx │ │ ├── Button │ │ │ └── Button.tsx │ │ ├── Chat │ │ │ ├── Bubble.tsx │ │ │ ├── ChatInput.tsx │ │ │ └── ChatBox.tsx │ │ ├── CommandPalette │ │ │ ├── functions.ts │ │ │ └── CommandPalette.tsx │ │ ├── Leaderboard │ │ │ ├── TableSkeleton.tsx │ │ │ └── TableRow.tsx │ │ ├── NextImage.tsx │ │ ├── Input.tsx │ │ ├── PasswordInput.tsx │ │ └── Seo.tsx │ ├── context │ │ ├── Preference │ │ │ ├── types.ts │ │ │ ├── reducer.ts │ │ │ └── PreferenceContext.tsx │ │ ├── Chat │ │ │ ├── types.ts │ │ │ ├── reducer.ts │ │ │ └── ChatContext.tsx │ │ └── Room │ │ │ ├── types.ts │ │ │ ├── reducer.ts │ │ │ └── RoomContext.tsx │ ├── pages │ │ ├── api │ │ │ ├── auth │ │ │ │ └── [...nextauth].ts │ │ │ ├── currentUser.ts │ │ │ ├── profile.ts │ │ │ └── leaderboard.ts │ │ ├── _document.tsx │ │ ├── 404.tsx │ │ ├── solo.tsx │ │ ├── _app.tsx │ │ ├── multiplayer │ │ │ ├── [id].tsx │ │ │ └── index.tsx │ │ ├── index.tsx │ │ ├── leaderboard.tsx │ │ └── account.tsx │ ├── hooks │ │ ├── useAuth.ts │ │ ├── useProfile.ts │ │ └── useLeaderboard.ts │ ├── styles │ │ ├── theme.css │ │ └── globals.css │ └── data │ │ ├── sentences.json │ │ ├── numbers.json │ │ └── commands.ts ├── vercel.json ├── .github │ ├── issue-branch.yml │ └── workflows │ │ ├── create-branch.yml │ │ ├── issue-autolink.yml │ │ └── lint.yml ├── next-sitemap.config.js ├── commitlint.config.js ├── .prettierignore ├── .gitignore ├── next.config.js ├── tsconfig.json ├── jest.config.js ├── README.md ├── tailwind.config.js ├── .eslintrc.js └── package.json └── socket ├── .gitignore ├── build ├── lib │ ├── types.js │ ├── gameHandler.js │ ├── disconnectHandler.js │ └── roomHandler.js ├── data │ ├── sentences.json │ ├── numbers.json │ └── words.json └── index.js ├── package.json ├── src ├── lib │ ├── types.ts │ ├── gameHandler.ts │ ├── disconnectHandler.ts │ └── roomHandler.ts └── index.ts └── yarn-error.log /app/.npmrc: -------------------------------------------------------------------------------- 1 | v16.14.0 2 | -------------------------------------------------------------------------------- /socket/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /app/jest.setup.js: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom/extend-expect'; 2 | -------------------------------------------------------------------------------- /app/.husky/post-merge: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn 5 | -------------------------------------------------------------------------------- /app/.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged -------------------------------------------------------------------------------- /socket/build/lib/types.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | -------------------------------------------------------------------------------- /app/.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx --no-install commitlint --edit "$1" 5 | -------------------------------------------------------------------------------- /app/public/images/new-tab.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thisissteven/monkeytype-clone/HEAD/app/public/images/new-tab.png -------------------------------------------------------------------------------- /app/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /app/public/favicon/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thisissteven/monkeytype-clone/HEAD/app/public/favicon/favicon.ico -------------------------------------------------------------------------------- /app/public/images/large-og.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thisissteven/monkeytype-clone/HEAD/app/public/images/large-og.png -------------------------------------------------------------------------------- /app/public/favicon/apple-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thisissteven/monkeytype-clone/HEAD/app/public/favicon/apple-icon.png -------------------------------------------------------------------------------- /app/public/favicon/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thisissteven/monkeytype-clone/HEAD/app/public/favicon/favicon-16x16.png -------------------------------------------------------------------------------- /app/public/favicon/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thisissteven/monkeytype-clone/HEAD/app/public/favicon/favicon-32x32.png -------------------------------------------------------------------------------- /app/public/favicon/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thisissteven/monkeytype-clone/HEAD/app/public/favicon/favicon-96x96.png -------------------------------------------------------------------------------- /app/public/favicon/ms-icon-70x70.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thisissteven/monkeytype-clone/HEAD/app/public/favicon/ms-icon-70x70.png -------------------------------------------------------------------------------- /app/public/favicon/apple-icon-57x57.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thisissteven/monkeytype-clone/HEAD/app/public/favicon/apple-icon-57x57.png -------------------------------------------------------------------------------- /app/public/favicon/apple-icon-60x60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thisissteven/monkeytype-clone/HEAD/app/public/favicon/apple-icon-60x60.png -------------------------------------------------------------------------------- /app/public/favicon/apple-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thisissteven/monkeytype-clone/HEAD/app/public/favicon/apple-icon-72x72.png -------------------------------------------------------------------------------- /app/public/favicon/apple-icon-76x76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thisissteven/monkeytype-clone/HEAD/app/public/favicon/apple-icon-76x76.png -------------------------------------------------------------------------------- /app/public/favicon/ms-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thisissteven/monkeytype-clone/HEAD/app/public/favicon/ms-icon-144x144.png -------------------------------------------------------------------------------- /app/public/favicon/ms-icon-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thisissteven/monkeytype-clone/HEAD/app/public/favicon/ms-icon-150x150.png -------------------------------------------------------------------------------- /app/public/favicon/ms-icon-310x310.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thisissteven/monkeytype-clone/HEAD/app/public/favicon/ms-icon-310x310.png -------------------------------------------------------------------------------- /app/public/fonts/inter-var-latin.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thisissteven/monkeytype-clone/HEAD/app/public/fonts/inter-var-latin.woff2 -------------------------------------------------------------------------------- /app/public/favicon/android-icon-36x36.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thisissteven/monkeytype-clone/HEAD/app/public/favicon/android-icon-36x36.png -------------------------------------------------------------------------------- /app/public/favicon/android-icon-48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thisissteven/monkeytype-clone/HEAD/app/public/favicon/android-icon-48x48.png -------------------------------------------------------------------------------- /app/public/favicon/android-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thisissteven/monkeytype-clone/HEAD/app/public/favicon/android-icon-72x72.png -------------------------------------------------------------------------------- /app/public/favicon/android-icon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thisissteven/monkeytype-clone/HEAD/app/public/favicon/android-icon-96x96.png -------------------------------------------------------------------------------- /app/public/favicon/apple-icon-114x114.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thisissteven/monkeytype-clone/HEAD/app/public/favicon/apple-icon-114x114.png -------------------------------------------------------------------------------- /app/public/favicon/apple-icon-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thisissteven/monkeytype-clone/HEAD/app/public/favicon/apple-icon-120x120.png -------------------------------------------------------------------------------- /app/public/favicon/apple-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thisissteven/monkeytype-clone/HEAD/app/public/favicon/apple-icon-144x144.png -------------------------------------------------------------------------------- /app/public/favicon/apple-icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thisissteven/monkeytype-clone/HEAD/app/public/favicon/apple-icon-152x152.png -------------------------------------------------------------------------------- /app/public/favicon/apple-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thisissteven/monkeytype-clone/HEAD/app/public/favicon/apple-icon-180x180.png -------------------------------------------------------------------------------- /app/public/favicon/android-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thisissteven/monkeytype-clone/HEAD/app/public/favicon/android-icon-144x144.png -------------------------------------------------------------------------------- /app/public/favicon/android-icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thisissteven/monkeytype-clone/HEAD/app/public/favicon/android-icon-192x192.png -------------------------------------------------------------------------------- /app/public/favicon/apple-icon-precomposed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thisissteven/monkeytype-clone/HEAD/app/public/favicon/apple-icon-precomposed.png -------------------------------------------------------------------------------- /app/.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | arrowParens: 'always', 3 | singleQuote: true, 4 | jsxSingleQuote: true, 5 | tabWidth: 2, 6 | semi: true, 7 | }; 8 | -------------------------------------------------------------------------------- /app/public/svg/Vercel.svg: -------------------------------------------------------------------------------- 1 | Vercel -------------------------------------------------------------------------------- /app/prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "postgresql" -------------------------------------------------------------------------------- /app/prisma/migrations/20230111134242_add_created_at_field_to_user/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "User" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; 3 | -------------------------------------------------------------------------------- /app/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /app/.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | // Tailwind CSS Intellisense 4 | "bradlc.vscode-tailwindcss", 5 | "esbenp.prettier-vscode", 6 | "dbaeumer.vscode-eslint", 7 | "aaron-bond.better-comments" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /app/.vscode/css.code-snippets: -------------------------------------------------------------------------------- 1 | { 2 | "Region CSS": { 3 | "prefix": "regc", 4 | "body": [ 5 | "/* #region /**=========== ${1} =========== */", 6 | "$0", 7 | "/* #endregion /**======== ${1} =========== */" 8 | ] 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /app/src/lib/clsxm.ts: -------------------------------------------------------------------------------- 1 | import clsx, { ClassValue } from 'clsx'; 2 | import { twMerge } from 'tailwind-merge'; 3 | 4 | /** Merge classes with tailwind-merge with clsx full feature */ 5 | export default function clsxm(...classes: ClassValue[]) { 6 | return twMerge(clsx(...classes)); 7 | } 8 | -------------------------------------------------------------------------------- /app/vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "headers": [ 3 | { 4 | "source": "/fonts/inter-var-latin.woff2", 5 | "headers": [ 6 | { 7 | "key": "Cache-Control", 8 | "value": "public, max-age=31536000, immutable" 9 | } 10 | ] 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /app/.github/issue-branch.yml: -------------------------------------------------------------------------------- 1 | # https://github.com/robvanderleek/create-issue-branch#option-2-configure-github-action 2 | 3 | # ex: i4-lower_camel_upper 4 | branchName: 'i${issue.number}-${issue.title,}' 5 | branches: 6 | - label: epic 7 | skip: true 8 | - label: debt 9 | skip: true 10 | -------------------------------------------------------------------------------- /app/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "css.validate": false, 3 | "editor.formatOnSave": true, 4 | "editor.tabSize": 2, 5 | "editor.codeActionsOnSave": { 6 | "source.fixAll": true 7 | }, 8 | "headwind.runOnSave": false, 9 | "typescript.preferences.importModuleSpecifier": "non-relative" 10 | } 11 | -------------------------------------------------------------------------------- /app/public/favicon/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | #ffffff -------------------------------------------------------------------------------- /app/src/lib/socket/roomHandler.ts: -------------------------------------------------------------------------------- 1 | import { Socket } from 'socket.io-client'; 2 | import { v4 } from 'uuid'; 3 | 4 | export const createRoom = ( 5 | socket: Socket, 6 | mode: 'words' | 'sentences' | 'numbers' 7 | ) => { 8 | const id = v4().slice(0, 6); 9 | // check whether id exists yet or not 10 | socket.emit('create room', id, mode); 11 | }; 12 | -------------------------------------------------------------------------------- /app/.github/workflows/create-branch.yml: -------------------------------------------------------------------------------- 1 | name: Create Branch from Issue 2 | 3 | on: 4 | issues: 5 | types: [assigned] 6 | 7 | jobs: 8 | create_issue_branch_job: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Create Issue Branch 12 | uses: robvanderleek/create-issue-branch@main 13 | env: 14 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 15 | -------------------------------------------------------------------------------- /app/.github/workflows/issue-autolink.yml: -------------------------------------------------------------------------------- 1 | name: 'Issue Autolink' 2 | on: 3 | pull_request: 4 | types: [opened] 5 | 6 | jobs: 7 | issue-links: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: tkt-actions/add-issue-links@v1.6.0 11 | with: 12 | repo-token: '${{ secrets.GITHUB_TOKEN }}' 13 | branch-prefix: 'i' 14 | resolve: 'true' 15 | -------------------------------------------------------------------------------- /app/src/components/Kbd.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | export default function Kbd({ 4 | children, 5 | className, 6 | }: { 7 | children: React.ReactNode; 8 | className?: string; 9 | }) { 10 | return ( 11 |
14 | {children} 15 |
16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /app/next-sitemap.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import('next-sitemap').IConfig} 3 | * @see https://github.com/iamvishnusankar/next-sitemap#readme 4 | */ 5 | module.exports = { 6 | // !STARTERCONF Change the siteUrl 7 | /** Without additional '/' on the end, e.g. https://theodorusclarence.com */ 8 | siteUrl: 'https://monkeytype-clone.vercel.app', 9 | generateRobotsTxt: true, 10 | robotsTxtOptions: { 11 | policies: [{ userAgent: '*', allow: '/' }], 12 | }, 13 | }; 14 | -------------------------------------------------------------------------------- /socket/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "server", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "dependencies": { 7 | "@types/express": "^4.17.13", 8 | "concurrently": "^7.3.0", 9 | "express": "^4.18.1", 10 | "lodash": "^4.17.21", 11 | "nodemon": "^2.0.19", 12 | "socket.io": "^4.5.1" 13 | }, 14 | "scripts": { 15 | "start:ts": "tsc -w", 16 | "start:js": "npx nodemon build/index.js", 17 | "start": "concurrently yarn:start:*" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /app/src/lib/prisma.ts: -------------------------------------------------------------------------------- 1 | // lib/prisma.ts 2 | import { PrismaClient } from '@prisma/client'; 3 | 4 | let prisma: PrismaClient; 5 | 6 | if (process.env.NODE_ENV === 'production') { 7 | prisma = new PrismaClient(); 8 | } else { 9 | const globalWithPrisma = global as unknown as typeof globalThis & { 10 | prisma: PrismaClient; 11 | }; 12 | if (!globalWithPrisma.prisma) { 13 | globalWithPrisma.prisma = new PrismaClient(); 14 | } 15 | prisma = globalWithPrisma.prisma; 16 | } 17 | 18 | export default prisma; 19 | -------------------------------------------------------------------------------- /app/commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@commitlint/config-conventional'], 3 | rules: { 4 | // TODO Add Scope Enum Here 5 | // 'scope-enum': [2, 'always', ['yourscope', 'yourscope']], 6 | 'type-enum': [ 7 | 2, 8 | 'always', 9 | [ 10 | 'feat', 11 | 'fix', 12 | 'docs', 13 | 'chore', 14 | 'style', 15 | 'refactor', 16 | 'ci', 17 | 'test', 18 | 'perf', 19 | 'revert', 20 | 'vercel', 21 | ], 22 | ], 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /app/.prettierignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | .next 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env.local 30 | .env.development.local 31 | .env.test.local 32 | .env.production.local 33 | 34 | # vercel 35 | .vercel 36 | 37 | # changelog 38 | CHANGELOG.md 39 | -------------------------------------------------------------------------------- /socket/src/lib/types.ts: -------------------------------------------------------------------------------- 1 | export type Player = { 2 | username: string; 3 | roomId: string; 4 | id: string; 5 | status: { 6 | wpm: number; 7 | progress: number; 8 | }; 9 | isPlaying: boolean; 10 | isReady: boolean; 11 | }; 12 | 13 | export type RoomState = { 14 | [key: string]: { 15 | toType: string; 16 | players: Player[]; 17 | inGame: boolean; 18 | winner: string | null; 19 | }; 20 | }; 21 | 22 | export type PlayerState = { 23 | [key: string]: string[]; 24 | }; 25 | 26 | export type SendChat = { 27 | username: string; 28 | id: string; 29 | value: string; 30 | roomId: string; 31 | }; 32 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env 29 | .env.local 30 | .env.development.local 31 | .env.test.local 32 | .env.production.local 33 | 34 | # vercel 35 | .vercel 36 | 37 | # next-sitemap 38 | robots.txt 39 | sitemap.xml 40 | sitemap-*.xml -------------------------------------------------------------------------------- /app/src/components/Layout/AnimateFade.tsx: -------------------------------------------------------------------------------- 1 | import { motion } from 'framer-motion'; 2 | import * as React from 'react'; 3 | 4 | const variants = { 5 | hidden: { opacity: 0 }, 6 | enter: { opacity: 1 }, 7 | exit: { opacity: 0 }, 8 | }; 9 | 10 | export default function AnimateFade({ 11 | children, 12 | }: { 13 | children: React.ReactNode; 14 | }) { 15 | return ( 16 | 23 | {children} 24 | 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /app/src/context/Preference/types.ts: -------------------------------------------------------------------------------- 1 | export type PreferenceState = { 2 | theme: string; 3 | fontFamily: string; 4 | isOpen: boolean; 5 | zenMode: boolean; 6 | type: string; 7 | time: string; 8 | }; 9 | 10 | export type Action = 11 | | { type: 'SET_THEME'; payload: string } 12 | | { type: 'SET_FONT_FAMILY'; payload: string } 13 | | { type: 'SET_TYPE'; payload: string } 14 | | { type: 'SET_TIME'; payload: string } 15 | | { type: 'SET_ZEN_MODE'; payload: boolean } 16 | | { type: 'TOGGLE_COMMAND_PALETTE' }; 17 | 18 | export type ProviderState = { 19 | preferences: PreferenceState; 20 | dispatch: React.Dispatch; 21 | }; 22 | -------------------------------------------------------------------------------- /app/next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | module.exports = { 3 | eslint: { 4 | dirs: ['src'], 5 | }, 6 | 7 | reactStrictMode: false, 8 | 9 | // Uncoment to add domain whitelist 10 | // images: { 11 | // domains: [ 12 | // 'res.cloudinary.com', 13 | // ], 14 | // }, 15 | 16 | // SVGR 17 | webpack(config) { 18 | config.module.rules.push({ 19 | test: /\.svg$/i, 20 | issuer: /\.[jt]sx?$/, 21 | use: [ 22 | { 23 | loader: '@svgr/webpack', 24 | options: { 25 | typescript: true, 26 | icon: true, 27 | }, 28 | }, 29 | ], 30 | }); 31 | 32 | return config; 33 | }, 34 | }; 35 | -------------------------------------------------------------------------------- /app/src/components/Multiplayer/Skeleton.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | export default function Skeleton() { 4 | return ( 5 |
6 |
7 | 8 | 9 |
10 |
11 |
17 |
18 |
19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /app/src/context/Chat/types.ts: -------------------------------------------------------------------------------- 1 | export type Chat = { 2 | username: string; 3 | value: string; 4 | id: string; 5 | type: 'notification' | 'message'; 6 | roomId: 'public' | string; 7 | }; 8 | 9 | export type ChatState = { 10 | publicChat: Chat[]; 11 | roomChat: Chat[]; 12 | onlineUsers: number; 13 | showNotification: boolean; 14 | }; 15 | 16 | export type ChatContextValues = { 17 | chat: ChatState; 18 | dispatch: React.Dispatch; 19 | }; 20 | 21 | export type Action = 22 | | { type: 'ADD_PUBLIC_CHAT'; payload: Chat } 23 | | { type: 'ADD_ROOM_CHAT'; payload: Chat } 24 | | { type: 'CLEAR_ROOM_CHAT' } 25 | | { type: 'SET_SHOW_NOTIFICATION'; payload: boolean } 26 | | { type: 'SET_ONLINE_USERS'; payload: number }; 27 | -------------------------------------------------------------------------------- /app/src/lib/__tests__/helper.test.ts: -------------------------------------------------------------------------------- 1 | import { openGraph } from '@/lib/helper'; 2 | 3 | describe('Open Graph function should work correctly', () => { 4 | it('should not return templateTitle when not specified', () => { 5 | const result = openGraph({ 6 | description: 'Test description', 7 | siteName: 'Test site name', 8 | }); 9 | expect(result).not.toContain('&templateTitle='); 10 | }); 11 | 12 | it('should return templateTitle when specified', () => { 13 | const result = openGraph({ 14 | templateTitle: 'Test Template Title', 15 | description: 'Test description', 16 | siteName: 'Test site name', 17 | }); 18 | expect(result).toContain('&templateTitle=Test%20Template%20Title'); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /app/src/pages/api/auth/[...nextauth].ts: -------------------------------------------------------------------------------- 1 | import { PrismaAdapter } from '@next-auth/prisma-adapter'; 2 | import { NextApiHandler } from 'next'; 3 | import NextAuth, { NextAuthOptions } from 'next-auth'; 4 | import GoogleProvider from 'next-auth/providers/google'; 5 | 6 | import prisma from '@/lib/prisma'; 7 | 8 | const authHandler: NextApiHandler = (req, res) => 9 | NextAuth(req, res, authOptions); 10 | 11 | export default authHandler; 12 | 13 | export const authOptions: NextAuthOptions = { 14 | providers: [ 15 | GoogleProvider({ 16 | clientId: process.env.GOOGLE_CLIENT_ID as string, 17 | clientSecret: process.env.GOOGLE_CLIENT_SECRET as string, 18 | }), 19 | ], 20 | adapter: PrismaAdapter(prisma), 21 | secret: process.env.NEXTAUTH_SECRET, 22 | }; 23 | -------------------------------------------------------------------------------- /socket/build/data/sentences.json: -------------------------------------------------------------------------------- 1 | [ 2 | "Sarah and Ira drove to the store.", 3 | "The ham, green beans, mashed potatoes, and corn are gluten-free.", 4 | "My mother hemmed and hawed over where to go for dinner.", 5 | "The mangy, scrawny stray dog hurriedly gobbled down the grain-free, organic dog food.", 6 | "I quickly put on my red winter jacket, black snow pants, waterproof boots, homemade mittens, and handknit scarf.", 7 | "The incessant ticking and chiming echoed off the weathered walls of the clock repair shop.", 8 | "Nervously, I unfolded the wrinkled and stained letter from my long-dead ancestor.", 9 | "Into the suitcase, I carelessly threw a pair of ripped jeans, my favorite sweater from high school, an old pair of tube socks with stripes, and $20,000 in cash." 10 | ] 11 | -------------------------------------------------------------------------------- /app/src/hooks/useAuth.ts: -------------------------------------------------------------------------------- 1 | import { getSession, signOut, useSession } from 'next-auth/react'; 2 | import { signIn } from 'next-auth/react'; 3 | import useSWR from 'swr'; 4 | 5 | import useProfile from './useProfile'; 6 | 7 | export const getUser = async () => { 8 | const user = await getSession(); 9 | return user; 10 | }; 11 | 12 | const useAuth = () => { 13 | const { data } = useSession(); 14 | 15 | const { clearUser } = useProfile(); 16 | 17 | const { isValidating, error } = useSWR('getUser', getUser, { 18 | fallbackData: data, 19 | }); 20 | 21 | const logout = () => { 22 | signOut({ redirect: false }).then(() => clearUser()); 23 | }; 24 | 25 | const login = () => signIn('google'); 26 | 27 | return { isValidating, error, logout, login }; 28 | }; 29 | 30 | export default useAuth; 31 | -------------------------------------------------------------------------------- /app/src/components/Game/functions.ts: -------------------------------------------------------------------------------- 1 | import numbers from '../../data/numbers.json'; 2 | import sentences from '../../data/sentences.json'; 3 | import words from '../../data/words.json'; 4 | 5 | // eslint-disable-next-line @typescript-eslint/no-var-requires 6 | const _ = require('lodash'); 7 | 8 | export const shuffleList = (type: string) => { 9 | switch (type) { 10 | case 'words': 11 | return _.shuffle(words).slice(0, 150); 12 | case 'numbers': 13 | return _.shuffle(numbers).slice(0, 50); 14 | case 'sentences': 15 | // eslint-disable-next-line no-case-declarations 16 | let sentencesArray = _.shuffle(sentences); 17 | sentencesArray = sentencesArray.slice(0, 12); 18 | return sentencesArray; 19 | default: 20 | return _.shuffle(words).slice(0, 150); 21 | } 22 | }; 23 | -------------------------------------------------------------------------------- /app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": false, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "baseUrl": ".", 17 | "paths": { 18 | "@/*": ["./src/*"], 19 | "~/*": ["./public/*"] 20 | }, 21 | "incremental": true 22 | }, 23 | "include": [ 24 | "next-env.d.ts", 25 | "**/*.ts", 26 | "**/*.tsx", 27 | "src/pages/api/socket.js" 28 | ], 29 | "exclude": ["node_modules"], 30 | "moduleResolution": ["node_modules", ".next", "node"] 31 | } 32 | -------------------------------------------------------------------------------- /app/src/components/Account/Login.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { CgSpinner } from 'react-icons/cg'; 3 | import { FcGoogle } from 'react-icons/fc'; 4 | 5 | import useAuth from '@/hooks/useAuth'; 6 | import useProfile from '@/hooks/useProfile'; 7 | 8 | import Button from '../Button/Button'; 9 | 10 | export default function Login() { 11 | const { isValidating, login } = useAuth(); 12 | const { isLoading } = useProfile(); 13 | 14 | return ( 15 | 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /app/src/components/Link/PrimaryLink.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import clsxm from '@/lib/clsxm'; 4 | 5 | import UnstyledLink, { 6 | UnstyledLinkProps, 7 | } from '@/components/Link/UnstyledLink'; 8 | 9 | const PrimaryLink = React.forwardRef( 10 | ({ className, children, ...rest }, ref) => { 11 | return ( 12 | 22 | {children} 23 | 24 | ); 25 | } 26 | ); 27 | 28 | export default PrimaryLink; 29 | -------------------------------------------------------------------------------- /socket/src/lib/gameHandler.ts: -------------------------------------------------------------------------------- 1 | import { Socket } from "socket.io"; 2 | import { io, rooms } from ".."; 3 | import { shuffleList } from "./functions"; 4 | 5 | export const endGameHander = (socket: Socket) => { 6 | socket.on("end game", (roomId: string, mode: "words" | "sentences" | "numbers") => { 7 | const toType = shuffleList(mode).join(" "); 8 | rooms[roomId] = { 9 | players: rooms[roomId].players, 10 | toType, 11 | inGame: false, 12 | winner: socket.id, 13 | }; 14 | // console.log(socket.id); 15 | // io.in(roomId).emit("winner", rooms[roomId].winner); 16 | io.in(roomId).emit("end game", socket.id); 17 | }); 18 | }; 19 | 20 | export const startGameHander = (socket: Socket) => { 21 | socket.on("start game", (roomId: string) => { 22 | io.in(roomId).emit("words generated", rooms[roomId].toType); 23 | io.in(roomId).emit("start game"); 24 | rooms[roomId].inGame = true; 25 | }); 26 | }; 27 | -------------------------------------------------------------------------------- /app/src/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import Document, { 2 | DocumentContext, 3 | Head, 4 | Html, 5 | Main, 6 | NextScript, 7 | } from 'next/document'; 8 | 9 | class MyDocument extends Document { 10 | static async getInitialProps(ctx: DocumentContext) { 11 | const initialProps = await Document.getInitialProps(ctx); 12 | return { ...initialProps }; 13 | } 14 | 15 | render() { 16 | return ( 17 | 18 | 19 | 26 | 27 | 28 | 29 |
30 | 31 | 32 | 33 | ); 34 | } 35 | } 36 | 37 | export default MyDocument; 38 | -------------------------------------------------------------------------------- /app/src/components/Link/UnderlineLink.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import clsxm from '@/lib/clsxm'; 4 | 5 | import UnstyledLink, { 6 | UnstyledLinkProps, 7 | } from '@/components/Link/UnstyledLink'; 8 | 9 | const UnderlineLink = React.forwardRef( 10 | ({ children, className, ...rest }, ref) => { 11 | return ( 12 | 22 | {children} 23 | 24 | ); 25 | } 26 | ); 27 | 28 | export default UnderlineLink; 29 | -------------------------------------------------------------------------------- /app/src/pages/404.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { RiAlarmWarningFill } from 'react-icons/ri'; 3 | 4 | import ArrowLink from '@/components/Link/ArrowLink'; 5 | import Seo from '@/components/Seo'; 6 | 7 | export default function NotFoundPage() { 8 | return ( 9 | <> 10 | 11 | 12 |
13 |
14 |
15 | 19 |

Page Not Found

20 | 21 | Back to Home 22 | 23 |
24 |
25 |
26 | 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /app/src/components/Tooltip.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import clsxm from '@/lib/clsxm'; 4 | 5 | type TooltipProps = { 6 | triangle?: string; 7 | } & React.ComponentPropsWithoutRef<'div'>; 8 | 9 | export default function Tooltip({ 10 | triangle, 11 | children, 12 | className, 13 | ...rest 14 | }: TooltipProps) { 15 | return ( 16 |
24 |
{children}
25 |
31 |
32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /app/src/context/Chat/reducer.ts: -------------------------------------------------------------------------------- 1 | import { Action, ChatState } from './types'; 2 | 3 | const reducer = (state: ChatState, action: Action): ChatState => { 4 | switch (action.type) { 5 | case 'ADD_PUBLIC_CHAT': 6 | return { 7 | ...state, 8 | publicChat: [...state.publicChat, action.payload], 9 | }; 10 | case 'ADD_ROOM_CHAT': 11 | return { 12 | ...state, 13 | roomChat: [...state.roomChat, action.payload], 14 | }; 15 | case 'CLEAR_ROOM_CHAT': 16 | return { 17 | ...state, 18 | roomChat: [], 19 | }; 20 | case 'SET_SHOW_NOTIFICATION': 21 | return { 22 | ...state, 23 | showNotification: action.payload, 24 | }; 25 | case 'SET_ONLINE_USERS': 26 | return { 27 | ...state, 28 | onlineUsers: action.payload, 29 | }; 30 | default: 31 | throw new Error('Unknown action type'); 32 | } 33 | }; 34 | 35 | export default reducer; 36 | -------------------------------------------------------------------------------- /app/src/components/Button/Button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | enum ButtonVariant { 4 | 'primary', 5 | 'outline', 6 | 'ghost', 7 | 'light', 8 | 'dark', 9 | } 10 | 11 | type ButtonProps = { 12 | isLoading?: boolean; 13 | isDarkBg?: boolean; 14 | variant?: keyof typeof ButtonVariant; 15 | } & React.ComponentPropsWithRef<'button'>; 16 | 17 | const Button = React.forwardRef( 18 | ({ children, className, ...rest }, ref) => { 19 | return ( 20 | 27 | ); 28 | } 29 | ); 30 | 31 | export default Button; 32 | -------------------------------------------------------------------------------- /app/public/favicon/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "App", 3 | "icons": [ 4 | { 5 | "src": "/android-icon-36x36.png", 6 | "sizes": "36x36", 7 | "type": "image/png", 8 | "density": "0.75" 9 | }, 10 | { 11 | "src": "/android-icon-48x48.png", 12 | "sizes": "48x48", 13 | "type": "image/png", 14 | "density": "1.0" 15 | }, 16 | { 17 | "src": "/android-icon-72x72.png", 18 | "sizes": "72x72", 19 | "type": "image/png", 20 | "density": "1.5" 21 | }, 22 | { 23 | "src": "/android-icon-96x96.png", 24 | "sizes": "96x96", 25 | "type": "image/png", 26 | "density": "2.0" 27 | }, 28 | { 29 | "src": "/android-icon-144x144.png", 30 | "sizes": "144x144", 31 | "type": "image/png", 32 | "density": "3.0" 33 | }, 34 | { 35 | "src": "/android-icon-192x192.png", 36 | "sizes": "192x192", 37 | "type": "image/png", 38 | "density": "4.0" 39 | } 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /app/jest.config.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-var-requires 2 | const nextJest = require('next/jest'); 3 | 4 | const createJestConfig = nextJest({ 5 | // Provide the path to your Next.js app to load next.config.js and .env files in your test environment 6 | dir: './', 7 | }); 8 | 9 | // Add any custom config to be passed to Jest 10 | const customJestConfig = { 11 | // Add more setup options before each test is run 12 | setupFilesAfterEnv: ['/jest.setup.js'], 13 | 14 | // if using TypeScript with a baseUrl set to the root directory then you need the below for alias' to work 15 | moduleDirectories: ['node_modules', '/'], 16 | 17 | testEnvironment: 'jest-environment-jsdom', 18 | 19 | /** 20 | * Absolute imports and Module Path Aliases 21 | */ 22 | moduleNameMapper: { 23 | '^@/(.*)$': '/src/$1', 24 | '^~/(.*)$': '/public/$1', 25 | }, 26 | }; 27 | 28 | // createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async 29 | module.exports = createJestConfig(customJestConfig); 30 | -------------------------------------------------------------------------------- /app/src/lib/helper.ts: -------------------------------------------------------------------------------- 1 | type OpenGraphType = { 2 | siteName: string; 3 | description: string; 4 | templateTitle?: string; 5 | logo?: string; 6 | }; 7 | // !STARTERCONF This OG is generated from https://github.com/theodorusclarence/og 8 | // Please clone them and self-host if your site is going to be visited by many people. 9 | // Then change the url and the default logo. 10 | export function openGraph({ 11 | siteName, 12 | templateTitle, 13 | description, 14 | // !STARTERCONF Or, you can use my server with your own logo. 15 | logo = 'https://og./images/logo.jpg', 16 | }: OpenGraphType): string { 17 | const ogLogo = encodeURIComponent(logo); 18 | const ogSiteName = encodeURIComponent(siteName.trim()); 19 | const ogTemplateTitle = templateTitle 20 | ? encodeURIComponent(templateTitle.trim()) 21 | : undefined; 22 | const ogDesc = encodeURIComponent(description.trim()); 23 | 24 | return `https://og./api/general?siteName=${ogSiteName}&description=${ogDesc}&logo=${ogLogo}${ 25 | ogTemplateTitle ? `&templateTitle=${ogTemplateTitle}` : '' 26 | }`; 27 | } 28 | -------------------------------------------------------------------------------- /socket/build/lib/gameHandler.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | exports.startGameHander = exports.endGameHander = void 0; 4 | const __1 = require(".."); 5 | const functions_1 = require("./functions"); 6 | const endGameHander = (socket) => { 7 | socket.on("end game", (roomId, mode) => { 8 | const toType = (0, functions_1.shuffleList)(mode).join(" "); 9 | __1.rooms[roomId] = { 10 | players: __1.rooms[roomId].players, 11 | toType, 12 | inGame: false, 13 | winner: socket.id, 14 | }; 15 | // console.log(socket.id); 16 | // io.in(roomId).emit("winner", rooms[roomId].winner); 17 | __1.io.in(roomId).emit("end game", socket.id); 18 | }); 19 | }; 20 | exports.endGameHander = endGameHander; 21 | const startGameHander = (socket) => { 22 | socket.on("start game", (roomId) => { 23 | __1.io.in(roomId).emit("words generated", __1.rooms[roomId].toType); 24 | __1.io.in(roomId).emit("start game"); 25 | __1.rooms[roomId].inGame = true; 26 | }); 27 | }; 28 | exports.startGameHander = startGameHander; 29 | -------------------------------------------------------------------------------- /app/src/components/Chat/Bubble.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | type BubbleProps = { 4 | username?: string; 5 | value: string; 6 | isYou?: boolean; 7 | type: 'notification' | 'message'; 8 | }; 9 | 10 | const Bubble: React.FC = ({ username, value, isYou, type }) => 11 | type === 'message' ? ( 12 | isYou ? ( 13 | 14 | {value} 15 | 16 | ) : ( 17 | 18 | {username}: 19 | {value} 20 | 21 | ) 22 | ) : ( 23 | 24 | {isYou ? ( 25 | 26 | You {value} the room. 27 | 28 | ) : ( 29 | 30 | {username} {value} the room. 31 | 32 | )} 33 | 34 | ); 35 | 36 | export default Bubble; 37 | -------------------------------------------------------------------------------- /app/README.md: -------------------------------------------------------------------------------- 1 |
2 |

⌨️ Typeracer App - Based on Monkeytype

3 |

Made using Theodorus Clarence's Next.js + Tailwind CSS + TypeScript starter pack.

4 |
5 | 6 | ![Monkeytype Clone](https://github.com/steven2801/monkeytype-clone/blob/main/public/images/large-og.png?raw=true) 7 | 8 | ## Getting Started 9 | 10 | ### 1. Clone this repository 11 | 12 | Go to your terminal and clone the project. 13 | 14 | ``` 15 | git clone https://github.com/steven2801/monkeytype-clone.git my-project 16 | ``` 17 | 18 | ### 2. Install dependencies 19 | 20 | It is encouraged to use **yarn** so the husky hooks can work properly. 21 | 22 | ```bash 23 | yarn install 24 | ``` 25 | 26 | ### 3. Fill out env variables 27 | 28 | Example: 29 | 30 | ``` 31 | NEXT_PUBLIC_API_URL=http://localhost:1338/graphql 32 | ``` 33 | 34 | ### 4. Run the development server 35 | 36 | You can start the server using this command: 37 | 38 | ```bash 39 | yarn dev 40 | ``` 41 | 42 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 43 | -------------------------------------------------------------------------------- /app/src/context/Chat/ChatContext.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import useProfile from '@/hooks/useProfile'; 4 | 5 | import reducer from './reducer'; 6 | import { ChatContextValues } from './types'; 7 | import { useRoomContext } from '../Room/RoomContext'; 8 | 9 | const ChatContext = React.createContext({} as ChatContextValues); 10 | 11 | export const ChatProvider = ({ children }: { children: React.ReactNode }) => { 12 | const [chat, dispatch] = React.useReducer(reducer, { 13 | publicChat: [], 14 | roomChat: [], 15 | onlineUsers: 0, 16 | showNotification: false, 17 | }); 18 | 19 | const { 20 | room: { socket }, 21 | } = useRoomContext(); 22 | 23 | const { user } = useProfile(); 24 | 25 | React.useEffect(() => { 26 | if (socket) { 27 | socket.emit('get online users'); 28 | socket 29 | .off('online users') 30 | .on('online users', (users: number) => 31 | dispatch({ type: 'SET_ONLINE_USERS', payload: users }) 32 | ); 33 | } 34 | }, [socket, user]); 35 | 36 | return ( 37 | 38 | {children} 39 | 40 | ); 41 | }; 42 | 43 | export const useChatContext = () => React.useContext(ChatContext); 44 | -------------------------------------------------------------------------------- /socket/build/data/numbers.json: -------------------------------------------------------------------------------- 1 | [ 2 | "6793164", 3 | "06473", 4 | "9034251", 5 | "929832", 6 | "73148", 7 | "640828", 8 | "53851", 9 | "96180296", 10 | "42176082", 11 | "17297527", 12 | "6434876", 13 | "67803208", 14 | "7404072", 15 | "416828", 16 | "34170", 17 | "34746", 18 | "860346", 19 | "487828", 20 | "0653705", 21 | "2825173", 22 | "569179", 23 | "720537", 24 | "97079", 25 | "86809", 26 | "82498", 27 | "3896258", 28 | "7364346", 29 | "1416031", 30 | "25483", 31 | "848494", 32 | "7952807", 33 | "7871742", 34 | "64189534", 35 | "3575421", 36 | "59343", 37 | "679435", 38 | "47372", 39 | "24284", 40 | "386183", 41 | "6594325", 42 | "53852", 43 | "97547", 44 | "21781", 45 | "1875646", 46 | "4146286", 47 | "80470", 48 | "62670609", 49 | "52182857", 50 | "7546070", 51 | "5125683", 52 | "17451916", 53 | "32808515", 54 | "40179", 55 | "2313156", 56 | "26235", 57 | "834084", 58 | "49281", 59 | "5621967", 60 | "3475103", 61 | "152727", 62 | "726063", 63 | "30254", 64 | "36787968", 65 | "56519", 66 | "3816451", 67 | "72567173" 68 | ] 69 | -------------------------------------------------------------------------------- /app/src/hooks/useProfile.ts: -------------------------------------------------------------------------------- 1 | import useSWR from 'swr'; 2 | 3 | import { LeaderboardPayload } from './useLeaderboard'; 4 | 5 | const API_URL = process.env.NEXT_PUBLIC_API_URL; 6 | 7 | type UserPayload = { 8 | name: string; 9 | createdAt: string; 10 | }; 11 | 12 | type ProfileStatsPayload = { 13 | best: LeaderboardPayload[]; 14 | recent: LeaderboardPayload[]; 15 | }; 16 | 17 | export const getCurrentUser = async (): Promise => { 18 | const res = await fetch(`${API_URL}/currentUser`); 19 | if (!res.ok) { 20 | throw new Error('An error occurred while fetching the data.'); 21 | } 22 | return res.json(); 23 | }; 24 | 25 | const getProfileStats = async (): Promise => { 26 | const res = await fetch(`${API_URL}/profile`); 27 | if (!res.ok) { 28 | throw new Error('An error occurred while fetching the data.'); 29 | } 30 | return res.json(); 31 | }; 32 | 33 | const useProfile = () => { 34 | const { 35 | data: user, 36 | isLoading, 37 | mutate, 38 | } = useSWR('getCurrentUser', getCurrentUser); 39 | 40 | const { data: profileStats } = useSWR('getProfileStats', getProfileStats); 41 | 42 | const clearUser = () => mutate(null, false); 43 | 44 | return { user, clearUser, isLoading, profileStats }; 45 | }; 46 | 47 | export default useProfile; 48 | -------------------------------------------------------------------------------- /app/src/components/Link/UnstyledLink.tsx: -------------------------------------------------------------------------------- 1 | import Link, { LinkProps } from 'next/link'; 2 | import * as React from 'react'; 3 | 4 | import clsxm from '@/lib/clsxm'; 5 | 6 | export type UnstyledLinkProps = { 7 | href: string; 8 | children: React.ReactNode; 9 | openNewTab?: boolean; 10 | className?: string; 11 | nextLinkProps?: Omit; 12 | } & React.ComponentPropsWithRef<'a'>; 13 | 14 | const UnstyledLink = React.forwardRef( 15 | ({ children, href, openNewTab, className, nextLinkProps, ...rest }, ref) => { 16 | const isNewTab = 17 | openNewTab !== undefined 18 | ? openNewTab 19 | : href && !href.startsWith('/') && !href.startsWith('#'); 20 | 21 | if (!isNewTab) { 22 | return ( 23 | 24 | 25 | {children} 26 | 27 | 28 | ); 29 | } 30 | 31 | return ( 32 | 40 | {children} 41 | 42 | ); 43 | } 44 | ); 45 | 46 | export default UnstyledLink; 47 | -------------------------------------------------------------------------------- /app/src/components/CommandPalette/functions.ts: -------------------------------------------------------------------------------- 1 | import { CommandType } from '@/data/commands'; 2 | 3 | import { Action } from '@/context/Preference/types'; 4 | 5 | export const filterCommands = (commands: CommandType[], query: string) => { 6 | return query 7 | ? commands.filter( 8 | (command) => 9 | command.commandName.toLowerCase().includes(query.toLowerCase()) || 10 | command.description.toLocaleLowerCase().includes(query.toLowerCase()) 11 | ) 12 | : commands; 13 | }; 14 | 15 | export const handleSelect = ( 16 | selected: string, 17 | value: string, 18 | dispatch: React.Dispatch 19 | ) => { 20 | switch (selected) { 21 | case 'theme': 22 | dispatch({ type: 'SET_THEME', payload: value }); 23 | break; 24 | case 'font family': 25 | dispatch({ type: 'SET_FONT_FAMILY', payload: value }); 26 | break; 27 | case 'type': 28 | dispatch({ type: 'SET_TYPE', payload: value }); 29 | break; 30 | case 'time': 31 | dispatch({ type: 'SET_TIME', payload: value }); 32 | break; 33 | case 'zen mode': 34 | // eslint-disable-next-line no-case-declarations 35 | const payload = value === 'on' ? true : false; 36 | dispatch({ type: 'SET_ZEN_MODE', payload }); 37 | break; 38 | default: 39 | return false; 40 | } 41 | }; 42 | -------------------------------------------------------------------------------- /app/src/components/Multiplayer/RoomCode.tsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from 'next/router'; 2 | import * as React from 'react'; 3 | import { FaCopy } from 'react-icons/fa'; 4 | import { toast } from 'react-toastify'; 5 | 6 | export default function Code() { 7 | const { query } = useRouter(); 8 | 9 | return ( 10 | 12 | navigator.clipboard.writeText(query?.id as string).then(() => 13 | toast.success('Copied successfully!', { 14 | position: toast.POSITION.TOP_CENTER, 15 | toastId: 'copy-success', 16 | autoClose: 3000, 17 | }) 18 | ) 19 | } 20 | className='relative z-10 flex cursor-pointer items-center rounded-md bg-hl px-4 pt-5 text-3xl font-bold text-bg' 21 | > 22 | 23 | copy and share 24 | 25 | {query?.id ? ( 26 | query?.id + ' ' 27 | ) : ( 28 |
29 |
30 |
31 |
32 | )} 33 | 34 |
35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /socket/src/lib/disconnectHandler.ts: -------------------------------------------------------------------------------- 1 | import { Socket } from "socket.io"; 2 | import { io, playerRooms, rooms } from ".."; 3 | 4 | export const disconnectHandler = (socket: Socket) => { 5 | socket.on("disconnect", () => { 6 | // disconnected client id 7 | // console.log("disconnected"); 8 | const sockets = Array.from(io.sockets.sockets).map((socket) => socket[0]); 9 | io.to("public").emit("online users", sockets.length); 10 | 11 | // the rooms player is currently in 12 | const disconnectPlayerFrom = playerRooms[socket.id]; 13 | if (!disconnectPlayerFrom) return; 14 | disconnectPlayerFrom.forEach((roomId) => { 15 | if (!rooms[roomId]) return; 16 | const players = rooms[roomId].players; 17 | rooms[roomId].players = players.filter((player) => { 18 | if (player.id === socket.id) { 19 | // io.in(roomId).emit("leave room", player.username); 20 | io.in(roomId).emit("receive chat", { username: player.username, value: "left", id: player.id }); 21 | } 22 | return player.id !== socket.id; 23 | }); 24 | 25 | io.in(roomId).emit("room update", rooms[roomId].players); 26 | if (rooms[roomId].players.length === 0) { 27 | delete rooms[roomId]; 28 | } 29 | }); 30 | 31 | // remove player 32 | delete playerRooms[socket.id]; 33 | 34 | // console.log("disconnect", rooms); 35 | // console.log(io.sockets.adapter.rooms); 36 | }); 37 | }; 38 | -------------------------------------------------------------------------------- /app/src/hooks/useLeaderboard.ts: -------------------------------------------------------------------------------- 1 | import useSWR from 'swr'; 2 | 3 | const API_URL = process.env.NEXT_PUBLIC_API_URL; 4 | 5 | export type LeaderboardPayload = { 6 | id?: string; 7 | createdAt?: string | undefined; 8 | name: string; 9 | wpm: number; 10 | type: string; 11 | time: number; 12 | }; 13 | 14 | type LeaderboardAPIPayload = { 15 | daily: LeaderboardPayload[]; 16 | allTime: LeaderboardPayload[]; 17 | }; 18 | 19 | export const getLeaderboard = async (): Promise => { 20 | const res = await fetch(`${API_URL}/leaderboard`); 21 | if (!res.ok) { 22 | throw new Error('An error occurred while fetching the data.'); 23 | } 24 | return res.json(); 25 | }; 26 | 27 | const useLeaderboard = () => { 28 | const { data, isLoading } = useSWR('getLeaderboard', getLeaderboard, { 29 | fallbackData: null, 30 | }); 31 | 32 | const createLeaderboardData = async (data: LeaderboardPayload) => { 33 | const res = await fetch(`${API_URL}/leaderboard`, { 34 | method: 'POST', 35 | headers: { 'Content-Type': 'application/json' }, 36 | body: JSON.stringify({ ...data }), 37 | }); 38 | if (!res.ok) { 39 | throw new Error('An error occurred while fetching the data.'); 40 | } 41 | return res.json(); 42 | }; 43 | 44 | return { 45 | daily: data?.daily, 46 | allTime: data?.allTime, 47 | isLoading, 48 | createLeaderboardData, 49 | }; 50 | }; 51 | 52 | export default useLeaderboard; 53 | -------------------------------------------------------------------------------- /app/src/components/Leaderboard/TableSkeleton.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | export default function TableSkeleton() { 4 | return ( 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /app/src/context/Room/types.ts: -------------------------------------------------------------------------------- 1 | import { Socket } from 'socket.io-client'; 2 | 3 | export type Player = { 4 | username: string; 5 | isOwner: boolean; 6 | roomId: string | null; 7 | id: string; 8 | status: { 9 | wpm: number; 10 | progress: number; 11 | }; 12 | isReady: boolean; 13 | }; 14 | 15 | export type RoomState = { 16 | user: Player; 17 | mode: 'words' | 'sentences' | 'numbers'; 18 | isFinished: boolean; 19 | isPlaying: boolean; 20 | isChatOpen: boolean; 21 | text: string; 22 | players: Player[]; 23 | socket: Socket; 24 | winner: string | null; 25 | }; 26 | 27 | export type RoomContextValues = { 28 | room: RoomState; 29 | dispatch: React.Dispatch; 30 | timeBeforeRestart: number; 31 | resetTime: (time: number) => Promise; 32 | }; 33 | 34 | export type Action = 35 | | { type: 'SET_ROOM_ID'; payload: string | null } 36 | | { type: 'SET_MODE'; payload: 'words' | 'sentences' | 'numbers' } 37 | | { type: 'SET_TEXT'; payload: string } 38 | | { type: 'SET_IS_OWNER'; payload: boolean } 39 | | { type: 'SET_USER_ID'; payload: string } 40 | | { 41 | type: 'SET_STATUS'; 42 | payload: { 43 | wpm: number; 44 | progress: number; 45 | }; 46 | } 47 | | { type: 'SET_NICKNAME'; payload: string } 48 | | { type: 'SET_PLAYERS'; payload: Player[] } 49 | | { type: 'SET_WINNER'; payload: string | null } 50 | | { type: 'SET_IS_PLAYING'; payload: boolean } 51 | | { type: 'TOGGLE_CHAT' } 52 | | { type: 'SET_IS_READY'; payload: boolean } 53 | | { type: 'SET_IS_FINISHED'; payload: boolean }; 54 | -------------------------------------------------------------------------------- /app/src/components/Leaderboard/TableRow.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { FaCrown, FaUserCircle } from 'react-icons/fa'; 3 | 4 | import clsxm from '@/lib/clsxm'; 5 | 6 | type TableRowProps = { 7 | index: number; 8 | wpm: number; 9 | type: string; 10 | time: number; 11 | date: string; 12 | username: string; 13 | }; 14 | 15 | const TableRow: React.FC = ({ 16 | index, 17 | wpm, 18 | type, 19 | time, 20 | date, 21 | username, 22 | }) => { 23 | return ( 24 | 29 | 30 | 31 | {/* first rank */} 32 | {index === 0 ? : index + 1} 33 | 34 | 35 | 36 | 37 |
38 | {username} 39 |
40 | 41 | 42 | 43 | 44 | {wpm} wpm 45 | 46 | 47 | 48 | 49 | {type} 50 | 51 | 52 | {time}s 53 | 54 | {date} 55 | 56 | ); 57 | }; 58 | 59 | export default TableRow; 60 | -------------------------------------------------------------------------------- /app/src/components/NextImage.tsx: -------------------------------------------------------------------------------- 1 | import Image, { ImageProps } from 'next/image'; 2 | import * as React from 'react'; 3 | 4 | import clsxm from '@/lib/clsxm'; 5 | 6 | type NextImageProps = { 7 | useSkeleton?: boolean; 8 | imgClassName?: string; 9 | blurClassName?: string; 10 | alt: string; 11 | width: string | number; 12 | } & ( 13 | | { width: string | number; height: string | number } 14 | | { layout: 'fill'; width?: string | number; height?: string | number } 15 | ) & 16 | ImageProps; 17 | 18 | /** 19 | * 20 | * @description Must set width using `w-` className 21 | * @param useSkeleton add background with pulse animation, don't use it if image is transparent 22 | */ 23 | export default function NextImage({ 24 | useSkeleton = false, 25 | src, 26 | width, 27 | height, 28 | alt, 29 | className, 30 | imgClassName, 31 | blurClassName, 32 | ...rest 33 | }: NextImageProps) { 34 | const [status, setStatus] = React.useState( 35 | useSkeleton ? 'loading' : 'complete' 36 | ); 37 | const widthIsSet = className?.includes('w-') ?? false; 38 | 39 | return ( 40 |
44 | {alt} setStatus('complete')} 54 | layout='responsive' 55 | {...rest} 56 | /> 57 |
58 | ); 59 | } 60 | -------------------------------------------------------------------------------- /app/src/context/Preference/reducer.ts: -------------------------------------------------------------------------------- 1 | import { Action, PreferenceState } from '@/context/Preference/types'; 2 | 3 | const reducer = (state: PreferenceState, action: Action) => { 4 | switch (action.type) { 5 | case 'SET_THEME': 6 | if (typeof window !== undefined) { 7 | window.localStorage.setItem('theme', action.payload); 8 | } 9 | return { 10 | ...state, 11 | theme: action.payload, 12 | }; 13 | case 'SET_FONT_FAMILY': 14 | if (typeof window !== undefined) { 15 | window.localStorage.setItem('font-family', action.payload); 16 | } 17 | return { 18 | ...state, 19 | fontFamily: action.payload, 20 | }; 21 | case 'SET_TYPE': 22 | if (typeof window !== undefined) { 23 | window.localStorage.setItem('type', action.payload); 24 | } 25 | return { 26 | ...state, 27 | type: action.payload, 28 | }; 29 | case 'SET_TIME': 30 | if (typeof window !== undefined) { 31 | window.localStorage.setItem('time', action.payload); 32 | } 33 | return { 34 | ...state, 35 | time: action.payload, 36 | }; 37 | case 'SET_ZEN_MODE': 38 | if (typeof window !== undefined) { 39 | window.localStorage.setItem('zen-mode', JSON.stringify(action.payload)); 40 | } 41 | return { 42 | ...state, 43 | zenMode: action.payload, 44 | }; 45 | case 'TOGGLE_COMMAND_PALETTE': 46 | return { 47 | ...state, 48 | isOpen: !state.isOpen, 49 | }; 50 | default: 51 | throw new Error('Unknown action type'); 52 | } 53 | }; 54 | 55 | export default reducer; 56 | -------------------------------------------------------------------------------- /app/src/components/Chat/ChatInput.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { IoMdSend } from 'react-icons/io'; 3 | 4 | import { useRoomContext } from '@/context/Room/RoomContext'; 5 | 6 | const ChatInput = ({ isPublic }: { isPublic: boolean }) => { 7 | const { 8 | room: { 9 | socket, 10 | user: { username, roomId, id }, 11 | }, 12 | } = useRoomContext(); 13 | 14 | return ( 15 |
{ 18 | e.preventDefault(); 19 | const { value } = e.target[0]; 20 | if (!value) return; 21 | e.target[0].value = ''; 22 | if (isPublic) { 23 | socket.emit('send chat', { username, value, roomId: 'public', id }); 24 | return; 25 | } 26 | socket.emit('send chat', { username, value, roomId, id }); 27 | }} 28 | className='relative mx-auto w-full xs:pr-4' 29 | > 30 | 35 | 43 |
44 | ); 45 | }; 46 | 47 | export default ChatInput; 48 | -------------------------------------------------------------------------------- /app/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | const { fontFamily } = require('tailwindcss/defaultTheme'); 3 | 4 | /** @type {import('tailwindcss').Config} */ 5 | module.exports = { 6 | content: ['./src/**/*.{js,jsx,ts,tsx}'], 7 | theme: { 8 | extend: { 9 | boxShadow: { 10 | b: '0 4px 0', 11 | }, 12 | screens: { 13 | xs: '400px', 14 | ns: '850px', 15 | }, 16 | fontFamily: { 17 | primary: ['var(--font-family)', ...fontFamily.sans], 18 | }, 19 | colors: { 20 | bg: 'rgb(var(--bg-color) / )', 21 | font: 'rgb(var(--font-color) / )', 22 | hl: 'rgb(var(--hl-color) / )', 23 | fg: 'rgb(var(--fg-color) / )', 24 | }, 25 | keyframes: { 26 | blink: { 27 | '0%, 100%': { 28 | opacity: 1, 29 | }, 30 | '50%': { 31 | opacity: 0, 32 | }, 33 | }, 34 | flicker: { 35 | '0%, 19.999%, 22%, 62.999%, 64%, 64.999%, 70%, 100%': { 36 | opacity: 0.99, 37 | filter: 38 | 'drop-shadow(0 0 1px rgba(252, 211, 77)) drop-shadow(0 0 15px rgba(245, 158, 11)) drop-shadow(0 0 1px rgba(252, 211, 77))', 39 | }, 40 | '20%, 21.999%, 63%, 63.999%, 65%, 69.999%': { 41 | opacity: 0.4, 42 | filter: 'none', 43 | }, 44 | }, 45 | }, 46 | animation: { 47 | flicker: 'flicker 3s linear infinite', 48 | blink: 'blink 1.5s infinite 1s', 49 | }, 50 | }, 51 | }, 52 | plugins: [require('@tailwindcss/forms')], 53 | }; 54 | -------------------------------------------------------------------------------- /app/src/context/Preference/PreferenceContext.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import reducer from '@/context/Preference/reducer'; 4 | import { ProviderState } from '@/context/Preference/types'; 5 | 6 | const PreferenceContext = React.createContext({} as ProviderState); 7 | 8 | export default function PreferenceProvider({ 9 | children, 10 | }: { 11 | children: React.ReactNode; 12 | }) { 13 | const [preferences, dispatch] = React.useReducer(reducer, { 14 | theme: 'default', 15 | fontFamily: 'poppins', 16 | isOpen: false, 17 | zenMode: false, 18 | type: 'words', 19 | time: '15', 20 | }); 21 | 22 | React.useEffect(() => { 23 | if (typeof window !== undefined) { 24 | const theme = window.localStorage.getItem('theme'); 25 | const fontFamily = window.localStorage.getItem('font-family'); 26 | const type = window.localStorage.getItem('type'); 27 | const time = window.localStorage.getItem('time'); 28 | const zenMode = window.localStorage.getItem('zen-mode'); 29 | if (theme) dispatch({ type: 'SET_THEME', payload: theme }); 30 | if (fontFamily) 31 | dispatch({ type: 'SET_FONT_FAMILY', payload: fontFamily }); 32 | if (type) dispatch({ type: 'SET_TYPE', payload: type }); 33 | if (time) dispatch({ type: 'SET_TIME', payload: time }); 34 | if (zenMode) 35 | dispatch({ type: 'SET_ZEN_MODE', payload: zenMode === 'true' }); 36 | } 37 | }, []); 38 | 39 | return ( 40 | 41 | {children} 42 | 43 | ); 44 | } 45 | 46 | export const usePreferenceContext = () => React.useContext(PreferenceContext); 47 | -------------------------------------------------------------------------------- /app/src/pages/api/currentUser.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next'; 2 | import { unstable_getServerSession } from 'next-auth/next'; 3 | 4 | import prisma from '@/lib/prisma'; 5 | 6 | import { authOptions } from './auth/[...nextauth]'; 7 | 8 | export default async function handler( 9 | req: NextApiRequest, 10 | res: NextApiResponse 11 | ) { 12 | try { 13 | const session = await unstable_getServerSession(req, res, authOptions); 14 | switch (req.method) { 15 | case 'GET': 16 | if (session && session.user?.email) { 17 | const user = await prisma.user.findUnique({ 18 | where: { 19 | email: session.user?.email, 20 | }, 21 | }); 22 | 23 | res.status(200).json(user); 24 | } else { 25 | res.status(200).json(null); 26 | } 27 | break; 28 | 29 | case 'PUT': 30 | if (session && session.user?.email) { 31 | const user = await prisma.user.update({ 32 | data: { 33 | name: 'Steven', 34 | }, 35 | where: { 36 | email: session.user?.email, 37 | }, 38 | }); 39 | 40 | res.status(200).json(user); 41 | } else { 42 | res.status(401).json({ message: 'Unauthorized' }); 43 | } 44 | 45 | break; 46 | 47 | default: 48 | res.status(401).json({ message: 'Unauthorized' }); 49 | } 50 | } catch (err: unknown) { 51 | if (err instanceof Error) { 52 | // eslint-disable-next-line no-console 53 | console.error(err.message); 54 | 55 | res.status(500).json({ 56 | statusCode: 500, 57 | message: err.message, 58 | }); 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /socket/build/lib/disconnectHandler.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | exports.disconnectHandler = void 0; 4 | const __1 = require(".."); 5 | const disconnectHandler = (socket) => { 6 | socket.on("disconnect", () => { 7 | // disconnected client id 8 | // console.log("disconnected"); 9 | const sockets = Array.from(__1.io.sockets.sockets).map((socket) => socket[0]); 10 | __1.io.to("public").emit("online users", sockets.length); 11 | // the rooms player is currently in 12 | const disconnectPlayerFrom = __1.playerRooms[socket.id]; 13 | if (!disconnectPlayerFrom) 14 | return; 15 | disconnectPlayerFrom.forEach((roomId) => { 16 | if (!__1.rooms[roomId]) 17 | return; 18 | const players = __1.rooms[roomId].players; 19 | __1.rooms[roomId].players = players.filter((player) => { 20 | if (player.id === socket.id) { 21 | // io.in(roomId).emit("leave room", player.username); 22 | __1.io.in(roomId).emit("receive chat", { username: player.username, value: "left", id: player.id }); 23 | } 24 | return player.id !== socket.id; 25 | }); 26 | __1.io.in(roomId).emit("room update", __1.rooms[roomId].players); 27 | if (__1.rooms[roomId].players.length === 0) { 28 | delete __1.rooms[roomId]; 29 | } 30 | }); 31 | // remove player 32 | delete __1.playerRooms[socket.id]; 33 | // console.log("disconnect", rooms); 34 | // console.log(io.sockets.adapter.rooms); 35 | }); 36 | }; 37 | exports.disconnectHandler = disconnectHandler; 38 | -------------------------------------------------------------------------------- /app/prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | datasource db { 2 | provider = "postgresql" 3 | url = env("DATABASE_URL") 4 | } 5 | 6 | generator client { 7 | provider = "prisma-client-js" 8 | } 9 | 10 | model Leaderboard { 11 | id String @id @default(cuid()) 12 | type String 13 | wpm Int 14 | time Int 15 | name String 16 | createdAt DateTime @default(now()) 17 | user User? @relation(fields: [userId], references: [id]) 18 | userId String? 19 | } 20 | 21 | model Account { 22 | id String @id @default(cuid()) 23 | userId String 24 | type String 25 | provider String 26 | providerAccountId String 27 | refresh_token String? @db.Text 28 | access_token String? @db.Text 29 | expires_at Int? 30 | token_type String? 31 | scope String? 32 | id_token String? @db.Text 33 | session_state String? 34 | 35 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 36 | 37 | @@unique([provider, providerAccountId]) 38 | } 39 | 40 | model Session { 41 | id String @id @default(cuid()) 42 | sessionToken String @unique 43 | userId String 44 | expires DateTime 45 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 46 | } 47 | 48 | model User { 49 | id String @id @default(cuid()) 50 | name String? 51 | email String? @unique 52 | emailVerified DateTime? 53 | image String? 54 | createdAt DateTime @default(now()) 55 | accounts Account[] 56 | sessions Session[] 57 | Leaderboard Leaderboard[] 58 | } 59 | 60 | model VerificationToken { 61 | identifier String 62 | token String @unique 63 | expires DateTime 64 | 65 | @@unique([identifier, token]) 66 | } 67 | -------------------------------------------------------------------------------- /app/src/pages/solo.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import Box from '@/components/Game/Box'; 4 | import Kbd from '@/components/Kbd'; 5 | import AnimateFade from '@/components/Layout/AnimateFade'; 6 | import Seo from '@/components/Seo'; 7 | 8 | /** 9 | * SVGR Support 10 | * Caveat: No React Props Type. 11 | * 12 | * You can override the next-env if the type is important to you 13 | * @see https://stackoverflow.com/questions/68103844/how-to-override-next-js-svg-module-declaration 14 | */ 15 | 16 | // !STARTERCONF -> Select !STARTERCONF and CMD + SHIFT + F 17 | // Before you begin editing, follow all comments with `STARTERCONF`, 18 | // to customize the default configuration. 19 | 20 | export default function SoloPage() { 21 | return ( 22 | 23 | 24 | 25 |
26 |
27 |
28 | 29 | 30 |
31 |
32 | tab 33 | + 34 | enter 35 | - restart test 36 |
37 |
38 | ctrl/cmd 39 | + 40 | k 41 | or 42 | p 43 | - command palette 44 |
45 |
46 |
47 |
48 |
49 |
50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /app/src/styles/theme.css: -------------------------------------------------------------------------------- 1 | .plain { 2 | --bg-color: 238 238 238; /* background color */ 3 | --font-color: 127 132 135; /* font color */ 4 | --hl-color: 65 63 66; /* caret, wrong characters, timer, selected and onhover colors */ 5 | --fg-color: 75 75 75; /* correctly typed characters */ 6 | } 7 | 8 | .winter { 9 | --bg-color: 21 114 161; 10 | --font-color: 196 221 255; 11 | --hl-color: 184 255 249; 12 | --fg-color: 239 255 253; 13 | } 14 | 15 | .snowy-night { 16 | --bg-color: 44 51 51; 17 | --font-color: 57 91 100; 18 | --hl-color: 165 201 202; 19 | --fg-color: 231 246 242; 20 | } 21 | 22 | .vintage { 23 | --bg-color: 55 125 113; 24 | --font-color: 148 180 159; 25 | --hl-color: 247 236 222; 26 | --fg-color: 233 218 193; 27 | } 28 | 29 | .vampire { 30 | --bg-color: 28 28 28; 31 | --font-color: 76 76 76; 32 | --hl-color: 200 200 200; 33 | --fg-color: 179 48 48; 34 | } 35 | 36 | .bubblegum { 37 | --bg-color: 77 76 125; 38 | --font-color: 200 182 226; 39 | --hl-color: 233 213 218; 40 | --fg-color: 193 255 207; 41 | } 42 | 43 | .green-tea { 44 | --bg-color: 85 124 85; 45 | --font-color: 166 207 152; 46 | --hl-color: 242 255 233; 47 | --fg-color: 227 243 172; 48 | } 49 | 50 | .wood { 51 | --bg-color: 74 64 58; 52 | --font-color: 104 94 88; 53 | --hl-color: 208 201 192; 54 | --fg-color: 160 147 125; 55 | } 56 | 57 | .beach { 58 | --bg-color: 0 120 170; 59 | --font-color: 58 180 242; 60 | --hl-color: 246 246 246; 61 | --fg-color: 242 223 58; 62 | } 63 | 64 | .halloween { 65 | --bg-color: 27 26 23; 66 | --font-color: 74 69 76; 67 | --hl-color: 201 201 201; 68 | --fg-color: 245 136 64; 69 | } 70 | 71 | .botanical { 72 | --bg-color: 75 134 115; 73 | --font-color: 130 162 132; 74 | --hl-color: 201 235 168; 75 | --fg-color: 242 240 233; 76 | } 77 | 78 | .eye-pain { 79 | --bg-color: 34 0 255; 80 | --font-color: 163 255 0; 81 | --hl-color: 255 0 0; 82 | --fg-color: 255 0 231; 83 | } 84 | 85 | .inter { 86 | --font-family: 'Inter'; 87 | } 88 | 89 | .poppins { 90 | --font-family: 'Poppins'; 91 | } 92 | 93 | .chakra-petch { 94 | --font-family: 'Chakra Petch'; 95 | } 96 | -------------------------------------------------------------------------------- /app/src/components/Input.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import { RegisterOptions, useFormContext } from 'react-hook-form'; 3 | import { HiExclamationCircle } from 'react-icons/hi'; 4 | 5 | export type InputProps = { 6 | id: string; 7 | name: string; 8 | placeholder?: string; 9 | helperText?: string; 10 | type?: string; 11 | readOnly?: boolean; 12 | validation?: RegisterOptions; 13 | } & React.ComponentPropsWithoutRef<'input'>; 14 | 15 | export default function Input({ 16 | placeholder = '', 17 | helperText, 18 | id, 19 | name, 20 | type = 'text', 21 | readOnly = false, 22 | validation, 23 | className, 24 | ...rest 25 | }: InputProps) { 26 | const { 27 | register, 28 | formState: { errors }, 29 | } = useFormContext(); 30 | 31 | return ( 32 |
33 |
34 | 53 | 54 | {errors[name] && ( 55 |
56 | 57 |
58 | )} 59 |
60 |
61 | {helperText &&

{helperText}

} 62 | {errors[name] && ( 63 | 64 | {errors[name]?.message as unknown as string} 65 | 66 | )} 67 |
68 |
69 | ); 70 | } 71 | -------------------------------------------------------------------------------- /app/src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import { AnimatePresence } from 'framer-motion'; 2 | import { AppProps } from 'next/app'; 3 | import { SessionProvider } from 'next-auth/react'; 4 | import { MdClose } from 'react-icons/md'; 5 | import { ToastContainer } from 'react-toastify'; 6 | 7 | import '@/styles/globals.css'; 8 | import '@/styles/theme.css'; 9 | import 'react-toastify/dist/ReactToastify.css'; 10 | 11 | import commands from '@/data/commands'; 12 | 13 | import CommandPalette from '@/components/CommandPalette/CommandPalette'; 14 | import Header from '@/components/Layout/Header'; 15 | import Layout from '@/components/Layout/Layout'; 16 | 17 | import { ChatProvider } from '@/context/Chat/ChatContext'; 18 | import PreferenceProvider from '@/context/Preference/PreferenceContext'; 19 | import { RoomProvider } from '@/context/Room/RoomContext'; 20 | 21 | /** 22 | * !STARTERCONF info 23 | * ? `Layout` component is called in every page using `np` snippets. If you have consistent layout across all page, you can add it here too 24 | */ 25 | 26 | function MyApp({ 27 | Component, 28 | pageProps: { session, ...pageProps }, 29 | router, 30 | }: AppProps) { 31 | return ( 32 | 33 | 34 | 35 | 36 | 38 | 'relative flex p-1 mt-4 min-h-10 rounded-md justify-between overflow-hidden cursor-pointer bg-hl text-bg border-2 border-hl mx-4' 39 | } 40 | bodyClassName={() => 41 | 'flex px-2 py-2 text-sm font-primary block accent-hl' 42 | } 43 | closeButton={() => ( 44 | 45 | )} 46 | /> 47 |
48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | ); 59 | } 60 | 61 | export default MyApp; 62 | -------------------------------------------------------------------------------- /socket/src/index.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import http from "http"; 3 | import cors from "cors"; 4 | import { Server } from "socket.io"; 5 | import { PlayerState, RoomState, SendChat } from "./lib/types"; 6 | import { createRoomHandler, joinRoomHander, leaveRoomHandler, updateRoomHandler } from "./lib/roomHandler"; 7 | import { disconnectHandler } from "./lib/disconnectHandler"; 8 | import { endGameHander, startGameHander } from "./lib/gameHandler"; 9 | 10 | const port = process.env.PORT || 8080; 11 | const app = express(); 12 | app.use(cors); 13 | 14 | const server = http.createServer(app); 15 | export const io = new Server(server, { 16 | cors: { 17 | origin: [ 18 | "http://localhost:3000", 19 | "https://mtype.vercel.app", 20 | "https://monkeytype-clone.vercel.app", 21 | "https://typez.vercel.app", 22 | ], 23 | methods: ["GET", "POST"], 24 | }, 25 | }); 26 | 27 | export const playerRooms: PlayerState = {}; 28 | 29 | // rooms will consist of key value pair, key being room id, pair being users inside that room and their corresponding data 30 | export const rooms: RoomState = {}; 31 | 32 | io.on("connection", (socket) => { 33 | // console.log(io.sockets.adapter.rooms); 34 | // console.log(sockets); 35 | // console.log(socket.rooms); 36 | // console.log("connected"); 37 | socket.join("public"); 38 | const sockets = Array.from(io.sockets.sockets).map((socket) => socket[0]); 39 | io.to("public").emit("online users", sockets.length); 40 | 41 | // send online users 42 | socket.on("get online users", () => { 43 | const sockets = Array.from(io.sockets.sockets).map((socket) => socket[0]); 44 | io.to("public").emit("online users", sockets.length); 45 | }); 46 | 47 | // chat handlers 48 | socket.on("send chat", ({ username, value, roomId, id }: SendChat) => { 49 | console.log(roomId); 50 | io.to(roomId).emit("receive chat", { username, value, id, type: "message", roomId }); 51 | }); 52 | 53 | // handle user disconnect 54 | disconnectHandler(socket); 55 | 56 | // game handlers 57 | startGameHander(socket); 58 | endGameHander(socket); 59 | 60 | // room handlers 61 | joinRoomHander(socket); 62 | leaveRoomHandler(socket); 63 | createRoomHandler(socket); 64 | updateRoomHandler(socket); 65 | }); 66 | 67 | server.listen(port, () => { 68 | console.log(`Listening to server on port ${port}`); 69 | }); 70 | -------------------------------------------------------------------------------- /app/src/components/Link/ArrowLink.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import clsxm from '@/lib/clsxm'; 4 | 5 | import UnderlineLink from '@/components/Link/UnderlineLink'; 6 | import { UnstyledLinkProps } from '@/components/Link/UnstyledLink'; 7 | 8 | type ArrowLinkProps = { 9 | as?: C; 10 | direction?: 'left' | 'right'; 11 | } & UnstyledLinkProps & 12 | React.ComponentProps; 13 | 14 | export default function ArrowLink({ 15 | children, 16 | className, 17 | direction = 'right', 18 | as, 19 | ...rest 20 | }: ArrowLinkProps) { 21 | const Component = as || UnderlineLink; 22 | 23 | return ( 24 | 32 | {children} 33 | 46 | 50 | 61 | 62 | 63 | ); 64 | } 65 | -------------------------------------------------------------------------------- /app/.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | # https://github.com/kentcdodds/kentcdodds.com/blob/main/.github/workflows/deployment.yml 2 | name: Code Check 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: {} 8 | 9 | jobs: 10 | lint: 11 | name: ⬣ ESLint 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: 🛑 Cancel Previous Runs 15 | uses: styfle/cancel-workflow-action@0.9.1 16 | 17 | - name: ⬇️ Checkout repo 18 | uses: actions/checkout@v2 19 | 20 | - name: ⎔ Setup node 21 | uses: actions/setup-node@v2 22 | with: 23 | node-version: 16 24 | 25 | - name: 📥 Download deps 26 | uses: bahmutov/npm-install@v1 27 | 28 | - name: 🔬 Lint 29 | run: yarn lint:strict 30 | 31 | typecheck: 32 | name: ʦ TypeScript 33 | runs-on: ubuntu-latest 34 | steps: 35 | - name: 🛑 Cancel Previous Runs 36 | uses: styfle/cancel-workflow-action@0.9.1 37 | 38 | - name: ⬇️ Checkout repo 39 | uses: actions/checkout@v2 40 | 41 | - name: ⎔ Setup node 42 | uses: actions/setup-node@v2 43 | with: 44 | node-version: 16 45 | 46 | - name: 📥 Download deps 47 | uses: bahmutov/npm-install@v1 48 | 49 | - name: 🔎 Type check 50 | run: yarn typecheck 51 | 52 | prettier: 53 | name: 💅 Prettier 54 | runs-on: ubuntu-latest 55 | steps: 56 | - name: 🛑 Cancel Previous Runs 57 | uses: styfle/cancel-workflow-action@0.9.1 58 | 59 | - name: ⬇️ Checkout repo 60 | uses: actions/checkout@v2 61 | 62 | - name: ⎔ Setup node 63 | uses: actions/setup-node@v2 64 | with: 65 | node-version: 16 66 | 67 | - name: 📥 Download deps 68 | uses: bahmutov/npm-install@v1 69 | 70 | - name: 🔎 Type check 71 | run: yarn format:check 72 | 73 | test: 74 | name: 🃏 Test 75 | runs-on: ubuntu-latest 76 | steps: 77 | - name: 🛑 Cancel Previous Runs 78 | uses: styfle/cancel-workflow-action@0.9.1 79 | 80 | - name: ⬇️ Checkout repo 81 | uses: actions/checkout@v2 82 | 83 | - name: ⎔ Setup node 84 | uses: actions/setup-node@v2 85 | with: 86 | node-version: 16 87 | 88 | - name: 📥 Download deps 89 | uses: bahmutov/npm-install@v1 90 | 91 | - name: 🃏 Run jest 92 | run: yarn test 93 | -------------------------------------------------------------------------------- /app/src/context/Room/reducer.ts: -------------------------------------------------------------------------------- 1 | import { Action, RoomState } from './types'; 2 | 3 | const reducer = (state: RoomState, action: Action): RoomState => { 4 | switch (action.type) { 5 | case 'SET_ROOM_ID': 6 | return { 7 | ...state, 8 | user: { 9 | ...state.user, 10 | roomId: action.payload, 11 | }, 12 | }; 13 | case 'SET_MODE': 14 | return { 15 | ...state, 16 | mode: action.payload, 17 | }; 18 | case 'SET_IS_OWNER': 19 | return { 20 | ...state, 21 | user: { 22 | ...state.user, 23 | isOwner: action.payload, 24 | }, 25 | }; 26 | case 'TOGGLE_CHAT': 27 | return { 28 | ...state, 29 | isChatOpen: !state.isChatOpen, 30 | }; 31 | case 'SET_USER_ID': 32 | return { 33 | ...state, 34 | user: { 35 | ...state.user, 36 | id: action.payload, 37 | }, 38 | }; 39 | case 'SET_NICKNAME': 40 | localStorage.setItem('nickname', action.payload); 41 | return { 42 | ...state, 43 | user: { 44 | ...state.user, 45 | username: action.payload, 46 | }, 47 | }; 48 | case 'SET_STATUS': 49 | return { 50 | ...state, 51 | user: { 52 | ...state.user, 53 | status: { 54 | progress: action.payload.progress, 55 | wpm: action.payload.wpm, 56 | }, 57 | }, 58 | }; 59 | case 'SET_IS_PLAYING': 60 | return { 61 | ...state, 62 | isPlaying: action.payload, 63 | }; 64 | case 'SET_IS_FINISHED': 65 | return { 66 | ...state, 67 | isFinished: action.payload, 68 | }; 69 | case 'SET_WINNER': 70 | return { 71 | ...state, 72 | winner: action.payload, 73 | }; 74 | case 'SET_IS_READY': 75 | return { 76 | ...state, 77 | user: { 78 | ...state.user, 79 | isReady: action.payload, 80 | }, 81 | }; 82 | case 'SET_PLAYERS': 83 | return { 84 | ...state, 85 | players: action.payload, 86 | }; 87 | case 'SET_TEXT': 88 | return { 89 | ...state, 90 | text: action.payload, 91 | }; 92 | default: 93 | throw new Error('Unknown action type'); 94 | } 95 | }; 96 | 97 | export default reducer; 98 | -------------------------------------------------------------------------------- /app/src/components/Game/Box.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { VscDebugRestart } from 'react-icons/vsc'; 3 | 4 | import { shuffleList } from '@/components/Game/functions'; 5 | import TypingInput from '@/components/Game/TypingInput'; 6 | import Tooltip from '@/components/Tooltip'; 7 | 8 | import { usePreferenceContext } from '@/context/Preference/PreferenceContext'; 9 | 10 | export default function Box() { 11 | // eslint-disable-next-line @typescript-eslint/no-var-requires 12 | const _ = require('lodash'); 13 | 14 | const { 15 | preferences: { type, time, isOpen }, 16 | } = usePreferenceContext(); 17 | 18 | const [list, setList] = React.useState(() => shuffleList(type)); 19 | 20 | React.useEffect(() => { 21 | const onKeyDown = (event: KeyboardEvent) => { 22 | if (isOpen) return; 23 | if (event.key === 'tab') { 24 | buttonRef.current.focus(); 25 | } else if (event.key !== 'Enter') { 26 | inputRef.current.focus(); 27 | } 28 | }; 29 | 30 | window.addEventListener('keydown', onKeyDown); 31 | return () => window.removeEventListener('keydown', onKeyDown); 32 | }, [isOpen]); 33 | 34 | React.useEffect(() => { 35 | setList(shuffleList(type)); 36 | }, [type]); 37 | 38 | const inputRef = React.useRef() as React.MutableRefObject; 39 | const buttonRef = React.useRef() as React.MutableRefObject; 40 | 41 | return ( 42 | <> 43 | {/* Box */} 44 | 45 | 46 | {/* Restart Button */} 47 | 61 | 62 | ); 63 | } 64 | -------------------------------------------------------------------------------- /app/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2021: true, 5 | node: true, 6 | }, 7 | plugins: ['@typescript-eslint', 'simple-import-sort', 'unused-imports'], 8 | extends: [ 9 | 'eslint:recommended', 10 | 'next', 11 | 'next/core-web-vitals', 12 | 'plugin:@typescript-eslint/recommended', 13 | 'prettier', 14 | ], 15 | rules: { 16 | 'no-unused-vars': 'off', 17 | 'no-console': 'warn', 18 | '@typescript-eslint/explicit-module-boundary-types': 'off', 19 | 20 | 'react/display-name': 'off', 21 | 'react/jsx-curly-brace-presence': [ 22 | 'warn', 23 | { props: 'never', children: 'never' }, 24 | ], 25 | 26 | //#region //*=========== Unused Import =========== 27 | '@typescript-eslint/no-unused-vars': 'off', 28 | 'unused-imports/no-unused-imports': 'warn', 29 | 'unused-imports/no-unused-vars': [ 30 | 'warn', 31 | { 32 | vars: 'all', 33 | varsIgnorePattern: '^_', 34 | args: 'after-used', 35 | argsIgnorePattern: '^_', 36 | }, 37 | ], 38 | //#endregion //*======== Unused Import =========== 39 | 40 | //#region //*=========== Import Sort =========== 41 | 'simple-import-sort/exports': 'warn', 42 | 'simple-import-sort/imports': [ 43 | 'warn', 44 | { 45 | groups: [ 46 | // ext library & side effect imports 47 | ['^@?\\w', '^\\u0000'], 48 | // {s}css files 49 | ['^.+\\.s?css$'], 50 | // Lib and hooks 51 | ['^@/lib', '^@/hooks'], 52 | // static data 53 | ['^@/data'], 54 | // components 55 | ['^@/components', '^@/container'], 56 | // zustand store 57 | ['^@/store'], 58 | // Other imports 59 | ['^@/'], 60 | // relative paths up until 3 level 61 | [ 62 | '^\\./?$', 63 | '^\\.(?!/?$)', 64 | '^\\.\\./?$', 65 | '^\\.\\.(?!/?$)', 66 | '^\\.\\./\\.\\./?$', 67 | '^\\.\\./\\.\\.(?!/?$)', 68 | '^\\.\\./\\.\\./\\.\\./?$', 69 | '^\\.\\./\\.\\./\\.\\.(?!/?$)', 70 | ], 71 | ['^@/types'], 72 | // other that didnt fit in 73 | ['^'], 74 | ], 75 | }, 76 | ], 77 | //#endregion //*======== Import Sort =========== 78 | }, 79 | globals: { 80 | React: true, 81 | JSX: true, 82 | }, 83 | }; 84 | -------------------------------------------------------------------------------- /app/src/pages/api/profile.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next'; 2 | import { unstable_getServerSession } from 'next-auth/next'; 3 | 4 | import prisma from '@/lib/prisma'; 5 | 6 | import { authOptions } from '../api/auth/[...nextauth]'; 7 | 8 | export default async function handler( 9 | req: NextApiRequest, 10 | res: NextApiResponse 11 | ) { 12 | try { 13 | const session = await unstable_getServerSession(req, res, authOptions); 14 | switch (req.method) { 15 | case 'GET': 16 | if (session && session.user?.email) { 17 | // eslint-disable-next-line no-case-declarations 18 | const [best, recent] = await Promise.all([ 19 | prisma.leaderboard.findMany({ 20 | where: { 21 | user: { 22 | email: session.user.email, 23 | }, 24 | }, 25 | orderBy: [ 26 | { 27 | wpm: 'desc', 28 | }, 29 | ], 30 | take: 4, 31 | select: { 32 | id: true, 33 | time: true, 34 | type: true, 35 | wpm: true, 36 | createdAt: true, 37 | }, 38 | }), 39 | 40 | prisma.leaderboard.findMany({ 41 | where: { 42 | user: { 43 | email: session.user.email, 44 | }, 45 | }, 46 | orderBy: [ 47 | { 48 | createdAt: 'desc', 49 | }, 50 | ], 51 | take: 4, 52 | select: { 53 | id: true, 54 | time: true, 55 | type: true, 56 | wpm: true, 57 | createdAt: true, 58 | }, 59 | }), 60 | ]); 61 | 62 | res.status(200).json({ 63 | best, 64 | recent, 65 | }); 66 | } else { 67 | res.status(401).json({ message: 'Unauthorized' }); 68 | } 69 | 70 | break; 71 | 72 | default: 73 | res.status(401).json({ message: 'Unauthorized' }); 74 | } 75 | } catch (err: unknown) { 76 | if (err instanceof Error) { 77 | // eslint-disable-next-line no-console 78 | console.error(err.message); 79 | 80 | res.status(500).json({ 81 | statusCode: 500, 82 | message: err.message, 83 | }); 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /app/src/components/PasswordInput.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import { useState } from 'react'; 3 | import { useFormContext } from 'react-hook-form'; 4 | import { FaEye, FaEyeSlash } from 'react-icons/fa'; 5 | 6 | import { InputProps } from './Input'; 7 | 8 | export default function PasswordInput({ 9 | placeholder = '', 10 | helperText, 11 | id, 12 | name, 13 | readOnly = false, 14 | validation, 15 | ...rest 16 | }: InputProps) { 17 | const { 18 | register, 19 | formState: { errors }, 20 | } = useFormContext(); 21 | 22 | const [showPassword, setShowPassword] = useState(false); 23 | 24 | return ( 25 |
26 |
27 | 45 | 46 | {showPassword ? ( 47 |
setShowPassword(false)} 50 | > 51 | 52 |
53 | ) : ( 54 |
setShowPassword(true)} 57 | > 58 | 59 |
60 | )} 61 |
62 |
63 | {helperText &&

{helperText}

} 64 | {errors[name] && ( 65 | 66 | {errors[name]?.message as unknown as string} 67 | 68 | )} 69 |
70 |
71 | ); 72 | } 73 | -------------------------------------------------------------------------------- /app/prisma/migrations/20230111112222_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "Leaderboard" ( 3 | "id" TEXT NOT NULL, 4 | "type" TEXT NOT NULL, 5 | "wpm" INTEGER NOT NULL, 6 | "time" INTEGER NOT NULL, 7 | "name" TEXT NOT NULL, 8 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 9 | "userId" TEXT, 10 | 11 | CONSTRAINT "Leaderboard_pkey" PRIMARY KEY ("id") 12 | ); 13 | 14 | -- CreateTable 15 | CREATE TABLE "Account" ( 16 | "id" TEXT NOT NULL, 17 | "userId" TEXT NOT NULL, 18 | "type" TEXT NOT NULL, 19 | "provider" TEXT NOT NULL, 20 | "providerAccountId" TEXT NOT NULL, 21 | "refresh_token" TEXT, 22 | "access_token" TEXT, 23 | "expires_at" INTEGER, 24 | "token_type" TEXT, 25 | "scope" TEXT, 26 | "id_token" TEXT, 27 | "session_state" TEXT, 28 | 29 | CONSTRAINT "Account_pkey" PRIMARY KEY ("id") 30 | ); 31 | 32 | -- CreateTable 33 | CREATE TABLE "Session" ( 34 | "id" TEXT NOT NULL, 35 | "sessionToken" TEXT NOT NULL, 36 | "userId" TEXT NOT NULL, 37 | "expires" TIMESTAMP(3) NOT NULL, 38 | 39 | CONSTRAINT "Session_pkey" PRIMARY KEY ("id") 40 | ); 41 | 42 | -- CreateTable 43 | CREATE TABLE "User" ( 44 | "id" TEXT NOT NULL, 45 | "name" TEXT, 46 | "email" TEXT, 47 | "emailVerified" TIMESTAMP(3), 48 | "image" TEXT, 49 | 50 | CONSTRAINT "User_pkey" PRIMARY KEY ("id") 51 | ); 52 | 53 | -- CreateTable 54 | CREATE TABLE "VerificationToken" ( 55 | "identifier" TEXT NOT NULL, 56 | "token" TEXT NOT NULL, 57 | "expires" TIMESTAMP(3) NOT NULL 58 | ); 59 | 60 | -- CreateIndex 61 | CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId"); 62 | 63 | -- CreateIndex 64 | CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken"); 65 | 66 | -- CreateIndex 67 | CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); 68 | 69 | -- CreateIndex 70 | CREATE UNIQUE INDEX "VerificationToken_token_key" ON "VerificationToken"("token"); 71 | 72 | -- CreateIndex 73 | CREATE UNIQUE INDEX "VerificationToken_identifier_token_key" ON "VerificationToken"("identifier", "token"); 74 | 75 | -- AddForeignKey 76 | ALTER TABLE "Leaderboard" ADD CONSTRAINT "Leaderboard_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; 77 | 78 | -- AddForeignKey 79 | ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; 80 | 81 | -- AddForeignKey 82 | ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; 83 | -------------------------------------------------------------------------------- /socket/build/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | var __importDefault = (this && this.__importDefault) || function (mod) { 3 | return (mod && mod.__esModule) ? mod : { "default": mod }; 4 | }; 5 | Object.defineProperty(exports, "__esModule", { value: true }); 6 | exports.rooms = exports.playerRooms = exports.io = void 0; 7 | const express_1 = __importDefault(require("express")); 8 | const http_1 = __importDefault(require("http")); 9 | const cors_1 = __importDefault(require("cors")); 10 | const socket_io_1 = require("socket.io"); 11 | const roomHandler_1 = require("./lib/roomHandler"); 12 | const disconnectHandler_1 = require("./lib/disconnectHandler"); 13 | const gameHandler_1 = require("./lib/gameHandler"); 14 | const port = process.env.PORT || 8080; 15 | const app = (0, express_1.default)(); 16 | app.use(cors_1.default); 17 | const server = http_1.default.createServer(app); 18 | exports.io = new socket_io_1.Server(server, { 19 | cors: { 20 | origin: [ 21 | "http://localhost:3000", 22 | "https://mtype.vercel.app", 23 | "https://monkeytype-clone.vercel.app", 24 | "https://typez.vercel.app", 25 | ], 26 | methods: ["GET", "POST"], 27 | }, 28 | }); 29 | exports.playerRooms = {}; 30 | // rooms will consist of key value pair, key being room id, pair being users inside that room and their corresponding data 31 | exports.rooms = {}; 32 | exports.io.on("connection", (socket) => { 33 | // console.log(io.sockets.adapter.rooms); 34 | // console.log(sockets); 35 | // console.log(socket.rooms); 36 | // console.log("connected"); 37 | socket.join("public"); 38 | const sockets = Array.from(exports.io.sockets.sockets).map((socket) => socket[0]); 39 | exports.io.to("public").emit("online users", sockets.length); 40 | // send online users 41 | socket.on("get online users", () => { 42 | const sockets = Array.from(exports.io.sockets.sockets).map((socket) => socket[0]); 43 | exports.io.to("public").emit("online users", sockets.length); 44 | }); 45 | // chat handlers 46 | socket.on("send chat", ({ username, value, roomId, id }) => { 47 | console.log(roomId); 48 | exports.io.to(roomId).emit("receive chat", { username, value, id, type: "message", roomId }); 49 | }); 50 | // handle user disconnect 51 | (0, disconnectHandler_1.disconnectHandler)(socket); 52 | // game handlers 53 | (0, gameHandler_1.startGameHander)(socket); 54 | (0, gameHandler_1.endGameHander)(socket); 55 | // room handlers 56 | (0, roomHandler_1.joinRoomHander)(socket); 57 | (0, roomHandler_1.leaveRoomHandler)(socket); 58 | (0, roomHandler_1.createRoomHandler)(socket); 59 | (0, roomHandler_1.updateRoomHandler)(socket); 60 | }); 61 | server.listen(port, () => { 62 | console.log(`Listening to server on port ${port}`); 63 | }); 64 | -------------------------------------------------------------------------------- /app/src/components/Link/ButtonLink.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import clsxm from '@/lib/clsxm'; 4 | 5 | import UnstyledLink, { 6 | UnstyledLinkProps, 7 | } from '@/components/Link/UnstyledLink'; 8 | 9 | enum ButtonVariant { 10 | 'primary', 11 | 'outline', 12 | 'ghost', 13 | 'light', 14 | 'dark', 15 | } 16 | 17 | type ButtonLinkProps = { 18 | isDarkBg?: boolean; 19 | variant?: keyof typeof ButtonVariant; 20 | } & UnstyledLinkProps; 21 | 22 | const ButtonLink = React.forwardRef( 23 | ( 24 | { children, className, variant = 'primary', isDarkBg = false, ...rest }, 25 | ref 26 | ) => { 27 | return ( 28 | 76 | {children} 77 | 78 | ); 79 | } 80 | ); 81 | 82 | export default ButtonLink; 83 | -------------------------------------------------------------------------------- /app/src/components/Layout/Layout.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import NextNProgress from 'nextjs-progressbar'; 3 | import * as React from 'react'; 4 | import { CgSpinner } from 'react-icons/cg'; 5 | 6 | import Footer from '@/components/Layout/Footer'; 7 | import Seo from '@/components/Seo'; 8 | 9 | import { usePreferenceContext } from '@/context/Preference/PreferenceContext'; 10 | 11 | export default function Layout({ children }: { children: React.ReactNode }) { 12 | const { 13 | preferences: { theme, fontFamily }, 14 | } = usePreferenceContext(); 15 | 16 | const [isClient, setIsClient] = React.useState(true); 17 | 18 | React.useEffect(() => { 19 | setTimeout(() => setIsClient(false), 500); 20 | }, []); 21 | 22 | return ( 23 | <> 24 | {isClient ? ( 25 | <> 26 | 27 |
34 |
35 |
36 | 37 |

38 | Monkeytype Clone - Typeracer App based on Monkeytype 39 |

40 |
41 | Preparing the page for you... 42 |
43 |
44 |
45 |
46 | 47 | ) : ( 48 |
55 |
56 | 63 | {children} 64 |
65 |
66 |
67 | )} 68 | 69 | ); 70 | } 71 | 72 | const progressColors = { 73 | default: '58 163 193', 74 | plain: '75 75 75', 75 | winter: '239 255 253', 76 | 'snowy-night': '231 246 242', 77 | vintage: '247 236 222', 78 | vampire: '179 48 48', 79 | bubblegum: '193 255 207', 80 | 'green-tea': '227 243 172', 81 | wood: '160 147 125', 82 | beach: '242 223 58', 83 | halloween: '245 136 64', 84 | botanical: '242 240 233', 85 | 'eye-pain': '255 0 231', 86 | }; 87 | 88 | type ProgressColorType = keyof typeof progressColors; 89 | -------------------------------------------------------------------------------- /app/src/components/Multiplayer/Multiplayer.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import * as React from 'react'; 3 | 4 | import TypingInput from '@/components/Multiplayer/TypingInput'; 5 | 6 | import { usePreferenceContext } from '@/context/Preference/PreferenceContext'; 7 | import { useRoomContext } from '@/context/Room/RoomContext'; 8 | 9 | export default function Multiplayer() { 10 | // eslint-disable-next-line @typescript-eslint/no-var-requires 11 | const _ = require('lodash'); 12 | 13 | const { 14 | preferences: { isOpen }, 15 | } = usePreferenceContext(); 16 | 17 | const { 18 | room: { 19 | isPlaying, 20 | winner, 21 | isChatOpen, 22 | socket, 23 | user: { id, roomId, isOwner }, 24 | }, 25 | timeBeforeRestart, 26 | } = useRoomContext(); 27 | 28 | React.useEffect(() => { 29 | isChatOpen && inputRef.current.blur(); 30 | }, [isChatOpen]); 31 | 32 | React.useEffect(() => { 33 | isPlaying && inputRef.current.focus(); 34 | !isPlaying && inputRef.current.blur(); 35 | }, [isPlaying]); 36 | 37 | React.useEffect(() => { 38 | const onKeyDown = (event: KeyboardEvent) => { 39 | if (isOpen || isChatOpen) return; 40 | if (event.key === 'tab') { 41 | buttonRef.current.focus(); 42 | } else if (event.key !== 'Enter' && !event.ctrlKey && isPlaying) { 43 | inputRef.current.focus(); 44 | } 45 | }; 46 | 47 | window.addEventListener('keydown', onKeyDown); 48 | return () => window.removeEventListener('keydown', onKeyDown); 49 | }, [isOpen, isChatOpen, isPlaying]); 50 | 51 | const inputRef = React.useRef() as React.MutableRefObject; 52 | const buttonRef = React.useRef() as React.MutableRefObject; 53 | 54 | return ( 55 | <> 56 | {/* Multiplayer */} 57 | 58 | 59 |
60 | 80 |
81 | 82 | ); 83 | } 84 | -------------------------------------------------------------------------------- /app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ts-nextjs-tailwind-starter", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "lint:fix": "eslint src --fix && yarn format", 11 | "lint:strict": "eslint --max-warnings=0 src", 12 | "typecheck": "tsc --noEmit --incremental false", 13 | "test:watch": "jest --watch", 14 | "test": "jest", 15 | "format": "prettier -w .", 16 | "format:check": "prettier -c .", 17 | "release": "standard-version", 18 | "push-release": "git push --follow-tags origin main", 19 | "postbuild": "next-sitemap --config next-sitemap.config.js", 20 | "postinstall": "prisma generate", 21 | "prepare": "cd .. && husky install app/.husky" 22 | }, 23 | "dependencies": { 24 | "@headlessui/react": "^1.6.6", 25 | "@hookform/resolvers": "^2.9.6", 26 | "@next-auth/prisma-adapter": "^1.0.5", 27 | "@prisma/client": "^4.8.1", 28 | "clsx": "^1.2.1", 29 | "framer-motion": "^6.5.0", 30 | "javascript-time-ago": "^2.5.6", 31 | "lodash": "^4.17.21", 32 | "next": "^12.2.1", 33 | "next-auth": "^4.18.8", 34 | "nextjs-progressbar": "^0.0.14", 35 | "react": "^18.2.0", 36 | "react-dom": "^18.2.0", 37 | "react-hook-form": "^7.33.1", 38 | "react-icons": "^4.4.0", 39 | "react-time-ago": "^7.2.1", 40 | "react-toastify": "^9.0.5", 41 | "react-typing-game-hook": "^1.3.4", 42 | "socket.io-client": "^4.5.1", 43 | "swr": "^2.0.0", 44 | "tailwind-merge": "^1.3.0", 45 | "unique-names-generator": "^4.7.1", 46 | "uuid": "^8.3.2", 47 | "yup": "^0.32.11" 48 | }, 49 | "devDependencies": { 50 | "@commitlint/cli": "^16.3.0", 51 | "@commitlint/config-conventional": "^16.2.4", 52 | "@svgr/webpack": "^6.2.1", 53 | "@tailwindcss/forms": "^0.5.2", 54 | "@testing-library/jest-dom": "^5.16.4", 55 | "@testing-library/react": "^13.3.0", 56 | "@types/react": "^18.0.15", 57 | "@types/uuid": "^8.3.4", 58 | "@typescript-eslint/eslint-plugin": "^5.30.5", 59 | "@typescript-eslint/parser": "^5.30.5", 60 | "autoprefixer": "^10.4.7", 61 | "eslint": "^8.19.0", 62 | "eslint-config-next": "^12.2.1", 63 | "eslint-config-prettier": "^8.5.0", 64 | "eslint-plugin-simple-import-sort": "^7.0.0", 65 | "eslint-plugin-unused-imports": "^2.0.0", 66 | "husky": "^7.0.4", 67 | "jest": "^27.5.1", 68 | "lint-staged": "^12.5.0", 69 | "next-sitemap": "^2.5.28", 70 | "postcss": "^8.4.14", 71 | "prettier": "^2.7.1", 72 | "prettier-plugin-tailwindcss": "^0.1.12", 73 | "prisma": "^4.8.1", 74 | "standard-version": "^9.5.0", 75 | "tailwindcss": "^3.1.5", 76 | "typescript": "^4.7.4" 77 | }, 78 | "lint-staged": { 79 | "src/**/*.{js,jsx,ts,tsx}": [ 80 | "eslint --max-warnings=0", 81 | "prettier -w" 82 | ], 83 | "src/**/*.{json,css,scss,md}": [ 84 | "prettier -w" 85 | ] 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /app/src/pages/api/leaderboard.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next'; 2 | import { unstable_getServerSession } from 'next-auth/next'; 3 | 4 | import prisma from '@/lib/prisma'; 5 | 6 | import { authOptions } from '../api/auth/[...nextauth]'; 7 | 8 | export default async function handler( 9 | req: NextApiRequest, 10 | res: NextApiResponse 11 | ) { 12 | try { 13 | const session = await unstable_getServerSession(req, res, authOptions); 14 | switch (req.method) { 15 | case 'GET': 16 | // eslint-disable-next-line no-case-declarations 17 | const yesterday = new Date(new Date(). getTime() - (24 * 60 * 60 * 1000)); 18 | 19 | // eslint-disable-next-line no-case-declarations 20 | const [daily, allTime] = await Promise.all([ 21 | prisma.leaderboard.findMany({ 22 | where: { 23 | createdAt: { 24 | gte: yesterday, 25 | }, 26 | }, 27 | orderBy: [ 28 | { 29 | wpm: 'desc', 30 | }, 31 | ], 32 | take: 100, 33 | select: { 34 | id: true, 35 | name: true, 36 | time: true, 37 | type: true, 38 | wpm: true, 39 | createdAt: true, 40 | }, 41 | }), 42 | 43 | prisma.leaderboard.findMany({ 44 | orderBy: [ 45 | { 46 | wpm: 'desc', 47 | }, 48 | ], 49 | take: 100, 50 | select: { 51 | id: true, 52 | name: true, 53 | time: true, 54 | type: true, 55 | wpm: true, 56 | createdAt: true, 57 | }, 58 | }), 59 | ]); 60 | 61 | res.status(200).json({ 62 | daily, 63 | allTime, 64 | }); 65 | 66 | break; 67 | 68 | case 'POST': 69 | if (session) { 70 | const user = await prisma.leaderboard.create({ 71 | data: { 72 | ...req.body, 73 | user: { 74 | connect: { 75 | email: session.user?.email as string, 76 | }, 77 | }, 78 | }, 79 | }); 80 | 81 | res.status(200).json(user); 82 | } else { 83 | const user = await prisma.leaderboard.create({ 84 | data: { 85 | ...req.body, 86 | }, 87 | }); 88 | 89 | res.status(200).json(user); 90 | } 91 | 92 | break; 93 | 94 | default: 95 | res.status(401).json({ message: 'Unauthorized' }); 96 | } 97 | } catch (err: unknown) { 98 | if (err instanceof Error) { 99 | // eslint-disable-next-line no-console 100 | console.error(err.message); 101 | 102 | res.status(500).json({ 103 | statusCode: 500, 104 | message: err.message, 105 | }); 106 | } 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /app/src/context/Room/RoomContext.tsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from 'next/router'; 2 | import * as React from 'react'; 3 | import { io } from 'socket.io-client'; 4 | import { animals, uniqueNamesGenerator } from 'unique-names-generator'; 5 | 6 | import useProfile from '@/hooks/useProfile'; 7 | 8 | import reducer from './reducer'; 9 | import { RoomContextValues } from './types'; 10 | 11 | const socket = io( 12 | process.env.NEXT_PUBLIC_SOCKET_URL || 'http://localhost:8080', 13 | { 14 | autoConnect: false, 15 | } 16 | ); 17 | 18 | const RoomContext = React.createContext({} as RoomContextValues); 19 | 20 | export const RoomProvider = ({ children }: { children: React.ReactNode }) => { 21 | const { user } = useProfile(); 22 | 23 | const [room, dispatch] = React.useReducer(reducer, { 24 | text: '', 25 | mode: 'words', 26 | isPlaying: false, 27 | isFinished: false, 28 | isChatOpen: false, 29 | winner: null, 30 | user: { 31 | isOwner: false, 32 | roomId: null, 33 | username: 34 | localStorage?.getItem('nickname') || 35 | user?.name || 36 | uniqueNamesGenerator({ 37 | dictionaries: [animals], 38 | style: 'lowerCase', 39 | }), 40 | id: '', 41 | status: { 42 | wpm: 0, 43 | progress: 0, 44 | }, 45 | isReady: false, 46 | }, 47 | players: [], 48 | socket, 49 | }); 50 | 51 | const [timeBeforeRestart, setTimeBeforeRestart] = React.useState(() => 0); 52 | 53 | const resetTime = async (time: number) => setTimeBeforeRestart(time); 54 | 55 | React.useEffect(() => { 56 | const dispatchTimeout = setTimeout(() => { 57 | room.user.isReady && dispatch({ type: 'SET_IS_PLAYING', payload: true }); 58 | }, 5000); 59 | 60 | const restartInterval = setInterval(() => { 61 | if (room.user.isReady) { 62 | setTimeBeforeRestart((previousTime) => { 63 | if (previousTime === 0) { 64 | clearInterval(restartInterval); 65 | } 66 | return previousTime - 1; 67 | }); 68 | } 69 | }, 1000); 70 | 71 | return () => { 72 | clearInterval(restartInterval); 73 | clearTimeout(dispatchTimeout); 74 | }; 75 | }, [room.user.isReady]); 76 | 77 | const { pathname } = useRouter(); 78 | 79 | socket.on('connect', () => { 80 | dispatch({ type: 'SET_USER_ID', payload: socket.id }); 81 | }); 82 | 83 | socket.on('disconnect', () => { 84 | dispatch({ type: 'SET_IS_READY', payload: false }); 85 | dispatch({ type: 'SET_ROOM_ID', payload: null }); 86 | }); 87 | 88 | React.useEffect(() => { 89 | if (room.user.id && room.user.roomId) { 90 | socket.emit('room update', room.user); 91 | } 92 | 93 | if (pathname === '/multiplayer' && room.user.roomId && room.user.id) { 94 | socket.emit('leave room', room.user); 95 | } 96 | 97 | socket.connect(); 98 | }, [pathname, room.user]); 99 | 100 | return ( 101 | 104 | {children} 105 | 106 | ); 107 | }; 108 | 109 | export const useRoomContext = () => React.useContext(RoomContext); 110 | -------------------------------------------------------------------------------- /socket/src/lib/roomHandler.ts: -------------------------------------------------------------------------------- 1 | import { Socket } from "socket.io"; 2 | import { io, playerRooms, rooms } from ".."; 3 | import { shuffleList } from "./functions"; 4 | import { Player } from "./types"; 5 | 6 | export const createRoomHandler = (socket: Socket) => { 7 | socket.on("create room", (roomId: string, mode: "words" | "sentences" | "numbers") => { 8 | if (io.sockets.adapter.rooms.get(roomId)) { 9 | socket.emit("room already exist"); 10 | } else { 11 | const toType = shuffleList(mode).join(" "); 12 | rooms[roomId] = { 13 | players: [], 14 | toType, 15 | inGame: false, 16 | winner: null, 17 | }; 18 | 19 | socket.emit("words generated", rooms[roomId].toType); 20 | socket.emit("create room success", roomId); 21 | // console.log(roomId); 22 | // console.log(io.sockets.adapter.rooms.get(roomId)); 23 | // const sockets = Array.from(io.sockets.sockets).map((socket) => socket[0]); 24 | // console.log("room created: ", socket.rooms); 25 | } 26 | }); 27 | }; 28 | 29 | export const updateRoomHandler = (socket: Socket) => { 30 | socket.on("room update", (user: Player) => { 31 | const { roomId } = user; 32 | if (!rooms[roomId]) return; 33 | const players = rooms[roomId].players; 34 | rooms[roomId].players = players.map((player) => (player.id !== user.id ? player : user)); 35 | io.in(roomId).emit("room update", rooms[roomId].players); 36 | 37 | // start game 38 | // const allPlayersReady = rooms[roomId].players.every((player) => player.isReady); 39 | // if (allPlayersReady) { 40 | // io.in(roomId).emit("start game"); 41 | // rooms[roomId].inGame = true; 42 | // } else { 43 | // rooms[roomId].inGame = false; 44 | // } 45 | }); 46 | }; 47 | 48 | export const joinRoomHander = (socket: Socket) => { 49 | socket.on("join room", ({ roomId, user }: { roomId: string; user: Player }) => { 50 | socket.emit("end game"); 51 | const room = rooms[roomId]; 52 | if (!room) { 53 | socket.emit("room invalid"); 54 | return; 55 | } else if (rooms[roomId].inGame) { 56 | socket.emit("room in game"); 57 | return; 58 | } else { 59 | rooms[roomId].players = [...rooms[roomId].players, user]; 60 | playerRooms[socket.id] = [roomId]; 61 | } 62 | 63 | socket.join(roomId); 64 | socket.emit("words generated", rooms[roomId].toType); 65 | io.in(roomId).emit("room update", rooms[roomId].players); 66 | // socket.to(roomId).emit("notify", `${user.username} is here.`); 67 | io.in(roomId).emit("receive chat", { username: user.username, value: "joined", id: user.id, type: "notification" }); 68 | // console.log("join", rooms); 69 | }); 70 | }; 71 | 72 | export const leaveRoomHandler = (socket: Socket) => { 73 | socket.on("leave room", (user: Player) => { 74 | const { roomId } = user; 75 | const players = rooms[roomId]; 76 | if (!players) return; 77 | rooms[roomId].players = players.players.filter((player) => { 78 | if (player.id === user.id) { 79 | // socket.to(roomId).emit("leave room", player.username); 80 | io.in(roomId).emit("receive chat", { username: player.username, value: "left", id: player.id }); 81 | } 82 | return player.id !== user.id; 83 | }); 84 | 85 | io.in(roomId).emit("room update", rooms[roomId].players); 86 | if (rooms[roomId].players.length === 0) { 87 | delete rooms[roomId]; 88 | } 89 | // console.log("leave ", rooms); 90 | }); 91 | }; 92 | -------------------------------------------------------------------------------- /socket/yarn-error.log: -------------------------------------------------------------------------------- 1 | Arguments: 2 | C:\Program Files\nodejs\node.exe C:\Users\Steven\AppData\Roaming\npm\node_modules\yarn\bin\yarn.js init iy 3 | 4 | PATH: 5 | C:\Users\Steven\bin;C:\Program Files\Git\mingw64\bin;C:\Program Files\Git\usr\local\bin;C:\Program Files\Git\usr\bin;C:\Program Files\Git\usr\bin;C:\Program Files\Git\mingw64\bin;C:\Program Files\Git\usr\bin;C:\Users\Steven\bin;C:\Program Files (x86)\Razer\ChromaBroadcast\bin;C:\Program Files\Razer\ChromaBroadcast\bin;C:\Program Files (x86)\VMware\VMware Player\bin;C:\Program Files\Common Files\Oracle\Java\javapath;C:\Python39\Scripts;C:\Python39;C:\Windows\system32;C:\Windows;C:\Windows\System32\Wbem;C:\Windows\System32\WindowsPowerShell\v1.0;C:\Windows\System32\OpenSSH;C:\Program Files (x86)\NVIDIA Corporation\PhysX\Common;C:\Program Files\NVIDIA Corporation\NVIDIA NvDLISR;C:\Users\Steven\AppData\Local\Programs\Python\Python38;C:\Users\Steven\AppData\Roaming\Microsoft\Windows\Start Menu\Programs\Python 3.8;C:\Users\Steven\AppData\Local\Programs\Python\Python38\Scripts;C:\WINDOWS\system32;C:\WINDOWS;C:\WINDOWS\System32\Wbem;C:\WINDOWS\System32\WindowsPowerShell\v1.0;C:\WINDOWS\System32\OpenSSH;C:\ProgramData\chocolatey\bin;C:\Program Files\Git\cmd;C:\Users\Steven\AppData\Roaming\Python\Python39\Scripts;C:\Program Files\Docker\Docker\resources\bin;C:\ProgramData\DockerDesktop\version-bin;C:\Users\Steven\AppData\Roaming\nvm;C:\Program Files\nodejs;C:\WINDOWS\system32\config\systemprofile\AppData\Local\Microsoft\WindowsApps;C:\Users\Steven\AppData\Roaming\nvm;C:\Program Files\nodejs;C:\Program Files\MySQL\MySQL Server 8.0\bin;C:\Program Files\MySQL\MySQL Shell 8.0\bin;C:\Windows\system32;C:\Windows;C:\Windows\System32\Wbem;C:\Windows\System32\WindowsPowerShell\v1.0;C:\Windows\System32\OpenSSH;C:\Program Files (x86)\NVIDIA Corporation\PhysX\Common;C:\Program Files\NVIDIA Corporation\NVIDIA NvDLISR;C:\Users\Steven\AppData\Local\Programs\Python\Python38;C:\Users\Steven\AppData\Local\Microsoft\WindowsApps;C:\Users\Steven\AppData\Local\Programs\Microsoft VS Code\bin;C:\Python38\Scripts;C:\Users\Steven\AppData\Local\Microsoft\WindowsApps;C:\Users\Steven\AppData\Local\GitHubDesktop\bin;C:\Program Files\heroku\bin;C:\Users\Steven\AppData\Roaming\Python\Python39\Scripts;C:\Users\Steven\AppData\Roaming\npm;C:\Users\Steven\Documents\flutter\bin;C:\Program Files\Java\jdk-15.0.2\bin;C:\Users\Steven\AppData\Roaming\nvm;C:\Program Files\nodejs;C:\Program Files\PostgreSQL\14\bin;C:\Program Files\Git\usr\bin\vendor_perl;C:\Program Files\Git\usr\bin\core_perl 6 | 7 | Yarn version: 8 | 1.22.17 9 | 10 | Node version: 11 | 16.16.0 12 | 13 | Platform: 14 | win32 x64 15 | 16 | Trace: 17 | Error: canceled 18 | at Interface. (C:\Users\Steven\AppData\Roaming\npm\node_modules\yarn\lib\cli.js:137143:13) 19 | at Interface.emit (node:events:527:28) 20 | at Interface._ttyWrite (node:readline:1081:16) 21 | at ReadStream.onkeypress (node:readline:288:10) 22 | at ReadStream.emit (node:events:527:28) 23 | at emitKeys (node:internal/readline/utils:358:14) 24 | at emitKeys.next () 25 | at ReadStream.onData (node:internal/readline/emitKeypressEvents:61:36) 26 | at ReadStream.emit (node:events:527:28) 27 | at addChunk (node:internal/streams/readable:315:12) 28 | 29 | npm manifest: 30 | No manifest 31 | 32 | yarn manifest: 33 | No manifest 34 | 35 | Lockfile: 36 | No lockfile 37 | -------------------------------------------------------------------------------- /app/src/data/sentences.json: -------------------------------------------------------------------------------- 1 | [ 2 | "She had some amazing news to share but nobody to share it with.", 3 | "The crowd yells and screams for more memes.", 4 | "She found his complete dullness interesting.", 5 | "The newly planted trees were held up by wooden frames in hopes they could survive the next storm.", 6 | "You can't compare apples and oranges, but what about bananas and plantains?", 7 | "He swore he just saw his sushi move.", 8 | "He found the chocolate covered roaches quite tasty.", 9 | "You have every right to be angry, but that doesn't give you the right to be mean.", 10 | "The delicious smell came from the array of food at the buffet.", 11 | "Brady met her gaze again, taking in the array of emotions crossing her features.", 12 | "The array of vegetables at the flea market fascinated Wren.", 13 | "If you go to the library, you will find books on an array of subjects.", 14 | "It was thought advisable for me to have my examinations in a room by myself, because the noise of the typewriter might disturb the other girls.", 15 | "I had made many mistakes, and Miss Sullivan had pointed them out again and again with gentle patience.", 16 | "Think how much worse you'd feel if the town you visualized really existed.", 17 | "It was always dangerous to drive with him since he insisted the safety cones were a slalom course.", 18 | "The blue parrot drove by the hitchhiking mongoose.", 19 | "If my calculator had a history, it would be more embarrassing than my browser history.", 20 | "All she wanted was the answer, but she had no idea how much she would hate it.", 21 | "In that instant, everything changed.", 22 | "He learned the important lesson that a picnic at the beach on a windy day is a bad idea.", 23 | "Potato wedges probably are not best for relationships.", 24 | "People who insist on picking their teeth with their elbows are so annoying!", 25 | "Whenever he saw a red flag warning at the beach he grabbed his surfboard.", 26 | "Various sea birds are elegant, but nothing is as elegant as a gliding pelican.", 27 | "The waves were crashing on the shore; it was a lovely sight.", 28 | "When she didn't like a guy who was trying to pick her up, she started using sign language.", 29 | "The hand sanitizer was actually clear glue.", 30 | "Jeanne wished she has chosen the red button.", 31 | "The river stole the gods.", 32 | "Greetings from the galaxy MACS0647-JD, or what we call home.", 33 | "Tuesdays are free if you bring a gnome costume.", 34 | "Car safety systems have come a long way, but he was out to prove they could be outsmarted.", 35 | "Thigh-high in the water, the fisherman's hope for dinner soon turned to despair.", 36 | "His confidence would have bee admirable if it wasn't for his stupidity.", 37 | "Some bathing suits just shouldn't be worn by some people.", 38 | "They got there early, and they got really good seats.", 39 | "He had unknowingly taken up sleepwalking as a nighttime hobby.", 40 | "When she didn't like a guy who was trying to pick her up, she started using sign language.", 41 | "She saw no irony asking me to change but wanting me to accept her for who she is.", 42 | "She wanted a pet platypus but ended up getting a duck and a ferret instead.", 43 | "Be careful with that butter knife.", 44 | "The beauty of the African sunset disguised the danger lurking nearby.", 45 | "The light in his life was actually a fire burning all around him.", 46 | "Random words in front of other random words create a random sentence.", 47 | "Red is greener than purple, for sure." 48 | ] 49 | -------------------------------------------------------------------------------- /socket/build/data/words.json: -------------------------------------------------------------------------------- 1 | [ 2 | "the", 3 | "be", 4 | "of", 5 | "and", 6 | "a", 7 | "to", 8 | "in", 9 | "he", 10 | "have", 11 | "it", 12 | "that", 13 | "for", 14 | "they", 15 | "I", 16 | "with", 17 | "as", 18 | "not", 19 | "on", 20 | "she", 21 | "at", 22 | "by", 23 | "this", 24 | "we", 25 | "you", 26 | "do", 27 | "but", 28 | "from", 29 | "or", 30 | "which", 31 | "one", 32 | "would", 33 | "all", 34 | "will", 35 | "there", 36 | "say", 37 | "who", 38 | "make", 39 | "when", 40 | "can", 41 | "more", 42 | "if", 43 | "no", 44 | "man", 45 | "out", 46 | "other", 47 | "so", 48 | "what", 49 | "time", 50 | "up", 51 | "go", 52 | "about", 53 | "than", 54 | "into", 55 | "could", 56 | "state", 57 | "only", 58 | "new", 59 | "year", 60 | "some", 61 | "take", 62 | "come", 63 | "these", 64 | "know", 65 | "see", 66 | "use", 67 | "get", 68 | "like", 69 | "then", 70 | "first", 71 | "any", 72 | "work", 73 | "now", 74 | "may", 75 | "such", 76 | "give", 77 | "over", 78 | "think", 79 | "most", 80 | "even", 81 | "find", 82 | "day", 83 | "also", 84 | "after", 85 | "way", 86 | "many", 87 | "must", 88 | "look", 89 | "before", 90 | "great", 91 | "back", 92 | "through", 93 | "long", 94 | "where", 95 | "much", 96 | "should", 97 | "well", 98 | "people", 99 | "down", 100 | "own", 101 | "just", 102 | "because", 103 | "good", 104 | "each", 105 | "those", 106 | "feel", 107 | "seem", 108 | "how", 109 | "high", 110 | "too", 111 | "place", 112 | "little", 113 | "world", 114 | "very", 115 | "still", 116 | "nation", 117 | "hand", 118 | "old", 119 | "life", 120 | "tell", 121 | "write", 122 | "become", 123 | "here", 124 | "show", 125 | "house", 126 | "both", 127 | "between", 128 | "need", 129 | "mean", 130 | "call", 131 | "develop", 132 | "under", 133 | "last", 134 | "right", 135 | "move", 136 | "thing", 137 | "general", 138 | "school", 139 | "never", 140 | "same", 141 | "another", 142 | "begin", 143 | "while", 144 | "number", 145 | "part", 146 | "turn", 147 | "real", 148 | "leave", 149 | "might", 150 | "want", 151 | "point", 152 | "form", 153 | "off", 154 | "child", 155 | "few", 156 | "small", 157 | "since", 158 | "against", 159 | "ask", 160 | "late", 161 | "home", 162 | "interest", 163 | "large", 164 | "person", 165 | "end", 166 | "open", 167 | "public", 168 | "follow", 169 | "during", 170 | "present", 171 | "without", 172 | "again", 173 | "hold", 174 | "govern", 175 | "around", 176 | "possible", 177 | "head", 178 | "consider", 179 | "word", 180 | "program", 181 | "problem", 182 | "however", 183 | "lead", 184 | "system", 185 | "set", 186 | "order", 187 | "eye", 188 | "plan", 189 | "run", 190 | "keep", 191 | "face", 192 | "fact", 193 | "group", 194 | "play", 195 | "stand", 196 | "increase", 197 | "early", 198 | "course", 199 | "change", 200 | "help", 201 | "line" 202 | ] 203 | -------------------------------------------------------------------------------- /app/src/components/Multiplayer/Players.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import * as React from 'react'; 3 | import { FaCrown } from 'react-icons/fa'; 4 | 5 | import { useRoomContext } from '@/context/Room/RoomContext'; 6 | 7 | import Skeleton from './Skeleton'; 8 | 9 | export default function Players() { 10 | const { 11 | room: { 12 | user: { id }, 13 | players, 14 | isPlaying, 15 | winner, 16 | }, 17 | } = useRoomContext(); 18 | 19 | return ( 20 |
25 | {players.length === 0 && ( 26 | <> 27 | 28 | 29 | 30 | )} 31 | {players.map((player) => 32 | player.id === id ? ( 33 |
37 |
38 | 39 | {winner === player.id && } 40 | You 41 | 42 | ( 43 | {isPlaying 44 | ? 'in game' 45 | : player.isOwner 46 | ? 'owner' 47 | : 'waiting for owner'} 48 | ) 49 | 50 | 51 | {player.status.wpm} wpm 52 |
53 |
54 |
60 |
61 |
62 | ) : ( 63 |
67 |
68 | 69 | {winner === player.id && ( 70 | 71 | )} 72 | {player.username} 73 | 74 | ( 75 | {isPlaying 76 | ? 'in game' 77 | : player.isOwner 78 | ? 'owner' 79 | : 'waiting for owner'} 80 | ) 81 | 82 | 83 | 84 | {player.status.wpm} wpm 85 | 86 |
87 |
88 |
94 |
95 |
96 | ) 97 | )} 98 |
99 | ); 100 | } 101 | -------------------------------------------------------------------------------- /socket/build/lib/roomHandler.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | exports.leaveRoomHandler = exports.joinRoomHander = exports.updateRoomHandler = exports.createRoomHandler = void 0; 4 | const __1 = require(".."); 5 | const functions_1 = require("./functions"); 6 | const createRoomHandler = (socket) => { 7 | socket.on("create room", (roomId, mode) => { 8 | if (__1.io.sockets.adapter.rooms.get(roomId)) { 9 | socket.emit("room already exist"); 10 | } 11 | else { 12 | const toType = (0, functions_1.shuffleList)(mode).join(" "); 13 | __1.rooms[roomId] = { 14 | players: [], 15 | toType, 16 | inGame: false, 17 | winner: null, 18 | }; 19 | socket.emit("words generated", __1.rooms[roomId].toType); 20 | socket.emit("create room success", roomId); 21 | // console.log(roomId); 22 | // console.log(io.sockets.adapter.rooms.get(roomId)); 23 | // const sockets = Array.from(io.sockets.sockets).map((socket) => socket[0]); 24 | // console.log("room created: ", socket.rooms); 25 | } 26 | }); 27 | }; 28 | exports.createRoomHandler = createRoomHandler; 29 | const updateRoomHandler = (socket) => { 30 | socket.on("room update", (user) => { 31 | const { roomId } = user; 32 | if (!__1.rooms[roomId]) 33 | return; 34 | const players = __1.rooms[roomId].players; 35 | __1.rooms[roomId].players = players.map((player) => (player.id !== user.id ? player : user)); 36 | __1.io.in(roomId).emit("room update", __1.rooms[roomId].players); 37 | // start game 38 | // const allPlayersReady = rooms[roomId].players.every((player) => player.isReady); 39 | // if (allPlayersReady) { 40 | // io.in(roomId).emit("start game"); 41 | // rooms[roomId].inGame = true; 42 | // } else { 43 | // rooms[roomId].inGame = false; 44 | // } 45 | }); 46 | }; 47 | exports.updateRoomHandler = updateRoomHandler; 48 | const joinRoomHander = (socket) => { 49 | socket.on("join room", ({ roomId, user }) => { 50 | socket.emit("end game"); 51 | const room = __1.rooms[roomId]; 52 | if (!room) { 53 | socket.emit("room invalid"); 54 | return; 55 | } 56 | else if (__1.rooms[roomId].inGame) { 57 | socket.emit("room in game"); 58 | return; 59 | } 60 | else { 61 | __1.rooms[roomId].players = [...__1.rooms[roomId].players, user]; 62 | __1.playerRooms[socket.id] = [roomId]; 63 | } 64 | socket.join(roomId); 65 | socket.emit("words generated", __1.rooms[roomId].toType); 66 | __1.io.in(roomId).emit("room update", __1.rooms[roomId].players); 67 | // socket.to(roomId).emit("notify", `${user.username} is here.`); 68 | __1.io.in(roomId).emit("receive chat", { username: user.username, value: "joined", id: user.id, type: "notification" }); 69 | // console.log("join", rooms); 70 | }); 71 | }; 72 | exports.joinRoomHander = joinRoomHander; 73 | const leaveRoomHandler = (socket) => { 74 | socket.on("leave room", (user) => { 75 | const { roomId } = user; 76 | const players = __1.rooms[roomId]; 77 | if (!players) 78 | return; 79 | __1.rooms[roomId].players = players.players.filter((player) => { 80 | if (player.id === user.id) { 81 | // socket.to(roomId).emit("leave room", player.username); 82 | __1.io.in(roomId).emit("receive chat", { username: player.username, value: "left", id: player.id }); 83 | } 84 | return player.id !== user.id; 85 | }); 86 | __1.io.in(roomId).emit("room update", __1.rooms[roomId].players); 87 | if (__1.rooms[roomId].players.length === 0) { 88 | delete __1.rooms[roomId]; 89 | } 90 | // console.log("leave ", rooms); 91 | }); 92 | }; 93 | exports.leaveRoomHandler = leaveRoomHandler; 94 | -------------------------------------------------------------------------------- /app/src/pages/multiplayer/[id].tsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from 'next/router'; 2 | import * as React from 'react'; 3 | import { toast } from 'react-toastify'; 4 | 5 | import Kbd from '@/components/Kbd'; 6 | import AnimateFade from '@/components/Layout/AnimateFade'; 7 | import Multiplayer from '@/components/Multiplayer/Multiplayer'; 8 | import Seo from '@/components/Seo'; 9 | 10 | import { useChatContext } from '@/context/Chat/ChatContext'; 11 | import { useRoomContext } from '@/context/Room/RoomContext'; 12 | import { Player } from '@/context/Room/types'; 13 | 14 | export default function MultiplayerPage() { 15 | const { 16 | room: { socket, user }, 17 | dispatch, 18 | resetTime, 19 | } = useRoomContext(); 20 | 21 | const { dispatch: chatDispatch } = useChatContext(); 22 | 23 | const router = useRouter(); 24 | 25 | React.useEffect(() => { 26 | if (user.id && router?.query?.id) { 27 | socket.emit('join room', { roomId: router?.query?.id, user }); 28 | dispatch({ type: 'SET_ROOM_ID', payload: router?.query?.id as string }); 29 | chatDispatch({ type: 'CLEAR_ROOM_CHAT' }); 30 | 31 | socket.off('room update').on('room update', (players: Player[]) => { 32 | dispatch({ type: 'SET_PLAYERS', payload: players }); 33 | }); 34 | 35 | socket.off('start game').on('start game', () => { 36 | dispatch({ type: 'SET_STATUS', payload: { progress: 0, wpm: 0 } }); 37 | dispatch({ type: 'SET_IS_FINISHED', payload: false }); 38 | dispatch({ type: 'SET_WINNER', payload: null }); 39 | resetTime(5).then(() => 40 | dispatch({ type: 'SET_IS_READY', payload: true }) 41 | ); 42 | }); 43 | 44 | dispatch({ type: 'SET_STATUS', payload: { progress: 0, wpm: 0 } }); 45 | dispatch({ type: 'SET_IS_READY', payload: false }); 46 | dispatch({ type: 'SET_IS_PLAYING', payload: false }); 47 | dispatch({ type: 'SET_IS_FINISHED', payload: false }); 48 | dispatch({ type: 'SET_WINNER', payload: null }); 49 | resetTime(0); 50 | 51 | socket.off('end game').on('end game', (playerId: string) => { 52 | dispatch({ type: 'SET_IS_PLAYING', payload: false }); 53 | dispatch({ type: 'SET_WINNER', payload: playerId }); 54 | dispatch({ type: 'SET_IS_READY', payload: false }); 55 | }); 56 | 57 | socket.off('room invalid').on('room invalid', () => { 58 | toast.error("Room doesn't exist.", { 59 | position: toast.POSITION.TOP_CENTER, 60 | toastId: "Room doesn't exist.", 61 | autoClose: 3000, 62 | }); 63 | router.push('/multiplayer'); 64 | }); 65 | 66 | socket.off('room in game').on('room in game', () => { 67 | toast.error('Room is currently in game.', { 68 | position: toast.POSITION.TOP_CENTER, 69 | toastId: 'Room is currently in game.', 70 | autoClose: 3000, 71 | }); 72 | router.push('/multiplayer'); 73 | }); 74 | 75 | socket.off('words generated').on('words generated', (text: string) => { 76 | dispatch({ type: 'SET_TEXT', payload: text }); 77 | }); 78 | } 79 | 80 | // eslint-disable-next-line react-hooks/exhaustive-deps 81 | }, [router.query, user.id]); 82 | 83 | return ( 84 | 85 | 86 | 87 |
88 |
89 |
90 | 91 | 92 |
93 |
94 | tab 95 | + 96 | enter 97 | - ready / cancel 98 |
99 |
100 | ctrl/cmd 101 | + 102 | k 103 | or 104 | p 105 | - command palette 106 |
107 |
108 |
109 |
110 |
111 |
112 | ); 113 | } 114 | -------------------------------------------------------------------------------- /app/src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from 'next/router'; 2 | import * as React from 'react'; 3 | import { FormProvider, useForm } from 'react-hook-form'; 4 | import { IoMdPerson } from 'react-icons/io'; 5 | import { RiTeamFill } from 'react-icons/ri'; 6 | 7 | import Button from '@/components/Button/Button'; 8 | import ChatBox from '@/components/Chat/ChatBox'; 9 | import Input from '@/components/Input'; 10 | import Kbd from '@/components/Kbd'; 11 | import AnimateFade from '@/components/Layout/AnimateFade'; 12 | import Seo from '@/components/Seo'; 13 | 14 | import { useRoomContext } from '@/context/Room/RoomContext'; 15 | 16 | export default function HomePage() { 17 | const router = useRouter(); 18 | 19 | const methods = useForm<{ code: string }>({ 20 | mode: 'onTouched', 21 | }); 22 | 23 | const { dispatch } = useRoomContext(); 24 | 25 | return ( 26 | 27 | 28 | 29 |
30 |
31 |
32 |
33 | 37 |
38 | 39 |
40 | 53 |
54 | 55 | { 63 | if (!e.target.value) return; 64 | dispatch({ type: 'SET_NICKNAME', payload: e.target.value }); 65 | }} 66 | className='text-center' 67 | /> 68 | 69 |
70 | 77 |
78 | 85 |
86 |
87 | 88 |
89 |
90 | tab 91 | + 92 | enter 93 | - restart test 94 |
95 |
96 | ctrl/cmd 97 | + 98 | k 99 | or 100 | p 101 | - command palette 102 |
103 |
104 |
105 |
106 |
107 |
108 | ); 109 | } 110 | -------------------------------------------------------------------------------- /app/src/components/Layout/Footer.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import * as React from 'react'; 3 | import { FaCode } from 'react-icons/fa'; 4 | 5 | import UnstyledLink from '@/components/Link/UnstyledLink'; 6 | 7 | export default function Footer() { 8 | return ( 9 |
14 |
15 | 19 | 20 |
github
21 |
22 | 26 |
inspired by
27 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 |
47 |
48 |
49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /app/src/components/Seo.tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head'; 2 | import { useRouter } from 'next/router'; 3 | 4 | // !STARTERCONF Change these default meta 5 | const defaultMeta = { 6 | title: 'Monkeytype Clone', 7 | siteName: 'Monkeytype Clone', 8 | description: 'A typeracer app based on Monkeytype', 9 | /** Without additional '/' on the end, e.g. https://theodorusclarence.com */ 10 | url: 'https://monkeytype-clone.vercel.app', 11 | type: 'website', 12 | robots: 'follow, index', 13 | /** 14 | * No need to be filled, will be populated with openGraph function 15 | * If you wish to use a normal image, just specify the path below 16 | */ 17 | image: 'https://monkeytype-clone.vercel.app/images/large-og.png', 18 | }; 19 | 20 | type SeoProps = { 21 | date?: string; 22 | templateTitle?: string; 23 | } & Partial; 24 | 25 | export default function Seo(props: SeoProps) { 26 | const router = useRouter(); 27 | const meta = { 28 | ...defaultMeta, 29 | ...props, 30 | }; 31 | meta['title'] = props.templateTitle 32 | ? `${props.templateTitle} | ${meta.siteName}` 33 | : meta.title; 34 | 35 | // Use siteName if there is templateTitle 36 | // but show full title if there is none 37 | // !STARTERCONF Follow config for opengraph, by deploying one on https://github.com/theodorusclarence/og 38 | // ? Uncomment code below if you want to use default open graph 39 | // meta['image'] = openGraph({ 40 | // description: meta.description, 41 | // siteName: props.templateTitle ? meta.siteName : meta.title, 42 | // templateTitle: props.templateTitle, 43 | // }); 44 | 45 | return ( 46 | 47 | {meta.title} 48 | 49 | 50 | 51 | 52 | {/* Open Graph */} 53 | 54 | 55 | 56 | 57 | 58 | {/* Twitter */} 59 | 60 | 61 | 62 | 63 | 64 | 65 | {/* Favicons */} 66 | {favicons.map((linkProps) => ( 67 | 68 | ))} 69 | 70 | 74 | 75 | 76 | ); 77 | } 78 | 79 | type Favicons = { 80 | rel: string; 81 | href: string; 82 | sizes?: string; 83 | type?: string; 84 | }; 85 | 86 | // !STARTERCONF this is the default favicon, you can generate your own from https://www.favicon-generator.org/ then replace the whole /public/favicon folder 87 | const favicons: Array = [ 88 | { 89 | rel: 'apple-touch-icon', 90 | sizes: '57x57', 91 | href: '/favicon/apple-icon-57x57.png', 92 | }, 93 | { 94 | rel: 'apple-touch-icon', 95 | sizes: '60x60', 96 | href: '/favicon/apple-icon-60x60.png', 97 | }, 98 | { 99 | rel: 'apple-touch-icon', 100 | sizes: '72x72', 101 | href: '/favicon/apple-icon-72x72.png', 102 | }, 103 | { 104 | rel: 'apple-touch-icon', 105 | sizes: '76x76', 106 | href: '/favicon/apple-icon-76x76.png', 107 | }, 108 | { 109 | rel: 'apple-touch-icon', 110 | sizes: '114x114', 111 | href: '/favicon/apple-icon-114x114.png', 112 | }, 113 | { 114 | rel: 'apple-touch-icon', 115 | sizes: '120x120', 116 | href: '/favicon/apple-icon-120x120.png', 117 | }, 118 | { 119 | rel: 'apple-touch-icon', 120 | sizes: '144x144', 121 | href: '/favicon/apple-icon-144x144.png', 122 | }, 123 | { 124 | rel: 'apple-touch-icon', 125 | sizes: '152x152', 126 | href: '/favicon/apple-icon-152x152.png', 127 | }, 128 | { 129 | rel: 'apple-touch-icon', 130 | sizes: '180x180', 131 | href: '/favicon/apple-icon-180x180.png', 132 | }, 133 | { 134 | rel: 'icon', 135 | type: 'image/png', 136 | sizes: '192x192', 137 | href: '/favicon/android-icon-192x192.png', 138 | }, 139 | { 140 | rel: 'icon', 141 | type: 'image/png', 142 | sizes: '32x32', 143 | href: '/favicon/favicon-32x32.png', 144 | }, 145 | { 146 | rel: 'icon', 147 | type: 'image/png', 148 | sizes: '96x96', 149 | href: '/favicon/favicon-96x96.png', 150 | }, 151 | { 152 | rel: 'icon', 153 | type: 'image/png', 154 | sizes: '16x16', 155 | href: '/favicon/favicon-16x16.png', 156 | }, 157 | { 158 | rel: 'manifest', 159 | href: '/favicon/manifest.json', 160 | }, 161 | ]; 162 | -------------------------------------------------------------------------------- /app/src/data/numbers.json: -------------------------------------------------------------------------------- 1 | [ 2 | "6793164", 3 | "06473", 4 | "9034251", 5 | "929832", 6 | "73148", 7 | "640828", 8 | "53851", 9 | "96180296", 10 | "42176082", 11 | "17297527", 12 | "6434876", 13 | "67803208", 14 | "7404072", 15 | "416828", 16 | "34170", 17 | "34746", 18 | "860346", 19 | "487828", 20 | "0653705", 21 | "2825173", 22 | "569179", 23 | "720537", 24 | "97079", 25 | "86809", 26 | "82498", 27 | "3896258", 28 | "7364346", 29 | "1416031", 30 | "25483", 31 | "848494", 32 | "7952807", 33 | "7871742", 34 | "64189534", 35 | "3575421", 36 | "59343", 37 | "679435", 38 | "47372", 39 | "24284", 40 | "386183", 41 | "6594325", 42 | "53852", 43 | "97547", 44 | "21781", 45 | "1875646", 46 | "4146286", 47 | "80470", 48 | "62670609", 49 | "52182857", 50 | "7546070", 51 | "5125683", 52 | "17451916", 53 | "32808515", 54 | "26260", 55 | "54567", 56 | "5354230", 57 | "5363587", 58 | "348478", 59 | "37982142", 60 | "51903848", 61 | "15207", 62 | "2073945", 63 | "4164275", 64 | "267630", 65 | "80265", 66 | "27261206", 67 | "26271", 68 | "521318", 69 | "532918", 70 | "124725", 71 | "604658", 72 | "464268", 73 | "79642702", 74 | "18939", 75 | "781325", 76 | "31870245", 77 | "31863215", 78 | "3434218", 79 | "9147936", 80 | "321268", 81 | "023656", 82 | "7630543", 83 | "54927", 84 | "15754965", 85 | "5736275", 86 | "981921", 87 | "5416728", 88 | "7516241", 89 | "9357918", 90 | "241726", 91 | "7268491", 92 | "072097", 93 | "43723", 94 | "5347823", 95 | "6213425", 96 | "54164317", 97 | "916272", 98 | "9408172", 99 | "29170", 100 | "129523", 101 | "63456", 102 | "5848528", 103 | "1919714", 104 | "947672", 105 | "76373", 106 | "276247", 107 | "4819856", 108 | "684757", 109 | "74156963", 110 | "04756", 111 | "1575232", 112 | "6485451", 113 | "68107350", 114 | "18387", 115 | "0583947", 116 | "83875645", 117 | "13164261", 118 | "171283", 119 | "31769", 120 | "89427646", 121 | "78639492", 122 | "87216", 123 | "2175938", 124 | "752365", 125 | "48464", 126 | "75396", 127 | "2502313", 128 | "94652852", 129 | "848427", 130 | "43164365", 131 | "51731", 132 | "04174", 133 | "84784", 134 | "28626", 135 | "197976", 136 | "8527548", 137 | "42743968", 138 | "518547", 139 | "175430", 140 | "9527230", 141 | "6462729", 142 | "172759", 143 | "1265478", 144 | "3043153", 145 | "14573", 146 | "8382082", 147 | "73075", 148 | "46725", 149 | "13716", 150 | "47136", 151 | "821653", 152 | "7938640", 153 | "62747", 154 | "2782170", 155 | "2468740", 156 | "8257138", 157 | "26836", 158 | "45737", 159 | "68351787", 160 | "75423", 161 | "3876974", 162 | "0910523", 163 | "9313036", 164 | "201540", 165 | "58613585", 166 | "62601737", 167 | "98738", 168 | "41321", 169 | "127567", 170 | "73617", 171 | "182151", 172 | "16510", 173 | "765702", 174 | "5614503", 175 | "1636079", 176 | "1976064", 177 | "17254272", 178 | "5489343", 179 | "040356", 180 | "564312", 181 | "64272684", 182 | "71786512", 183 | "502741", 184 | "56181", 185 | "3275759", 186 | "89358", 187 | "85364395", 188 | "3915128", 189 | "7546140", 190 | "46483547", 191 | "59642", 192 | "19724937", 193 | "1582340", 194 | "457256", 195 | "1235404", 196 | "84626", 197 | "1585306", 198 | "47947", 199 | "31323", 200 | "20454", 201 | "6727820", 202 | "321058", 203 | "6865437", 204 | "53541", 205 | "319374", 206 | "5708061", 207 | "1954978", 208 | "457414", 209 | "478751", 210 | "457517", 211 | "743198", 212 | "567641", 213 | "348153", 214 | "59543606", 215 | "1304371", 216 | "5073726", 217 | "87518173", 218 | "0741783", 219 | "72421", 220 | "673471", 221 | "273837", 222 | "476860", 223 | "9725121", 224 | "64582", 225 | "49280", 226 | "1352525", 227 | "3512641", 228 | "81671", 229 | "67284", 230 | "83475", 231 | "780873", 232 | "516405", 233 | "06382", 234 | "935273", 235 | "32767645", 236 | "2156367", 237 | "84567350", 238 | "826183", 239 | "26192", 240 | "3518545", 241 | "25829", 242 | "4379216", 243 | "5057838", 244 | "46472", 245 | "519282", 246 | "41451589", 247 | "21412374", 248 | "72762686", 249 | "16825", 250 | "24715", 251 | "46531", 252 | "561371", 253 | "7393075", 254 | "54095232", 255 | "6715729", 256 | "57678", 257 | "357474", 258 | "18265", 259 | "31495897", 260 | "675061", 261 | "237587", 262 | "42632", 263 | "124712", 264 | "453032", 265 | "75245473", 266 | "040270", 267 | "69458431", 268 | "41576", 269 | "517471", 270 | "610631", 271 | "29715", 272 | "879872", 273 | "72712", 274 | "96875", 275 | "417686", 276 | "7376253", 277 | "578484", 278 | "6327084", 279 | "42526", 280 | "27696", 281 | "6062375", 282 | "89782", 283 | "58173", 284 | "21091", 285 | "8632731", 286 | "92531", 287 | "40179", 288 | "2313156", 289 | "26235", 290 | "834084", 291 | "49281", 292 | "5621967", 293 | "3475103", 294 | "152727", 295 | "726063", 296 | "30254", 297 | "36787968", 298 | "56519", 299 | "3816451", 300 | "72567173" 301 | ] 302 | -------------------------------------------------------------------------------- /app/src/styles/globals.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,300;0,500;0,600;1,400&display=swap'); 2 | @import url('https://fonts.googleapis.com/css2?family=Chakra+Petch:wght@300;400;500;600;700&display=swap'); 3 | 4 | @tailwind base; 5 | @tailwind components; 6 | @tailwind utilities; 7 | 8 | :root { 9 | /* #region /**=========== Default Color =========== */ 10 | 11 | --bg-color: 20 20 20; 12 | --font-color: 82 82 82; 13 | --hl-color: 178 177 185; 14 | --fg-color: 58 163 193; 15 | --font-family: 'Poppins'; 16 | 17 | /* #endregion /**======== Default Color =========== */ 18 | } 19 | 20 | @layer base { 21 | /* inter var - latin */ 22 | @font-face { 23 | font-family: 'Inter'; 24 | font-style: normal; 25 | font-weight: 100 900; 26 | font-display: optional; 27 | src: url('/fonts/inter-var-latin.woff2') format('woff2'); 28 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, 29 | U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, 30 | U+2215, U+FEFF, U+FFFD; 31 | } 32 | 33 | .cursor-newtab { 34 | cursor: url('/images/new-tab.png') 10 10, pointer; 35 | } 36 | 37 | .default { 38 | --bg-color: 20 20 20; 39 | --font-color: 82 82 82; 40 | --hl-color: 178 177 185; 41 | --fg-color: 58 163 193; 42 | } 43 | 44 | /* #region /**=========== Typography =========== */ 45 | .h0 { 46 | @apply font-primary text-3xl font-bold text-hl md:text-5xl; 47 | } 48 | 49 | h1, 50 | .h1 { 51 | @apply font-primary text-2xl font-bold text-hl md:text-4xl; 52 | } 53 | 54 | h2, 55 | .h2 { 56 | @apply font-primary text-xl font-bold text-hl md:text-3xl; 57 | } 58 | 59 | h3, 60 | .h3 { 61 | @apply font-primary text-lg font-bold text-hl md:text-2xl; 62 | } 63 | 64 | h4, 65 | .h4 { 66 | @apply font-primary text-base font-bold text-hl md:text-lg; 67 | } 68 | 69 | body, 70 | .p { 71 | @apply font-primary text-sm text-hl md:text-base; 72 | } 73 | 74 | p, 75 | span { 76 | @apply font-primary text-hl; 77 | } 78 | /* #endregion /**======== Typography =========== */ 79 | 80 | .layout { 81 | /* 1100px */ 82 | max-width: 68.75rem; 83 | @apply mx-auto w-11/12; 84 | --toastify-color-success: rgb(var(--fg-color)); 85 | --toastify-color-error: rgb(var(--bg-color)); 86 | --toastify-color-progress-success: rgb(var(--bg-color)); 87 | --toastify-icon-color-success: rgb(var(--bg-color)); 88 | --toastify-color-progress-error: rgb(var(--bg-color)); 89 | --toastify-icon-color-error: rgb(var(--bg-color)); 90 | } 91 | 92 | .caret { 93 | transition: left 0.1s ease; 94 | position: absolute; 95 | display: inline; 96 | } 97 | 98 | .bg-dark a.custom-link { 99 | @apply border-gray-200 hover:border-gray-200/0; 100 | } 101 | 102 | /* Class to adjust with sticky footer */ 103 | .min-h-main { 104 | @apply min-h-[calc(100vh-56px)]; 105 | } 106 | } 107 | 108 | @layer utilities { 109 | /* Customize website's scrollbar like Mac OS 110 | Not supports in Firefox and IE */ 111 | 112 | /* total width */ 113 | .scrollbar::-webkit-scrollbar { 114 | /* Uncomment the following code to hide scrollbar, while still being able to scroll */ 115 | /* display: none; */ 116 | width: 16px; 117 | @apply bg-transparent; 118 | } 119 | 120 | /* background of the scrollbar except button or resizer */ 121 | .scrollbar::-webkit-scrollbar-track { 122 | @apply bg-transparent; 123 | } 124 | .scrollbar::-webkit-scrollbar-track:hover { 125 | @apply bg-transparent; 126 | } 127 | 128 | /* scrollbar itself */ 129 | .scrollbar::-webkit-scrollbar-thumb { 130 | background-color: rgb(var(--fg-color)); 131 | border-radius: 16px; 132 | border: 5px solid rgb(var(--bg-color)); 133 | } 134 | .scrollbar::-webkit-scrollbar-thumb:hover { 135 | background-color: rgba(var(--fg-color) / 0.8); 136 | } 137 | 138 | /* set button(top and bottom of the scrollbar) */ 139 | .scrollbar::-webkit-scrollbar-button { 140 | display: none; 141 | } 142 | 143 | .scrollbar-hide { 144 | -ms-overflow-style: none; /* Internet Explorer 10+ */ 145 | scrollbar-width: none; /* Firefox */ 146 | } 147 | 148 | .scrollbar-hide::-webkit-scrollbar { 149 | display: none; /* Safari and Chrome */ 150 | } 151 | 152 | .animated-underline { 153 | background-image: linear-gradient(#33333300, #33333300), 154 | linear-gradient(to right, rgb(var(--hl-color)), rgb(var(--font-color))); 155 | background-size: 100% 2px, 0 2px; 156 | background-position: 100% 100%, 0 100%; 157 | background-repeat: no-repeat; 158 | } 159 | @media (prefers-reduced-motion: no-preference) { 160 | .animated-underline { 161 | transition: 0.3s ease; 162 | transition-property: background-size, color, background-color, 163 | border-color; 164 | } 165 | } 166 | .animated-underline:hover, 167 | .animated-underline:focus-visible { 168 | background-size: 0 2px, 100% 2px; 169 | } 170 | 171 | .loading { 172 | display: inline-block; 173 | clip-path: inset(0 0.9ch 0 0); 174 | animation: l 1s steps(4) infinite; 175 | } 176 | 177 | @keyframes l { 178 | to { 179 | clip-path: inset(0 -1ch 0 0); 180 | } 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /app/src/pages/leaderboard.tsx: -------------------------------------------------------------------------------- 1 | // !STARTERCONF You can delete this page 2 | import clsx from 'clsx'; 3 | import TimeAgo from 'javascript-time-ago'; 4 | import en from 'javascript-time-ago/locale/en'; 5 | import Link from 'next/link'; 6 | import * as React from 'react'; 7 | 8 | import useLeaderboard, { LeaderboardPayload } from '@/hooks/useLeaderboard'; 9 | import useProfile from '@/hooks/useProfile'; 10 | 11 | import AnimateFade from '@/components/Layout/AnimateFade'; 12 | import TableRow from '@/components/Leaderboard/TableRow'; 13 | import TableSkeleton from '@/components/Leaderboard/TableSkeleton'; 14 | import ArrowLink from '@/components/Link/ArrowLink'; 15 | import Seo from '@/components/Seo'; 16 | 17 | // English. 18 | TimeAgo.addLocale(en); 19 | 20 | export default function LeaderboardPage() { 21 | // todo: Get all leaderboards 22 | // todo: Get daily leaderboards 23 | 24 | const { user } = useProfile(); 25 | const { daily, allTime, isLoading } = useLeaderboard(); 26 | 27 | // Create formatter (English). 28 | const timeAgo = new TimeAgo('en-US'); 29 | 30 | const [selected, setSelected] = React.useState('daily'); 31 | 32 | return ( 33 | 34 | 35 | 36 |
37 |
38 |
39 |
40 | 41 | back to home 42 | 43 | {!user && ( 44 | 45 | 46 |
47 | tip: sign in to save your stats 48 |
49 |
50 | 51 | )} 52 |
53 |

top 100 leaderboard

54 |
55 | 64 | 73 |
74 | 75 |
76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | {isLoading && selected === 'all time' && } 89 | {isLoading && selected === 'daily' && } 90 | 91 | {selected === 'all time' && 92 | allTime?.map( 93 | (leaderboard: LeaderboardPayload, index: number) => { 94 | const { wpm, time, type, createdAt, name, id } = 95 | leaderboard; 96 | const date = timeAgo.format( 97 | new Date(createdAt as string) 98 | ); 99 | return ( 100 | 109 | ); 110 | } 111 | )} 112 | {selected === 'daily' && 113 | daily?.map( 114 | (leaderboard: LeaderboardPayload, index: number) => { 115 | const { id, wpm, time, type, createdAt, name } = 116 | leaderboard; 117 | const date = timeAgo.format( 118 | new Date(createdAt as string) 119 | ); 120 | return ( 121 | 130 | ); 131 | } 132 | )} 133 | 134 |
#userwpmtypetimedate
135 |
136 |
137 |
138 |
139 |
140 | ); 141 | } 142 | -------------------------------------------------------------------------------- /app/.vscode/typescriptreact.code-snippets: -------------------------------------------------------------------------------- 1 | { 2 | //#region //*=========== React =========== 3 | "import React": { 4 | "prefix": "ir", 5 | "body": ["import * as React from 'react';"] 6 | }, 7 | "React.useState": { 8 | "prefix": "us", 9 | "body": [ 10 | "const [${1}, set${1/(^[a-zA-Z])(.*)/${1:/upcase}${2}/}] = React.useState<$3>(${2:initial${1/(^[a-zA-Z])(.*)/${1:/upcase}${2}/}})$0" 11 | ] 12 | }, 13 | "React.useEffect": { 14 | "prefix": "uf", 15 | "body": ["React.useEffect(() => {", " $0", "}, []);"] 16 | }, 17 | "React.useReducer": { 18 | "prefix": "ur", 19 | "body": [ 20 | "const [state, dispatch] = React.useReducer(${0:someReducer}, {", 21 | " ", 22 | "})" 23 | ] 24 | }, 25 | "React.useRef": { 26 | "prefix": "urf", 27 | "body": ["const ${1:someRef} = React.useRef($0)"] 28 | }, 29 | "React Functional Component": { 30 | "prefix": "rc", 31 | "body": [ 32 | "import * as React from 'react';\n", 33 | "export default function ${1:${TM_FILENAME_BASE}}() {", 34 | " return (", 35 | "
", 36 | " $0", 37 | "
", 38 | " )", 39 | "}" 40 | ] 41 | }, 42 | "React Functional Component with Props": { 43 | "prefix": "rcp", 44 | "body": [ 45 | "import * as React from 'react';\n", 46 | "import clsxm from '@/lib/clsxm';\n", 47 | "type ${1:${TM_FILENAME_BASE}}Props= {\n", 48 | "} & React.ComponentPropsWithoutRef<'div'>\n", 49 | "export default function ${1:${TM_FILENAME_BASE}}({className, ...rest}: ${1:${TM_FILENAME_BASE}}Props) {", 50 | " return (", 51 | "
", 52 | " $0", 53 | "
", 54 | " )", 55 | "}" 56 | ] 57 | }, 58 | //#endregion //*======== React =========== 59 | 60 | //#region //*=========== Commons =========== 61 | "Region": { 62 | "prefix": "reg", 63 | "scope": "javascript, typescript, javascriptreact, typescriptreact", 64 | "body": [ 65 | "//#region //*=========== ${1} ===========", 66 | "${TM_SELECTED_TEXT}$0", 67 | "//#endregion //*======== ${1} ===========" 68 | ] 69 | }, 70 | "Region CSS": { 71 | "prefix": "regc", 72 | "scope": "css, scss", 73 | "body": [ 74 | "/* #region /**=========== ${1} =========== */", 75 | "${TM_SELECTED_TEXT}$0", 76 | "/* #endregion /**======== ${1} =========== */" 77 | ] 78 | }, 79 | //#endregion //*======== Commons =========== 80 | 81 | //#region //*=========== Nextjs =========== 82 | "Next Pages": { 83 | "prefix": "np", 84 | "body": [ 85 | "import * as React from 'react';\n", 86 | "import Layout from '@/components/layout/Layout';", 87 | "import Seo from '@/components/Seo';\n", 88 | "export default function ${1:${TM_FILENAME_BASE/(^[a-zA-Z])(.*)/${1:/upcase}${2}/}}Page() {", 89 | " return (", 90 | " ", 91 | " \n", 92 | "
\n", 93 | "
", 94 | "
", 95 | " $0", 96 | "
", 97 | "
", 98 | "
", 99 | "
", 100 | " )", 101 | "}" 102 | ] 103 | }, 104 | "Next API": { 105 | "prefix": "napi", 106 | "body": [ 107 | "import { NextApiRequest, NextApiResponse } from 'next';\n", 108 | "export default async function ${1:${TM_FILENAME_BASE}}(req: NextApiRequest, res: NextApiResponse) {", 109 | " if (req.method === 'GET') {", 110 | " res.status(200).json({ name: 'Bambang' });", 111 | " } else {", 112 | " res.status(405).json({ message: 'Method Not Allowed' });", 113 | " }", 114 | "}" 115 | ] 116 | }, 117 | "Get Static Props": { 118 | "prefix": "gsp", 119 | "body": [ 120 | "export const getStaticProps = async (context: GetStaticPropsContext) => {", 121 | " return {", 122 | " props: {}", 123 | " };", 124 | "}" 125 | ] 126 | }, 127 | "Get Static Paths": { 128 | "prefix": "gspa", 129 | "body": [ 130 | "export const getStaticPaths: GetStaticPaths = async () => {", 131 | " return {", 132 | " paths: [", 133 | " { params: { $1 }}", 134 | " ],", 135 | " fallback: ", 136 | " };", 137 | "}" 138 | ] 139 | }, 140 | "Get Server Side Props": { 141 | "prefix": "gssp", 142 | "body": [ 143 | "export const getServerSideProps = async (context: GetServerSidePropsContext) => {", 144 | " return {", 145 | " props: {}", 146 | " };", 147 | "}" 148 | ] 149 | }, 150 | "Infer Get Static Props": { 151 | "prefix": "igsp", 152 | "body": "InferGetStaticPropsType" 153 | }, 154 | "Infer Get Server Side Props": { 155 | "prefix": "igssp", 156 | "body": "InferGetServerSidePropsType" 157 | }, 158 | "Import useRouter": { 159 | "prefix": "imust", 160 | "body": ["import { useRouter } from 'next/router';"] 161 | }, 162 | "Import Next Image": { 163 | "prefix": "imimg", 164 | "body": ["import Image from 'next/image';"] 165 | }, 166 | "Import Next Link": { 167 | "prefix": "iml", 168 | "body": ["import Link from 'next/link';"] 169 | }, 170 | //#endregion //*======== Nextjs =========== 171 | 172 | //#region //*=========== Snippet Wrap =========== 173 | "Wrap with Fragment": { 174 | "prefix": "ff", 175 | "body": ["<>", "\t${TM_SELECTED_TEXT}", ""] 176 | }, 177 | "Wrap with clsx": { 178 | "prefix": "cx", 179 | "body": ["{clsx(${TM_SELECTED_TEXT}$0)}"] 180 | }, 181 | "Wrap with clsxm": { 182 | "prefix": "cxm", 183 | "body": ["{clsxm(${TM_SELECTED_TEXT}$0, className)}"] 184 | } 185 | //#endregion //*======== Snippet Wrap =========== 186 | } 187 | -------------------------------------------------------------------------------- /app/src/data/commands.ts: -------------------------------------------------------------------------------- 1 | const commands = [ 2 | { 3 | icon: '', 4 | commandName: 'theme', 5 | contents: [ 6 | { 7 | icon: '', 8 | commandName: 'default', 9 | contents: [], 10 | description: '- black, light-gray, and blue', 11 | }, 12 | { 13 | icon: '', 14 | commandName: 'plain', 15 | contents: [], 16 | description: '- black, gray, and white', 17 | }, 18 | { 19 | icon: '', 20 | commandName: 'winter', 21 | contents: [], 22 | description: '- blue, cyan, and white', 23 | }, 24 | { 25 | icon: '', 26 | commandName: 'snowy-night', 27 | contents: [], 28 | description: '- black, dark-gray, and skyblue', 29 | }, 30 | { 31 | icon: '', 32 | commandName: 'vintage', 33 | contents: [], 34 | description: '- green and peach', 35 | }, 36 | { 37 | icon: '', 38 | commandName: 'vampire', 39 | contents: [], 40 | description: '- black, red, and white', 41 | }, 42 | { 43 | icon: '', 44 | commandName: 'bubblegum', 45 | contents: [], 46 | description: '- lightblue, pink, and purple', 47 | }, 48 | { 49 | icon: '', 50 | commandName: 'green-tea', 51 | contents: [], 52 | description: '- white, green, and yellowish-green', 53 | }, 54 | { 55 | icon: '', 56 | commandName: 'wood', 57 | contents: [], 58 | description: '- brown, dark-gray, and light-gray', 59 | }, 60 | { 61 | icon: '', 62 | commandName: 'beach', 63 | contents: [], 64 | description: '- blue, white, and yellow', 65 | }, 66 | { 67 | icon: '', 68 | commandName: 'halloween', 69 | contents: [], 70 | description: '- black, orange, and white', 71 | }, 72 | { 73 | icon: '', 74 | commandName: 'botanical', 75 | contents: [], 76 | description: '- light-green, green, and peach', 77 | }, 78 | { 79 | icon: '', 80 | commandName: 'eye-pain', 81 | contents: [], 82 | description: "- you probably won't like this", 83 | }, 84 | { 85 | icon: '', 86 | commandName: 'exit', 87 | contents: [], 88 | description: '- quit command palette', 89 | }, 90 | ], 91 | description: '- choose your own theme', 92 | }, 93 | { 94 | icon: '', 95 | commandName: 'time', 96 | contents: [ 97 | { 98 | icon: '', 99 | commandName: '15', 100 | contents: [], 101 | description: '- 15 seconds', 102 | }, 103 | { 104 | icon: '', 105 | commandName: '30', 106 | contents: [], 107 | description: '- 30 seconds', 108 | }, 109 | { 110 | icon: '', 111 | commandName: '45', 112 | contents: [], 113 | description: '- 45 seconds', 114 | }, 115 | { 116 | icon: '', 117 | commandName: '60', 118 | contents: [], 119 | description: '- equivalent to 1 minute', 120 | }, 121 | { 122 | icon: '', 123 | commandName: '120', 124 | contents: [], 125 | description: '- equivalent to 2 minutes', 126 | }, 127 | { 128 | icon: '', 129 | commandName: 'exit', 130 | contents: [], 131 | description: '- quit command palette', 132 | }, 133 | ], 134 | description: '- pick your own pace', 135 | }, 136 | { 137 | icon: '', 138 | commandName: 'type', 139 | contents: [ 140 | { 141 | icon: '', 142 | commandName: 'words', 143 | contents: [], 144 | description: '- words only', 145 | }, 146 | { 147 | icon: '', 148 | commandName: 'sentences', 149 | contents: [], 150 | description: '- sentences only', 151 | }, 152 | { 153 | icon: '', 154 | commandName: 'numbers', 155 | contents: [], 156 | description: '- numbers only', 157 | }, 158 | { 159 | icon: '', 160 | commandName: 'exit', 161 | contents: [], 162 | description: '- quit command palette', 163 | }, 164 | ], 165 | description: '- words? sentences? numbers?', 166 | }, 167 | { 168 | icon: '', 169 | commandName: 'font family', 170 | contents: [ 171 | { 172 | icon: '', 173 | commandName: 'inter', 174 | contents: [], 175 | description: '- The five boxing wizards jump quickly.', 176 | }, 177 | { 178 | icon: '', 179 | commandName: 'poppins', 180 | contents: [], 181 | description: '- The five boxing wizards jump quickly.', 182 | }, 183 | { 184 | icon: '', 185 | commandName: 'chakra-petch', 186 | contents: [], 187 | description: '- The five boxing wizards jump quickly.', 188 | }, 189 | { 190 | icon: '', 191 | commandName: 'exit', 192 | contents: [], 193 | description: '- quit command palette', 194 | }, 195 | ], 196 | description: '- choose your own font', 197 | }, 198 | { 199 | icon: '', 200 | commandName: 'zen mode', 201 | contents: [ 202 | { 203 | icon: '', 204 | commandName: 'on', 205 | contents: [], 206 | description: '- turn on zen mode', 207 | }, 208 | { 209 | icon: '', 210 | commandName: 'off', 211 | contents: [], 212 | description: '- turn off zen mode', 213 | }, 214 | ], 215 | description: '- only show time and words when typing', 216 | }, 217 | { 218 | icon: '', 219 | commandName: 'exit', 220 | contents: [], 221 | description: '- quit command palette', 222 | }, 223 | ]; 224 | 225 | export type CommandType = typeof commands[number]; 226 | 227 | export default commands; 228 | -------------------------------------------------------------------------------- /app/src/components/Chat/ChatBox.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import { AnimatePresence, motion } from 'framer-motion'; 3 | import { useRouter } from 'next/router'; 4 | import * as React from 'react'; 5 | import { GiDiscussion } from 'react-icons/gi'; 6 | 7 | import { useChatContext } from '@/context/Chat/ChatContext'; 8 | import { Chat } from '@/context/Chat/types'; 9 | import { useRoomContext } from '@/context/Room/RoomContext'; 10 | 11 | import Bubble from './Bubble'; 12 | import ChatInput from './ChatInput'; 13 | 14 | export default function ChatBox({ 15 | className, 16 | label, 17 | isRoomChat, 18 | }: { 19 | className: string; 20 | label: string; 21 | isRoomChat?: boolean; 22 | }) { 23 | const { 24 | room: { 25 | isChatOpen, 26 | user: { id }, 27 | socket, 28 | }, 29 | dispatch, 30 | } = useRoomContext(); 31 | 32 | const { 33 | chat: { roomChat, publicChat, onlineUsers, showNotification }, 34 | dispatch: chatDispatch, 35 | } = useChatContext(); 36 | 37 | const { pathname } = useRouter(); 38 | 39 | const [isPublic, setIsPublic] = React.useState(false); 40 | 41 | const divRef = React.useRef() as React.MutableRefObject; 42 | 43 | React.useEffect(() => { 44 | isChatOpen && 45 | chatDispatch({ type: 'SET_SHOW_NOTIFICATION', payload: false }); 46 | socket 47 | .off('receive chat') 48 | .on('receive chat', ({ id, username, value, type, roomId }: Chat) => { 49 | if (roomId === 'public') { 50 | chatDispatch({ 51 | type: 'ADD_PUBLIC_CHAT', 52 | payload: { id, username, value, type, roomId }, 53 | }); 54 | } else { 55 | chatDispatch({ 56 | type: 'ADD_ROOM_CHAT', 57 | payload: { id, username, value, type, roomId }, 58 | }); 59 | } 60 | if (!isChatOpen) 61 | chatDispatch({ type: 'SET_SHOW_NOTIFICATION', payload: true }); 62 | }); 63 | }, [chatDispatch, isChatOpen, socket]); 64 | 65 | React.useEffect(() => { 66 | if (divRef.current && isChatOpen) { 67 | divRef.current.scrollTop = divRef.current.scrollHeight; 68 | } 69 | }, [roomChat, publicChat, isChatOpen, isPublic]); 70 | 71 | return ( 72 | 73 | 80 | {showNotification && ( 81 |
86 | ! 87 |
88 | )} 89 |
isChatOpen && dispatch({ type: 'TOGGLE_CHAT' })} 91 | className={`fixed inset-0 flex cursor-default gap-4 rounded-lg bg-bg/90 opacity-0 transition-all duration-300 ${ 92 | isChatOpen ? 'z-30 opacity-100' : 'pointer-events-none -z-10' 93 | } ${className}`} 94 | >
95 | 96 | {isChatOpen && ( 97 | 104 |
105 |
106 |
107 | {isRoomChat && ( 108 | 117 | )} 118 | 127 |
128 | 129 | {onlineUsers} online 130 | 131 |
132 |
136 | {(isPublic || ['/multiplayer', '/'].includes(pathname)) && 137 | publicChat.map((chat, index) => 138 | chat.id === id ? ( 139 | 145 | ) : ( 146 | 152 | ) 153 | )} 154 | {!isPublic && 155 | isRoomChat && 156 | roomChat.map((chat, index) => 157 | chat.id === id ? ( 158 | 164 | ) : ( 165 | 171 | ) 172 | )} 173 |
174 | 179 |
180 |
181 | )} 182 |
183 |
184 | ); 185 | } 186 | -------------------------------------------------------------------------------- /app/src/pages/account.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import clsx from 'clsx'; 3 | import { AnimatePresence, motion } from 'framer-motion'; 4 | import TimeAgo from 'javascript-time-ago'; 5 | import en from 'javascript-time-ago/locale/en'; 6 | import * as React from 'react'; 7 | import { FaSignOutAlt, FaUserCircle } from 'react-icons/fa'; 8 | 9 | import useAuth from '@/hooks/useAuth'; 10 | import { LeaderboardPayload } from '@/hooks/useLeaderboard'; 11 | import useProfile from '@/hooks/useProfile'; 12 | 13 | import Login from '@/components/Account/Login'; 14 | import Button from '@/components/Button/Button'; 15 | import AnimateFade from '@/components/Layout/AnimateFade'; 16 | import ArrowLink from '@/components/Link/ArrowLink'; 17 | import Seo from '@/components/Seo'; 18 | 19 | // English. 20 | TimeAgo.addLocale(en); 21 | 22 | export default function AccountPage() { 23 | const { logout, isValidating } = useAuth(); 24 | const { user, profileStats } = useProfile(); 25 | 26 | // Create formatter (English). 27 | const timeAgo = new TimeAgo('en-US'); 28 | 29 | return ( 30 | 31 | 32 | 33 |
34 |
35 |
36 | 37 | back to home 38 | 39 |

account

40 | 41 | 42 | {user ? ( 43 | 50 |
51 |
52 | 53 |
54 |
55 | {user.name} 56 | 57 | Joined{' '} 58 | {timeAgo && timeAgo?.format(new Date(user.createdAt))} 59 | 60 |
61 |
62 |
63 |

64 | Personal Best 65 |

66 |
67 | {profileStats?.best.length === 0 68 | ? '-' 69 | : profileStats?.best.map((best: LeaderboardPayload) => ( 70 |
74 | 75 | 76 | {best.wpm} wpm 77 | 78 | 79 | {best.type} 80 | 81 | 82 | {best.time}s 83 | 84 | 85 | 86 | {timeAgo && 87 | timeAgo?.format( 88 | new Date(best.createdAt as string) 89 | )} 90 | 91 |
92 | ))} 93 |
94 |
95 |
96 |

97 | Recent tests 98 |

99 |
100 | {profileStats?.recent.length === 0 101 | ? '-' 102 | : profileStats?.recent.map((recent: any) => ( 103 |
107 | 108 | 109 | {recent.wpm} wpm 110 | 111 | 112 | {recent.type} 113 | 114 | 115 | {recent.time}s 116 | 117 | 118 | 119 | {timeAgo && 120 | timeAgo?.format( 121 | new Date(recent.createdAt as string) 122 | )} 123 | 124 |
125 | ))} 126 |
127 |
128 |
129 | {user && ( 130 | 138 | )} 139 |
140 | ) : ( 141 | 147 | 148 | 149 | )} 150 |
151 |
152 |
153 |
154 |
155 | ); 156 | } 157 | -------------------------------------------------------------------------------- /app/src/pages/multiplayer/index.tsx: -------------------------------------------------------------------------------- 1 | import { yupResolver } from '@hookform/resolvers/yup'; 2 | import clsx from 'clsx'; 3 | import { useRouter } from 'next/router'; 4 | import * as React from 'react'; 5 | import { FormProvider, useForm } from 'react-hook-form'; 6 | import { CgSpinner } from 'react-icons/cg'; 7 | import { FaArrowRight } from 'react-icons/fa'; 8 | import { RiTeamFill } from 'react-icons/ri'; 9 | import { toast } from 'react-toastify'; 10 | import * as yup from 'yup'; 11 | 12 | import { createRoom } from '@/lib/socket/roomHandler'; 13 | 14 | import Button from '@/components/Button/Button'; 15 | import ChatBox from '@/components/Chat/ChatBox'; 16 | import Input from '@/components/Input'; 17 | import AnimateFade from '@/components/Layout/AnimateFade'; 18 | import Seo from '@/components/Seo'; 19 | 20 | import { useRoomContext } from '@/context/Room/RoomContext'; 21 | 22 | const schema = yup.object().shape({ 23 | code: yup 24 | .string() 25 | .required('code is required') 26 | .length(6, 'code must be 6 characters long'), 27 | }); 28 | 29 | export default function MultiplayerPage() { 30 | const methods = useForm<{ code: string }>({ 31 | mode: 'onTouched', 32 | resolver: yupResolver(schema), 33 | }); 34 | const { handleSubmit } = methods; 35 | 36 | const { 37 | room: { socket, mode }, 38 | dispatch, 39 | resetTime, 40 | } = useRoomContext(); 41 | 42 | const router = useRouter(); 43 | 44 | const [isCreatingRoom, setIsCreatingRoom] = React.useState(false); 45 | const [isJoiningRoom, setIsJoiningRoom] = React.useState(false); 46 | 47 | React.useEffect(() => { 48 | socket.emit('hi', 'hello'); 49 | 50 | // create another room id if already exist 51 | socket.off('room already exist').on('room already exist', () => { 52 | createRoom(socket, mode); 53 | }); 54 | 55 | socket.off('end game').on('end game', () => { 56 | dispatch({ type: 'SET_STATUS', payload: { progress: 0, wpm: 0 } }); 57 | dispatch({ type: 'SET_IS_READY', payload: false }); 58 | dispatch({ type: 'SET_IS_PLAYING', payload: false }); 59 | dispatch({ type: 'SET_IS_FINISHED', payload: false }); 60 | dispatch({ type: 'SET_WINNER', payload: null }); 61 | resetTime(0); 62 | }); 63 | 64 | // on create room success, redirect to that room 65 | socket 66 | .off('create room success') 67 | .on('create room success', (roomId: string) => { 68 | toast.success('Room successfully created!', { 69 | position: toast.POSITION.TOP_CENTER, 70 | toastId: 'create-room', 71 | autoClose: 3000, 72 | }); 73 | setIsCreatingRoom(false); 74 | dispatch({ type: 'SET_IS_OWNER', payload: true }); 75 | router.push(`/multiplayer/${roomId}`); 76 | }); 77 | 78 | // eslint-disable-next-line react-hooks/exhaustive-deps 79 | }, []); 80 | 81 | const onSubmit = ({ code }: { code: string }) => { 82 | setIsJoiningRoom(true); 83 | router.push(`/multiplayer/${code}`); 84 | }; 85 | 86 | return ( 87 | 88 | 89 | 90 |
91 |
92 |
93 |
94 | 98 |
99 |
100 | 101 |

multiplayer mode

102 | 103 |
104 |
105 | 112 | 121 |
122 |
123 |
124 | 125 | or 126 |
127 | 138 | 153 | 164 |
165 |
166 | 183 |
184 |
185 |
186 |
187 |
188 |
189 | ); 190 | } 191 | -------------------------------------------------------------------------------- /app/src/components/CommandPalette/CommandPalette.tsx: -------------------------------------------------------------------------------- 1 | import { Combobox, Dialog, Transition } from '@headlessui/react'; 2 | import clsx from 'clsx'; 3 | import { AnimatePresence, motion } from 'framer-motion'; 4 | import * as React from 'react'; 5 | import { FaSearch } from 'react-icons/fa'; 6 | 7 | import { CommandType } from '@/data/commands'; 8 | 9 | import { 10 | filterCommands, 11 | handleSelect, 12 | } from '@/components/CommandPalette/functions'; 13 | 14 | import { usePreferenceContext } from '@/context/Preference/PreferenceContext'; 15 | 16 | const CommandPalette = ({ data }: { data: CommandType[] }) => { 17 | const { 18 | preferences: { theme, fontFamily, isOpen }, 19 | dispatch, 20 | } = usePreferenceContext(); 21 | 22 | const [commands, setCommands] = React.useState(() => data); 23 | const [query, setQuery] = React.useState(''); 24 | const [selected, setSelected] = React.useState(''); 25 | 26 | const [page, setPage] = React.useState<1 | 2>(1); 27 | 28 | React.useEffect(() => { 29 | if (selected && page === 1) { 30 | setQuery(''); 31 | setPage(2); 32 | setCommands((commands) => { 33 | const selectedCommand = commands.filter( 34 | (item) => item.commandName === selected 35 | ); 36 | if (selectedCommand.length === 0) return data; 37 | return selectedCommand[0].contents; 38 | }); 39 | } 40 | }, [selected, page, data]); 41 | 42 | const filteredCommands = React.useMemo( 43 | () => filterCommands(commands, query), 44 | [commands, query] 45 | ); 46 | 47 | React.useEffect(() => { 48 | const onKeyDown = (event: KeyboardEvent) => { 49 | if (page === 2 && event.key === 'Escape') { 50 | setCommands(data); 51 | setPage(1); 52 | setSelected(''); 53 | return; 54 | } 55 | if ( 56 | (event.key === 'p' || event.key === 'k') && 57 | (event.metaKey || event.ctrlKey) 58 | ) { 59 | event.preventDefault(); 60 | dispatch({ type: 'TOGGLE_COMMAND_PALETTE' }); 61 | } 62 | }; 63 | 64 | window.addEventListener('keydown', onKeyDown); 65 | return () => window.removeEventListener('keydown', onKeyDown); 66 | // eslint-disable-next-line react-hooks/exhaustive-deps 67 | }, [dispatch, page]); 68 | 69 | return ( 70 | { 74 | setQuery(''); 75 | setPage(1); 76 | setCommands(data); 77 | setSelected(''); 78 | }} 79 | > 80 | { 82 | if (page === 1) dispatch({ type: 'TOGGLE_COMMAND_PALETTE' }); 83 | }} 84 | className={clsx( 85 | 'pointer-events-none fixed inset-0 z-50 overflow-y-auto p-4 pt-[25vh] font-primary', 86 | theme, 87 | fontFamily 88 | )} 89 | > 90 | 98 | 99 | 100 | 108 | { 111 | if (value === 'exit') { 112 | dispatch({ type: 'TOGGLE_COMMAND_PALETTE' }); 113 | setQuery(''); 114 | setPage(1); 115 | setCommands(data); 116 | setSelected(''); 117 | return; 118 | } 119 | setSelected(value); 120 | if (page === 2) { 121 | handleSelect(selected, value, dispatch); 122 | setCommands(data); 123 | dispatch({ type: 'TOGGLE_COMMAND_PALETTE' }); 124 | } 125 | }} 126 | as='div' 127 | className='pointer-events-auto relative mx-auto max-w-xl divide-y divide-font/50 overflow-hidden rounded-xl bg-bg shadow-2xl ring-1 ring-bg/5' 128 | > 129 |
130 | 131 | ) => { 134 | setQuery(e.target.value); 135 | }} 136 | placeholder='Type to search' 137 | className='h-12 w-full border-0 bg-transparent text-sm text-fg placeholder-font caret-hl focus:ring-0' 138 | /> 139 |
140 | 141 | {filteredCommands.length > 0 && ( 142 | 146 | 147 | 153 | {filteredCommands.map((command, index) => { 154 | return ( 155 | 159 | {({ active }) => { 160 | return ( 161 |
168 | 173 | {command.commandName} 174 | 175 | 180 | {command.description} 181 | 182 |
183 | ); 184 | }} 185 |
186 | ); 187 | })} 188 |
189 |
190 |
191 | )} 192 | {query && filteredCommands.length === 0 && ( 193 |

No results found.

194 | )} 195 |
196 |
197 |
198 |
199 | ); 200 | }; 201 | 202 | export default CommandPalette; 203 | --------------------------------------------------------------------------------