├── .env-example ├── .eslintrc.json ├── .github └── workflows │ └── keylabs-deploy.yml ├── .gitignore ├── .prettierignore ├── .prettierrc ├── Dockerfile ├── LICENSE ├── README.md ├── next.config.mjs ├── package.json ├── pnpm-lock.yaml ├── postcss.config.mjs ├── prisma ├── migrations │ ├── 20241007100732_init │ │ └── migration.sql │ ├── 20241007111409_added_user_date_joined │ │ └── migration.sql │ ├── 20241008044333_added_best_entry │ │ └── migration.sql │ └── migration_lock.toml ├── schema.prisma └── scripts │ └── recalculateFromStats.ts ├── public ├── assets │ ├── RuffByte.jpg │ ├── images │ │ ├── edge100.png │ │ ├── grid60.png │ │ ├── grid72.png │ │ ├── grid80.png │ │ ├── icon-image.png │ │ └── test100.png │ └── svgs │ │ ├── grid-80.svg │ │ └── test-100.svg ├── favicon.svg ├── og │ ├── og-image.png │ └── og-twitter.png └── themes │ ├── catcopy.css │ ├── dark_mono.css │ ├── dark_retro.css │ ├── default.css │ ├── mute.css │ └── template.css ├── src ├── app │ ├── actions.ts │ ├── api │ │ ├── auth │ │ │ └── google │ │ │ │ └── callback │ │ │ │ └── route.ts │ │ └── data │ │ │ ├── leaderboard │ │ │ └── route.ts │ │ │ ├── user │ │ │ └── route.ts │ │ │ └── userBest │ │ │ └── route.ts │ ├── client-page.tsx │ ├── dashboard │ │ └── page.tsx │ ├── favicon.ico │ ├── globals.css │ ├── hooks │ │ └── query │ │ │ ├── useGenerateWords.tsx │ │ │ └── useLeaderboard.tsx │ ├── layout.tsx │ ├── leaderboard │ │ └── page.tsx │ ├── login │ │ ├── LoginForm.tsx │ │ ├── SignUpForm.tsx │ │ ├── forgot-password │ │ │ ├── actions.ts │ │ │ ├── forgetPasswordForm.tsx │ │ │ └── page.tsx │ │ ├── login.action.ts │ │ └── page.tsx │ ├── not-found.tsx │ ├── page.tsx │ ├── reset-password │ │ ├── actions.ts │ │ ├── page.tsx │ │ └── resetPasswordForm.tsx │ ├── testpage │ │ └── page.tsx │ └── types │ │ ├── gameData.ts │ │ ├── safeUser.ts │ │ └── user.ts ├── components │ ├── ClientLeaderboardPage.tsx │ ├── authentication │ │ ├── GoogleOAuthButton.tsx │ │ ├── SignOutButton.tsx │ │ └── TextInput.tsx │ ├── common │ │ ├── Button.tsx │ │ ├── Debugger.tsx │ │ ├── Dialog.tsx │ │ ├── FunctionDebugger.tsx │ │ ├── KeyLabsLogo.tsx │ │ ├── Select.tsx │ │ └── ui │ │ │ ├── dashboard │ │ │ ├── AccountDetails │ │ │ │ ├── AccountDetails.tsx │ │ │ │ ├── AvatarAndName.tsx │ │ │ │ ├── ExperienceBar.tsx │ │ │ │ ├── TestStats.tsx │ │ │ │ └── UserContext.tsx │ │ │ ├── DashBoard.tsx │ │ │ └── ModeStats │ │ │ │ ├── ModeStatBox.tsx │ │ │ │ └── ModeStats.tsx │ │ │ ├── emailTemplates │ │ │ ├── resetPasswordEmail.tsx │ │ │ └── testEmail.tsx │ │ │ ├── game │ │ │ ├── EndGameScreen.tsx │ │ │ ├── GameBoard.tsx │ │ │ ├── LanguageSelectionDialog.tsx │ │ │ ├── OptionsBar.tsx │ │ │ ├── StartButton.tsx │ │ │ ├── ThemeSelectDialog.tsx │ │ │ ├── Timer.tsx │ │ │ └── WordsBar.tsx │ │ │ ├── navigation │ │ │ ├── keylabslogo.tsx │ │ │ ├── navbar.tsx │ │ │ └── usernav.tsx │ │ │ ├── transition │ │ │ ├── TLink.tsx │ │ │ └── Transition.tsx │ │ │ └── wrapper │ │ │ ├── LimitScreenSize.tsx │ │ │ ├── ThemeProvider.tsx │ │ │ └── dropdown.tsx │ ├── leaderboards │ │ ├── ClientLeaderboardPage.tsx │ │ ├── Leaderboard.tsx │ │ └── UserLeaderboard.tsx │ └── providers │ │ └── QueryClientProvider.tsx ├── devconfig.ts ├── fonts │ ├── GeistMonoVF.woff │ ├── GeistVF.woff │ └── Kollektif │ │ ├── Kollektif-Bold.ttf │ │ ├── Kollektif-BoldItalic.ttf │ │ ├── Kollektif-Italic.ttf │ │ └── Kollektif.ttf ├── lib │ ├── antAuth │ │ ├── cookies.ts │ │ └── sessions.ts │ ├── email.tsx │ ├── get-ip.ts │ ├── googleOauth.ts │ ├── hash.ts │ ├── limiter.ts │ ├── prisma.ts │ ├── resend.ts │ ├── utils.ts │ ├── utils │ │ ├── date.ts │ │ └── queryKeys.ts │ └── variants │ │ └── variants.ts ├── middleware.ts ├── schemas │ └── zod │ │ └── schemas.ts ├── services │ ├── auth │ │ └── tokens │ │ │ ├── createPasswordResetToken.ts │ │ │ ├── deleteExpiredTokens.ts │ │ │ ├── generateRandomToken.ts │ │ │ └── token-consts.ts │ ├── email │ │ └── sendResetEmail.tsx │ ├── leaderboard │ │ └── fetchLeaderboard.ts │ ├── points │ │ └── generate-point.ts │ ├── utils.ts │ └── words │ │ └── generate-word.ts └── static │ ├── language │ ├── _list.json │ ├── english.json │ ├── english_1k.json │ ├── english_5k.json │ └── test.json │ └── themes │ └── _list.json ├── tailwind.config.ts └── tsconfig.json /.env-example: -------------------------------------------------------------------------------- 1 | 2 | ENVIRONMENT=DEVELOPMENT 3 | DATABASE_URL=EXAMPLE_DATABASE_URL 4 | 5 | DIRECT_URL=EXAMPLE_DATABASE_URL 6 | 7 | GOOGLE_CLIENT_ID=EXAMPLE_CLIENT_ID 8 | GOOGLE_CLIENT_SECRET=EXAMPLE_CLIENT_SECRET 9 | NEXT_PUBLIC_URL="http://localhost:3000" 10 | 11 | RESEND_API_KEY==EXAMPLE_RESEND_URL 12 | EMAIL_FROM=EXAMPLE_EMAIL_FROM -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["next", "next/core-web-vitals", "prettier"], 3 | "plugins": ["prettier"], 4 | "rules": { 5 | "prettier/prettier": "off" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.github/workflows/keylabs-deploy.yml: -------------------------------------------------------------------------------- 1 | name: Build and Deploy to Cloud Run 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | deploy: 10 | permissions: 11 | contents: 'read' 12 | id-token: 'write' 13 | runs-on: ubuntu-latest 14 | environment: GCR 15 | env: 16 | PROJECT_ID: ${{ secrets.PROJECT_ID }} 17 | GAR_LOCATION: ${{ secrets.GAR_LOCATION }} 18 | REPOSITORY: ${{ secrets.REPOSITORY }} 19 | SERVICE: keylabs 20 | REGION: ${{ secrets.GAR_LOCATION }} 21 | DATABASE_URL: ${{secrets.DATABASE_URL}} 22 | DIRECT_URL: ${{secrets.DIRECT_URL}} 23 | GOOGLE_CLIENT_ID: ${{secrets.GOOGLE_CLIENT_ID}} 24 | GOOGLE_CLIENT_SECRET: ${{secrets.GOOGLE_CLIENT_SECRET}} 25 | NEXT_PUBLIC_URL: ${{secrets.NEXT_PUBLIC_URL}} 26 | RESEND_API_KEY: ${{secrets.RESEND_API_KEY}} 27 | EMAIL_FROM: ${{secrets.EMAIL_FROM}} 28 | 29 | steps: 30 | - name: Checkout 31 | uses: actions/checkout@v2 32 | 33 | - name: Google Auth 34 | id: auth 35 | uses: 'google-github-actions/auth@v2' 36 | with: 37 | credentials_json: '${{ secrets.GCP_CREDENTIALS }}' 38 | token_format: 'access_token' 39 | 40 | - name: Docker Auth 41 | run: |- 42 | gcloud auth configure-docker "${{ env.GAR_LOCATION }}-docker.pkg.dev" 43 | 44 | - name: Build and Push Container 45 | run: |- 46 | docker build -t "${{ env.GAR_LOCATION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/${{ env.REPOSITORY }}/${{ env.SERVICE }}:${{ github.sha }}" ./ 47 | docker push "${{ env.GAR_LOCATION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/${{ env.REPOSITORY }}/${{ env.SERVICE }}:${{ github.sha }}" 48 | 49 | - name: Deploy to Cloud Run 50 | run: |- 51 | gcloud run deploy ${{ env.SERVICE }} \ 52 | --image=${{ env.GAR_LOCATION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/${{ env.REPOSITORY }}/${{ env.SERVICE }}:${{ github.sha }} \ 53 | --region=${{ env.REGION }} \ 54 | --max-instances=3 \ 55 | --platform=managed \ 56 | --allow-unauthenticated \ 57 | --set-env-vars PROJECT_ID=${{secrets.PROJECT_ID}},GAR_LOCATION=${{secrets.GAR_LOCATION}},REPOSITORY=${{secrets.REPOSITORY}},SERVICE=keylabs,REGION=${{secrets.GAR_LOCATION}},DATABASE_URL=${{secrets.DATABASE_URL}},DIRECT_URL=${{secrets.DIRECT_URL}},GOOGLE_CLIENT_ID=${{secrets.GOOGLE_CLIENT_ID}},GOOGLE_CLIENT_SECRET=${{secrets.GOOGLE_CLIENT_SECRET}},NEXT_PUBLIC_URL=${{secrets.NEXT_PUBLIC_URL}},RESEND_API_KEY=${{secrets.RESEND_API_KEY}},EMAIL_FROM="KeyLabs " 58 | 59 | - name: Show Output 60 | run: echo "Deployment completed" 61 | -------------------------------------------------------------------------------- /.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 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 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 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | 38 | .env -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .next 3 | out 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": true, 4 | "trailingComma": "es5", 5 | "tabWidth": 2, 6 | "useTabs": false, 7 | "plugins": [ 8 | "@ianvs/prettier-plugin-sort-imports", 9 | "prettier-plugin-tailwindcss" 10 | ], 11 | "importOrder": [ 12 | "^(react/(.*)$)|^(react$)", 13 | "^(next/(.*)$)|^(next$)", 14 | "", 15 | "", 16 | "^types$", 17 | "^@/(.*)$", 18 | "^[./]" 19 | ], 20 | "tailwindFunctions": ["tv", "cva"], 21 | "importOrderParserPlugins": ["typescript", "jsx", "decorators-legacy"] 22 | } 23 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Use the official Node.js image as the base image 2 | FROM node:20-alpine AS builder 3 | 4 | # Set the working directory 5 | WORKDIR /app 6 | 7 | # Install pnpm globally 8 | RUN npm install -g pnpm 9 | 10 | # Copy package.json and pnpm-lock.yaml 11 | COPY package.json pnpm-lock.yaml ./ 12 | 13 | # Install dependencies 14 | RUN pnpm install --frozen-lockfile 15 | 16 | # Copy the Prisma schema and application code 17 | COPY ./prisma ./prisma 18 | COPY . . 19 | 20 | # Generate Prisma client 21 | 22 | # Build the Next.js application 23 | RUN pnpm build 24 | 25 | # Use a minimal production image 26 | FROM node:20-alpine AS runner 27 | 28 | # Set the working directory 29 | WORKDIR /app 30 | ENV NODE_ENV production 31 | 32 | # Copy necessary files from the builder stage 33 | COPY --from=builder /app/.next ./.next 34 | COPY --from=builder /app/public ./public 35 | COPY --from=builder /app/package.json ./package.json 36 | COPY --from=builder /app/pnpm-lock.yaml ./pnpm-lock.yaml 37 | COPY --from=builder /app/prisma ./prisma 38 | 39 | # Install production dependencies only 40 | RUN npm install -g pnpm && pnpm install --prod --frozen-lockfile 41 | 42 | # Set environment variables for Next.js 43 | ENV NODE_ENV production 44 | 45 | # Expose the port 46 | EXPOSE 3000 47 | 48 | # Start the Next.js application 49 | CMD ["pnpm", "start"] 50 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # KeyLabs 2 | 3 | This repository hosts the in progress codebase for [KeyLabs](https://github.com/tulza/KeyLabs) V2!
4 | 5 | KeyLabs is an online game inspired by typing tests such as [MonkeyType](https://monkeytype.com/) and [TypeRacer](https://play.typeracer.com/) and aiming games such as [AimLabs](https://aimlabs.com/) and [Aimbooster](https://www.aimbooster.com/).
6 | 7 | Originally KeyLabs was developed during the Terrible Ideas Hackathon, where we managed to score second place, however due to the lack of time, some features were unpolished / uncompleted and needed major refactoring. Hence KeyLabs V2. 8 | 9 | | Developer | Github | 10 | | --------- | -------------------------------- | 11 | | Tulza | [here](https://github.com/Tulza) | 12 | | Antga | [here](https://github.com/AntGa) | 13 | 14 | ### Technologies: 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 | 42 |
43 | 44 |
45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 |
60 | 61 |
62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 |
73 |
74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 |
83 | 84 | ## Running the project 85 | 86 | cloning the project 87 | 88 | ```bash 89 | git clone https://github.com/RuffByte/KeyLabs.git 90 | ``` 91 | 92 | change directory to the project then install dependencies 93 | 94 | ```bash 95 | cd .\KeyLabs\ 96 | pnpm install 97 | ``` 98 | 99 | Create the .env file then update the values, 100 | 101 | ```env 102 | cp .env-example .env 103 | ``` 104 | 105 | run development server 106 | 107 | ```bash 108 | pnpm run dev 109 | ``` 110 | 111 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result! 112 | 113 | --- 114 | 115 |
116 |
117 | 118 |
119 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | // compiler: { 4 | // removeConsole: { exclude: ['error'] }, 5 | // }, 6 | webpack: (config) => { 7 | config.externals.push('@node-rs/argon2', '@node-rs/bcrypt'); 8 | return config; 9 | }, 10 | }; 11 | 12 | export default nextConfig; 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "keylabs", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "npx prisma generate && next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "format": "prettier --write ." 11 | }, 12 | "dependencies": { 13 | "@hookform/resolvers": "^3.9.0", 14 | "@lucia-auth/adapter-prisma": "^4.0.1", 15 | "@oslojs/crypto": "^1.0.1", 16 | "@oslojs/encoding": "^1.1.0", 17 | "@prisma/client": "^5.19.1", 18 | "@react-email/components": "0.0.25", 19 | "@remixicon/react": "^4.2.0", 20 | "@tanstack/react-query": "^5.56.2", 21 | "@tanstack/react-query-devtools": "^5.58.0", 22 | "@uidotdev/usehooks": "^2.4.1", 23 | "arctic": "^1.9.2", 24 | "clsx": "^2.1.1", 25 | "crypto": "^1.0.1", 26 | "framer-motion": "^11.5.5", 27 | "lucide-react": "^0.445.0", 28 | "next": "14.2.13", 29 | "oslo": "^1.2.1", 30 | "react-hook-form": "^7.53.0", 31 | "resend": "^4.0.0", 32 | "sonner": "^1.5.0", 33 | "tailwind-merge": "^2.5.2", 34 | "tailwind-variants": "^0.2.1", 35 | "use-sound": "^4.0.3", 36 | "zod": "^3.23.8", 37 | "zustand": "5.0.0-rc.2" 38 | }, 39 | "devDependencies": { 40 | "@ianvs/prettier-plugin-sort-imports": "^4.3.1", 41 | "@types/node": "^20", 42 | "@types/react": "^18.3.9", 43 | "@types/react-dom": "^18.3.0", 44 | "eslint": "^8", 45 | "eslint-config-next": "14.2.13", 46 | "eslint-config-prettier": "^9.1.0", 47 | "eslint-plugin-prettier": "^5.2.1", 48 | "postcss": "^8", 49 | "prettier": "^3.3.3", 50 | "prettier-plugin-tailwindcss": "^0.6.6", 51 | "prisma": "^5.19.1", 52 | "tailwindcss": "^3.4.1", 53 | "tailwindcss-3d": "^1.0.7", 54 | "typescript": "^5" 55 | }, 56 | "browser": { 57 | "crypto": false 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /prisma/migrations/20241007100732_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "Session" ( 3 | "id" TEXT NOT NULL, 4 | "userId" TEXT NOT NULL, 5 | "expiresAt" TIMESTAMP(3) NOT NULL, 6 | 7 | CONSTRAINT "Session_pkey" PRIMARY KEY ("id") 8 | ); 9 | 10 | -- CreateTable 11 | CREATE TABLE "PasswordResetToken" ( 12 | "id" TEXT NOT NULL, 13 | "userId" TEXT NOT NULL, 14 | "token" TEXT NOT NULL, 15 | "expiresAt" TIMESTAMP(3) NOT NULL, 16 | 17 | CONSTRAINT "PasswordResetToken_pkey" PRIMARY KEY ("id") 18 | ); 19 | 20 | -- CreateTable 21 | CREATE TABLE "User" ( 22 | "id" TEXT NOT NULL, 23 | "email" TEXT NOT NULL, 24 | "name" TEXT NOT NULL, 25 | "role" TEXT, 26 | "hashedPassword" TEXT, 27 | "picture" TEXT, 28 | "totalGames" INTEGER NOT NULL DEFAULT 0, 29 | "totalTime" DOUBLE PRECISION NOT NULL DEFAULT 0.0, 30 | 31 | CONSTRAINT "User_pkey" PRIMARY KEY ("id") 32 | ); 33 | 34 | -- CreateTable 35 | CREATE TABLE "UserStats" ( 36 | "id" TEXT NOT NULL, 37 | "userId" TEXT NOT NULL, 38 | "mode" TEXT NOT NULL, 39 | "category" TEXT NOT NULL, 40 | "avgLpm" DOUBLE PRECISION NOT NULL DEFAULT 0.0, 41 | "avgAccuracy" DOUBLE PRECISION NOT NULL DEFAULT 0.0, 42 | "totalGames" INTEGER NOT NULL DEFAULT 0, 43 | "totalTime" DOUBLE PRECISION NOT NULL DEFAULT 0.0, 44 | 45 | CONSTRAINT "UserStats_pkey" PRIMARY KEY ("id") 46 | ); 47 | 48 | -- CreateTable 49 | CREATE TABLE "GameEntry" ( 50 | "id" TEXT NOT NULL, 51 | "userId" TEXT NOT NULL, 52 | "mode" TEXT NOT NULL, 53 | "language" TEXT NOT NULL, 54 | "wpm" DOUBLE PRECISION NOT NULL, 55 | "rawWpm" DOUBLE PRECISION NOT NULL, 56 | "lpm" DOUBLE PRECISION NOT NULL, 57 | "rawLpm" DOUBLE PRECISION NOT NULL, 58 | "totalChar" INTEGER NOT NULL, 59 | "totalClicks" INTEGER NOT NULL, 60 | "totalTime" DOUBLE PRECISION NOT NULL, 61 | "accuracy" DOUBLE PRECISION NOT NULL, 62 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 63 | "targetSize" INTEGER NOT NULL, 64 | 65 | CONSTRAINT "GameEntry_pkey" PRIMARY KEY ("id") 66 | ); 67 | 68 | -- CreateIndex 69 | CREATE UNIQUE INDEX "PasswordResetToken_token_key" ON "PasswordResetToken"("token"); 70 | 71 | -- CreateIndex 72 | CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); 73 | 74 | -- CreateIndex 75 | CREATE UNIQUE INDEX "User_name_key" ON "User"("name"); 76 | 77 | -- CreateIndex 78 | CREATE UNIQUE INDEX "UserStats_userId_mode_category_key" ON "UserStats"("userId", "mode", "category"); 79 | 80 | -- CreateIndex 81 | CREATE INDEX "GameEntry_lpm_userId_idx" ON "GameEntry"("lpm", "userId"); 82 | 83 | -- AddForeignKey 84 | ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 85 | 86 | -- AddForeignKey 87 | ALTER TABLE "PasswordResetToken" ADD CONSTRAINT "PasswordResetToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 88 | 89 | -- AddForeignKey 90 | ALTER TABLE "UserStats" ADD CONSTRAINT "UserStats_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 91 | 92 | -- AddForeignKey 93 | ALTER TABLE "GameEntry" ADD CONSTRAINT "GameEntry_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 94 | -------------------------------------------------------------------------------- /prisma/migrations/20241007111409_added_user_date_joined/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "User" ADD COLUMN "joinedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; 3 | -------------------------------------------------------------------------------- /prisma/migrations/20241008044333_added_best_entry/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "UserStats" ADD COLUMN "bestGameEntryId" TEXT; 3 | 4 | -- AddForeignKey 5 | ALTER TABLE "UserStats" ADD CONSTRAINT "UserStats_bestGameEntryId_fkey" FOREIGN KEY ("bestGameEntryId") REFERENCES "GameEntry"("id") ON DELETE SET NULL ON UPDATE CASCADE; 6 | -------------------------------------------------------------------------------- /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" -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | // Looking for ways to speed up your queries, or scale easily with your serverless or edge functions? 5 | // Try Prisma Accelerate: https://pris.ly/cli/accelerate-init 6 | 7 | generator client { 8 | provider = "prisma-client-js" 9 | } 10 | 11 | datasource db { 12 | provider = "postgresql" 13 | url = env("DATABASE_URL") 14 | directUrl = env("DIRECT_URL") 15 | } 16 | 17 | model Session { 18 | id String @id 19 | userId String 20 | expiresAt DateTime 21 | 22 | user User @relation(fields: [userId], references: [id]) 23 | } 24 | 25 | model PasswordResetToken { 26 | id String @id @default(cuid()) 27 | userId String 28 | token String @unique 29 | expiresAt DateTime 30 | user User @relation(fields: [userId], references: [id]) 31 | } 32 | 33 | model User { 34 | id String @id @default(cuid()) 35 | email String @unique 36 | name String @unique 37 | role String? 38 | hashedPassword String? 39 | picture String? 40 | session Session[] 41 | resetTokens PasswordResetToken[] 42 | GameEntry GameEntry[] 43 | userStats UserStats[] 44 | totalGames Int @default(0) // Total games played across all modes 45 | totalTime Float @default(0.0) // Total time spent across all modes 46 | joinedAt DateTime @default(now()) // Timestamp of when the user joined 47 | } 48 | 49 | //super duper mega scalable :) (im lying) 50 | model UserStats { 51 | id String @id @default(cuid()) 52 | userId String 53 | mode String 54 | category String 55 | avgLpm Float @default(0.0) 56 | avgAccuracy Float @default(0.0) 57 | totalGames Int @default(0) 58 | totalTime Float @default(0.0) 59 | bestGameEntryId String? 60 | bestGameEntry GameEntry? @relation(fields: [bestGameEntryId], references: [id]) 61 | user User @relation(fields: [userId], references: [id]) 62 | 63 | @@unique([userId, mode, category]) 64 | } 65 | 66 | model GameEntry { 67 | id String @id @default(cuid()) 68 | userId String 69 | mode String 70 | language String 71 | wpm Float 72 | rawWpm Float 73 | lpm Float 74 | rawLpm Float 75 | totalChar Int 76 | totalClicks Int 77 | totalTime Float 78 | accuracy Float 79 | createdAt DateTime @default(now()) 80 | targetSize Int 81 | user User @relation(fields: [userId], references: [id]) 82 | UserStats UserStats[] 83 | 84 | @@index([lpm, userId]) 85 | } 86 | -------------------------------------------------------------------------------- /prisma/scripts/recalculateFromStats.ts: -------------------------------------------------------------------------------- 1 | //this is just a function in case we manually invalidate a score and need to recalc some1s stats 2 | -------------------------------------------------------------------------------- /public/assets/RuffByte.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RuffByte/KeyLabs/8430a91b9a0cfd36eb1da941d7c59844d1163be1/public/assets/RuffByte.jpg -------------------------------------------------------------------------------- /public/assets/images/edge100.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RuffByte/KeyLabs/8430a91b9a0cfd36eb1da941d7c59844d1163be1/public/assets/images/edge100.png -------------------------------------------------------------------------------- /public/assets/images/grid60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RuffByte/KeyLabs/8430a91b9a0cfd36eb1da941d7c59844d1163be1/public/assets/images/grid60.png -------------------------------------------------------------------------------- /public/assets/images/grid72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RuffByte/KeyLabs/8430a91b9a0cfd36eb1da941d7c59844d1163be1/public/assets/images/grid72.png -------------------------------------------------------------------------------- /public/assets/images/grid80.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RuffByte/KeyLabs/8430a91b9a0cfd36eb1da941d7c59844d1163be1/public/assets/images/grid80.png -------------------------------------------------------------------------------- /public/assets/images/icon-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RuffByte/KeyLabs/8430a91b9a0cfd36eb1da941d7c59844d1163be1/public/assets/images/icon-image.png -------------------------------------------------------------------------------- /public/assets/images/test100.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RuffByte/KeyLabs/8430a91b9a0cfd36eb1da941d7c59844d1163be1/public/assets/images/test100.png -------------------------------------------------------------------------------- /public/assets/svgs/grid-80.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /public/assets/svgs/test-100.svg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RuffByte/KeyLabs/8430a91b9a0cfd36eb1da941d7c59844d1163be1/public/assets/svgs/test-100.svg -------------------------------------------------------------------------------- /public/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /public/og/og-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RuffByte/KeyLabs/8430a91b9a0cfd36eb1da941d7c59844d1163be1/public/og/og-image.png -------------------------------------------------------------------------------- /public/og/og-twitter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RuffByte/KeyLabs/8430a91b9a0cfd36eb1da941d7c59844d1163be1/public/og/og-twitter.png -------------------------------------------------------------------------------- /public/themes/catcopy.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --background: 233 18% 18%; /* catppuccin clone */ 3 | --secondary: 274 71% 77%; 4 | --foreground: 317 76% 84%; 5 | --tertiary: #000; 6 | --hover: 0 0% 100%; 7 | } 8 | -------------------------------------------------------------------------------- /public/themes/dark_mono.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --background: 309 19% 7%; 3 | --secondary: 0 0% 80%; 4 | --foreground: 0 0% 86%; 5 | --tertiary: 0 0% 77%; 6 | --hover: 0 0% 100%; 7 | } 8 | -------------------------------------------------------------------------------- /public/themes/dark_retro.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --background: 318 45% 6%; 3 | --secondary: 68 100% 88%; 4 | --foreground: 68 100% 53%; 5 | --tertiary: 0 0% 71%; 6 | --hover: 0 0% 100%; 7 | } 8 | 9 | /* 10 | :root { 11 | --background: #150811; 12 | --secondary: #f7ffc3; 13 | --foreground: #DFFF11; 14 | --tertiary: #b4b4b4; 15 | --hover: #ffffff; 16 | } 17 | */ 18 | -------------------------------------------------------------------------------- /public/themes/default.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --background: 0 0% 96%; /* hsl(0, 0%, 96%) */ 3 | --secondary: 0 0% 31%; /* hsl(0, 0%, 31%) */ 4 | --foreground: 0 0% 12%; /* hsl(0, 0%, 12%) */ 5 | --tertiary: 0 0% 88%; /* hsl(0, 0%, 88%) */ 6 | --hover: 0 0% 5%; /* hsl(0, 0%, 5%) */ 7 | } 8 | -------------------------------------------------------------------------------- /public/themes/mute.css: -------------------------------------------------------------------------------- 1 | /* Some awwwards site i stole */ 2 | 3 | :root { 4 | --background: 220 4% 13%; 5 | --secondary: 50 100% 96%; 6 | --foreground: 50 100% 96%; 7 | --tertiary: 0 0% 100%; 8 | --hover: 0 0% 100%; 9 | } 10 | -------------------------------------------------------------------------------- /public/themes/template.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --background: #000; 3 | --secondary: #000; 4 | --foreground: #000; 5 | --tertiary: #000; 6 | --hover: #000; 7 | } 8 | -------------------------------------------------------------------------------- /src/app/actions.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import { revalidateTag } from 'next/cache'; 4 | 5 | import { prisma } from '@/lib/prisma'; 6 | import { GameData } from './types/gameData'; 7 | 8 | export async function submitGameData(gameData: GameData) { 9 | const user = await prisma.user.findUnique({ 10 | where: { 11 | name: gameData.userName, 12 | }, 13 | select: { 14 | id: true, 15 | }, 16 | }); 17 | 18 | if (!user) { 19 | throw new Error(`User with username ${gameData.userName} not found`); 20 | } 21 | 22 | // Create game entry 23 | const gameEntry = await prisma.gameEntry.create({ 24 | data: { 25 | userId: user.id, 26 | mode: gameData.mode, 27 | language: gameData.language, 28 | totalChar: gameData.totalHit, //total char is buggy ig swapped to total hit 29 | totalClicks: gameData.totalClick, 30 | totalTime: gameData.totalTime, 31 | accuracy: gameData.accuracy, 32 | wpm: gameData.wpm, 33 | rawWpm: gameData.rawWpm, 34 | lpm: gameData.lpm, 35 | rawLpm: gameData.rawLpm, 36 | targetSize: gameData.targetSize, 37 | }, 38 | }); 39 | 40 | // Update user's total games and total time 41 | await prisma.user.update({ 42 | where: { id: user.id }, 43 | data: { 44 | totalGames: { increment: 1 }, 45 | totalTime: { increment: gameData.totalTime }, 46 | }, 47 | }); 48 | 49 | // Determine the category (time or characters) 50 | const category = 51 | gameData.mode === 'time' ? `${gameData.totalTime}` : `${gameData.totalHit}`; 52 | 53 | // Get current user stats 54 | const currentStats = await prisma.userStats.findUnique({ 55 | where: { 56 | userId_mode_category: { 57 | userId: user.id, 58 | mode: gameData.mode, 59 | category: category, 60 | }, 61 | }, 62 | include: { 63 | bestGameEntry: true, // Fetch the current best game entry 64 | }, 65 | }); 66 | 67 | // Calculate new average LPM and accuracy 68 | const newTotalGames = (currentStats?.totalGames || 0) + 1; 69 | const newTotalLpm = 70 | (currentStats?.avgLpm || 0) * (currentStats?.totalGames || 0) + 71 | gameData.lpm; 72 | const newTotalAccuracy = 73 | (currentStats?.avgAccuracy || 0) * (currentStats?.totalGames || 0) + 74 | gameData.accuracy; 75 | 76 | const newAvgLpm = newTotalLpm / newTotalGames; 77 | const newAvgAccuracy = newTotalAccuracy / newTotalGames; 78 | 79 | // Check if the current game entry is the best (highest lpm) 80 | const isBestEntry = 81 | !currentStats?.bestGameEntry || 82 | gameData.lpm > currentStats.bestGameEntry.lpm; 83 | 84 | // Upsert (create or update) user stats 85 | await prisma.userStats.upsert({ 86 | where: { 87 | userId_mode_category: { 88 | userId: user.id, 89 | mode: gameData.mode, 90 | category: category, 91 | }, 92 | }, 93 | update: { 94 | avgLpm: newAvgLpm, 95 | avgAccuracy: newAvgAccuracy, 96 | totalGames: { increment: 1 }, 97 | totalTime: { increment: gameData.totalTime }, 98 | // Update best game entry if this one is better 99 | bestGameEntryId: isBestEntry 100 | ? gameEntry.id 101 | : currentStats.bestGameEntryId, 102 | }, 103 | create: { 104 | userId: user.id, 105 | mode: gameData.mode, 106 | category: category, 107 | avgLpm: gameData.lpm, 108 | avgAccuracy: gameData.accuracy, 109 | totalGames: 1, 110 | totalTime: gameData.totalTime, 111 | bestGameEntryId: gameEntry.id, // Set the current game entry as the best one initially 112 | }, 113 | }); 114 | 115 | revalidateTag('leaderboard'); 116 | console.log('Score submitted successfully.'); 117 | } 118 | -------------------------------------------------------------------------------- /src/app/api/auth/google/callback/route.ts: -------------------------------------------------------------------------------- 1 | import { cookies } from 'next/headers'; 2 | import { redirect } from 'next/navigation'; 3 | import { NextRequest } from 'next/server'; 4 | 5 | import { setSessionTokenCookie } from '@/lib/antAuth/cookies'; 6 | import { createSession, generateSessionToken } from '@/lib/antAuth/sessions'; 7 | import { googleOAuthClient } from '@/lib/googleOauth'; 8 | import { prisma } from '@/lib/prisma'; 9 | 10 | export async function GET(req: NextRequest) { 11 | const url = req.nextUrl; 12 | const code = url.searchParams.get('code'); 13 | const state = url.searchParams.get('state'); 14 | 15 | if (!code || !state) { 16 | console.error('no code or state'); 17 | return new Response('Invalid Request', { status: 400 }); 18 | } 19 | 20 | const codeVerifier = cookies().get('codeVerifier')?.value; 21 | const savedState = cookies().get('state')?.value; 22 | 23 | if (!codeVerifier || !savedState) { 24 | console.error('no code verifier or state'); 25 | return new Response('Invalid Request', { status: 400 }); 26 | } 27 | 28 | if (state !== savedState) { 29 | console.error('state mismatch'); 30 | return new Response('Invalid Request', { status: 400 }); 31 | } 32 | 33 | const { accessToken } = await googleOAuthClient.validateAuthorizationCode( 34 | code, 35 | codeVerifier 36 | ); 37 | const googleResponse = await fetch( 38 | 'https://www.googleapis.com/oauth2/v1/userinfo', 39 | { 40 | headers: { 41 | Authorization: `Bearer ${accessToken}`, 42 | }, 43 | } 44 | ); 45 | 46 | const googleData = (await googleResponse.json()) as { 47 | id: string; 48 | email: string; 49 | name: string; 50 | picture: string; 51 | }; 52 | 53 | let userId: string = ''; 54 | // if the email exists in our record, we can create a cookie for them and sign them in 55 | // if the email doesn't exist, we create a new user, then craete cookie to sign them in 56 | 57 | const existingUser = await prisma.user.findUnique({ 58 | where: { 59 | email: googleData.email, 60 | }, 61 | }); 62 | if (existingUser) { 63 | userId = existingUser.id; 64 | } else { 65 | const user = await prisma.user.create({ 66 | data: { 67 | name: googleData.name, 68 | email: googleData.email, 69 | picture: googleData.picture, 70 | }, 71 | }); 72 | userId = user.id; 73 | } 74 | 75 | const sessionToken = generateSessionToken(); 76 | const sessionCookie = await createSession(sessionToken, userId); 77 | setSessionTokenCookie(sessionToken, sessionCookie.expiresAt); 78 | 79 | return redirect('/dashboard'); 80 | } 81 | -------------------------------------------------------------------------------- /src/app/api/data/leaderboard/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server'; 2 | 3 | import { prisma } from '@/lib/prisma'; 4 | 5 | export const dynamic = 'force-dynamic'; 6 | export const GET = async (req: Request) => { 7 | try { 8 | const url = new URL(req.url); 9 | const mode = url.searchParams.get('mode'); 10 | const subMode = url.searchParams.get('subMode'); 11 | 12 | // Check if mode or subMode is missing 13 | if (!mode || !subMode) { 14 | return NextResponse.json( 15 | { 16 | error: 'Missing required query parameters: mode or subMode', 17 | message: 18 | 'Please provide both the "mode" (either "time" or "characters") and the appropriate "subMode".', 19 | example: { 20 | timeModeExample: '/api/leaderboard?mode=time&subMode=15', 21 | charactersModeExample: 22 | '/api/leaderboard?mode=characters&subMode=50', 23 | }, 24 | }, 25 | { status: 400 } 26 | ); 27 | } 28 | 29 | let whereClause = { mode: mode, category: subMode }; 30 | const userStats = await prisma.userStats.findMany({ 31 | where: whereClause, 32 | orderBy: { 33 | bestGameEntry: { 34 | lpm: 'desc', 35 | }, 36 | }, 37 | take: 50, 38 | include: { 39 | user: { 40 | select: { 41 | name: true, 42 | }, 43 | }, 44 | bestGameEntry: true, 45 | }, 46 | }); 47 | //somehow i have entries with no best game entry idk how it's possible but will wait for score purge. 48 | const gameEntries = userStats 49 | .filter((stats) => stats.bestGameEntry !== null) 50 | .map((stats) => ({ 51 | ...stats.bestGameEntry, 52 | user: { 53 | name: stats.user.name, 54 | }, 55 | })); 56 | 57 | return NextResponse.json(gameEntries); 58 | } catch (error) { 59 | console.error('Error fetching leaderboard:', error); 60 | return NextResponse.json( 61 | { error: 'Failed to fetch leaderboard' }, 62 | { status: 500 } 63 | ); 64 | } 65 | }; 66 | -------------------------------------------------------------------------------- /src/app/api/data/user/route.ts: -------------------------------------------------------------------------------- 1 | // src/app/api/data/user.ts 2 | import { NextResponse } from 'next/server'; 3 | 4 | import { prisma } from '@/lib/prisma'; 5 | 6 | export const dynamic = 'force-dynamic'; 7 | 8 | export async function GET(request: Request) { 9 | const { searchParams } = new URL(request.url); 10 | const name = searchParams.get('name'); 11 | 12 | if (!name) { 13 | return NextResponse.json({ error: 'Name is required' }, { status: 400 }); 14 | } 15 | 16 | const fullUser = await prisma.user.findUnique({ 17 | where: { name: name }, 18 | select: { 19 | id: true, 20 | email: true, 21 | name: true, 22 | role: true, 23 | picture: true, 24 | totalGames: true, 25 | totalTime: true, 26 | joinedAt: true, 27 | }, 28 | }); 29 | 30 | if (!fullUser) { 31 | return NextResponse.json( 32 | { error: 'Full user data not found' }, 33 | { status: 404 } 34 | ); 35 | } 36 | 37 | const userStats = await prisma.userStats.findMany({ 38 | where: { userId: fullUser.id }, 39 | }); 40 | 41 | return NextResponse.json({ user: fullUser, stats: userStats }); 42 | } 43 | -------------------------------------------------------------------------------- /src/app/api/data/userBest/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server'; 2 | 3 | import { prisma } from '@/lib/prisma'; 4 | 5 | export const dynamic = 'force-dynamic'; 6 | export async function GET(request: Request) { 7 | const { searchParams } = new URL(request.url); 8 | const name = searchParams.get('name'); 9 | 10 | if (!name) { 11 | return NextResponse.json({ error: 'Name is required' }, { status: 400 }); 12 | } 13 | 14 | // Fetch the user by name to get userId 15 | const user = await prisma.user.findUnique({ 16 | where: { name }, 17 | select: { id: true }, 18 | }); 19 | 20 | if (!user) { 21 | return NextResponse.json({ error: 'User not found' }, { status: 404 }); 22 | } 23 | 24 | // Get the best scores for each category 25 | const bestScores = await prisma.userStats.findMany({ 26 | where: { userId: user.id }, 27 | include: { 28 | bestGameEntry: true, 29 | }, 30 | }); 31 | 32 | const bestScoresFormatted = bestScores.map((stat) => ({ 33 | mode: stat.mode, 34 | category: stat.category, 35 | avgLpm: stat.avgLpm, 36 | avgAccuracy: stat.avgAccuracy, 37 | bestGame: stat.bestGameEntry 38 | ? { 39 | lpm: stat.bestGameEntry.lpm, 40 | rawLpm: stat.bestGameEntry.rawLpm, 41 | accuracy: stat.bestGameEntry.accuracy, 42 | createdAt: stat.bestGameEntry.createdAt, 43 | language: stat.bestGameEntry.language, 44 | totalChar: stat.bestGameEntry.totalChar, 45 | totalClicks: stat.bestGameEntry.totalClicks, 46 | } 47 | : null, 48 | })); 49 | 50 | return NextResponse.json(bestScoresFormatted); 51 | } 52 | -------------------------------------------------------------------------------- /src/app/client-page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React, { createContext, useContext, useEffect, useState } from 'react'; 4 | import { PrefetchKind } from 'next/dist/client/components/router-reducer/router-reducer-types'; 5 | import { useRouter } from 'next/navigation'; 6 | import { useQueryClient } from '@tanstack/react-query'; 7 | import { AnimatePresence, motion } from 'framer-motion'; 8 | import { Globe } from 'lucide-react'; 9 | import { create } from 'zustand'; 10 | 11 | // import { Debugger } from '@/components/common/Debugger'; 12 | import Dialog, { 13 | DialogContent, 14 | DialogTriggerButton, 15 | } from '@/components/common/Dialog'; 16 | import { FunctionDebugger } from '@/components/common/FunctionDebugger'; 17 | import { EndGameScreen } from '@/components/common/ui/game/EndGameScreen'; 18 | import GameBoard from '@/components/common/ui/game/GameBoard'; 19 | import { LanguageSelectionDialog } from '@/components/common/ui/game/LanguageSelectionDialog'; 20 | import { OptionsBar } from '@/components/common/ui/game/OptionsBar'; 21 | import { WordsBar } from '@/components/common/ui/game/WordsBar'; 22 | import { NavigationBar } from '@/components/common/ui/navigation/navbar'; 23 | import { devConfig } from '@/devconfig'; 24 | import { QUERY_KEY } from '@/lib/utils/queryKeys'; 25 | import { OptionBarOutVariants } from '@/lib/variants/variants'; 26 | import { generatePoint } from '@/services/points/generate-point'; 27 | import { wordSet } from '@/services/words/generate-word'; 28 | import { submitGameData } from './actions'; 29 | import { useGenerateWords } from './hooks/query/useGenerateWords'; 30 | import { GameData } from './types/gameData'; 31 | import { User } from './types/user'; 32 | 33 | // *=================================================================================================== 34 | // *=================================================================================================== 35 | // *=================================================================================================== 36 | 37 | export type Screen = { 38 | screen: { width: number; height: number }; 39 | setScreen: (screen: { width: number; height: number }) => void; 40 | }; 41 | 42 | export const useScreen = create()((set) => ({ 43 | screen: { width: 15 * 72, height: 9 * 72 }, 44 | setScreen: (screen) => set({ screen }), 45 | })); 46 | 47 | // *=================================================================================================== 48 | // *=================================================================================================== 49 | // *=================================================================================================== 50 | 51 | export type PreGameConfig = { 52 | config: { 53 | mode: string; 54 | language: string; 55 | isCustom: boolean; 56 | time: number; 57 | lengthChar: number; 58 | targetSize: number; 59 | }; 60 | setConfig: (config: PreGameConfig['config']) => void; 61 | resetConfig: () => void; 62 | }; 63 | 64 | export const usePreConfig = create()((set) => ({ 65 | config: { 66 | theme: 'default', 67 | mode: 'characters', 68 | language: 'english_5k', 69 | time: 0, 70 | isCustom: false, 71 | lengthChar: 30, 72 | targetSize: 16 * 6, 73 | }, 74 | setConfig: (config) => set({ config }), 75 | resetConfig: () => set({ config: { ...usePreConfig.getState().config } }), 76 | })); 77 | 78 | // *=================================================================================================== 79 | // *=================================================================================================== 80 | // *=================================================================================================== 81 | 82 | export type PointStack = { 83 | points: Point[]; 84 | hitPoints: (index: number) => void; 85 | setPoints: (points: Point[]) => void; 86 | handleGenerate: ( 87 | words: string, 88 | targetSize: number, 89 | screen: { width: number; height: number } 90 | ) => void; 91 | handleClear: () => void; 92 | }; 93 | 94 | export type Point = { 95 | index: number; 96 | value: string; 97 | x: number; 98 | y: number; 99 | key: string; 100 | }; 101 | 102 | export const usePointsStack = create()((set) => ({ 103 | points: [] as Point[], 104 | hitPoints: (index: number) => 105 | set((state) => { 106 | let arr = [...state.points]; 107 | console.log(state.points, index, arr.length); 108 | if (index === arr.length - 1) { 109 | arr.pop(); 110 | } else { 111 | // this is so cursed lmfao 112 | // swap the value with the popped one 113 | // situation: popped value is the same as top of stack 114 | [arr[index], arr[arr.length - 1]] = [ 115 | { ...arr[arr.length - 1], index: index }, 116 | { ...arr[index], index: arr.length - 1 }, 117 | ]; 118 | arr.pop(); 119 | } 120 | return { points: arr }; 121 | }), 122 | setPoints: (points: Point[]) => set({ points }), 123 | handleGenerate: (word, targetSize, screen) => 124 | set({ 125 | points: generatePoint(word, targetSize, screen), 126 | }), 127 | handleClear: () => set({ points: [] }), 128 | })); 129 | 130 | // *=================================================================================================== 131 | // *=================================================================================================== 132 | // *=================================================================================================== 133 | 134 | export type GameDataProps = { 135 | mode: string; 136 | language: string; 137 | words: string[]; 138 | // bool 139 | hasStart: boolean; 140 | hasFinish: boolean; 141 | // target 142 | charCount: number; 143 | targetSize: number; 144 | charIndex: number; 145 | wordIndex: number; 146 | // 147 | totalTime: number; 148 | totalChar: number; 149 | totalClick: number; 150 | totalHit: number; 151 | setMode: (words: wordSet) => void; 152 | setTime: (time: number) => void; 153 | InitializeGame: (game: GameInitializeProps) => void; 154 | handleNextWord: () => void; 155 | incrementCharIndex: () => void; 156 | endGame: () => void; 157 | finishGame: () => void; 158 | resetGame: () => void; 159 | incrementClick: () => void; 160 | incrementHit: () => void; 161 | handleFinish: () => void; 162 | }; 163 | 164 | export type GameInitializeProps = { 165 | mode: string; 166 | totalTime: number; 167 | totalChar: number; 168 | targetSize: number; 169 | languge: string; 170 | }; 171 | 172 | export const useCurrentGame = create()((set) => ({ 173 | mode: 'characters', 174 | language: 'english', 175 | words: [], 176 | totalTime: 0, 177 | totalChar: 0, 178 | charCount: 0, 179 | hasStart: false, 180 | hasFinish: false, 181 | targetSize: 0, 182 | charIndex: 0, 183 | wordIndex: 0, 184 | totalClick: 0, 185 | totalHit: 0, 186 | setMode: (state: wordSet) => 187 | set({ words: state.words, language: state.name }), 188 | handleNextWord: () => 189 | set((prevs) => { 190 | return { charIndex: 0, wordIndex: prevs.wordIndex + 1 }; 191 | }), 192 | incrementCharIndex: () => 193 | set((prevs) => { 194 | return { charIndex: prevs.charIndex + 1 }; 195 | }), 196 | setTime: (time: number) => set({ totalTime: time }), 197 | InitializeGame: (game) => 198 | set({ 199 | ...game, 200 | hasStart: true, 201 | hasFinish: false, 202 | charIndex: 0, 203 | charCount: 0, 204 | wordIndex: 0, 205 | totalClick: 0, 206 | totalHit: 0, 207 | }), 208 | endGame: () => 209 | set({ 210 | hasStart: false, 211 | }), 212 | finishGame: () => 213 | set({ 214 | hasFinish: true, 215 | }), 216 | resetGame: () => 217 | set({ 218 | totalTime: 0, 219 | hasStart: false, 220 | hasFinish: false, 221 | charIndex: 0, 222 | charCount: 0, 223 | wordIndex: 0, 224 | totalClick: 0, 225 | totalHit: 0, 226 | }), 227 | incrementClick: () => 228 | set((prevs) => { 229 | return { totalClick: prevs.totalClick + 1 }; 230 | }), 231 | incrementHit: () => 232 | set((prevs) => { 233 | return { totalHit: prevs.totalHit + 1, charCount: prevs.charCount + 1 }; 234 | }), 235 | handleFinish: () => set({ hasFinish: true }), 236 | })); 237 | 238 | type GameContextProps = { 239 | Gamedata: GameDataProps; 240 | handleResetGame: () => void; 241 | handleEndGame: () => void; 242 | handleStartGame: () => void; 243 | }; 244 | 245 | // *=================================================================================================== 246 | // *=================================================================================================== 247 | // *=================================================================================================== 248 | // ? context, use to cohaerere global state into handle and pass to other component 249 | 250 | const GameContext = createContext({} as GameContextProps); 251 | 252 | export const useGameContext = () => { 253 | const context = useContext(GameContext); 254 | if (!context) { 255 | throw new Error('useGameContext must be used within a GameProvider'); 256 | } 257 | return context; 258 | }; 259 | 260 | const saveToLocalStorage = (gameData: GameData) => { 261 | // Retrieve existing game data from local storage 262 | const existingData = JSON.parse(localStorage.getItem('gameData') || '[]'); 263 | 264 | // Check the maximum limit 265 | if (existingData.length >= 10) { 266 | existingData.shift(); // Remove the oldest entry 267 | } 268 | 269 | existingData.push(gameData); // Add new game data 270 | localStorage.setItem('gameData', JSON.stringify(existingData)); // Save back to local storage 271 | }; 272 | 273 | // *=================================================================================================== 274 | // *=================================================================================================== 275 | // *=================================================================================================== 276 | 277 | const calculateEndGameData = ( 278 | totalHit: number, 279 | totalClick: number, 280 | totalTime: number, 281 | targetSize: number, 282 | config: PreGameConfig['config'] 283 | ) => { 284 | const accuracy: number = parseFloat( 285 | ((totalHit / totalClick) * 100).toFixed(2) 286 | ); 287 | const rawWpm: number = parseFloat( 288 | ((totalHit / 5) * (60 / totalTime)).toFixed(2) 289 | ); 290 | const wpm: number = parseFloat((rawWpm * (accuracy / 100)).toFixed(2)); 291 | const rawLpm: number = parseFloat((totalHit * (60 / totalTime)).toFixed(2)); 292 | const lmp: number = parseFloat((rawLpm * (accuracy / 100)).toFixed(2)); 293 | 294 | return { 295 | mode: config.mode, 296 | language: config.language, 297 | totalTime: totalTime, 298 | totalChar: totalHit, 299 | totalClick: totalClick, 300 | totalHit: totalHit, 301 | targetSize: targetSize, 302 | wpm: wpm, 303 | rawWpm: rawWpm, 304 | lpm: lmp, 305 | rawLpm: rawLpm, 306 | accuracy: accuracy, 307 | }; 308 | }; 309 | 310 | let allowReset = false; 311 | 312 | const ClientGamePage = ({ user }: { user: User }) => { 313 | const { config } = usePreConfig(); 314 | const { screen } = useScreen(); 315 | const { 316 | setMode, 317 | endGame, 318 | targetSize, 319 | wordIndex, 320 | hasStart, 321 | resetGame, 322 | hasFinish, 323 | finishGame, 324 | totalClick, 325 | totalTime, 326 | totalHit, 327 | InitializeGame, 328 | } = useCurrentGame(); 329 | const { handleGenerate, handleClear } = usePointsStack(); 330 | const [isRestarting, setRestarting] = useState(false); 331 | const queryClient = useQueryClient(); 332 | const [endGameData, setEndGameData] = useState(null); // Add state for end game data 333 | 334 | const router = useRouter(); 335 | 336 | useEffect(() => { 337 | router.prefetch('/leaderboard', { kind: PrefetchKind.FULL }); 338 | }, [router]); 339 | 340 | // * inital fetching 341 | const { data } = useGenerateWords(config.language); 342 | 343 | // ! handles 344 | 345 | // * handle to reset game 346 | const handleRestart = () => { 347 | if (isRestarting) { 348 | queryClient.resetQueries({ 349 | queryKey: [QUERY_KEY.STATIC_WORDS], 350 | }); 351 | } 352 | setRestarting(false); 353 | resetGame(); 354 | handleClear(); 355 | endGame(); 356 | }; 357 | 358 | const handleStartGame = () => { 359 | InitializeGame({ 360 | mode: config.mode, 361 | totalTime: config.time, 362 | totalChar: config.lengthChar, 363 | targetSize: config.targetSize, 364 | languge: config.language, 365 | }); 366 | }; 367 | 368 | // * handle to blur then reset 369 | const handleBlurToRestart = () => { 370 | setRestarting(true); 371 | }; 372 | 373 | // * handle to go to endscreen menu 374 | const handleFinishGame = () => { 375 | finishGame(); 376 | }; 377 | 378 | // * Logic for submitting game data 379 | const handleSubmitGameData = () => { 380 | const gameData = calculateEndGameData( 381 | totalHit, 382 | totalClick, 383 | totalTime, 384 | targetSize, 385 | config 386 | ); 387 | setEndGameData(gameData); // Store game data for the EndGameScreen 388 | 389 | if (user) { 390 | submitGameData({ 391 | ...gameData, 392 | userName: user.name, 393 | }); 394 | } else { 395 | saveToLocalStorage({ ...gameData, wpm: gameData.wpm }); 396 | } 397 | }; 398 | 399 | useEffect(() => { 400 | // workaround as wordbar sets hasFinish to true on load fml 401 | if (hasFinish && totalClick > 0) { 402 | handleSubmitGameData(); 403 | } 404 | }, [hasFinish]); 405 | 406 | // * Effects 407 | useEffect(() => { 408 | if (data) { 409 | setMode(data); 410 | } 411 | }, [data]); 412 | 413 | useEffect(() => { 414 | if (data && hasStart) { 415 | handleGenerate(data.words[wordIndex], targetSize, screen); 416 | } 417 | }, [wordIndex, hasStart]); 418 | 419 | // ! Events 420 | 421 | // * Reset 422 | document.addEventListener('keydown', function (event) { 423 | if (event.repeat != undefined) { 424 | allowReset = !event.repeat; 425 | } 426 | if (!allowReset) return; 427 | // key r 428 | if (event.key.toLocaleLowerCase() === 'r') { 429 | if (isRestarting) return; 430 | setRestarting(true); 431 | } 432 | }); 433 | 434 | document.addEventListener('keyup', function () { 435 | allowReset = true; 436 | }); 437 | 438 | document.addEventListener('focus', function () { 439 | allowReset = true; 440 | }); 441 | 442 | return ( 443 | 451 | {/* otherstuff */} 452 | {devConfig.DEBUG_FUNCTION && ( 453 | 458 | )} 459 | 460 | 465 | 466 | {/* Game Container */} 467 | 468 | 476 | {/* Game */} 477 | 478 | {hasFinish ? ( 479 | 480 | ) : ( 481 | 482 | 483 | 484 | 488 | 489 | {config.language} 490 | 491 | 492 | 493 | 494 | )} 495 | 496 | {/* Game */} 497 | 498 | 499 | 500 | 501 | 502 | 503 | ); 504 | }; 505 | 506 | export default ClientGamePage; 507 | -------------------------------------------------------------------------------- /src/app/dashboard/page.tsx: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import React from 'react'; 4 | import { redirect } from 'next/navigation'; 5 | 6 | import AccountPage from '@/components/common/ui/dashboard/DashBoard'; 7 | import { getUser } from '@/lib/antAuth/sessions'; 8 | 9 | const Page = async () => { 10 | //check for login its called userCheck cause user got taken below so sad 11 | const userCheck = await getUser(); 12 | if (!userCheck) { 13 | redirect('/login'); 14 | } 15 | //api route is like this so we can have public api route i guess for querying later. (maybe idk) 16 | const response = await fetch( 17 | `${process.env.NEXT_PUBLIC_URL}/api/data/user?name=${userCheck.name}` 18 | ); 19 | 20 | const bestResponse = await fetch( 21 | `${process.env.NEXT_PUBLIC_URL}/api/data/userBest?name=${userCheck.name}` 22 | ); 23 | 24 | const bestScores = await bestResponse.json(); 25 | const { user, stats } = await response.json(); 26 | 27 | return ; 28 | }; 29 | 30 | export default Page; 31 | -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RuffByte/KeyLabs/8430a91b9a0cfd36eb1da941d7c59844d1163be1/src/app/favicon.ico -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | * { 6 | @apply transition-colors; 7 | font-family: var(--font-kollektic); 8 | } 9 | 10 | html { 11 | overflow: hidden; 12 | } 13 | 14 | :root { 15 | --background: 0 0% 96%; /* hsl(0, 0%, 96%) */ 16 | --secondary: 0 0% 31%; /* hsl(0, 0%, 31%) */ 17 | --foreground: 0 0% 12%; /* hsl(0, 0%, 12%) */ 18 | --tertiary: 0 0% 88%; /* hsl(0, 0%, 88%) */ 19 | --hover: 0 0% 5%; /* hsl(0, 0%, 5%) */ 20 | --highlight: 0 0% 31%; /* hsl(0 0% 31%) */ 21 | } 22 | 23 | .grid-72 { 24 | mask-image: url('../../public/assets/images/grid72.png'); 25 | mask-size: 72px; 26 | } 27 | 28 | .test-100 { 29 | mask-image: url('../../public/assets/images/test100.png'); 30 | mask-size: 80px; 31 | } 32 | 33 | .edge-32 { 34 | mask-image: url('../../public/assets/images/edge100.png'); 35 | mask-size: 32px; 36 | } 37 | 38 | .glow { 39 | box-shadow: 40 | 0px 0px 241.92px #0005, 41 | 0px 0px 138.24px #0005, 42 | 0px 0px 80.64px #0005, 43 | 0px 0px 40.32px #0005, 44 | 0px 0px 11.52px #0005, 45 | 0px 0px 5.76px #0005; 46 | } 47 | 48 | .no-scrollbar { 49 | scrollbar-width: none; 50 | } 51 | 52 | .no-scrollbar::-webkit-scrollbar { 53 | width: 0px; 54 | display: none; 55 | background: transparent; 56 | } 57 | -------------------------------------------------------------------------------- /src/app/hooks/query/useGenerateWords.tsx: -------------------------------------------------------------------------------- 1 | import { useQuery } from '@tanstack/react-query'; 2 | 3 | import { QUERY_KEY } from '@/lib/utils/queryKeys'; 4 | import { generateWords } from '@/services/words/generate-word'; 5 | 6 | export const useGenerateWords = (wordset: string) => { 7 | const query = useQuery({ 8 | queryKey: [QUERY_KEY.STATIC_WORDS], 9 | queryFn: () => generateWords(wordset), 10 | }); 11 | 12 | return query; 13 | }; 14 | -------------------------------------------------------------------------------- /src/app/hooks/query/useLeaderboard.tsx: -------------------------------------------------------------------------------- 1 | import { useQuery } from '@tanstack/react-query'; 2 | 3 | import { QUERY_KEY } from '@/lib/utils/queryKeys'; 4 | import { fetchLeaderboard } from '@/services/leaderboard/fetchLeaderboard'; 5 | 6 | export const useLeaderboardScore = ( 7 | currentMode: string, 8 | currentSubMode: number 9 | ) => { 10 | const query = useQuery({ 11 | queryKey: [QUERY_KEY.STATIC_LEADERBOARD], 12 | queryFn: () => fetchLeaderboard(currentMode, currentSubMode), 13 | staleTime: 60 * 2 14 | }); 15 | 16 | return query; 17 | }; 18 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next'; 2 | import localFont from 'next/font/local'; 3 | 4 | import './globals.css'; 5 | 6 | import Head from 'next/head'; 7 | import { Toaster } from 'sonner'; 8 | 9 | import Transition from '@/components/common/ui/transition/Transition'; 10 | import ThemeWrapper from '@/components/common/ui/wrapper/ThemeProvider'; 11 | import QueryClientProvider from '@/components/providers/QueryClientProvider'; 12 | 13 | const geistSans = localFont({ 14 | src: '../fonts/GeistVF.woff', 15 | variable: '--font-geist-sans', 16 | weight: '100 900', 17 | }); 18 | const geistMono = localFont({ 19 | src: '../fonts/GeistMonoVF.woff', 20 | variable: '--font-geist-mono', 21 | weight: '100 900', 22 | }); 23 | 24 | const kollektif = localFont({ 25 | src: [ 26 | { 27 | path: '../fonts/Kollektif/Kollektif.ttf', 28 | weight: '400', 29 | style: 'normal', 30 | }, 31 | { 32 | path: '../fonts/Kollektif/Kollektif-Bold.ttf', 33 | weight: '700', 34 | style: 'normal', 35 | }, 36 | { 37 | path: '../fonts/Kollektif/Kollektif-BoldItalic.ttf', 38 | weight: '700', 39 | style: 'italic', 40 | }, 41 | { 42 | path: '../fonts/Kollektif/Kollektif-Italic.ttf', 43 | weight: '400', 44 | style: 'italic', 45 | }, 46 | ], 47 | variable: '--font-kollektic', 48 | }); 49 | 50 | export const metadata: Metadata = { 51 | title: 'KeyLabs', 52 | description: 'A website where you click and aim letters', 53 | icons: { 54 | icon: { 55 | url: '/favicon.svg', 56 | }, 57 | }, 58 | twitter: { 59 | title: 'KeyLabs', 60 | description: 'A website where you click and aim letters', 61 | card: 'summary_large_image', 62 | images: 'https://keylabs.app/og/og-twitter.png', 63 | }, 64 | openGraph: { 65 | title: 'KeyLabs', 66 | description: 'A website where you click and aim letters', 67 | url: 'https://keylabs.app', 68 | images: 'https://keylabs.app/og/og-image.png', 69 | siteName: 'KeyLabs', 70 | }, 71 | }; 72 | 73 | export default function RootLayout({ 74 | children, 75 | }: Readonly<{ 76 | children: React.ReactNode; 77 | }>) { 78 | return ( 79 | 80 | 81 | 82 | 85 | 86 |
87 |
88 | {children} 89 |
90 |
91 |
92 | 93 | 94 |
95 | {/* */} 96 |
97 | 98 | ); 99 | } 100 | -------------------------------------------------------------------------------- /src/app/leaderboard/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { ClientLeaderboardPage } from '@/components/ClientLeaderboardPage'; 4 | 5 | const LeaderboardPage = () => { 6 | return ; 7 | }; 8 | export default LeaderboardPage; 9 | -------------------------------------------------------------------------------- /src/app/login/LoginForm.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React from 'react'; 4 | import { useRouter } from 'next/navigation'; 5 | import { zodResolver } from '@hookform/resolvers/zod'; 6 | import { useForm } from 'react-hook-form'; 7 | import { toast } from 'sonner'; 8 | import { z } from 'zod'; 9 | 10 | import { GoogleOAuthButton } from '@/components/authentication/GoogleOAuthButton'; 11 | import TextInput from '@/components/authentication/TextInput'; // Import the TextInput component 12 | import Button from '@/components/common/Button'; 13 | import TLink from '@/components/common/ui/transition/TLink'; 14 | import { signInSchema } from '@/schemas/zod/schemas'; 15 | import { signIn } from './login.action'; 16 | 17 | const LoginForm = () => { 18 | const router = useRouter(); 19 | // Setup form with react-hook-form and zod schema 20 | const { 21 | register, 22 | handleSubmit, 23 | formState: { errors }, 24 | } = useForm>({ 25 | resolver: zodResolver(signInSchema), 26 | defaultValues: { 27 | email: '', 28 | password: '', 29 | }, 30 | }); 31 | 32 | // Handle form submission 33 | async function onSubmit(values: z.infer) { 34 | const res = await signIn(values); 35 | 36 | if (res.success) { 37 | toast.success('Login successful'); 38 | router.push('/dashboard'); 39 | } else { 40 | toast.error(res.error); 41 | } 42 | } 43 | 44 | return ( 45 |
46 |

47 | Login 48 |

49 |
50 | 51 |
52 | 59 | 66 | 67 | 68 | 69 | 70 | 71 | 72 |
73 |
74 | ); 75 | }; 76 | 77 | export default LoginForm; 78 | -------------------------------------------------------------------------------- /src/app/login/SignUpForm.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React from 'react'; 4 | import { useRouter } from 'next/navigation'; 5 | import { zodResolver } from '@hookform/resolvers/zod'; 6 | import { useForm } from 'react-hook-form'; 7 | import { toast } from 'sonner'; 8 | import { z } from 'zod'; 9 | 10 | import TextInput from '@/components/authentication/TextInput'; // Import the TextInput component 11 | 12 | import Button from '@/components/common/Button'; 13 | import { signUpSchema } from '@/schemas/zod/schemas'; 14 | import { signUp } from './login.action'; 15 | 16 | const SignUpForm = () => { 17 | const router = useRouter(); 18 | const { 19 | register, 20 | handleSubmit, 21 | formState: { errors }, 22 | } = useForm>({ 23 | resolver: zodResolver(signUpSchema), 24 | defaultValues: { 25 | name: '', 26 | email: '', 27 | password: '', 28 | confirmPassword: '', 29 | }, 30 | }); 31 | 32 | // Handle form submission 33 | async function onSubmit(values: z.infer) { 34 | const res = await signUp(values); 35 | if (res.success) { 36 | toast.success('Account created successfully'); 37 | router.push('/dashboard'); 38 | } else { 39 | toast.error(res.error); 40 | } 41 | } 42 | 43 | return ( 44 |
45 |

Sign up

46 |
47 | 54 | 61 | 68 | 75 | 76 | 77 |
78 | ); 79 | }; 80 | 81 | export default SignUpForm; 82 | -------------------------------------------------------------------------------- /src/app/login/forgot-password/actions.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import { z } from 'zod'; 4 | 5 | import { rateLimitByIp } from '@/lib/limiter'; 6 | import { sendResetEmail } from '@/services/email/sendResetEmail'; 7 | 8 | const resetPasswordInputSchema = z.object({ 9 | email: z.string().email(), 10 | }); 11 | 12 | export const resetPasswordAction = async (input: { email: string }) => { 13 | const validatedInput = resetPasswordInputSchema.parse(input); 14 | 15 | // add this rate limit stuff later - anotn 16 | //await rateLimitByIp({ key: validatedInput.email, limit: 1, window: 30000 }); 17 | 18 | try { 19 | await sendResetEmail(validatedInput.email); 20 | return { success: true, message: 'An email has been sent!' }; 21 | } catch (error) { 22 | if (error instanceof Error) { 23 | if (error.message === 'No user found with that email') { 24 | return { success: false, message: 'No user found with that email.' }; 25 | } 26 | console.log(error.message); 27 | return { success: false, message: 'Email has failed to send.' }; 28 | } 29 | 30 | return { success: false, message: 'An unknown error occurred.' }; 31 | } 32 | }; 33 | -------------------------------------------------------------------------------- /src/app/login/forgot-password/forgetPasswordForm.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React from 'react'; 4 | import { zodResolver } from '@hookform/resolvers/zod'; 5 | import { useForm } from 'react-hook-form'; 6 | import { toast } from 'sonner'; 7 | import { z } from 'zod'; 8 | 9 | import TextInput from '@/components/authentication/TextInput'; 10 | import Button from '@/components/common/Button'; 11 | import { forgetPasswordSchema } from '@/schemas/zod/schemas'; 12 | import { resetPasswordAction } from './actions'; 13 | 14 | const ForgetPasswordForm: React.FC = () => { 15 | const { 16 | register, 17 | handleSubmit, 18 | formState: { errors }, 19 | } = useForm>({ 20 | resolver: zodResolver(forgetPasswordSchema), 21 | defaultValues: { 22 | email: '', 23 | }, 24 | }); 25 | 26 | const onSubmit = async (values: z.infer) => { 27 | const result = await resetPasswordAction(values); 28 | if (result.success) { 29 | toast.success(result.message); 30 | } else { 31 | toast.error(result.message); 32 | } 33 | }; 34 | 35 | return ( 36 |
40 | 47 | 53 | 54 | ); 55 | }; 56 | 57 | export default ForgetPasswordForm; 58 | -------------------------------------------------------------------------------- /src/app/login/forgot-password/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React from 'react'; 4 | 5 | import ForgetPasswordForm from './forgetPasswordForm'; 6 | 7 | const ForgetPasswordPage = () => { 8 | return ( 9 |
10 |

Forget Password

11 | 12 |
13 | ); 14 | }; 15 | 16 | export default ForgetPasswordPage; 17 | -------------------------------------------------------------------------------- /src/app/login/login.action.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import { cookies } from 'next/headers'; 4 | import { redirect } from 'next/navigation'; 5 | import { generateState } from 'arctic'; 6 | import { generateCodeVerifier } from 'oslo/oauth2'; 7 | import { Argon2id } from 'oslo/password'; 8 | import { z } from 'zod'; 9 | 10 | import { 11 | deleteSessionTokenCookie, 12 | setSessionTokenCookie, 13 | } from '@/lib/antAuth/cookies'; 14 | import { createSession, generateSessionToken } from '@/lib/antAuth/sessions'; 15 | import { googleOAuthClient } from '@/lib/googleOauth'; 16 | import { prisma } from '@/lib/prisma'; 17 | import { signInSchema, signUpSchema } from '@/schemas/zod/schemas'; 18 | 19 | export const signUp = async (values: z.infer) => { 20 | try { 21 | //if user exists, explode 22 | const existingUserByEmail = await prisma.user.findUnique({ 23 | where: { 24 | email: values.email.toLowerCase(), 25 | }, 26 | }); 27 | 28 | const existingUserByName = await prisma.user.findUnique({ 29 | where: { 30 | name: values.name, 31 | }, 32 | }); 33 | 34 | if (existingUserByEmail) { 35 | return { error: 'Email already in use', success: false }; 36 | } 37 | 38 | if (existingUserByName) { 39 | return { error: 'Name already in use', success: false }; 40 | } 41 | 42 | //hash password with argon2 apparently better than bcrypt (will research later) 43 | const hashedPassword = await new Argon2id().hash(values.password); 44 | 45 | const user = await prisma.user.create({ 46 | data: { 47 | email: values.email.toLowerCase(), 48 | name: values.name, 49 | hashedPassword, 50 | }, 51 | }); 52 | 53 | const sessionToken = generateSessionToken(); 54 | const sessionCookie = await createSession(sessionToken, user.id); 55 | setSessionTokenCookie(sessionToken, sessionCookie.expiresAt); 56 | return { success: true }; 57 | } catch (error) { 58 | return { error: 'Something went wrong', success: false }; 59 | } 60 | }; 61 | 62 | export const signIn = async (values: z.infer) => { 63 | const user = await prisma.user.findUnique({ 64 | where: { 65 | email: values.email, 66 | }, 67 | }); 68 | if (!user || !user.hashedPassword) 69 | return { success: false, error: 'Invalid Credentials!' }; 70 | 71 | const passwordMatch = await new Argon2id().verify( 72 | user.hashedPassword, 73 | values.password 74 | ); 75 | 76 | if (!passwordMatch) { 77 | return { success: false, error: 'Invalid Credentials!' }; 78 | } 79 | 80 | const sessionToken = generateSessionToken(); 81 | const sessionCookie = await createSession(sessionToken, user.id); 82 | setSessionTokenCookie(sessionToken, sessionCookie.expiresAt); 83 | return { success: true }; 84 | }; 85 | 86 | export const logOut = async () => { 87 | deleteSessionTokenCookie(); 88 | return redirect('/login'); 89 | }; 90 | 91 | export const getGoogleOauthConsentUrl = async () => { 92 | try { 93 | const state = generateState(); 94 | const codeVerifier = generateCodeVerifier(); 95 | 96 | cookies().set('codeVerifier', codeVerifier, { 97 | httpOnly: true, 98 | secure: process.env.NODE_ENV === 'production', 99 | }); 100 | cookies().set('state', state, { 101 | httpOnly: true, 102 | secure: process.env.NODE_ENV === 'production', 103 | }); 104 | 105 | const authUrl = await googleOAuthClient.createAuthorizationURL( 106 | state, 107 | codeVerifier, 108 | { 109 | scopes: ['email', 'profile'], 110 | } 111 | ); 112 | return { success: true, url: authUrl.toString() }; 113 | } catch (error) { 114 | return { success: false, error: 'Something went wrong' }; 115 | } 116 | }; 117 | -------------------------------------------------------------------------------- /src/app/login/page.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { redirect } from 'next/navigation'; 3 | 4 | import { NavigationBar } from '@/components/common/ui/navigation/navbar'; 5 | import { getUser } from '@/lib/antAuth/sessions'; 6 | import LoginForm from './LoginForm'; 7 | import SignUpForm from './SignUpForm'; 8 | 9 | const LoginPage = async () => { 10 | const user = await getUser(); 11 | if (user) return redirect('/dashboard'); 12 | 13 | return ( 14 |
15 | 16 |
17 | 18 | 19 |
20 |
21 | ); 22 | }; 23 | 24 | export default LoginPage; 25 | -------------------------------------------------------------------------------- /src/app/not-found.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useRouter } from 'next/navigation'; 4 | 5 | import { devConfig } from '@/devconfig'; 6 | 7 | export default function Redirect404() { 8 | const router = useRouter(); 9 | 10 | if (devConfig.DISABLE_NOTFOUND) { 11 | router.push('/'); 12 | } 13 | return
not found xd
; 14 | } 15 | -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { LimitScreenSize } from '@/components/common/ui/wrapper/LimitScreenSize'; 4 | import { getUser } from '@/lib/antAuth/sessions'; 5 | import ClientGamePage from './client-page'; 6 | 7 | //get user on load (idk a better way sry ruining ur archetecture) 8 | const Home = async () => { 9 | const user = await getUser(); 10 | 11 | return ( 12 | 13 | 14 | 15 | ); 16 | }; 17 | 18 | export default Home; 19 | -------------------------------------------------------------------------------- /src/app/reset-password/actions.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import { Argon2id } from 'oslo/password'; 4 | import { z } from 'zod'; 5 | 6 | import { prisma } from '@/lib/prisma'; 7 | import { resetPasswordSchema } from '@/schemas/zod/schemas'; 8 | 9 | // Define the reset password action 10 | export const resetPasswordAction = async ({ 11 | token, 12 | password, 13 | confirmPassword, 14 | }: { 15 | token: string | null; 16 | password: string; 17 | confirmPassword: string; 18 | }) => { 19 | if (!token) { 20 | return { success: false, message: 'Invalid or missing token.' }; 21 | } 22 | 23 | const validation = resetPasswordSchema.safeParse({ 24 | password, 25 | confirmPassword, 26 | }); 27 | 28 | if (!validation.success) { 29 | return { 30 | success: false, 31 | message: validation.error.flatten().formErrors.join(', '), 32 | }; 33 | } 34 | 35 | try { 36 | const resetToken = await prisma.passwordResetToken.findUnique({ 37 | where: { token }, 38 | include: { user: true }, 39 | }); 40 | 41 | if (!resetToken || resetToken.expiresAt < new Date()) { 42 | return { success: false, message: 'Invalid or expired token.' }; 43 | } 44 | 45 | const hashedPassword = await new Argon2id().hash(password); 46 | 47 | await prisma.user.update({ 48 | where: { id: resetToken.userId }, 49 | data: { hashedPassword: hashedPassword }, 50 | }); 51 | 52 | await prisma.passwordResetToken.delete({ 53 | where: { id: resetToken.id }, 54 | }); 55 | 56 | return { success: true, message: 'Password reset successfully.' }; 57 | } catch (error) { 58 | return { success: false, message: 'Failed to reset the password.' }; 59 | } 60 | }; 61 | -------------------------------------------------------------------------------- /src/app/reset-password/page.tsx: -------------------------------------------------------------------------------- 1 | import React, { Suspense } from 'react'; 2 | 3 | import ResetPasswordForm from './resetPasswordForm'; 4 | 5 | const ResetPasswordPage = () => { 6 | return ( 7 |
8 |

