├── 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 |
--------------------------------------------------------------------------------
/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 |
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 |
5 |
6 | 
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 |
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 | 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 |
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 |
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 |
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 |
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 |
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 |
53 |
top 100 leaderboard
54 |
55 |
64 |
73 |
74 |
75 |
76 |
77 |
78 |
79 | | # |
80 | user |
81 | wpm |
82 | type |
83 | time |
84 | date |
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 |
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 |
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 |
198 |
199 | );
200 | };
201 |
202 | export default CommandPalette;
203 |
--------------------------------------------------------------------------------