Reset Password

9 | Loading...
}> 10 | 11 | 12 | Loading...}> 13 | 14 | 15 | 16 | ); 17 | }; 18 | 19 | export default ResetPasswordPage; 20 | -------------------------------------------------------------------------------- /src/app/reset-password/resetPasswordForm.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React, { useEffect } from 'react'; 4 | import { redirect, useRouter, useSearchParams } from 'next/navigation'; 5 | import { zodResolver } from '@hookform/resolvers/zod'; 6 | import { useForm } from 'react-hook-form'; 7 | import { toast } from 'sonner'; 8 | 9 | import TextInput from '@/components/authentication/TextInput'; 10 | import Button from '@/components/common/Button'; 11 | import { resetPasswordSchema } from '@/schemas/zod/schemas'; 12 | import { resetPasswordAction } from './actions'; 13 | 14 | const ResetPasswordForm = () => { 15 | const router = useRouter(); 16 | const searchParams = useSearchParams(); 17 | const token = searchParams.get('token'); 18 | 19 | useEffect(() => { 20 | if (!token) { 21 | toast.error('Invalid or missing reset token.'); 22 | redirect('/login'); 23 | } 24 | }, [token]); 25 | 26 | const { 27 | register, 28 | handleSubmit, 29 | formState: { errors }, 30 | } = useForm({ 31 | resolver: zodResolver(resetPasswordSchema), 32 | defaultValues: { 33 | password: '', 34 | confirmPassword: '', 35 | }, 36 | }); 37 | 38 | const onSubmit = async (values: { 39 | password: string; 40 | confirmPassword: string; 41 | }) => { 42 | if (!token) return; 43 | 44 | const result = await resetPasswordAction({ token, ...values }); 45 | if (result.success) { 46 | toast.success(result.message); 47 | router.push('/login'); 48 | } else { 49 | toast.error(result.message); 50 | } 51 | }; 52 | 53 | return ( 54 |
58 | 65 | 72 | 75 | 76 | ); 77 | }; 78 | 79 | export default ResetPasswordForm; 80 | -------------------------------------------------------------------------------- /src/app/testpage/page.tsx: -------------------------------------------------------------------------------- 1 | import { ClientLeaderboardPage } from '@/components/ClientLeaderboardPage'; 2 | 3 | const Testpage = () => { 4 | return ( 5 |
6 | 7 |
8 | ); 9 | }; 10 | 11 | export default Testpage; 12 | -------------------------------------------------------------------------------- /src/app/types/gameData.ts: -------------------------------------------------------------------------------- 1 | export type GameData = { 2 | mode: string; 3 | language: string; 4 | totalTime: number; 5 | totalChar: number; 6 | totalClick: number; 7 | totalHit: number; 8 | targetSize: number; 9 | wpm: number; 10 | rawWpm: number; 11 | lpm: number; 12 | rawLpm: number; 13 | accuracy: number; 14 | userName?: string; 15 | }; 16 | -------------------------------------------------------------------------------- /src/app/types/safeUser.ts: -------------------------------------------------------------------------------- 1 | import type { User } from '@prisma/client'; 2 | 3 | //it's literally just the full user table but without the password because im scared lol 4 | export type SafeUser = Omit; 5 | -------------------------------------------------------------------------------- /src/app/types/user.ts: -------------------------------------------------------------------------------- 1 | export type User = { 2 | name: string; 3 | email: string; 4 | } | null; 5 | -------------------------------------------------------------------------------- /src/components/ClientLeaderboardPage.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React, { useEffect, useState } from 'react'; 4 | import { GameEntry } from '@prisma/client'; 5 | import { useQueryClient } from '@tanstack/react-query'; 6 | import { motion } from 'framer-motion'; 7 | 8 | import { useLeaderboardScore } from '@/app/hooks/query/useLeaderboard'; 9 | import { dateToHHMMSS } from '@/lib/utils/date'; 10 | import { QUERY_KEY } from '@/lib/utils/queryKeys'; 11 | import Button from './common/Button'; 12 | import { Select, SelectContent, SelectItem } from './common/Select'; 13 | import { NavigationBar } from './common/ui/navigation/navbar'; 14 | 15 | interface LeaderboardEntry extends GameEntry { 16 | user: { 17 | name: string; 18 | }; 19 | } 20 | 21 | export const ClientLeaderboardPage = () => { 22 | const queryClient = useQueryClient(); 23 | const [currentMode, setCurrentMode] = useState('characters'); 24 | const [currentSubMode, setCurrentSubMode] = useState(30); 25 | 26 | const { data } = useLeaderboardScore(currentMode, currentSubMode); 27 | 28 | console.log(data); 29 | useEffect(() => { 30 | queryClient.resetQueries({ 31 | queryKey: [QUERY_KEY.STATIC_LEADERBOARD], 32 | }); 33 | }, [currentMode, currentSubMode]); 34 | 35 | useEffect(() => { 36 | console.log(data); 37 | }, [data]); 38 | 39 | const handleModeChange = (mode: string) => { 40 | setCurrentMode(mode); 41 | setCurrentSubMode(mode === 'time' ? 15 : 30); 42 | }; 43 | 44 | const handleSubModeChange = (subMode: number) => { 45 | setCurrentSubMode(subMode); 46 | }; 47 | 48 | return ( 49 | <> 50 | 51 |
52 |
53 |

Leaderboards

54 |

English 5k

55 |
56 |
57 |
58 | 64 |
65 | {currentMode === 'time' && ( 66 | <> 67 | 68 | 69 | 70 | 71 | )} 72 | {currentMode === 'characters' && ( 73 | <> 74 | 75 | 76 | 77 | 78 | )} 79 |
80 |
81 |
82 | 83 |
84 |
85 | 86 | 87 |
88 |
89 | {data ? ( 90 | <> 91 | {data.map((item, i) => ( 92 | 93 | ))} 94 | {Array.from({ length: 15 - data?.length! }).map((_, i) => ( 95 | 96 | ))} 97 | 98 | ) : ( 99 | Array.from({ length: 15 }).map((_, i) => ( 100 | 101 | )) 102 | )} 103 |
104 |
105 |
106 | 107 | ); 108 | }; 109 | 110 | const ScoreRowLabel = () => { 111 | return ( 112 |
113 |

#

114 |

name

115 |

accuracy

116 |

lpm

117 |

wpm

118 |

date

119 |
120 | ); 121 | }; 122 | 123 | const ScoreRow = ({ 124 | score, 125 | index, 126 | }: { 127 | score: LeaderboardEntry; 128 | index: number; 129 | }) => { 130 | const date = new Date(score.createdAt); 131 | return ( 132 | <> 133 | 134 |

{index}

135 |

{score.user?.name}

136 |

{score.accuracy.toFixed(2)}

137 |

{score.lpm.toFixed(2)}

138 |

{score.wpm.toFixed(2)}

139 |
140 |

{date.toLocaleDateString()}

141 |

{dateToHHMMSS(date)}

142 |
143 | {/* Replace with actual date */} 144 |
145 | 146 | ); 147 | }; 148 | 149 | const ScoreRowSkeleton = () => { 150 | return ( 151 |
152 | 158 |
159 |
160 |
161 |
162 |
163 |
164 | 165 |
166 | ); 167 | }; 168 | 169 | const ScoreRowEmpty = () => { 170 | return
; 171 | }; 172 | -------------------------------------------------------------------------------- /src/components/authentication/GoogleOAuthButton.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React from 'react'; 4 | import { RiGoogleFill } from '@remixicon/react'; 5 | import { toast } from 'sonner'; 6 | 7 | import { getGoogleOauthConsentUrl } from '@/app/login/login.action'; 8 | import Button from '../common/Button'; 9 | 10 | export const GoogleOAuthButton = () => { 11 | return ( 12 | 26 | ); 27 | }; 28 | -------------------------------------------------------------------------------- /src/components/authentication/SignOutButton.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React from 'react'; 4 | 5 | import { logOut } from '@/app/login/login.action'; 6 | 7 | type Props = { 8 | children: React.ReactNode; 9 | }; 10 | 11 | export const SignOutButton = ({ children }: Props) => { 12 | return ( 13 | 21 | ); 22 | }; 23 | 24 | export default SignOutButton; 25 | -------------------------------------------------------------------------------- /src/components/authentication/TextInput.tsx: -------------------------------------------------------------------------------- 1 | // TextInput.tsx 2 | import React, { forwardRef } from 'react'; 3 | 4 | interface TextInputProps extends React.InputHTMLAttributes { 5 | errors?: string; 6 | classname?: string; 7 | } 8 | 9 | const TextInput = forwardRef( 10 | ({ errors, classname, ...props }, ref) => { 11 | return ( 12 |
13 | 18 | {errors &&

{errors}

} 19 |
20 | ); 21 | } 22 | ); 23 | 24 | TextInput.displayName = 'TextInput'; 25 | 26 | export default TextInput; 27 | -------------------------------------------------------------------------------- /src/components/common/Button.tsx: -------------------------------------------------------------------------------- 1 | import React, { PropsWithChildren } from 'react'; 2 | import { tv } from 'tailwind-variants'; 3 | 4 | import { cn } from '@/lib/utils'; 5 | 6 | interface ButtonProps 7 | extends PropsWithChildren> { 8 | variant?: 'outline'; 9 | } 10 | 11 | const button = tv({ 12 | base: 'flex items-center justify-center rounded-md bg-secondary p-2 px-8 text-sm text-background transition-colors *:stroke-background hover:bg-hover', 13 | variants: { 14 | variant: { 15 | outline: 16 | 'hover:bg-accent border border-secondary/70 bg-background text-foreground *:stroke-foreground hover:border-foreground', 17 | }, 18 | }, 19 | }); 20 | 21 | const Button = ({ variant, ...props }: ButtonProps) => { 22 | return ( 23 | 29 | ); 30 | }; 31 | 32 | export default Button; 33 | -------------------------------------------------------------------------------- /src/components/common/Debugger.tsx: -------------------------------------------------------------------------------- 1 | // import React from 'react'; 2 | 3 | // import { useGameContext } from '@/app/client-page'; 4 | 5 | // export const Debugger = () => { 6 | // const { Gamedata } = useGameContext(); 7 | // return ( 8 | //
9 | // {Object.keys(Gamedata).map((key) => ( 10 | //

11 | // {key} {typeof Gamedata[key]}: 12 | // {(key === 'words' && `${Gamedata[key].length} total words`) || 13 | // (typeof Gamedata[key] === 'boolean' && 14 | // (Gamedata[key] ? 'true' : 'false')) || 15 | // (Gamedata[key] as any)} 16 | //

17 | // ))} 18 | //
19 | // ); 20 | // }; 21 | -------------------------------------------------------------------------------- /src/components/common/Dialog.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React, { createContext, useState } from 'react'; 4 | import { AnimatePresence, motion } from 'framer-motion'; 5 | 6 | import { cn } from '@/lib/utils'; 7 | import Button from './Button'; 8 | 9 | type DialogContextProps = { 10 | open: boolean; 11 | handleToggle: () => void; 12 | handleClose: () => void; 13 | }; 14 | 15 | const DialogContext = createContext({} as DialogContextProps); 16 | 17 | export const useDialog = () => { 18 | const context = React.useContext(DialogContext); 19 | if (!context) { 20 | throw new Error('useDialog must be used within a DialogProvider'); 21 | } 22 | return context; 23 | }; 24 | 25 | export const Dialog = ({ children }: { children: React.ReactNode }) => { 26 | const [open, setopen] = useState(false); 27 | 28 | const handleClose = () => { 29 | setopen(false); 30 | }; 31 | 32 | const handleToggle = () => { 33 | setopen(() => !open); 34 | }; 35 | 36 | return ( 37 | 44 | {open && } 45 | {children} 46 | 47 | ); 48 | }; 49 | 50 | const Modal = () => { 51 | const { handleClose } = useDialog(); 52 | return ( 53 | 54 | 62 | 63 | ); 64 | }; 65 | 66 | const dialogVariants = { 67 | initial: { 68 | scale: 0.8, 69 | opacity: 0, 70 | clipPath: 'inset(50% 0 round 16px)', 71 | }, 72 | enter: { 73 | scale: 1, 74 | opacity: 1, 75 | clipPath: 'inset(0% 0)', 76 | transition: { 77 | ease: 'easeInOut', 78 | duration: 0.1, 79 | clipPath: { 80 | delay: 0.1, 81 | }, 82 | }, 83 | }, 84 | exit: { 85 | opacity: 0, 86 | scale: 0.95, 87 | clipPath: 'inset(50% 0)', 88 | }, 89 | }; 90 | 91 | export const DialogContent = ({ children }: { children: React.ReactNode }) => { 92 | const { open } = useDialog(); 93 | return ( 94 | 95 | {open && ( 96 | 104 | {children} 105 | 106 | )} 107 | 108 | ); 109 | }; 110 | 111 | export const DialogTriggerButton = ({ 112 | children, 113 | }: { 114 | children: React.ReactNode; 115 | }) => { 116 | const { handleToggle, open } = useDialog(); 117 | return ( 118 | 125 | ); 126 | }; 127 | 128 | export default Dialog; 129 | -------------------------------------------------------------------------------- /src/components/common/FunctionDebugger.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export const FunctionDebugger = ({ ...props }: any) => { 4 | return ( 5 |
6 | {Object.keys(props).map((item, i) => { 7 | return ( 8 | 15 | ); 16 | })} 17 |
18 | ); 19 | }; 20 | -------------------------------------------------------------------------------- /src/components/common/KeyLabsLogo.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const KeyLabsLogo = ({ 4 | className, 5 | stroke, 6 | }: { 7 | className?: string; 8 | stroke?: string; 9 | }) => { 10 | return ( 11 | 19 | 20 | 27 | 34 | 41 | 48 | 55 | 62 | 69 | 76 | 77 | 78 | 79 | 84 | 85 | 86 | 87 | ); 88 | }; 89 | 90 | export default KeyLabsLogo; 91 | -------------------------------------------------------------------------------- /src/components/common/Select.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | createContext, 3 | useContext, 4 | useLayoutEffect, 5 | useState, 6 | type HTMLAttributes, 7 | } from 'react'; 8 | import { AnimatePresence, motion } from 'framer-motion'; 9 | import { ChevronDown } from 'lucide-react'; 10 | 11 | import { cn } from '@/lib/utils'; 12 | 13 | export interface SelectProps extends HTMLAttributes { 14 | className?: string; 15 | name: string; 16 | float?: 'left' | 'right'; 17 | children?: React.ReactNode; 18 | } 19 | 20 | export const SelectItem = ({ value }: { value: string }) => { 21 | const { handleClose, handleSelect, value: current } = useSelectContext(); 22 | 23 | return ( 24 | 38 | ); 39 | }; 40 | 41 | const SelectContext = createContext( 42 | {} as { 43 | handleToggle: () => void; 44 | handleClose: () => void; 45 | handleSelect: (val: string) => void; 46 | open: boolean; 47 | value?: string; 48 | } 49 | ); 50 | 51 | const useSelectContext = () => { 52 | const context = useContext(SelectContext); 53 | if (!context) { 54 | throw new Error('useSelectContext must be used within a SelectProvider'); 55 | } 56 | return context; 57 | }; 58 | 59 | type SelectPropss = { 60 | children: React.ReactNode; 61 | defaultValue?: string; 62 | onChange?: (value: string) => void; 63 | }; 64 | 65 | export const Select = ({ children, defaultValue, onChange }: SelectPropss) => { 66 | const [open, setOpen] = useState(false); 67 | const [value, setValue] = useState( 68 | defaultValue ?? undefined 69 | ); 70 | const handleToggle = () => { 71 | setOpen(!open); 72 | }; 73 | const handleClose = () => { 74 | setOpen(false); 75 | }; 76 | const handleSelect = (value: string) => { 77 | setValue(value); 78 | }; 79 | 80 | useLayoutEffect(() => { 81 | if (value && onChange) { 82 | onChange(value); 83 | } 84 | }, [value]); 85 | 86 | return ( 87 | 90 | {children} 91 | 92 | ); 93 | }; 94 | 95 | export const SelectContent = ({ 96 | className, 97 | name, 98 | children, 99 | float = 'left', 100 | ...props 101 | }: SelectProps) => { 102 | const { open, handleToggle, value } = useSelectContext(); 103 | return ( 104 |
105 | 116 | 117 | {open && ( 118 | 125 | {children} 126 | 127 | )} 128 | 129 |
130 | ); 131 | }; 132 | -------------------------------------------------------------------------------- /src/components/common/ui/dashboard/AccountDetails/AccountDetails.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import SignOutButton from '@/components/authentication/SignOutButton'; 4 | import { AvatarAndName } from './AvatarAndName'; 5 | import { ExperienceBar } from './ExperienceBar'; 6 | import { TestStats } from './TestStats'; 7 | 8 | export const AccountDetails = () => { 9 | return ( 10 |
11 | 12 | 13 | 14 | Sign out 15 |
16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /src/components/common/ui/dashboard/AccountDetails/AvatarAndName.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { UserRoundPen } from 'lucide-react'; 3 | 4 | import { useUserContext } from './UserContext'; 5 | 6 | export const AvatarAndName = () => { 7 | const { user } = useUserContext(); 8 | 9 | // Format the date as "Joined 18 Feb 2022" 10 | const formattedDate = user?.joinedAt 11 | ? new Date(user.joinedAt).toLocaleDateString('en-GB', { 12 | day: 'numeric', 13 | month: 'short', 14 | year: 'numeric', 15 | }) 16 | : 'Loading...'; 17 | 18 | return ( 19 |
20 | 21 |
22 |

{user?.name ?? 'Loading...'}

23 |

Joined {formattedDate}

24 |
25 |
26 | ); 27 | }; 28 | -------------------------------------------------------------------------------- /src/components/common/ui/dashboard/AccountDetails/ExperienceBar.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export const ExperienceBar = () => { 4 | return ( 5 |
6 |
38
7 |
8 |
60/100
9 |
10 | ); 11 | }; 12 | -------------------------------------------------------------------------------- /src/components/common/ui/dashboard/AccountDetails/TestStats.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { useUserContext } from './UserContext'; 4 | 5 | const formatTime = (totalTime: number) => { 6 | const totalSeconds = Math.floor(totalTime); 7 | const minutes = Math.floor(totalSeconds / 60); 8 | const seconds = totalSeconds % 60; 9 | return `${minutes} min ${seconds} sec`; 10 | }; 11 | 12 | export const TestStats = () => { 13 | const { user } = useUserContext(); 14 | //"responsive design hahaha" 15 | // t: "bruh wtf is this :skull:" 16 | return ( 17 |
18 |
19 |

Games Played

20 |

{user?.totalGames}

21 |
22 |
23 |

Playtime

24 |

25 | {user ? formatTime(user.totalTime) : 'Loading...'} 26 |

27 |
28 |
29 | ); 30 | }; 31 | -------------------------------------------------------------------------------- /src/components/common/ui/dashboard/AccountDetails/UserContext.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useContext } from 'react'; 2 | import { UserStats } from '@prisma/client'; 3 | 4 | import { SafeUser } from '@/app/types/safeUser'; 5 | 6 | // Define the structure for BestGame and BestScore 7 | export interface BestGame { 8 | lpm: number; 9 | rawLpm: number; 10 | accuracy: number; 11 | createdAt: string; 12 | language: string; 13 | totalChar: number; 14 | totalClicks: number; 15 | } 16 | 17 | export interface BestScore { 18 | mode: string; 19 | category: string; 20 | avgLpm: number; 21 | avgAccuracy: number; 22 | bestGame: BestGame | null; 23 | } 24 | 25 | export interface UserContextType { 26 | user: SafeUser | null; 27 | userStats: UserStats[] | null; 28 | bestScores: BestScore[] | null; 29 | } 30 | 31 | // Create a context with the updated structure 32 | const UserContext = createContext(undefined); 33 | 34 | // UserProvider now takes user, userStats, and bestScores as props 35 | export const UserProvider: React.FC<{ 36 | user: SafeUser; 37 | userStats: UserStats[]; 38 | bestScores: BestScore[]; // Add bestScores to the provider 39 | children: React.ReactNode; 40 | }> = ({ user, userStats, bestScores, children }) => { 41 | return ( 42 | 43 | {children} 44 | 45 | ); 46 | }; 47 | 48 | // Hook to use the context in components 49 | export const useUserContext = () => { 50 | const context = useContext(UserContext); 51 | if (context === undefined) { 52 | throw new Error('useUserContext must be used within a UserProvider'); 53 | } 54 | return context; 55 | }; 56 | -------------------------------------------------------------------------------- /src/components/common/ui/dashboard/DashBoard.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React from 'react'; 4 | import { UserStats } from '@prisma/client'; 5 | 6 | import { SafeUser } from '@/app/types/safeUser'; 7 | import { NavigationBar } from '../navigation/navbar'; 8 | import { AccountDetails } from './AccountDetails/AccountDetails'; 9 | import { UserProvider } from './AccountDetails/UserContext'; 10 | import { ModeStats } from './ModeStats/ModeStats'; 11 | 12 | //i'll export this later gl refactoring all that. 13 | interface BestGame { 14 | lpm: number; 15 | rawLpm: number; 16 | accuracy: number; 17 | createdAt: string; 18 | language: string; 19 | totalChar: number; 20 | totalClicks: number; 21 | } 22 | 23 | interface BestScore { 24 | mode: string; 25 | category: string; 26 | avgLpm: number; 27 | avgAccuracy: number; 28 | bestGame: BestGame | null; 29 | } 30 | 31 | interface AccountPageProps { 32 | user: SafeUser; 33 | userStats: UserStats[]; 34 | bestScores: BestScore[]; 35 | } 36 | 37 | const DashBoard: React.FC = ({ 38 | user, 39 | userStats, 40 | bestScores, 41 | }) => { 42 | return ( 43 | 44 | 45 |
46 |
47 |
48 | 49 | 50 |
51 |
52 |
53 |
54 | ); 55 | }; 56 | 57 | export default DashBoard; 58 | -------------------------------------------------------------------------------- /src/components/common/ui/dashboard/ModeStats/ModeStatBox.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { AnimatePresence, motion } from 'framer-motion'; 3 | 4 | import { dateToHHMMSS } from '@/lib/utils/date'; 5 | import { BestGame } from '../AccountDetails/UserContext'; 6 | 7 | interface ModeStatBoxProps extends Partial { 8 | label: string; 9 | } 10 | 11 | const ModeStatBox = ({ label, ...bestScore }: ModeStatBoxProps) => { 12 | const [isHovered, setIsHovered] = useState(false); 13 | 14 | const hasStats = 15 | bestScore.lpm !== undefined && bestScore.accuracy !== undefined; 16 | 17 | const date = new Date(bestScore?.createdAt || ''); 18 | 19 | return ( 20 | setIsHovered(true)} 23 | onMouseLeave={() => setIsHovered(false)} 24 | > 25 | 26 | {!isHovered ? ( 27 | 35 |

{label}

36 | {hasStats ? ( 37 | <> 38 |

LPM: {bestScore.lpm?.toFixed(2)}

39 |

Accuracy: {bestScore.accuracy?.toFixed(2)}%

40 | 41 | ) : ( 42 |

No information

43 | )} 44 |
45 | ) : ( 46 | 54 | {hasStats ? ( 55 | <> 56 | {label} 57 |

58 | LPM: {bestScore?.lpm} /{bestScore?.rawLpm} 59 |

60 |

Accuracy: {bestScore?.accuracy}%

61 |

{date.toLocaleDateString()}

62 | 63 | ) : ( 64 |

No additional information

65 | )} 66 |
67 | )} 68 |
69 |
70 | ); 71 | }; 72 | 73 | export default ModeStatBox; 74 | -------------------------------------------------------------------------------- /src/components/common/ui/dashboard/ModeStats/ModeStats.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { useUserContext } from '../AccountDetails/UserContext'; 4 | import ModeStatBox from './ModeStatBox'; 5 | 6 | export const ModeStats = () => { 7 | const { bestScores } = useUserContext(); 8 | 9 | const characterModes = ['30', '50', '100']; 10 | const timeModes = ['15', '30', '60']; 11 | 12 | // Helper function to find the best score for a given mode and category 13 | const findBestScore = (mode: string, category: string) => { 14 | return bestScores?.find( 15 | (score) => score.mode === mode && score.category === category 16 | ); 17 | }; 18 | 19 | return ( 20 |
21 | {/* Character modes: 30c, 50c, 100c */} 22 |
23 | {characterModes.map((characterMode) => { 24 | const bestScore = findBestScore('characters', characterMode); 25 | 26 | return ( 27 | 32 | ); 33 | })} 34 |
35 |
36 | {timeModes.map((timeMode) => { 37 | const bestScore = findBestScore('time', timeMode); 38 | return ( 39 | 44 | ); 45 | })} 46 |
47 |
48 | ); 49 | }; 50 | -------------------------------------------------------------------------------- /src/components/common/ui/emailTemplates/resetPasswordEmail.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { 3 | Body, 4 | Container, 5 | Head, 6 | Hr, 7 | Html, 8 | Img, 9 | Link, 10 | Preview, 11 | Section, 12 | Tailwind, 13 | Text, 14 | } from '@react-email/components'; 15 | 16 | export const BASE_URL = process.env.NEXT_PUBLIC_URL; 17 | 18 | export function ResetPasswordEmail({ token }: { token: string }) { 19 | return ( 20 | 21 | 22 | Reset your password 23 | 24 | 25 | 26 | 27 |
28 | {/*StarterKit*/} 35 |
36 | 37 |
38 | 39 | Click the following link to reset your password 40 | 41 | 42 | 43 | 48 | Reset Password 49 | 50 | 51 |
52 | 53 |
54 | 55 | 56 | © 2024 KeyLabs. All rights reserved. 57 | 58 |
59 | 60 |
61 |
62 | 63 | ); 64 | } 65 | -------------------------------------------------------------------------------- /src/components/common/ui/emailTemplates/testEmail.tsx: -------------------------------------------------------------------------------- 1 | // /components/emailTemplates/testEmail.tsx 2 | 3 | import React from 'react'; 4 | 5 | const TestEmail = () => { 6 | return
Hello, this is a test email component!
; 7 | }; 8 | 9 | export default TestEmail; 10 | -------------------------------------------------------------------------------- /src/components/common/ui/game/EndGameScreen.tsx: -------------------------------------------------------------------------------- 1 | import React, { HTMLAttributes } from 'react'; 2 | import { ArrowRight, Crown, MoveRight, RotateCcw } from 'lucide-react'; 3 | import { toast } from 'sonner'; 4 | 5 | import { useGameContext } from '@/app/client-page'; 6 | import { GameData } from '@/app/types/gameData'; 7 | import { cn } from '@/lib/utils'; 8 | import Button from '../../Button'; 9 | import { useTransition } from '../transition/Transition'; 10 | 11 | interface StatBoxProps extends HTMLAttributes { 12 | label: string; 13 | value: string | number; 14 | className?: string; 15 | } 16 | 17 | //welcome back to anton front end (rip) 18 | const StatBox = ({ label, value, className }: StatBoxProps) => { 19 | return ( 20 |
26 |

{label}

27 |

{value}

28 |
29 | ); 30 | }; 31 | 32 | interface StatRowProps extends HTMLAttributes { 33 | label: string; 34 | value: string | number; 35 | className?: string; 36 | } 37 | 38 | const StatRow = ({ label, value, className }: StatRowProps) => { 39 | return ( 40 | //cn magic wow (takes last argument over the first wow (wow magic)) 41 |
47 |

{label}

48 |

{value}

49 |
50 | ); 51 | }; 52 | 53 | interface EndGameScreenProps extends HTMLAttributes { 54 | gameData: GameData | null; 55 | } 56 | 57 | export const EndGameScreen = ({ gameData }: EndGameScreenProps) => { 58 | const { handleRouteChange } = useTransition(); 59 | const { handleResetGame } = useGameContext(); 60 | if (!gameData) { 61 | return
No game data available.
; // Handle null case 62 | } 63 | 64 | return ( 65 |
66 | 67 | 71 | 75 | 76 |
77 | 78 | 79 | 80 | 81 |
82 |
83 | 86 | 89 |
90 |
91 | ); 92 | }; 93 | -------------------------------------------------------------------------------- /src/components/common/ui/game/GameBoard.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React, { MouseEvent, useRef } from 'react'; 4 | import { AnimatePresence, motion } from 'framer-motion'; 5 | 6 | import { useCurrentGame, usePointsStack, useScreen } from '@/app/client-page'; 7 | import { hitVariants } from '@/lib/variants/variants'; 8 | import { distance } from '@/services/utils'; 9 | import { StartButton } from './StartButton'; 10 | 11 | const GameBoard = () => { 12 | const containerRef = useRef(null); 13 | // const { width, height } = useScreenSize() 14 | 15 | const { points, hitPoints } = usePointsStack(); 16 | const { screen } = useScreen(); 17 | const { 18 | targetSize, 19 | hasStart, 20 | incrementCharIndex, 21 | handleNextWord, 22 | incrementClick, 23 | incrementHit, 24 | } = useCurrentGame(); 25 | 26 | const handleClick = (e: MouseEvent) => { 27 | if (!containerRef.current) return; 28 | const { clientX, clientY } = e; 29 | const { left, top } = containerRef.current.getBoundingClientRect(); 30 | const [clickX, clickY] = [clientX - left, clientY - top]; 31 | console.log(clickX, clickY); 32 | incrementClick(); 33 | 34 | for (const point of points) { 35 | if (distance(clickX, clickY, point.x, point.y) > targetSize / 2) continue; 36 | if (point.value !== points[points.length - 1].value) continue; 37 | incrementCharIndex(); 38 | if (points.length <= 1) handleNextWord(); 39 | hitPoints(point.index); 40 | incrementHit(); 41 | break; 42 | } 43 | }; 44 | 45 | return ( 46 |
50 | 51 | {points.map((point, i) => ( 52 | 66 | {point.value} 67 | 68 | ))} 69 | 70 |
75 | {!hasStart && } 76 |
77 | ); 78 | }; 79 | 80 | export default GameBoard; 81 | -------------------------------------------------------------------------------- /src/components/common/ui/game/LanguageSelectionDialog.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Check } from 'lucide-react'; 3 | 4 | import { useGameContext, usePreConfig } from '@/app/client-page'; 5 | import languages from '@/static/language/_list.json'; 6 | 7 | export const LanguageSelectionDialog = () => { 8 | const { handleResetGame } = useGameContext(); 9 | const { config, setConfig } = usePreConfig(); 10 | return ( 11 |
12 |
13 |

Languages set

14 |
15 | {(languages as string[]).map((language) => ( 16 | { 21 | setConfig({ ...config, language: language }); 22 | handleResetGame(); 23 | }} 24 | /> 25 | ))} 26 |
27 | ); 28 | }; 29 | 30 | type LanguageItemProps = { 31 | name: string; 32 | currentLang: string; 33 | onClick: () => void; 34 | }; 35 | const LanguageItem = ({ name, currentLang, onClick }: LanguageItemProps) => { 36 | return ( 37 | 48 | ); 49 | }; 50 | -------------------------------------------------------------------------------- /src/components/common/ui/game/OptionsBar.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React from 'react'; 4 | import { HTMLMotionProps, motion } from 'framer-motion'; 5 | import { CaseSensitive, Timer } from 'lucide-react'; 6 | 7 | import { usePreConfig } from '@/app/client-page'; 8 | import { devConfig } from '@/devconfig'; 9 | import { cn } from '@/lib/utils'; 10 | 11 | export const OptionsBar = ({ ...props }: HTMLMotionProps<'div'>) => { 12 | const { config, setConfig } = usePreConfig(); 13 | 14 | const handleChangeMode = (mode: string) => { 15 | if (config.mode === 'characters') 16 | setConfig({ ...config, mode: mode, lengthChar: 0, time: 30 }); 17 | else setConfig({ ...config, mode: mode, lengthChar: 30, time: 0 }); 18 | }; 19 | 20 | const handleSetTime = (time: number) => { 21 | setConfig({ ...config, lengthChar: 0, time: time }); 22 | }; 23 | 24 | const handleSetChar = (length: number) => { 25 | setConfig({ ...config, lengthChar: length, time: 0 }); 26 | }; 27 | 28 | return ( 29 | 33 | 34 | 38 | 55 | {config.mode === 'characters' && ( 56 | 81 | )} 82 | {config.mode === 'time' && ( 83 | 108 | )} 109 | 110 | 111 | 112 | ); 113 | }; 114 | 115 | type OptionProps = { 116 | label: string | React.ReactNode; 117 | children?: React.ReactNode; 118 | hasLabel?: boolean; 119 | }; 120 | 121 | const Option = ({ label, children, hasLabel = false }: OptionProps) => { 122 | return ( 123 | 129 | {hasLabel && ( 130 | <> 131 |
{label}
132 | 133 | )} 134 |
135 | {children} 136 |
137 |
138 | ); 139 | }; 140 | 141 | type OptionItemProps = { 142 | value: string; 143 | name?: string; 144 | defaultChecked?: boolean; 145 | onChange?: () => void; 146 | }; 147 | 148 | const OptionItem = ({ 149 | value, 150 | name, 151 | onChange, 152 | defaultChecked = false, 153 | }: OptionItemProps) => { 154 | return ( 155 | <> 156 |
157 | 164 |
165 | {value} 166 |
167 |
168 | 169 | ); 170 | }; 171 | 172 | const OptionEdge = ({ className }: { className?: string }) => ( 173 |
179 | ); 180 | -------------------------------------------------------------------------------- /src/components/common/ui/game/StartButton.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React from 'react'; 4 | import { motion } from 'framer-motion'; 5 | import { Waypoints } from 'lucide-react'; 6 | 7 | import { useGameContext } from '@/app/client-page'; 8 | 9 | export const StartButton = () => { 10 | const { handleStartGame } = useGameContext(); 11 | return ( 12 |
13 |
14 | 20 | 21 | 22 |
23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /src/components/common/ui/game/ThemeSelectDialog.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React from 'react'; 4 | import { Preview } from '@react-email/components'; 5 | import { Check } from 'lucide-react'; 6 | 7 | import themes from '@/static/themes/_list.json'; 8 | import { useTheme } from '../wrapper/ThemeProvider'; 9 | 10 | type themeType = { 11 | name: string; 12 | preview: { 13 | background: string; 14 | secondary: string; 15 | foreground: string; 16 | tertiary: string; 17 | }; 18 | }; 19 | 20 | export const ThemeSelectionDialog = () => { 21 | const { theme: currentTheme, handleSetTheme } = useTheme(); 22 | return ( 23 |
24 |
25 |

Themes

26 |
27 | {(themes as themeType[]).map((theme) => ( 28 | { 33 | handleSetTheme(theme.name); 34 | }} 35 | preview={theme.preview} 36 | /> 37 | ))} 38 |
39 | ); 40 | }; 41 | 42 | type ThemeItemProps = { 43 | name: string; 44 | currentLang: string; 45 | onClick: () => void; 46 | preview: themeType['preview']; 47 | }; 48 | const ThemeItem = ({ name, preview, currentLang, onClick }: ThemeItemProps) => { 49 | return ( 50 | 71 | ); 72 | }; 73 | -------------------------------------------------------------------------------- /src/components/common/ui/game/Timer.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef } from 'react'; 2 | import { Timer } from 'lucide-react'; 3 | 4 | import { useGameContext, usePreConfig } from '@/app/client-page'; 5 | 6 | export const GameTimer = forwardRef((props, ref) => { 7 | const { config } = usePreConfig(); 8 | const { Gamedata } = useGameContext(); 9 | 10 | return ( 11 |

12 |

13 | {Gamedata.hasStart ? ( 14 | 15 | {config.mode === 'time' ? `${config.time}s` : '0s'} 16 | 17 | ) : ( 18 | {config.time ? `${config.time}s` : '0s'} 19 | )} 20 | 21 |

22 |

23 | ); 24 | }); 25 | //need this for build i guess 26 | GameTimer.displayName = 'GameTimer'; 27 | -------------------------------------------------------------------------------- /src/components/common/ui/game/WordsBar.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef, useState } from 'react'; 2 | import { motion, useIsomorphicLayoutEffect } from 'framer-motion'; 3 | import { CaseSensitive } from 'lucide-react'; 4 | 5 | import { 6 | GameDataProps, 7 | PreGameConfig, 8 | useGameContext, 9 | usePreConfig, 10 | useScreen, 11 | } from '@/app/client-page'; 12 | import { cn } from '@/lib/utils'; 13 | import { GameTimer } from './Timer'; 14 | 15 | export const WordsBar = () => { 16 | const { screen } = useScreen(); 17 | const { config } = usePreConfig(); 18 | const { Gamedata } = useGameContext(); 19 | const refTimer = useRef(null); // Ref for the timer 20 | const [elapsedTime, setElapsedTime] = useState(0); 21 | 22 | useIsomorphicLayoutEffect(() => { 23 | if (Gamedata.mode !== 'characters') return; 24 | if (Gamedata.charCount >= Gamedata.totalChar) { 25 | Gamedata.setTime(elapsedTime); 26 | Gamedata.finishGame(); 27 | } 28 | }, [Gamedata.charCount]); 29 | 30 | //timer loic 31 | useIsomorphicLayoutEffect(() => { 32 | if (!Gamedata.hasStart) return; 33 | 34 | const startTime = new Date(); 35 | const intervalId = setInterval(() => { 36 | const currentTime = new Date(); 37 | const elapsedMilliseconds = currentTime.getTime() - startTime.getTime(); 38 | const elapsedSeconds = (elapsedMilliseconds / 1000).toFixed(2); // Store with two decimal places 39 | 40 | if (config.mode === 'time') { 41 | const remainingTime = ( 42 | config.time - parseFloat(elapsedSeconds) 43 | ).toFixed(2); 44 | if (parseFloat(remainingTime) <= 0) { 45 | clearInterval(intervalId); 46 | Gamedata.handleFinish(); 47 | } 48 | 49 | setElapsedTime(parseFloat(remainingTime)); // Store as 2 decimal places 50 | if (refTimer.current) { 51 | // Display only the whole number part 52 | refTimer.current.innerHTML = `${Math.floor(parseFloat(remainingTime))}s`; 53 | } 54 | } else if (config.mode === 'characters') { 55 | setElapsedTime(parseFloat(elapsedSeconds)); // Store as 2 decimal places 56 | if (refTimer.current) { 57 | refTimer.current.innerHTML = `${Math.floor(parseFloat(elapsedSeconds))}s`; 58 | } 59 | } 60 | }, 100); 61 | 62 | return () => clearInterval(intervalId); 63 | }, [config.mode, config.time, Gamedata.hasStart]); 64 | 65 | return ( 66 |
70 | 71 | 72 | 73 |
74 | 79 |
80 | 81 |
82 | ); 83 | }; 84 | 85 | const CharacterDisplay = ({ 86 | config, 87 | Gamedata, 88 | }: { 89 | config: PreGameConfig['config']; 90 | Gamedata: GameDataProps; 91 | }) => { 92 | if (!Gamedata.hasStart) { 93 | return ( 94 | <> 95 | 96 | {config.mode === 'characters' && `0/${config.lengthChar}`} 97 | {config.mode === 'time' && config.lengthChar} 98 | 99 | ); 100 | } else { 101 | return ( 102 | <> 103 | 104 | {Gamedata.mode === 'characters' && 105 | `${Gamedata.charCount}/${Gamedata.totalChar}`} 106 | {Gamedata.mode === 'time' && Gamedata.charCount} 107 | 108 | ); 109 | } 110 | }; 111 | 112 | const DisplayWrapper = ({ children }: { children: React.ReactNode }) => { 113 | return ( 114 |

115 |

116 | {children} 117 |

118 |

119 | ); 120 | }; 121 | 122 | type WordViewProps = { words: string[]; index: number; letterIndex: number }; 123 | 124 | const WordsView = ({ words, index, letterIndex }: WordViewProps) => { 125 | const [currentOffset, setCurrentOffset] = React.useState(0); 126 | 127 | const refs = React.useRef<(HTMLParagraphElement | null)[]>([]); 128 | 129 | const handleSetOffset = (offset: number) => { 130 | setCurrentOffset(offset); 131 | }; 132 | 133 | let currentWords: string[] = []; 134 | if (words.length < 10) { 135 | currentWords = words; 136 | } else { 137 | currentWords = words.slice(0, index + 15); 138 | } 139 | 140 | React.useEffect(() => { 141 | const ref = refs.current[index]; 142 | if (ref) { 143 | const { offsetWidth, offsetLeft } = ref; 144 | console.log(offsetWidth, offsetLeft); 145 | handleSetOffset(offsetLeft + offsetWidth / 2); 146 | } else { 147 | console.log('no ref'); 148 | } 149 | }, [currentWords, index]); 150 | 151 | return ( 152 | 157 | {currentWords.map((word, i) => ( 158 |

{ 161 | refs.current[i] = el; 162 | }} 163 | > 164 | {index === i ? ( 165 | word.split('').map((letter, j) => ( 166 | j && 'text-foreground')} 169 | > 170 | {letter} 171 | 172 | )) 173 | ) : ( 174 | i && 'text-foreground/70')}> 175 | {word} 176 | 177 | )} 178 |

179 | ))} 180 |
181 | ); 182 | }; 183 | 184 | export default WordsView; 185 | -------------------------------------------------------------------------------- /src/components/common/ui/navigation/keylabslogo.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Keyboard } from 'lucide-react'; 3 | 4 | import KeyLabsLogo from '../../KeyLabsLogo'; 5 | import TLink from '../transition/TLink'; 6 | 7 | export const Keylabslogo = () => { 8 | return ( 9 | 10 |
11 | 12 |

KeyLabs

13 |
14 |
15 | ); 16 | }; 17 | -------------------------------------------------------------------------------- /src/components/common/ui/navigation/navbar.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React from 'react'; 4 | import { useRouter } from 'next/navigation'; 5 | import { HTMLMotionProps, motion } from 'framer-motion'; 6 | import { Brush, Crown, Info, Settings, User } from 'lucide-react'; 7 | import { toast } from 'sonner'; 8 | 9 | import { useCurrentGame } from '@/app/client-page'; 10 | import { cn } from '@/lib/utils'; 11 | import { NavigationOutVariants } from '@/lib/variants/variants'; 12 | import Button from '../../Button'; 13 | import Dialog, { DialogContent, DialogTriggerButton } from '../../Dialog'; 14 | import { ThemeSelectionDialog } from '../game/ThemeSelectDialog'; 15 | import { useTransition } from '../transition/Transition'; 16 | import { Dropdown, DropdownLinkItem } from '../wrapper/dropdown'; 17 | import { Keylabslogo } from './keylabslogo'; 18 | 19 | interface NavigationBarProp extends HTMLMotionProps<'div'> { 20 | enabled?: boolean; 21 | } 22 | 23 | export const NavigationBar = ({ ...props }: NavigationBarProp) => { 24 | const { hasStart, hasFinish } = useCurrentGame(); 25 | const { handleRouteChange } = useTransition(); 26 | return ( 27 | 28 | 35 | 36 | 41 | 42 | 43 | 44 | 47 | 48 | 51 | 52 | 55 | } 57 | dropdownItems={ 58 | 62 | } 63 | > 64 | 65 | 66 | 67 | 68 | 69 | 70 | ); 71 | }; 72 | -------------------------------------------------------------------------------- /src/components/common/ui/navigation/usernav.tsx: -------------------------------------------------------------------------------- 1 | // components/UserDropdown.tsx 2 | 3 | import React from 'react'; 4 | 5 | interface UserDropdownProps { 6 | isOpen: boolean; 7 | } 8 | 9 | const UserDropdown: React.FC = ({ isOpen }) => { 10 | if (!isOpen) return null; 11 | 12 | return ( 13 |
14 |
    15 |
  • 16 | User Account 17 |
  • 18 |
  • 19 | User Settings 20 |
  • 21 |
  • Sign Out
  • 22 |
23 |
24 | ); 25 | }; 26 | 27 | export default UserDropdown; 28 | -------------------------------------------------------------------------------- /src/components/common/ui/transition/TLink.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React, { AnchorHTMLAttributes, HTMLAttributes } from 'react'; 4 | 5 | import { cn } from '@/lib/utils'; 6 | import { useTransition } from './Transition'; 7 | 8 | interface TransitionLinkProps extends AnchorHTMLAttributes { 9 | className?: string; 10 | href: string; 11 | children: React.ReactNode; 12 | } 13 | 14 | export const TLink = ({ 15 | className, 16 | children, 17 | href, 18 | ...props 19 | }: TransitionLinkProps) => { 20 | const { handleRouteChange } = useTransition(); 21 | return ( 22 | handleRouteChange(href)} 26 | > 27 | {children} 28 | 29 | ); 30 | }; 31 | 32 | export default TLink; 33 | -------------------------------------------------------------------------------- /src/components/common/ui/transition/Transition.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React, { useState } from 'react'; 4 | import { usePathname, useRouter } from 'next/navigation'; 5 | import { motion, Variants } from 'framer-motion'; 6 | 7 | import { devConfig } from '@/devconfig'; 8 | import { cn } from '@/lib/utils'; 9 | 10 | type TransitionProps = { 11 | children: React.ReactNode; 12 | }; 13 | 14 | type TransitionContext = { 15 | handleRouteChange: (path?: string) => void; 16 | }; 17 | 18 | const TransitionContext = React.createContext({} as TransitionContext); 19 | 20 | export const useTransition = () => { 21 | const context = React.useContext(TransitionContext); 22 | if (!context) { 23 | throw new Error('useTransition must be used within a TransitionProvider'); 24 | } 25 | return context; 26 | }; 27 | 28 | const pageContainerVariants: Variants = { 29 | initial: { filter: 'blur(4px)' }, 30 | animate: { filter: 'blur(4px)' }, 31 | finish: { filter: 'blur(0px)' }, 32 | }; 33 | 34 | const SheetInVariants: Variants = { 35 | initial: { y: '100%' }, 36 | animate: { y: '0%' }, 37 | }; 38 | 39 | const SheetOutVariants: Variants = { 40 | initial: { y: '0%' }, 41 | animate: { y: '-100%' }, 42 | }; 43 | 44 | const Transition = ({ children }: TransitionProps) => { 45 | const router = useRouter(); 46 | const pathname = usePathname(); 47 | 48 | const handleRouteChange = (path = '/') => { 49 | if (pathname === path) return; 50 | if (!devConfig.PAGE_TRANSITION) { 51 | router.push(path); 52 | } 53 | if (isTransitioning) { 54 | return; 55 | } 56 | setTransitioning(true); 57 | setPath(path); 58 | }; 59 | 60 | const handleTransitionRoute = () => { 61 | setTransitioning(false); 62 | router.push(path); 63 | }; 64 | const [isTransitioning, setTransitioning] = useState(false); 65 | const [path, setPath] = useState('/'); 66 | 67 | if (!devConfig.PAGE_TRANSITION) { 68 | return ( 69 | 70 | {children} 71 | 72 | ); 73 | } 74 | 75 | return ( 76 | 77 | 87 | {children} 88 | 89 | {/* transition */} 90 |
91 | {isTransitioning && ( 92 | 100 | )} 101 | {!isTransitioning && ( 102 | 109 | )} 110 |
111 |
112 | ); 113 | }; 114 | 115 | export default Transition; 116 | -------------------------------------------------------------------------------- /src/components/common/ui/wrapper/LimitScreenSize.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React, { useLayoutEffect, useState } from 'react'; 4 | import { useMediaQuery } from '@uidotdev/usehooks'; 5 | import { Frown } from 'lucide-react'; 6 | 7 | import { devConfig } from '@/devconfig'; 8 | 9 | const [W_MIN, H_MIN] = [1280, 800]; 10 | 11 | const ClientLimitScreenSize = ({ children }: { children: React.ReactNode }) => { 12 | const isDesktop = useMediaQuery( 13 | `only screen and (min-width : ${W_MIN}px) and (min-height : ${H_MIN}px)` 14 | ); 15 | 16 | if (!isDesktop) { 17 | return ( 18 |
19 |

Your screen size is currently not supported.

20 |

21 | min width {W_MIN}px, min height: {H_MIN}px 22 |

23 | 24 |

zoom out

25 |

or

26 |

Check again in the future for support on this screen size

27 |

Version: {devConfig.VERSION}

28 |
29 | ); 30 | } 31 | 32 | return <>{children}; 33 | }; 34 | 35 | export const LimitScreenSize = ({ 36 | children, 37 | }: { 38 | children: React.ReactNode; 39 | }) => { 40 | const [isClient, setIsClient] = useState(false); 41 | 42 | useLayoutEffect(() => { 43 | setIsClient(true); 44 | }, []); 45 | 46 | if (!isClient) { 47 | return null; 48 | } 49 | 50 | return {children}; 51 | }; 52 | -------------------------------------------------------------------------------- /src/components/common/ui/wrapper/ThemeProvider.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React, { useInsertionEffect } from 'react'; 4 | import { create } from 'zustand'; 5 | 6 | export type ThemeType = { 7 | theme: string; 8 | handleSetTheme: (theme: string) => void; 9 | }; 10 | 11 | export const useTheme = create()((set) => ({ 12 | theme: 13 | typeof window !== 'undefined' ? localStorage.getItem('theme')! : 'default', 14 | handleSetTheme: (theme: string) => 15 | set(() => { 16 | if (typeof window !== 'undefined') { 17 | localStorage.setItem('theme', theme); 18 | } 19 | return { theme }; 20 | }), 21 | })); 22 | 23 | const ThemeWrapper = ({ children }: { children: React.ReactNode }) => { 24 | const { theme } = useTheme(); 25 | 26 | useInsertionEffect(() => { 27 | const existingLink = document.querySelector('link[data-theme="dynamic"]'); 28 | let link: HTMLLinkElement; 29 | 30 | if (existingLink) { 31 | link = existingLink as HTMLLinkElement; 32 | } else { 33 | link = document.createElement('link'); 34 | link.rel = 'stylesheet'; 35 | link.type = 'text/css'; 36 | link.setAttribute('data-theme', 'dynamic'); 37 | document.head.appendChild(link); 38 | } 39 | 40 | link.href = `/themes/${theme}.css`; 41 | }, [theme]); 42 | 43 | return <>{children}; 44 | }; 45 | 46 | export default ThemeWrapper; 47 | -------------------------------------------------------------------------------- /src/components/common/ui/wrapper/dropdown.tsx: -------------------------------------------------------------------------------- 1 | import React, { AnchorHTMLAttributes } from 'react'; 2 | 3 | import Button from '../../Button'; 4 | import TLink from '../transition/TLink'; 5 | 6 | interface DropdownProp extends React.HTMLAttributes { 7 | dropdownDisplay: React.ReactNode; 8 | dropdownItems: React.ReactNode; 9 | classname?: string; 10 | } 11 | 12 | export const Dropdown = ({ 13 | dropdownDisplay, 14 | dropdownItems, 15 | ...props 16 | }: DropdownProp) => { 17 | return ( 18 |
19 | 20 |
21 |
22 | {dropdownItems} 23 |
24 |
25 |
26 | ); 27 | }; 28 | 29 | interface DropdownItemProp extends AnchorHTMLAttributes { 30 | dropdownItem: (React.ReactNode | string)[]; 31 | href: string; 32 | } 33 | 34 | export const DropdownLinkItem = ({ 35 | href, 36 | dropdownItem, 37 | ...props 38 | }: DropdownItemProp) => { 39 | return ( 40 | 41 | {dropdownItem.map((item, i) => ( 42 |
46 | {item} 47 |
48 | ))} 49 |
50 | ); 51 | }; 52 | -------------------------------------------------------------------------------- /src/components/leaderboards/ClientLeaderboardPage.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React, { useEffect, useState } from 'react'; 4 | import { GameEntry } from '@prisma/client'; 5 | 6 | import Button from '../common/Button'; 7 | import { Select, SelectContent, SelectItem } from '../common/Select'; 8 | import { NavigationBar } from '../common/ui/navigation/navbar'; 9 | 10 | interface LeaderboardEntry extends GameEntry { 11 | user: { 12 | name: string; 13 | }; 14 | } 15 | 16 | export const ClientLeaderboardPage = () => { 17 | const [leaderboardData, setLeaderboardData] = useState( 18 | [] 19 | ); 20 | const [currentMode, setCurrentMode] = useState('time'); 21 | const [currentSubMode, setCurrentSubMode] = useState(5); 22 | 23 | useEffect(() => { 24 | const fetchLeaderboard = async () => { 25 | try { 26 | const response = await fetch( 27 | `/api/data/leaderboard?mode=${currentMode}&subMode=${currentSubMode}` 28 | ); 29 | const data: LeaderboardEntry[] = await response.json(); 30 | setLeaderboardData(data); 31 | } catch (error) { 32 | console.error('Error fetching leaderboard data:', error); 33 | } 34 | }; 35 | 36 | fetchLeaderboard(); 37 | }, [currentMode, currentSubMode]); 38 | 39 | useEffect(() => { 40 | console.log(leaderboardData); 41 | }, [leaderboardData]); 42 | 43 | const handleModeChange = (mode: string) => { 44 | setCurrentMode(mode); 45 | setCurrentSubMode(mode === 'time' ? 15 : 30); 46 | }; 47 | 48 | const handleSubModeChange = (subMode: number) => { 49 | setCurrentSubMode(subMode); 50 | }; 51 | 52 | return ( 53 | <> 54 | 55 |
56 |
57 |

Leaderboards

58 |

English 5k

59 |
60 |
61 |
62 | 68 |
69 | {currentMode === 'time' && ( 70 | <> 71 | 72 | 73 | 74 | 75 | )} 76 | {currentMode === 'characters' && ( 77 | <> 78 | 79 | 80 | 81 | 82 | )} 83 |
84 |
85 |
86 | 87 |
88 |
89 | 90 |
91 | 92 | {leaderboardData.map((item, i) => ( 93 | 94 | ))} 95 |
96 |
97 | 98 | ); 99 | }; 100 | 101 | const ScoreRowLabel = () => { 102 | return ( 103 |
104 |

name

105 |

accuracy

106 |

lpm

107 |

wpm

108 |

date

109 |
110 | ); 111 | }; 112 | 113 | const ScoreRow = ({ score }: { score: LeaderboardEntry }) => { 114 | return ( 115 |
116 |

{score.user?.name}

117 |

{score.accuracy.toFixed(2)}

118 |

{score.lpm.toFixed(2)}

119 |

{score.wpm.toFixed(2)}

120 |

121 | {new Date(score.createdAt).toLocaleDateString()} 122 |

123 | {/* Replace with actual date */} 124 |
125 | ); 126 | }; 127 | -------------------------------------------------------------------------------- /src/components/leaderboards/Leaderboard.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React, { useEffect, useState } from 'react'; 4 | 5 | import Button from '../common/Button'; 6 | import { NavigationBar } from '../common/ui/navigation/navbar'; 7 | 8 | interface LeaderboardEntry { 9 | id: string; 10 | user: { 11 | name: string; 12 | } | null; 13 | wpm: number; 14 | lpm: number; 15 | accuracy: number; 16 | charsTyped: number; 17 | } 18 | 19 | export const ClientLeaderboardPage = () => { 20 | const [leaderboardData, setLeaderboardData] = useState( 21 | [] 22 | ); 23 | const [currentMode, setCurrentMode] = useState('time'); 24 | const [currentSubMode, setCurrentSubMode] = useState(5); 25 | 26 | useEffect(() => { 27 | const fetchLeaderboard = async () => { 28 | try { 29 | const response = await fetch( 30 | `/api/data/leaderboard?mode=${currentMode}&subMode=${currentSubMode}` 31 | ); 32 | const data: LeaderboardEntry[] = await response.json(); 33 | setLeaderboardData(data); 34 | } catch (error) { 35 | console.error('Error fetching leaderboard data:', error); 36 | } 37 | }; 38 | 39 | fetchLeaderboard(); 40 | }, [currentMode, currentSubMode]); 41 | 42 | useEffect(() => { 43 | console.log(leaderboardData); 44 | }, [leaderboardData]); 45 | 46 | const handleModeChange = (mode: string) => { 47 | setCurrentMode(mode); 48 | setCurrentSubMode(mode === 'time' ? 5 : 25); 49 | }; 50 | 51 | const handleSubModeChange = (subMode: number) => { 52 | setCurrentSubMode(subMode); 53 | }; 54 | 55 | return ( 56 | <> 57 | 58 |
59 |
60 |

Leaderboards

61 |

English 5k

62 |
63 |
64 |
65 | 71 | 74 |
75 |
76 |
77 | 78 |
79 |
80 |
81 | {currentMode === 'time' ? ( 82 | 92 | ) : ( 93 | 104 | )} 105 |
106 |
107 |
108 | 109 | {leaderboardData.map((item, i) => ( 110 | 111 | ))} 112 |
113 |
114 | 115 | {Array.from({ length: 10 }).map((_, i) => ( 116 | 117 | ))} 118 |
119 |
120 |
121 | 122 | ); 123 | }; 124 | 125 | const ScoreRowLabel = () => { 126 | return ( 127 |
128 |

name

129 |

lpm

130 |

wpm

131 |

date

132 |
133 | ); 134 | }; 135 | 136 | const ScoreRow = ({ score }: { score: LeaderboardEntry }) => { 137 | return ( 138 |
139 |

{score.user?.name}

140 |

{score.lpm.toFixed(2)}

141 |

{score.wpm.toFixed(2)}

142 |

NaN

{/* Replace with actual date */} 143 |
144 | ); 145 | }; 146 | 147 | const ScoreRowEmpty = () => { 148 | return ( 149 |
150 |

-

151 |

-

152 |

-

153 |

NaN

154 |
155 | ); 156 | }; 157 | -------------------------------------------------------------------------------- /src/components/leaderboards/UserLeaderboard.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React, { useEffect, useState } from 'react'; 4 | import { GameEntry } from '@prisma/client'; 5 | 6 | import Button from '../common/Button'; 7 | import { Select, SelectContent, SelectItem } from '../common/Select'; 8 | import { NavigationBar } from '../common/ui/navigation/navbar'; 9 | 10 | interface LeaderboardEntry extends GameEntry { 11 | user: { 12 | name: string; 13 | }; 14 | } 15 | 16 | export const ClientLeaderboardPage = () => { 17 | const [leaderboardData, setLeaderboardData] = useState( 18 | [] 19 | ); 20 | const [currentMode, setCurrentMode] = useState('time'); 21 | const [currentSubMode, setCurrentSubMode] = useState(5); 22 | 23 | useEffect(() => { 24 | const fetchLeaderboard = async () => { 25 | try { 26 | const response = await fetch( 27 | `/api/data/leaderboard?mode=${currentMode}&subMode=${currentSubMode}` 28 | ); 29 | const data: LeaderboardEntry[] = await response.json(); 30 | setLeaderboardData(data); 31 | } catch (error) { 32 | console.error('Error fetching leaderboard data:', error); 33 | } 34 | }; 35 | 36 | fetchLeaderboard(); 37 | }, [currentMode, currentSubMode]); 38 | 39 | useEffect(() => { 40 | console.log(leaderboardData); 41 | }, [leaderboardData]); 42 | 43 | const handleModeChange = (mode: string) => { 44 | setCurrentMode(mode); 45 | setCurrentSubMode(mode === 'time' ? 15 : 30); 46 | }; 47 | 48 | const handleSubModeChange = (subMode: number) => { 49 | setCurrentSubMode(subMode); 50 | }; 51 | 52 | return ( 53 | <> 54 | 55 |
56 |
57 |

Leaderboards

58 |

English 5k

59 |
60 |
61 |
62 | 68 |
69 | {currentMode === 'time' && ( 70 | <> 71 | 72 | 73 | 74 | 75 | )} 76 | {currentMode === 'characters' && ( 77 | <> 78 | 79 | 80 | 81 | 82 | )} 83 |
84 |
85 |
86 | 87 |
88 |
89 | 90 |
91 | 92 | {leaderboardData.map((item, i) => ( 93 | 94 | ))} 95 |
96 |
97 | 98 | ); 99 | }; 100 | 101 | const ScoreRowLabel = () => { 102 | return ( 103 |
104 |

name

105 |

accuracy

106 |

lpm

107 |

wpm

108 |

date

109 |
110 | ); 111 | }; 112 | 113 | const ScoreRow = ({ score }: { score: LeaderboardEntry }) => { 114 | return ( 115 |
116 |

{score.user?.name}

117 |

{score.accuracy.toFixed(2)}

118 |

{score.lpm.toFixed(2)}

119 |

{score.wpm.toFixed(2)}

120 |

121 | {new Date(score.createdAt).toLocaleDateString()} 122 |

123 | {/* Replace with actual date */} 124 |
125 | ); 126 | }; 127 | -------------------------------------------------------------------------------- /src/components/providers/QueryClientProvider.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import type { ReactNode } from 'react'; 4 | import { 5 | QueryClient, 6 | QueryClientProvider as ReactQueryClientProvider, 7 | } from '@tanstack/react-query'; 8 | 9 | const queryClient = new QueryClient(); 10 | 11 | export default function QueryClientProvider({ 12 | children, 13 | }: { 14 | children: ReactNode; 15 | }) { 16 | return ( 17 | 18 | {children} 19 | 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /src/devconfig.ts: -------------------------------------------------------------------------------- 1 | const colours = { 2 | reset: '\x1b[0m', 3 | bright: '\x1b[1m', 4 | dim: '\x1b[2m', 5 | underscore: '\x1b[4m', 6 | blink: '\x1b[5m', 7 | reverse: '\x1b[7m', 8 | hidden: '\x1b[8m', 9 | 10 | fg: { 11 | black: '\x1b[30m', 12 | red: '\x1b[31m', 13 | green: '\x1b[32m', 14 | yellow: '\x1b[33m', 15 | blue: '\x1b[34m', 16 | magenta: '\x1b[35m', 17 | cyan: '\x1b[36m', 18 | white: '\x1b[37m', 19 | gray: '\x1b[90m', 20 | crimson: '\x1b[38m', 21 | }, 22 | bg: { 23 | black: '\x1b[40m', 24 | red: '\x1b[41m', 25 | green: '\x1b[42m', 26 | yellow: '\x1b[43m', 27 | blue: '\x1b[44m', 28 | magenta: '\x1b[45m', 29 | cyan: '\x1b[46m', 30 | white: '\x1b[47m', 31 | gray: '\x1b[100m', 32 | crimson: '\x1b[48m', 33 | }, 34 | }; 35 | 36 | if (process.env.NODE_ENV === 'development') { 37 | console.log( 38 | `${colours.bg.green}\n`, 39 | '\n', 40 | 'CURRENTLY RUNNING IN DEVELOPMENT MODE', 41 | '\n', 42 | '\n', 43 | colours.reset 44 | ); 45 | } 46 | 47 | type DevConfigType = { 48 | PAGE_TRANSITION: boolean; 49 | VERSION: string; 50 | DEBUG_QUERY: boolean; 51 | DEBUG_MENU: boolean; 52 | DEBUG_FUNCTION: boolean; 53 | DISABLE_NOTFOUND: boolean; 54 | ENABLE_DEBUG_GAMEMODE_OPTION: boolean; 55 | }; 56 | 57 | const isdev = process.env.NODE_ENV === 'development'; 58 | 59 | export const devConfig: DevConfigType = { 60 | VERSION: '0.0.0', 61 | PAGE_TRANSITION: isdev ? true : true, 62 | DEBUG_QUERY: isdev ? true : false, 63 | DEBUG_MENU: isdev ? true : false, 64 | DEBUG_FUNCTION: isdev ? false : false, 65 | DISABLE_NOTFOUND: isdev ? false : true, 66 | ENABLE_DEBUG_GAMEMODE_OPTION: isdev ? true : false, 67 | }; 68 | -------------------------------------------------------------------------------- /src/fonts/GeistMonoVF.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RuffByte/KeyLabs/8430a91b9a0cfd36eb1da941d7c59844d1163be1/src/fonts/GeistMonoVF.woff -------------------------------------------------------------------------------- /src/fonts/GeistVF.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RuffByte/KeyLabs/8430a91b9a0cfd36eb1da941d7c59844d1163be1/src/fonts/GeistVF.woff -------------------------------------------------------------------------------- /src/fonts/Kollektif/Kollektif-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RuffByte/KeyLabs/8430a91b9a0cfd36eb1da941d7c59844d1163be1/src/fonts/Kollektif/Kollektif-Bold.ttf -------------------------------------------------------------------------------- /src/fonts/Kollektif/Kollektif-BoldItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RuffByte/KeyLabs/8430a91b9a0cfd36eb1da941d7c59844d1163be1/src/fonts/Kollektif/Kollektif-BoldItalic.ttf -------------------------------------------------------------------------------- /src/fonts/Kollektif/Kollektif-Italic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RuffByte/KeyLabs/8430a91b9a0cfd36eb1da941d7c59844d1163be1/src/fonts/Kollektif/Kollektif-Italic.ttf -------------------------------------------------------------------------------- /src/fonts/Kollektif/Kollektif.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RuffByte/KeyLabs/8430a91b9a0cfd36eb1da941d7c59844d1163be1/src/fonts/Kollektif/Kollektif.ttf -------------------------------------------------------------------------------- /src/lib/antAuth/cookies.ts: -------------------------------------------------------------------------------- 1 | import { cookies } from 'next/headers'; 2 | 3 | export function setSessionTokenCookie(token: string, expiresAt: Date): void { 4 | cookies().set('session', token, { 5 | httpOnly: true, 6 | path: '/', 7 | secure: process.env.NODE_ENV === 'production', 8 | sameSite: 'lax', 9 | expires: expiresAt, 10 | }); 11 | } 12 | 13 | export function deleteSessionTokenCookie(): void { 14 | cookies().set('session', '', { 15 | httpOnly: true, 16 | path: '/', 17 | secure: process.env.NODE_ENV === 'production', 18 | sameSite: 'lax', 19 | maxAge: 0, 20 | }); 21 | } 22 | -------------------------------------------------------------------------------- /src/lib/antAuth/sessions.ts: -------------------------------------------------------------------------------- 1 | import { cache } from 'react'; 2 | import { cookies } from 'next/headers'; 3 | import { sha256 } from '@oslojs/crypto/sha2'; 4 | import { 5 | encodeBase32LowerCaseNoPadding, 6 | encodeHexLowerCase, 7 | } from '@oslojs/encoding'; 8 | import type { Session, User } from '@prisma/client'; 9 | 10 | import type { User as UserType } from '@/app/types/user'; 11 | import { prisma } from '../prisma'; 12 | 13 | export function generateSessionToken(): string { 14 | const bytes = new Uint8Array(20); 15 | crypto.getRandomValues(bytes); 16 | const token = encodeBase32LowerCaseNoPadding(bytes); 17 | return token; 18 | } 19 | 20 | export async function createSession( 21 | token: string, 22 | userId: string 23 | ): Promise { 24 | const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); 25 | const session: Session = { 26 | id: sessionId, 27 | userId, 28 | expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30), 29 | }; 30 | await prisma.session.create({ 31 | data: session, 32 | }); 33 | return session; 34 | } 35 | 36 | export async function validateSessionToken( 37 | token: string 38 | ): Promise { 39 | const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); 40 | const result = await prisma.session.findUnique({ 41 | where: { 42 | id: sessionId, 43 | }, 44 | include: { 45 | user: true, 46 | }, 47 | }); 48 | if (result === null) { 49 | return { session: null, user: null }; 50 | } 51 | const { user, ...session } = result; 52 | if (Date.now() >= session.expiresAt.getTime()) { 53 | await prisma.session.delete({ 54 | where: { 55 | id: session.id, 56 | }, 57 | }); 58 | return { session: null, user: null }; 59 | } 60 | if (Date.now() >= session.expiresAt.getTime() - 1000 * 60 * 60 * 24 * 15) { 61 | session.expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 30); 62 | await prisma.session.update({ 63 | where: { 64 | id: session.id, 65 | }, 66 | data: { 67 | expiresAt: session.expiresAt, 68 | }, 69 | }); 70 | } 71 | return { session, user }; 72 | } 73 | 74 | export async function invalidateSession(sessionId: string): Promise { 75 | await prisma.session.delete({ 76 | where: { 77 | id: sessionId, 78 | }, 79 | }); 80 | } 81 | 82 | export type SessionValidationResult = 83 | | { session: Session; user: User } 84 | | { session: null; user: null }; 85 | 86 | //get user 2.0.0 (will improve later for now i cba just need mvp) 87 | export const getUser = cache(async (): Promise => { 88 | const token = cookies().get('session')?.value ?? null; 89 | if (token === null) { 90 | return null; 91 | } 92 | const result = await validateSessionToken(token); 93 | 94 | const name = result.user?.name; 95 | const email = result.user?.email; 96 | 97 | // janky fix for now its allgs 98 | if (!name || !email) { 99 | return null; 100 | } 101 | 102 | return { name, email }; 103 | }); 104 | 105 | // ... 106 | //what the frick man i could've just got the entire USER object from the start will do some refactoring later to avoid the 10 loops of hell 107 | // export const getCurrentSession = cache(async (): Promise => { 108 | // const token = cookies().get("session")?.value ?? null; 109 | // if (token === null) { 110 | // return { session: null, user: null }; 111 | // } 112 | // const result = await validateSessionToken(token); 113 | // return result; 114 | // }); 115 | -------------------------------------------------------------------------------- /src/lib/email.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode } from 'react'; 2 | 3 | import { fromEmail, resend } from './resend'; 4 | 5 | export async function sendEmail( 6 | email: string, 7 | subject: string, 8 | body: ReactNode 9 | ) { 10 | const { error } = await resend.emails.send({ 11 | from: fromEmail, 12 | to: email, 13 | subject, 14 | react: <>{body}, 15 | }); 16 | 17 | if (error) { 18 | throw error; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/lib/get-ip.ts: -------------------------------------------------------------------------------- 1 | import { headers } from 'next/headers'; 2 | 3 | export function getIp() { 4 | const forwardedFor = headers().get('x-forwarded-for'); 5 | const realIp = headers().get('x-real-ip'); 6 | 7 | if (forwardedFor) { 8 | return forwardedFor.split(',')[0].trim(); 9 | } 10 | 11 | if (realIp) { 12 | return realIp.trim(); 13 | } 14 | 15 | return null; 16 | } 17 | -------------------------------------------------------------------------------- /src/lib/googleOauth.ts: -------------------------------------------------------------------------------- 1 | import { Google } from 'arctic'; 2 | 3 | export const googleOAuthClient = new Google( 4 | process.env.GOOGLE_CLIENT_ID!, 5 | process.env.GOOGLE_CLIENT_SECRET!, 6 | process.env.NEXT_PUBLIC_URL + '/api/auth/google/callback' 7 | ); 8 | -------------------------------------------------------------------------------- /src/lib/hash.ts: -------------------------------------------------------------------------------- 1 | import { Argon2id } from 'oslo/password'; 2 | 3 | export const hashPassword = async (password: string) => { 4 | return await new Argon2id().hash(password); 5 | }; 6 | 7 | export const verifyPassword = async ( 8 | hashedPassword: string, 9 | password: string 10 | ) => { 11 | return await new Argon2id().verify(hashedPassword, password); 12 | }; 13 | -------------------------------------------------------------------------------- /src/lib/limiter.ts: -------------------------------------------------------------------------------- 1 | import { getIp } from './get-ip'; // Function to get the user's IP address 2 | 3 | class RateLimitError extends Error { 4 | constructor() { 5 | super('Rate limit exceeded'); 6 | this.name = 'RateLimitError'; 7 | } 8 | } 9 | // Constants for the rate limiter 10 | const PRUNE_INTERVAL = 60 * 1000; // 1 minute for pruning expired trackers 11 | 12 | // Map to store request trackers 13 | const trackers = new Map< 14 | string, 15 | { 16 | count: number; // Count of requests made 17 | expiresAt: number; // Timestamp when the limit will reset 18 | } 19 | >(); 20 | 21 | // Function to prune expired trackers 22 | function pruneTrackers() { 23 | const now = Date.now(); // Get the current time 24 | 25 | for (const [key, value] of trackers.entries()) { 26 | if (value.expiresAt < now) { 27 | trackers.delete(key); // Remove trackers that have expired 28 | } 29 | } 30 | } 31 | 32 | // Set an interval to regularly prune expired trackers 33 | setInterval(pruneTrackers, PRUNE_INTERVAL); 34 | 35 | // Rate limiting function based on IP address 36 | export async function rateLimitByIp({ 37 | key = 'global', // Default key if none provided 38 | limit = 1, // Max requests allowed 39 | window = 10000, // Time window in milliseconds 40 | }: { 41 | key: string; 42 | limit: number; 43 | window: number; 44 | }) { 45 | const ip = getIp(); // Get the user's IP address 46 | 47 | if (!ip) { 48 | throw new RateLimitError(); // Throw an error if no IP is found 49 | } 50 | 51 | await rateLimitByKey({ 52 | // Call the generic rate limiting function 53 | key: `${ip}-${key}`, // Combine IP and custom key for uniqueness 54 | limit, 55 | window, 56 | }); 57 | } 58 | 59 | // Generic rate limiting function 60 | export async function rateLimitByKey({ 61 | key = 'global', // Default key 62 | limit = 1, // Max requests allowed 63 | window = 10000, // Time window in milliseconds 64 | }: { 65 | key: string; 66 | limit: number; 67 | window: number; 68 | }) { 69 | let tracker = trackers.get(key) || { count: 0, expiresAt: 0 }; // Get or create tracker 70 | 71 | if (tracker.expiresAt < Date.now()) { 72 | // Check if the tracker has expired 73 | tracker.count = 0; // Reset count if expired 74 | tracker.expiresAt = Date.now() + window; // Set new expiration time 75 | } 76 | 77 | tracker.count++; // Increment the request count 78 | 79 | if (tracker.count > limit) { 80 | // Check if the limit has been exceeded 81 | throw new RateLimitError(); // Throw an error if the limit is exceeded 82 | } 83 | 84 | trackers.set(key, tracker); // Update the tracker in the map 85 | } 86 | -------------------------------------------------------------------------------- /src/lib/prisma.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client'; 2 | 3 | const client = new PrismaClient({ 4 | log: process.env.NODE_ENV === 'development' ? ['error', 'warn'] : ['error'], 5 | }); 6 | 7 | // global variable that stores the client 8 | const globalForPrisma = globalThis as unknown as { 9 | prisma: PrismaClient | undefined; 10 | }; 11 | 12 | export const prisma = globalForPrisma.prisma ?? client; 13 | 14 | if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = client; 15 | -------------------------------------------------------------------------------- /src/lib/resend.ts: -------------------------------------------------------------------------------- 1 | import { Resend } from 'resend'; 2 | 3 | //resend setup wow it's like autoload from godot (i'm going insane) (singleton from CS230 REFERENCE?) 4 | 5 | export const resend = new Resend(process.env.RESEND_API_KEY); 6 | 7 | const emailFrom = process.env.EMAIL_FROM; 8 | 9 | if (!emailFrom) { 10 | throw new Error('EMAIL_FROM is not set in environment variables.'); 11 | } 12 | 13 | export const fromEmail = emailFrom; 14 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import clsx, { ClassValue } from 'clsx'; 2 | import { twMerge } from 'tailwind-merge'; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | -------------------------------------------------------------------------------- /src/lib/utils/date.ts: -------------------------------------------------------------------------------- 1 | export const dateToHHMMSS = (date: Date) => 2 | date.getHours().toString().padStart(2, '0') + 3 | ':' + 4 | date.getMinutes().toString().padStart(2, '0'); 5 | -------------------------------------------------------------------------------- /src/lib/utils/queryKeys.ts: -------------------------------------------------------------------------------- 1 | export enum QUERY_KEY { 2 | STATIC_WORDS = 'words', 3 | STATIC_LEADERBOARD = 'leaderboard', 4 | } 5 | -------------------------------------------------------------------------------- /src/lib/variants/variants.ts: -------------------------------------------------------------------------------- 1 | import { Variants } from 'framer-motion'; 2 | 3 | export const hitVariants = (index: number): Variants => { 4 | return { 5 | initial: { opacity: 0 }, 6 | animate: { opacity: 1, transition: { duration: 0.2, delay: index * 0.1 } }, 7 | exit: { opacity: 0, scale: 1.2, transition: { duration: 0.1 } }, 8 | } as Variants; 9 | }; 10 | 11 | export const OptionBarOutVariants = (hasStart: boolean): Variants => { 12 | return { 13 | initial: { x: '-50%' }, 14 | animate: { 15 | opacity: hasStart ? 0 : 1, 16 | y: hasStart ? '100%' : '0%', 17 | transition: { ease: 'easeInOut', duration: 0.25 }, 18 | }, 19 | }; 20 | }; 21 | 22 | export const NavigationOutVariants = ( 23 | hasStart: boolean, 24 | hasFinish: boolean 25 | ): Variants => { 26 | return { 27 | animate: { 28 | opacity: hasStart ? (hasFinish ? 1 : 0) : 1, 29 | y: hasStart ? (hasFinish ? '0%' : '-100%') : '0%', 30 | transition: { ease: 'easeInOut', duration: 0.25 }, 31 | }, 32 | }; 33 | }; 34 | -------------------------------------------------------------------------------- /src/middleware.ts: -------------------------------------------------------------------------------- 1 | //guess we doing middleware now 2 | 3 | import { NextResponse } from 'next/server'; 4 | import type { NextRequest } from 'next/server'; 5 | 6 | export async function middleware(request: NextRequest): Promise { 7 | if (request.method === 'GET') { 8 | const response = NextResponse.next(); 9 | const token = request.cookies.get('session')?.value ?? null; 10 | if (token !== null) { 11 | // Only extend cookie expiration on GET requests since we can be sure 12 | // a new session wasn't set when handling the request. 13 | response.cookies.set('session', token, { 14 | path: '/', 15 | maxAge: 60 * 60 * 24 * 30, 16 | sameSite: 'lax', 17 | httpOnly: true, 18 | secure: process.env.NODE_ENV === 'production', 19 | }); 20 | } 21 | return response; 22 | } 23 | 24 | const originHeader = request.headers.get('Origin'); 25 | // NOTE: You may need to use `X-Forwarded-Host` instead 26 | const hostHeader = request.headers.get('Host'); 27 | if (originHeader === null || hostHeader === null) { 28 | return new NextResponse(null, { 29 | status: 403, 30 | }); 31 | } 32 | let origin: URL; 33 | try { 34 | origin = new URL(originHeader); 35 | } catch { 36 | return new NextResponse(null, { 37 | status: 403, 38 | }); 39 | } 40 | if (origin.host !== hostHeader) { 41 | return new NextResponse(null, { 42 | status: 403, 43 | }); 44 | } 45 | return NextResponse.next(); 46 | } 47 | -------------------------------------------------------------------------------- /src/schemas/zod/schemas.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | export const signInSchema = z.object({ 4 | email: z.string().email(), 5 | password: z.string().min(8, 'Password must be at least 8 characters long'), 6 | }); 7 | 8 | export const signUpSchema = z 9 | .object({ 10 | name: z.string().min(3, 'Name must be at least 3 characters long'), 11 | email: z.string().email(), 12 | password: z 13 | .string() 14 | .min(8, 'Password must be at least 8 characters long') 15 | .regex(/[A-Z]/, 'Password must contain at least one uppercase letter') 16 | .regex(/[a-z]/, 'Password must contain at least one lowercase letter') 17 | .regex(/[0-9]/, 'Password must contain at least one number') 18 | .regex(/[\W_]/, 'Password must contain at least one special character'), 19 | confirmPassword: z 20 | .string() 21 | .min(8, 'Password must be at least 8 characters long'), 22 | }) 23 | .refine((data) => data.password === data.confirmPassword, { 24 | message: 'Passwords do not match', 25 | path: ['confirmPassword'], 26 | }); 27 | 28 | export const forgetPasswordSchema = z.object({ 29 | email: z.string().email(), 30 | }); 31 | 32 | export const resetPasswordSchema = z 33 | .object({ 34 | password: z 35 | .string() 36 | .min(8, 'Password must be at least 8 characters long') 37 | .regex(/[A-Z]/, 'Password must contain at least one uppercase letter') 38 | .regex(/[a-z]/, 'Password must contain at least one lowercase letter') 39 | .regex(/[0-9]/, 'Password must contain at least one number') 40 | .regex(/[\W_]/, 'Password must contain at least one special character'), 41 | confirmPassword: z 42 | .string() 43 | .min(8, 'Password must be at least 8 characters long'), 44 | }) 45 | .refine((data) => data.password === data.confirmPassword, { 46 | message: 'Passwords do not match', 47 | path: ['confirmPassword'], 48 | }); 49 | -------------------------------------------------------------------------------- /src/services/auth/tokens/createPasswordResetToken.ts: -------------------------------------------------------------------------------- 1 | // /lib/createPasswordResetToken.ts 2 | 3 | import { PrismaClient } from '@prisma/client'; 4 | 5 | import { generateRandomToken } from './generateRandomToken'; 6 | import { TOKEN_LENGTH, TOKEN_TTL } from './token-consts'; 7 | 8 | const prisma = new PrismaClient(); 9 | 10 | export async function createPasswordResetToken(userId: string) { 11 | const token = await generateRandomToken(TOKEN_LENGTH); 12 | const tokenExpiresAt = new Date(Date.now() + TOKEN_TTL); 13 | 14 | // Delete all active password reset tokens so only one token is active at a time 15 | await prisma.passwordResetToken.deleteMany({ 16 | where: { 17 | userId: userId, // Use userId instead of user.email 18 | }, 19 | }); 20 | 21 | // Insert new token into DB 22 | await prisma.passwordResetToken.create({ 23 | data: { 24 | token, 25 | expiresAt: tokenExpiresAt, 26 | user: { 27 | connect: { 28 | id: userId, // Connect using userId 29 | }, 30 | }, 31 | }, 32 | }); 33 | 34 | return token; 35 | } 36 | -------------------------------------------------------------------------------- /src/services/auth/tokens/deleteExpiredTokens.ts: -------------------------------------------------------------------------------- 1 | import { prisma } from '@/lib/prisma'; 2 | 3 | //ggs gonna have to find a way to periodically run this script man (it's a production issue :D ) 4 | async function deleteExpiredTokens() { 5 | try { 6 | const result = await prisma.passwordResetToken.deleteMany({ 7 | where: { 8 | expiresAt: { lt: new Date() }, // delete tokens that have an expirey date less than today 9 | }, 10 | }); 11 | console.log(`Deleted ${result.count} expired tokens.`); 12 | } catch (error) { 13 | console.error('Error deleting expired tokens:', error); 14 | } 15 | } 16 | 17 | export default deleteExpiredTokens; 18 | -------------------------------------------------------------------------------- /src/services/auth/tokens/generateRandomToken.ts: -------------------------------------------------------------------------------- 1 | import { randomBytes } from 'crypto'; 2 | 3 | // Function to generate a secure random token of the specified length 4 | export async function generateRandomToken(length: number) { 5 | const buf = await new Promise((resolve, reject) => { 6 | randomBytes(Math.ceil(length / 2), (err, buf) => { 7 | if (err !== null) { 8 | reject(err); 9 | } else { 10 | resolve(buf); 11 | } 12 | }); 13 | }); 14 | 15 | return buf.toString('hex'); 16 | } 17 | -------------------------------------------------------------------------------- /src/services/auth/tokens/token-consts.ts: -------------------------------------------------------------------------------- 1 | //abstraction my beloved - Anton 2 | 3 | export const TOKEN_TTL = 60 * 60 * 1000; // 1 hour; 4 | export const TOKEN_LENGTH = 32; 5 | -------------------------------------------------------------------------------- /src/services/email/sendResetEmail.tsx: -------------------------------------------------------------------------------- 1 | import { ResetPasswordEmail } from '@/components/common/ui/emailTemplates/resetPasswordEmail'; 2 | import { sendEmail } from '@/lib/email'; 3 | import { prisma } from '@/lib/prisma'; 4 | import { createPasswordResetToken } from '../auth/tokens/createPasswordResetToken'; 5 | 6 | export async function sendResetEmail(email: string) { 7 | const user = await prisma.user.findUnique({ 8 | where: { email }, 9 | }); 10 | 11 | if (!user) { 12 | throw new Error('No user found with that email'); 13 | } 14 | 15 | const token = await createPasswordResetToken(user.id); 16 | 17 | try { 18 | await sendEmail( 19 | email, 20 | 'Your password reset link for KeyLabs', 21 | 22 | ); 23 | } catch (sendError) { 24 | //testing without a domain so sad console.error my beloved 25 | console.error('Email sending failed:', sendError); 26 | throw sendError; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/services/leaderboard/fetchLeaderboard.ts: -------------------------------------------------------------------------------- 1 | import { GameEntry } from '@prisma/client'; 2 | 3 | interface LeaderboardEntry extends GameEntry { 4 | user: { 5 | name: string; 6 | }; 7 | } 8 | 9 | export const fetchLeaderboard = async ( 10 | currentMode: string, 11 | currentSubMode: number 12 | ) => { 13 | const response = await fetch( 14 | `/api/data/leaderboard?mode=${currentMode}&subMode=${currentSubMode}` 15 | ); 16 | const data: LeaderboardEntry[] = await response.json(); 17 | console.log(data); 18 | return data; 19 | }; 20 | -------------------------------------------------------------------------------- /src/services/points/generate-point.ts: -------------------------------------------------------------------------------- 1 | import { toast } from 'sonner'; 2 | 3 | import { Point, useCurrentGame } from '@/app/client-page'; 4 | import { distance } from '../utils'; 5 | 6 | export const generatePoint = ( 7 | word: string = '', 8 | targetSize: number, 9 | screen: { width: number; height: number } 10 | ) => { 11 | const letters = word.trim().split(''); 12 | const zeroIndexLength = word.length - 1; 13 | const MAX_FAILS = 100; 14 | const MAX_LOOP_RETRY = 5; 15 | 16 | const [widthRange, heightRange] = [ 17 | screen.width - targetSize, 18 | screen.height - targetSize, 19 | ]; 20 | 21 | let stack: Point[] = []; 22 | let count = 0; 23 | let retry = 0; 24 | let index = 0; 25 | let baseDistMultiplier = 2; 26 | 27 | while (true) { 28 | if (count > MAX_FAILS) { 29 | if (retry > MAX_LOOP_RETRY) { 30 | toast.error('Failed to generate points'); 31 | break; 32 | } 33 | stack = Array.from({ length: 0 }); 34 | count = 0; 35 | index = 0; 36 | retry++; 37 | baseDistMultiplier -= 0.2; 38 | // console.log(baseDistMultiplier); 39 | console.log('lowering base dist multiplier, curr:', baseDistMultiplier); 40 | } 41 | 42 | if (stack.length === letters.length) { 43 | break; 44 | } 45 | 46 | const [randomX, randomY] = [ 47 | Math.floor(Math.random() * widthRange) + targetSize / 2, 48 | Math.floor(Math.random() * heightRange) + targetSize / 2, 49 | ]; 50 | let valid = true; 51 | for (const point of stack) { 52 | if ( 53 | distance(point.x, point.y, randomX, randomY) < 54 | targetSize * baseDistMultiplier 55 | ) { 56 | count++; 57 | valid = false; 58 | break; 59 | } 60 | } 61 | if (valid) { 62 | count = 0; 63 | stack.push({ 64 | index: zeroIndexLength - index, 65 | value: letters[index], 66 | x: randomX, 67 | y: randomY, 68 | key: `${word}-${zeroIndexLength - index}`, 69 | }); 70 | index++; 71 | } 72 | } 73 | console.log(letters); 74 | console.log(stack); 75 | return stack.reverse(); 76 | }; 77 | -------------------------------------------------------------------------------- /src/services/utils.ts: -------------------------------------------------------------------------------- 1 | export const distance = (x1: number, y1: number, x2: number, y2: number) => { 2 | const x = x2 - x1; 3 | const y = y2 - y1; 4 | return Math.sqrt(x * x + y * y); 5 | }; 6 | -------------------------------------------------------------------------------- /src/services/words/generate-word.ts: -------------------------------------------------------------------------------- 1 | import { devConfig } from '@/devconfig'; 2 | 3 | export type wordSet = { 4 | name: string; 5 | words: string[]; 6 | }; 7 | 8 | const getWordSet = async (wordSetName: string): Promise => { 9 | let fetchPromise; 10 | try { 11 | fetchPromise = require(`@/static/language/${wordSetName}.json`); 12 | } catch { 13 | fetchPromise = require(`@/static/language/english.json`); 14 | } 15 | return await fetchPromise; 16 | }; 17 | 18 | type GameOptions = { 19 | length?: number; 20 | }; 21 | 22 | export const generateWords = async ( 23 | wordSetName: string, 24 | options: GameOptions = {} as GameOptions 25 | ): Promise => { 26 | const wordSet = await getWordSet(wordSetName); 27 | const length: number = wordSet.words.length; 28 | let words = wordSet.words; 29 | 30 | // owo 31 | if (devConfig.DEBUG_QUERY) { 32 | console.log(wordSetName); 33 | } 34 | 35 | for (let currentIndex = 0; currentIndex < length; currentIndex++) { 36 | const randomIndex: number = Math.floor(Math.random() * length); 37 | [words[currentIndex], words[randomIndex]] = [ 38 | words[randomIndex], 39 | words[currentIndex], 40 | ]; 41 | } 42 | 43 | return { 44 | name: wordSet.name, 45 | words: words, 46 | }; 47 | }; 48 | -------------------------------------------------------------------------------- /src/static/language/_list.json: -------------------------------------------------------------------------------- 1 | ["english", "english_1k", "english_5k", "test"] 2 | -------------------------------------------------------------------------------- /src/static/language/english.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "english", 3 | "noLazyMode": true, 4 | "orderedByFrequency": true, 5 | "words": [ 6 | "the", 7 | "be", 8 | "of", 9 | "and", 10 | "a", 11 | "to", 12 | "in", 13 | "he", 14 | "have", 15 | "it", 16 | "that", 17 | "for", 18 | "they", 19 | "I", 20 | "with", 21 | "as", 22 | "not", 23 | "on", 24 | "she", 25 | "at", 26 | "by", 27 | "this", 28 | "we", 29 | "you", 30 | "do", 31 | "but", 32 | "from", 33 | "or", 34 | "which", 35 | "one", 36 | "would", 37 | "all", 38 | "will", 39 | "there", 40 | "say", 41 | "who", 42 | "make", 43 | "when", 44 | "can", 45 | "more", 46 | "if", 47 | "no", 48 | "man", 49 | "out", 50 | "other", 51 | "so", 52 | "what", 53 | "time", 54 | "up", 55 | "go", 56 | "about", 57 | "than", 58 | "into", 59 | "could", 60 | "state", 61 | "only", 62 | "new", 63 | "year", 64 | "some", 65 | "take", 66 | "come", 67 | "these", 68 | "know", 69 | "see", 70 | "use", 71 | "get", 72 | "like", 73 | "then", 74 | "first", 75 | "any", 76 | "work", 77 | "now", 78 | "may", 79 | "such", 80 | "give", 81 | "over", 82 | "think", 83 | "most", 84 | "even", 85 | "find", 86 | "day", 87 | "also", 88 | "after", 89 | "way", 90 | "many", 91 | "must", 92 | "look", 93 | "before", 94 | "great", 95 | "back", 96 | "through", 97 | "long", 98 | "where", 99 | "much", 100 | "should", 101 | "well", 102 | "people", 103 | "down", 104 | "own", 105 | "just", 106 | "because", 107 | "good", 108 | "each", 109 | "those", 110 | "feel", 111 | "seem", 112 | "how", 113 | "high", 114 | "too", 115 | "place", 116 | "little", 117 | "world", 118 | "very", 119 | "still", 120 | "nation", 121 | "hand", 122 | "old", 123 | "life", 124 | "tell", 125 | "write", 126 | "become", 127 | "here", 128 | "show", 129 | "house", 130 | "both", 131 | "between", 132 | "need", 133 | "mean", 134 | "call", 135 | "develop", 136 | "under", 137 | "last", 138 | "right", 139 | "move", 140 | "thing", 141 | "general", 142 | "school", 143 | "never", 144 | "same", 145 | "another", 146 | "begin", 147 | "while", 148 | "number", 149 | "part", 150 | "turn", 151 | "real", 152 | "leave", 153 | "might", 154 | "want", 155 | "point", 156 | "form", 157 | "off", 158 | "child", 159 | "few", 160 | "small", 161 | "since", 162 | "against", 163 | "ask", 164 | "late", 165 | "home", 166 | "interest", 167 | "large", 168 | "person", 169 | "end", 170 | "open", 171 | "public", 172 | "follow", 173 | "during", 174 | "present", 175 | "without", 176 | "again", 177 | "hold", 178 | "govern", 179 | "around", 180 | "possible", 181 | "head", 182 | "consider", 183 | "word", 184 | "program", 185 | "problem", 186 | "however", 187 | "lead", 188 | "system", 189 | "set", 190 | "order", 191 | "eye", 192 | "plan", 193 | "run", 194 | "keep", 195 | "face", 196 | "fact", 197 | "group", 198 | "play", 199 | "stand", 200 | "increase", 201 | "early", 202 | "course", 203 | "change", 204 | "help", 205 | "line" 206 | ] 207 | } 208 | -------------------------------------------------------------------------------- /src/static/language/test.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "test", 3 | "noLazyMode": true, 4 | "orderedByFrequency": true, 5 | "words": ["supercalifragilisticexpialidocious"] 6 | } 7 | -------------------------------------------------------------------------------- /src/static/themes/_list.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "default", 4 | "preview": { 5 | "background": "hsl(0, 0%, 96%)", 6 | "secondary": "hsl(0, 0%, 31%)", 7 | "foreground": "hsl(0, 0%, 12%)", 8 | "tertiary": "hsl(0, 0%, 88%)" 9 | } 10 | }, 11 | { 12 | "name": "dark_mono", 13 | "preview": { 14 | "background": "hsl(309 19% 7%)", 15 | "secondary": "hsl(0 0% 80%)", 16 | "foreground": "hsl(0 0% 86%)", 17 | "tertiary": "hsl(0 0% 77%)" 18 | } 19 | }, 20 | { 21 | "name": "dark_retro", 22 | "preview": { 23 | "background": "hsl(318 45% 6%)", 24 | "secondary": "hsl(68 100% 88%)", 25 | "foreground": "hsl(68 100% 53%)", 26 | "tertiary": "hsl(0 0% 71%)" 27 | } 28 | }, 29 | { 30 | "name": "catcopy", 31 | "preview": { 32 | "background": "hsl(233 18% 18%)", 33 | "secondary": "hsl(274 71% 77%)", 34 | "foreground": "hsl(317 76% 84%)", 35 | "tertiary": "hsl(0 0% 0%)" 36 | } 37 | }, 38 | { 39 | "name": "mute", 40 | "preview": { 41 | "background": "hsl(220 4% 13%)", 42 | "secondary": "hsl(50 100% 96%)", 43 | "foreground": "hsl(50 100% 96%)", 44 | "tertiary": "hsl(0 0% 100%)" 45 | } 46 | } 47 | ] 48 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'tailwindcss'; 2 | 3 | const config: Config = { 4 | content: [ 5 | './src/pages/**/*.{js,ts,jsx,tsx,mdx}', 6 | './src/components/**/*.{js,ts,jsx,tsx,mdx}', 7 | './src/app/**/*.{js,ts,jsx,tsx,mdx}', 8 | ], 9 | theme: { 10 | extend: { 11 | colors: { 12 | background: 'hsl(var(--background))', 13 | secondary: 'hsl(var(--secondary))', 14 | foreground: 'hsl(var(--foreground))', 15 | tertiary: 'hsl(var(--tertiary))', 16 | input: 'hsl(var(--tertiary))', 17 | hover: 'hsl(var(--hover))', 18 | }, 19 | fontFamily: { 20 | kollektif: ['var(--font-kollektic)'], 21 | }, 22 | screens: { 23 | desktop: '1280px', 24 | }, 25 | width: { 26 | desktop: '1280px', 27 | }, 28 | minWidth: { 29 | desktop: '1280px', 30 | }, 31 | borderRadius: { 32 | input: '8px', 33 | }, 34 | height: { 35 | input: '40px', 36 | }, 37 | }, 38 | }, 39 | plugins: [], 40 | }; 41 | 42 | export default config; 43 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "noEmit": true, 8 | "esModuleInterop": true, 9 | "module": "esnext", 10 | "moduleResolution": "bundler", 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "jsx": "preserve", 14 | "incremental": true, 15 | "plugins": [ 16 | { 17 | "name": "next" 18 | } 19 | ], 20 | "paths": { 21 | "@/*": ["./src/*"] 22 | }, 23 | "target": "ES2017" 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | --------------------------------------------------------------------------------