├── app ├── favicon.ico ├── api │ ├── auth │ │ └── [...all] │ │ │ └── route.ts │ └── leaderboard │ │ └── top │ │ └── route.ts ├── page.tsx ├── actions │ └── share.ts ├── layout.tsx ├── leaderboard │ ├── page.tsx │ └── opengraph-image.tsx ├── profile │ └── page.tsx ├── s │ └── [shortId] │ │ ├── opengraph-image.tsx │ │ └── page.tsx ├── globals.css └── opengraph-image.tsx ├── drizzle ├── 0001_lively_joseph.sql ├── 0004_slimy_vance_astro.sql ├── 0003_pretty_felicia_hardy.sql ├── 0002_lonely_anita_blake.sql ├── meta │ ├── _journal.json │ ├── 0000_snapshot.json │ ├── 0001_snapshot.json │ ├── 0002_snapshot.json │ ├── 0003_snapshot.json │ └── 0004_snapshot.json └── 0000_remarkable_cobalt_man.sql ├── public ├── sounds │ ├── press │ │ ├── ENTER.mp3 │ │ ├── SPACE.mp3 │ │ ├── BACKSPACE.mp3 │ │ ├── GENERIC_R0.mp3 │ │ ├── GENERIC_R1.mp3 │ │ ├── GENERIC_R2.mp3 │ │ ├── GENERIC_R3.mp3 │ │ └── GENERIC_R4.mp3 │ └── release │ │ ├── ENTER.mp3 │ │ ├── SPACE.mp3 │ │ ├── GENERIC.mp3 │ │ └── BACKSPACE.mp3 ├── fonts │ ├── CursorGothic-Bold.woff2 │ ├── CursorGothic-Regular.ttf │ ├── CursorGothic-Italic.woff2 │ ├── CursorGothic-Regular.woff2 │ └── CursorGothic-BoldItalic.woff2 ├── vercel.svg ├── window.svg ├── file.svg ├── globe.svg └── next.svg ├── postcss.config.mjs ├── lib ├── utils.ts ├── auth-client.ts ├── auth-server.ts ├── db │ ├── index.ts │ └── schema.ts ├── auth.ts ├── excerpts.ts └── use-keyboard-sounds.ts ├── drizzle.config.ts ├── components ├── wpm-chart-wrapper.tsx ├── theme-provider.tsx ├── bottom-nav.tsx ├── ui │ ├── toaster.tsx │ ├── table.tsx │ ├── use-toast.ts │ └── toast.tsx ├── navigation.tsx ├── results-view.tsx ├── wpm-chart.tsx └── typing-game.tsx ├── next.config.ts ├── components.json ├── eslint.config.mjs ├── .gitignore ├── tsconfig.json ├── package.json └── README.md /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leerob/typing-game/HEAD/app/favicon.ico -------------------------------------------------------------------------------- /drizzle/0001_lively_joseph.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "gameResults" ALTER COLUMN "userId" DROP NOT NULL; -------------------------------------------------------------------------------- /drizzle/0004_slimy_vance_astro.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "gameResults" ADD COLUMN "wpmHistory" jsonb; -------------------------------------------------------------------------------- /public/sounds/press/ENTER.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leerob/typing-game/HEAD/public/sounds/press/ENTER.mp3 -------------------------------------------------------------------------------- /public/sounds/press/SPACE.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leerob/typing-game/HEAD/public/sounds/press/SPACE.mp3 -------------------------------------------------------------------------------- /public/sounds/release/ENTER.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leerob/typing-game/HEAD/public/sounds/release/ENTER.mp3 -------------------------------------------------------------------------------- /public/sounds/release/SPACE.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leerob/typing-game/HEAD/public/sounds/release/SPACE.mp3 -------------------------------------------------------------------------------- /public/sounds/press/BACKSPACE.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leerob/typing-game/HEAD/public/sounds/press/BACKSPACE.mp3 -------------------------------------------------------------------------------- /public/sounds/press/GENERIC_R0.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leerob/typing-game/HEAD/public/sounds/press/GENERIC_R0.mp3 -------------------------------------------------------------------------------- /public/sounds/press/GENERIC_R1.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leerob/typing-game/HEAD/public/sounds/press/GENERIC_R1.mp3 -------------------------------------------------------------------------------- /public/sounds/press/GENERIC_R2.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leerob/typing-game/HEAD/public/sounds/press/GENERIC_R2.mp3 -------------------------------------------------------------------------------- /public/sounds/press/GENERIC_R3.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leerob/typing-game/HEAD/public/sounds/press/GENERIC_R3.mp3 -------------------------------------------------------------------------------- /public/sounds/press/GENERIC_R4.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leerob/typing-game/HEAD/public/sounds/press/GENERIC_R4.mp3 -------------------------------------------------------------------------------- /public/sounds/release/GENERIC.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leerob/typing-game/HEAD/public/sounds/release/GENERIC.mp3 -------------------------------------------------------------------------------- /public/fonts/CursorGothic-Bold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leerob/typing-game/HEAD/public/fonts/CursorGothic-Bold.woff2 -------------------------------------------------------------------------------- /public/fonts/CursorGothic-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leerob/typing-game/HEAD/public/fonts/CursorGothic-Regular.ttf -------------------------------------------------------------------------------- /public/sounds/release/BACKSPACE.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leerob/typing-game/HEAD/public/sounds/release/BACKSPACE.mp3 -------------------------------------------------------------------------------- /public/fonts/CursorGothic-Italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leerob/typing-game/HEAD/public/fonts/CursorGothic-Italic.woff2 -------------------------------------------------------------------------------- /public/fonts/CursorGothic-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leerob/typing-game/HEAD/public/fonts/CursorGothic-Regular.woff2 -------------------------------------------------------------------------------- /public/fonts/CursorGothic-BoldItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leerob/typing-game/HEAD/public/fonts/CursorGothic-BoldItalic.woff2 -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | const config = { 2 | plugins: { 3 | "@tailwindcss/postcss": {}, 4 | }, 5 | }; 6 | 7 | export default config; 8 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/api/auth/[...all]/route.ts: -------------------------------------------------------------------------------- 1 | import { auth } from "@/lib/auth"; 2 | import { toNextJsHandler } from "better-auth/next-js"; 3 | 4 | export const { GET, POST } = toNextJsHandler(auth); 5 | 6 | -------------------------------------------------------------------------------- /lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /drizzle/0003_pretty_felicia_hardy.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "gameResults" DROP CONSTRAINT "wpm_check";--> statement-breakpoint 2 | ALTER TABLE "gameResults" ADD CONSTRAINT "wpm_check" CHECK ("gameResults"."wpm" >= 0 AND "gameResults"."wpm" <= 350); -------------------------------------------------------------------------------- /drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "drizzle-kit"; 2 | 3 | export default { 4 | schema: "./lib/db/schema.ts", 5 | out: "./drizzle", 6 | dialect: "postgresql", 7 | dbCredentials: { 8 | url: process.env.DATABASE_URL || "", 9 | }, 10 | } satisfies Config; 11 | 12 | -------------------------------------------------------------------------------- /lib/auth-client.ts: -------------------------------------------------------------------------------- 1 | import { createAuthClient } from "better-auth/react"; 2 | 3 | export const authClient = createAuthClient({ 4 | baseURL: process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3000", 5 | }); 6 | 7 | export const { signIn, signOut, signUp, useSession } = authClient; 8 | 9 | -------------------------------------------------------------------------------- /components/wpm-chart-wrapper.tsx: -------------------------------------------------------------------------------- 1 | import { WPMChart } from "@/components/wpm-chart"; 2 | 3 | interface WPMChartWrapperProps { 4 | data: Array<{ time: number; wpm: number }>; 5 | } 6 | 7 | export function WPMChartWrapper({ data }: WPMChartWrapperProps) { 8 | return ; 9 | } 10 | 11 | -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from "next"; 2 | 3 | const nextConfig: NextConfig = { 4 | images: { 5 | remotePatterns: [ 6 | { 7 | protocol: "https", 8 | hostname: "lh3.googleusercontent.com", 9 | }, 10 | ], 11 | }, 12 | }; 13 | 14 | export default nextConfig; 15 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { TypingGame } from "@/components/typing-game"; 4 | import { Navigation } from "@/components/navigation"; 5 | 6 | export default function Home() { 7 | return ( 8 |
9 | 10 | 11 |
12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /components/theme-provider.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import { ThemeProvider as NextThemesProvider } from "next-themes" 5 | 6 | export function ThemeProvider({ 7 | children, 8 | ...props 9 | }: React.ComponentProps) { 10 | return {children} 11 | } 12 | 13 | -------------------------------------------------------------------------------- /public/window.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/file.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /drizzle/0002_lonely_anita_blake.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "gameResults" ADD CONSTRAINT "wpm_check" CHECK ("gameResults"."wpm" >= 0 AND "gameResults"."wpm" <= 250);--> statement-breakpoint 2 | ALTER TABLE "gameResults" ADD CONSTRAINT "accuracy_check" CHECK ("gameResults"."accuracy" >= 0 AND "gameResults"."accuracy" <= 100);--> statement-breakpoint 3 | ALTER TABLE "gameResults" ADD CONSTRAINT "duration_check" CHECK ("gameResults"."duration" >= 0 AND "gameResults"."duration" <= 300); -------------------------------------------------------------------------------- /components/bottom-nav.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Link from "next/link"; 4 | import { Keyboard } from "lucide-react"; 5 | 6 | export function BottomNav() { 7 | return ( 8 | 13 | 14 | 15 | ); 16 | } 17 | 18 | -------------------------------------------------------------------------------- /lib/auth-server.ts: -------------------------------------------------------------------------------- 1 | import { auth } from "@/lib/auth"; 2 | import { redirect } from "next/navigation"; 3 | 4 | export async function getSession() { 5 | const session = await auth.api.getSession({ 6 | headers: await import("next/headers").then((h) => h.headers()), 7 | }); 8 | 9 | return session; 10 | } 11 | 12 | export async function requireAuth() { 13 | const session = await getSession(); 14 | 15 | if (!session?.session) { 16 | redirect("/"); 17 | } 18 | 19 | return session.session; 20 | } 21 | 22 | -------------------------------------------------------------------------------- /lib/db/index.ts: -------------------------------------------------------------------------------- 1 | import { drizzle } from "drizzle-orm/postgres-js"; 2 | import postgres from "postgres"; 3 | import * as schema from "./schema"; 4 | 5 | const connectionString = process.env.DATABASE_URL!; 6 | 7 | if (!connectionString) { 8 | throw new Error("DATABASE_URL environment variable is not set"); 9 | } 10 | 11 | // Disable prefetch as it is not supported for "Transaction" pool mode 12 | const client = postgres(connectionString, { prepare: false }); 13 | 14 | export const db = drizzle(client, { schema }); 15 | 16 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "", 8 | "css": "app/globals.css", 9 | "baseColor": "stone", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "iconLibrary": "lucide", 14 | "aliases": { 15 | "components": "@/components", 16 | "utils": "@/lib/utils", 17 | "ui": "@/components/ui", 18 | "lib": "@/lib", 19 | "hooks": "@/hooks" 20 | }, 21 | "registries": {} 22 | } 23 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig, globalIgnores } from "eslint/config"; 2 | import nextVitals from "eslint-config-next/core-web-vitals"; 3 | import nextTs from "eslint-config-next/typescript"; 4 | 5 | const eslintConfig = defineConfig([ 6 | ...nextVitals, 7 | ...nextTs, 8 | // Override default ignores of eslint-config-next. 9 | globalIgnores([ 10 | // Default ignores of eslint-config-next: 11 | ".next/**", 12 | "out/**", 13 | "build/**", 14 | "next-env.d.ts", 15 | ]), 16 | ]); 17 | 18 | export default eslintConfig; 19 | -------------------------------------------------------------------------------- /lib/auth.ts: -------------------------------------------------------------------------------- 1 | import { betterAuth } from "better-auth"; 2 | import { drizzleAdapter } from "better-auth/adapters/drizzle"; 3 | import { db } from "./db"; 4 | 5 | export const auth = betterAuth({ 6 | database: drizzleAdapter(db, { 7 | provider: "pg", 8 | }), 9 | emailAndPassword: { 10 | enabled: false, 11 | }, 12 | socialProviders: { 13 | google: { 14 | clientId: process.env.GOOGLE_CLIENT_ID!, 15 | clientSecret: process.env.GOOGLE_CLIENT_SECRET!, 16 | }, 17 | }, 18 | }); 19 | 20 | export type Session = typeof auth.$Infer.Session; 21 | 22 | -------------------------------------------------------------------------------- /.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.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # env files (can opt-in for committing if needed) 34 | .env* 35 | 36 | # vercel 37 | .vercel 38 | 39 | # typescript 40 | *.tsbuildinfo 41 | next-env.d.ts 42 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "react-jsx", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./*"] 23 | } 24 | }, 25 | "include": [ 26 | "next-env.d.ts", 27 | "**/*.ts", 28 | "**/*.tsx", 29 | ".next/types/**/*.ts", 30 | ".next/dev/types/**/*.ts", 31 | "**/*.mts" 32 | ], 33 | "exclude": ["node_modules"] 34 | } 35 | -------------------------------------------------------------------------------- /components/ui/toaster.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { 4 | Toast, 5 | ToastClose, 6 | ToastDescription, 7 | ToastProvider, 8 | ToastTitle, 9 | ToastViewport, 10 | } from "@/components/ui/toast" 11 | import { useToast } from "@/components/ui/use-toast" 12 | 13 | export function Toaster() { 14 | const { toasts } = useToast() 15 | 16 | return ( 17 | 18 | {toasts.map(function ({ id, title, description, action, ...props }) { 19 | return ( 20 | 21 |
22 | {title && {title}} 23 | {description && ( 24 | {description} 25 | )} 26 |
27 | {action} 28 | 29 |
30 | ) 31 | })} 32 | 33 |
34 | ) 35 | } 36 | 37 | -------------------------------------------------------------------------------- /drizzle/meta/_journal.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "7", 3 | "dialect": "postgresql", 4 | "entries": [ 5 | { 6 | "idx": 0, 7 | "version": "7", 8 | "when": 1761311215340, 9 | "tag": "0000_remarkable_cobalt_man", 10 | "breakpoints": true 11 | }, 12 | { 13 | "idx": 1, 14 | "version": "7", 15 | "when": 1761311380743, 16 | "tag": "0001_lively_joseph", 17 | "breakpoints": true 18 | }, 19 | { 20 | "idx": 2, 21 | "version": "7", 22 | "when": 1761441868151, 23 | "tag": "0002_lonely_anita_blake", 24 | "breakpoints": true 25 | }, 26 | { 27 | "idx": 3, 28 | "version": "7", 29 | "when": 1761441944582, 30 | "tag": "0003_pretty_felicia_hardy", 31 | "breakpoints": true 32 | }, 33 | { 34 | "idx": 4, 35 | "version": "7", 36 | "when": 1761444967804, 37 | "tag": "0004_slimy_vance_astro", 38 | "breakpoints": true 39 | } 40 | ] 41 | } -------------------------------------------------------------------------------- /public/globe.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/api/leaderboard/top/route.ts: -------------------------------------------------------------------------------- 1 | import { db } from "@/lib/db"; 2 | import { gameResults, user } from "@/lib/db/schema"; 3 | import { desc, eq } from "drizzle-orm"; 4 | import { NextResponse } from "next/server"; 5 | 6 | export async function GET() { 7 | try { 8 | const result = await db 9 | .select({ 10 | wpm: gameResults.wpm, 11 | accuracy: gameResults.accuracy, 12 | duration: gameResults.duration, 13 | playerName: user.name, 14 | createdAt: gameResults.createdAt, 15 | }) 16 | .from(gameResults) 17 | .leftJoin(user, eq(gameResults.userId, user.id)) 18 | .orderBy(desc(gameResults.wpm)) 19 | .limit(1); 20 | 21 | if (result.length === 0) { 22 | return NextResponse.json({ wpm: 0, playerName: null }); 23 | } 24 | 25 | return NextResponse.json(result[0]); 26 | } catch (error) { 27 | console.error("Error fetching top player:", error); 28 | return NextResponse.json({ wpm: 0, playerName: null }, { status: 500 }); 29 | } 30 | } 31 | 32 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "anytype", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "eslint", 10 | "db:generate": "drizzle-kit generate", 11 | "db:migrate": "drizzle-kit migrate", 12 | "db:push": "drizzle-kit push", 13 | "db:studio": "drizzle-kit studio" 14 | }, 15 | "dependencies": { 16 | "@radix-ui/react-toast": "^1.2.15", 17 | "@types/howler": "^2.2.12", 18 | "better-auth": "^1.4.5", 19 | "class-variance-authority": "^0.7.1", 20 | "clipboard": "^2.0.11", 21 | "clsx": "^2.1.1", 22 | "drizzle-kit": "^0.31.8", 23 | "drizzle-orm": "^0.45.0", 24 | "howler": "^2.2.4", 25 | "jose": "^6.1.3", 26 | "lucide-react": "^0.555.0", 27 | "nanoid": "^5.1.6", 28 | "next": "16.0.7", 29 | "next-themes": "^0.4.6", 30 | "postgres": "^3.4.7", 31 | "react": "19.2.1", 32 | "react-dom": "19.2.1", 33 | "recharts": "^3.5.1", 34 | "sonner": "^2.0.7", 35 | "tailwind-merge": "^3.4.0", 36 | "zod": "^4.1.13" 37 | }, 38 | "devDependencies": { 39 | "@tailwindcss/postcss": "^4.1.17", 40 | "@types/node": "^24.10.1", 41 | "@types/react": "^19.2.7", 42 | "@types/react-dom": "^19.2.3", 43 | "eslint": "^9.39.1", 44 | "eslint-config-next": "16.0.7", 45 | "tailwindcss": "^4.1.17", 46 | "tw-animate-css": "^1.4.0", 47 | "typescript": "^5.9.3" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /app/actions/share.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { db } from "@/lib/db"; 4 | import { gameResults, shareableResults } from "@/lib/db/schema"; 5 | import { getSession } from "@/lib/auth-server"; 6 | 7 | export async function shareGameResult(data: { 8 | shortId: string; 9 | wpm: number; 10 | accuracy: number; 11 | duration: number; 12 | wpmHistory?: Array<{ time: number; wpm: number }>; 13 | }) { 14 | try { 15 | if (data.accuracy < 0 || data.accuracy > 100) { 16 | throw new Error("Invalid accuracy value"); 17 | } 18 | if (data.wpm < 0 || data.wpm > 350) { 19 | throw new Error("Invalid WPM value"); 20 | } 21 | if (data.duration < 0 || data.duration > 300) { 22 | throw new Error("Invalid duration value"); 23 | } 24 | 25 | const session = await getSession(); 26 | const userId = session?.user?.id || null; 27 | 28 | const [gameResult] = await db 29 | .insert(gameResults) 30 | .values({ 31 | userId, 32 | wpm: data.wpm, 33 | accuracy: data.accuracy, 34 | duration: data.duration, 35 | textExcerpt: "", 36 | wpmHistory: data.wpmHistory || null, 37 | }) 38 | .returning(); 39 | 40 | await db.insert(shareableResults).values({ 41 | shortId: data.shortId, 42 | gameResultId: gameResult.id, 43 | }); 44 | 45 | return { success: true }; 46 | } catch (error) { 47 | console.error("Error sharing game result:", error); 48 | throw new Error("Failed to share game result"); 49 | } 50 | } 51 | 52 | -------------------------------------------------------------------------------- /components/navigation.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Image from "next/image"; 4 | import { authClient } from "@/lib/auth-client"; 5 | import { useSession } from "@/lib/auth-client"; 6 | 7 | export function Navigation() { 8 | const { data: sessionData, isPending } = useSession(); 9 | 10 | const handleSignIn = async () => { 11 | await authClient.signIn.social({ 12 | provider: "google", 13 | callbackURL: "/", 14 | }); 15 | }; 16 | 17 | return ( 18 | 48 | ); 49 | } 50 | 51 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata, Viewport } from "next"; 2 | import localFont from "next/font/local"; 3 | import "./globals.css"; 4 | import { ThemeProvider } from "@/components/theme-provider"; 5 | import { Toaster } from "sonner"; 6 | 7 | const cursorSans = localFont({ 8 | variable: "--font-cursor-sans", 9 | display: "swap", 10 | fallback: ["system-ui", "Helvetica Neue", "Helvetica", "Arial", "sans-serif"], 11 | src: [ 12 | { 13 | path: "../public/fonts/CursorGothic-Regular.woff2", 14 | weight: "400", 15 | style: "normal", 16 | }, 17 | { 18 | path: "../public/fonts/CursorGothic-Italic.woff2", 19 | weight: "400", 20 | style: "italic", 21 | }, 22 | { 23 | path: "../public/fonts/CursorGothic-Bold.woff2", 24 | weight: "700", 25 | style: "normal", 26 | }, 27 | { 28 | path: "../public/fonts/CursorGothic-BoldItalic.woff2", 29 | weight: "700", 30 | style: "italic", 31 | }, 32 | ], 33 | }); 34 | 35 | export const metadata: Metadata = { 36 | title: "anytype", 37 | description: "A minimal typing simulator game", 38 | }; 39 | 40 | export const viewport: Viewport = { 41 | themeColor: [ 42 | { media: '(prefers-color-scheme: light)', color: 'white' }, 43 | { media: '(prefers-color-scheme: dark)', color: 'black' }, 44 | ], 45 | }; 46 | 47 | export default function RootLayout({ 48 | children, 49 | }: Readonly<{ 50 | children: React.ReactNode; 51 | }>) { 52 | return ( 53 | 54 | 55 | 61 | {children} 62 | 63 | 64 | 65 | 66 | ); 67 | } 68 | -------------------------------------------------------------------------------- /lib/excerpts.ts: -------------------------------------------------------------------------------- 1 | const excerpts = [ 2 | "The quick brown fox jumps over the lazy dog. This classic pangram contains every letter of the alphabet at least once. Typing exercises help us master keyboard layouts and improve our communication skills.", 3 | "In the depths of winter, I finally learned that there was in me an invincible summer. The trees swayed gently in the evening breeze. Nature reminds us of the cycles of life and the resilience within all living things.", 4 | "Technology is best when it brings people together. The future of computing lies in understanding human connections and building bridges. Innovation thrives when we collaborate and share knowledge across boundaries.", 5 | "Writing is thinking on paper. Every word we type, every sentence we compose, shapes our thoughts and communicates our ideas. The act of writing clarifies our thinking and helps us understand ourselves better.", 6 | "The journey of a thousand miles begins with a single step. Progress happens gradually, one moment at a time. Patience and persistence are the keys to achieving our long-term goals and aspirations.", 7 | "Code is like humor. When you have to explain it, it's bad. The best solutions are elegant and immediately understandable. Clear code speaks for itself and requires no additional documentation to convey its purpose.", 8 | "In the quiet moments between keystrokes, ideas take shape. Focus and concentration create the space for creativity to flourish. Deep work requires us to eliminate distractions and embrace the flow state.", 9 | "Practice makes perfect. Every repetition strengthens neural pathways, building muscle memory and skill. Consistent effort over time transforms beginners into experts through dedicated training.", 10 | "The internet has become a vast repository of human knowledge. We can learn anything, connect with anyone, and explore endless possibilities. This digital age presents unprecedented opportunities for growth and discovery.", 11 | "Keyboard shortcuts save time and keep our hands on the keys. Efficiency comes from understanding the tools at our disposal. Mastering the fundamentals enables us to work faster and more effectively in any environment.", 12 | ]; 13 | 14 | export function getRandomExcerpt() { 15 | return excerpts[Math.floor(Math.random() * excerpts.length)]; 16 | } 17 | 18 | export { excerpts }; 19 | 20 | -------------------------------------------------------------------------------- /components/results-view.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { copy } from "clipboard"; 4 | import { nanoid } from "nanoid"; 5 | import { toast } from "sonner"; 6 | import { shareGameResult } from "@/app/actions/share"; 7 | 8 | interface GameResult { 9 | wpm: number; 10 | accuracy: number; 11 | duration: number; 12 | } 13 | 14 | interface ResultsViewProps { 15 | result: GameResult; 16 | onRestart: () => void; 17 | } 18 | 19 | export function ResultsView({ result, onRestart }: ResultsViewProps) { 20 | const handleShare = async () => { 21 | const id = nanoid(8); 22 | 23 | // Optimistically copy to clipboard and show success 24 | const shareUrl = `${window.location.origin}/s/${id}`; 25 | await copy(shareUrl); 26 | toast.success("Link copied to clipboard!"); 27 | 28 | // Save to database in the background 29 | try { 30 | await shareGameResult({ 31 | shortId: id, 32 | wpm: result.wpm, 33 | accuracy: result.accuracy, 34 | duration: result.duration, 35 | }); 36 | } catch { 37 | toast.error("Failed to save results"); 38 | } 39 | }; 40 | 41 | return ( 42 |
43 |
44 |

Results

45 | 46 |
47 |
48 |
{result.wpm}
49 |
WPM
50 |
51 |
52 |
{result.accuracy}%
53 |
Accuracy
54 |
55 |
56 | 57 |
58 | 65 | 72 |
73 |
74 |
75 | ); 76 | } 77 | 78 | -------------------------------------------------------------------------------- /components/wpm-chart.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { LineChart, Line, XAxis, YAxis, ResponsiveContainer, Tooltip } from "recharts"; 4 | 5 | interface WPMChartProps { 6 | data: Array<{ time: number; wpm: number }>; 7 | } 8 | 9 | export function WPMChart({ data }: WPMChartProps) { 10 | // Transform data for the chart - ensure we have data points for all 30 seconds with equal spacing 11 | const chartData = []; 12 | 13 | // Always create data points for exactly 30 seconds (0-30) 14 | for (let i = 0; i <= 30; i++) { 15 | const dataPoint = data.find(d => d.time === i); 16 | if (dataPoint) { 17 | chartData.push({ time: i, wpm: dataPoint.wpm }); 18 | } else if (i > 0) { 19 | // Use the last known WPM if no data point exists for this second 20 | const lastDataPoint = data.filter(d => d.time < i).pop(); 21 | chartData.push({ 22 | time: i, 23 | wpm: lastDataPoint?.wpm || 0 24 | }); 25 | } else { 26 | chartData.push({ time: i, wpm: 0 }); 27 | } 28 | } 29 | 30 | // Custom tooltip to show formatted WPM 31 | const CustomTooltip = ({ active, payload }: { active?: boolean; payload?: Array<{ payload: { time: number; wpm: number }; value: number }> }) => { 32 | if (active && payload && payload.length) { 33 | return ( 34 |
35 |

36 | {`${payload[0].payload.time}s`} 37 |

38 |

39 | {`${payload[0].value} WPM`} 40 |

41 |
42 | ); 43 | } 44 | return null; 45 | }; 46 | 47 | return ( 48 | 49 | 53 | `${value}s`} 58 | domain={[0, 30]} 59 | type="number" 60 | ticks={[0, 5, 10, 15, 20, 25, 30]} 61 | /> 62 | `${value}`} 66 | /> 67 | } /> 68 | 76 | 77 | 78 | ); 79 | } 80 | 81 | -------------------------------------------------------------------------------- /drizzle/0000_remarkable_cobalt_man.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE "account" ( 2 | "id" text PRIMARY KEY NOT NULL, 3 | "accountId" text NOT NULL, 4 | "providerId" text NOT NULL, 5 | "userId" text NOT NULL, 6 | "accessToken" text, 7 | "refreshToken" text, 8 | "idToken" text, 9 | "accessTokenExpiresAt" timestamp, 10 | "refreshTokenExpiresAt" timestamp, 11 | "scope" text, 12 | "password" text, 13 | "createdAt" timestamp NOT NULL, 14 | "updatedAt" timestamp NOT NULL 15 | ); 16 | --> statement-breakpoint 17 | CREATE TABLE "gameResults" ( 18 | "id" text PRIMARY KEY NOT NULL, 19 | "userId" text NOT NULL, 20 | "wpm" integer NOT NULL, 21 | "accuracy" integer NOT NULL, 22 | "duration" integer NOT NULL, 23 | "textExcerpt" text NOT NULL, 24 | "createdAt" timestamp DEFAULT now() NOT NULL 25 | ); 26 | --> statement-breakpoint 27 | CREATE TABLE "session" ( 28 | "id" text PRIMARY KEY NOT NULL, 29 | "expiresAt" timestamp NOT NULL, 30 | "token" text NOT NULL, 31 | "createdAt" timestamp NOT NULL, 32 | "updatedAt" timestamp NOT NULL, 33 | "ipAddress" text, 34 | "userAgent" text, 35 | "userId" text NOT NULL, 36 | CONSTRAINT "session_token_unique" UNIQUE("token") 37 | ); 38 | --> statement-breakpoint 39 | CREATE TABLE "shareableResults" ( 40 | "id" text PRIMARY KEY NOT NULL, 41 | "shortId" text NOT NULL, 42 | "gameResultId" text NOT NULL, 43 | "createdAt" timestamp DEFAULT now() NOT NULL, 44 | CONSTRAINT "shareableResults_shortId_unique" UNIQUE("shortId") 45 | ); 46 | --> statement-breakpoint 47 | CREATE TABLE "user" ( 48 | "id" text PRIMARY KEY NOT NULL, 49 | "name" text NOT NULL, 50 | "email" text NOT NULL, 51 | "emailVerified" boolean NOT NULL, 52 | "image" text, 53 | "createdAt" timestamp NOT NULL, 54 | "updatedAt" timestamp NOT NULL, 55 | CONSTRAINT "user_email_unique" UNIQUE("email") 56 | ); 57 | --> statement-breakpoint 58 | CREATE TABLE "verification" ( 59 | "id" text PRIMARY KEY NOT NULL, 60 | "identifier" text NOT NULL, 61 | "value" text NOT NULL, 62 | "expiresAt" timestamp NOT NULL, 63 | "createdAt" timestamp, 64 | "updatedAt" timestamp 65 | ); 66 | --> statement-breakpoint 67 | ALTER TABLE "account" ADD CONSTRAINT "account_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint 68 | ALTER TABLE "gameResults" ADD CONSTRAINT "gameResults_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint 69 | ALTER TABLE "session" ADD CONSTRAINT "session_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint 70 | ALTER TABLE "shareableResults" ADD CONSTRAINT "shareableResults_gameResultId_gameResults_id_fk" FOREIGN KEY ("gameResultId") REFERENCES "public"."gameResults"("id") ON DELETE cascade ON UPDATE no action; -------------------------------------------------------------------------------- /components/ui/table.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | function Table({ className, ...props }: React.ComponentProps<"table">) { 8 | return ( 9 |
13 | 18 | 19 | ) 20 | } 21 | 22 | function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { 23 | return ( 24 | 29 | ) 30 | } 31 | 32 | function TableBody({ className, ...props }: React.ComponentProps<"tbody">) { 33 | return ( 34 | 39 | ) 40 | } 41 | 42 | function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) { 43 | return ( 44 | tr]:last:border-b-0", 48 | className 49 | )} 50 | {...props} 51 | /> 52 | ) 53 | } 54 | 55 | function TableRow({ className, ...props }: React.ComponentProps<"tr">) { 56 | return ( 57 | 65 | ) 66 | } 67 | 68 | function TableHead({ className, ...props }: React.ComponentProps<"th">) { 69 | return ( 70 |
[role=checkbox]]:translate-y-[2px]", 74 | className 75 | )} 76 | {...props} 77 | /> 78 | ) 79 | } 80 | 81 | function TableCell({ className, ...props }: React.ComponentProps<"td">) { 82 | return ( 83 | [role=checkbox]]:translate-y-[2px]", 87 | className 88 | )} 89 | {...props} 90 | /> 91 | ) 92 | } 93 | 94 | function TableCaption({ 95 | className, 96 | ...props 97 | }: React.ComponentProps<"caption">) { 98 | return ( 99 |
104 | ) 105 | } 106 | 107 | export { 108 | Table, 109 | TableHeader, 110 | TableBody, 111 | TableFooter, 112 | TableHead, 113 | TableRow, 114 | TableCell, 115 | TableCaption, 116 | } 117 | -------------------------------------------------------------------------------- /app/leaderboard/page.tsx: -------------------------------------------------------------------------------- 1 | import { Navigation } from "@/components/navigation"; 2 | import { BottomNav } from "@/components/bottom-nav"; 3 | import { Trophy, Medal } from "lucide-react"; 4 | import { db } from "@/lib/db"; 5 | import { gameResults, user } from "@/lib/db/schema"; 6 | import { desc, eq } from "drizzle-orm"; 7 | 8 | async function getTopPlayers() { 9 | try { 10 | const results = await db 11 | .select({ 12 | wpm: gameResults.wpm, 13 | accuracy: gameResults.accuracy, 14 | duration: gameResults.duration, 15 | playerName: user.name, 16 | createdAt: gameResults.createdAt, 17 | }) 18 | .from(gameResults) 19 | .leftJoin(user, eq(gameResults.userId, user.id)) 20 | .orderBy(desc(gameResults.wpm)) 21 | .limit(10); 22 | 23 | return results; 24 | } catch (error) { 25 | console.error("Error fetching top players:", error); 26 | return []; 27 | } 28 | } 29 | 30 | export default async function LeaderboardPage() { 31 | const topPlayers = await getTopPlayers(); 32 | 33 | return ( 34 |
35 | 36 | 37 |
38 |
39 |

40 | LEADERBOARD 41 |

42 | 43 | {topPlayers.length === 0 ? ( 44 |
45 |

No results yet. Be the first to set a record!

46 |
47 | ) : ( 48 |
49 | {topPlayers.map((player, index) => ( 50 |
54 |
55 | {index === 0 ? ( 56 | 57 | ) : index === 1 ? ( 58 | 59 | ) : index === 2 ? ( 60 | 61 | ) : ( 62 | 63 | {index + 1} 64 | 65 | )} 66 |
67 | 68 |
69 |
70 | {player.playerName || "Anonymous"} 71 |
72 |
73 | {player.accuracy}% accuracy • {player.duration}s 74 |
75 |
76 | 77 |
78 |
{player.wpm}
79 |
80 |
81 | ))} 82 |
83 | )} 84 |
85 |
86 |
87 | ); 88 | } 89 | 90 | -------------------------------------------------------------------------------- /lib/db/schema.ts: -------------------------------------------------------------------------------- 1 | import { pgTable, text, timestamp, integer, boolean, check, jsonb } from "drizzle-orm/pg-core"; 2 | import { relations, sql } from "drizzle-orm"; 3 | import { nanoid } from "nanoid"; 4 | 5 | // Better Auth tables 6 | export const user = pgTable("user", { 7 | id: text("id").primaryKey(), 8 | name: text("name").notNull(), 9 | email: text("email").notNull().unique(), 10 | emailVerified: boolean("emailVerified").notNull(), 11 | image: text("image"), 12 | createdAt: timestamp("createdAt").notNull(), 13 | updatedAt: timestamp("updatedAt").notNull(), 14 | }); 15 | 16 | export const session = pgTable("session", { 17 | id: text("id").primaryKey(), 18 | expiresAt: timestamp("expiresAt").notNull(), 19 | token: text("token").notNull().unique(), 20 | createdAt: timestamp("createdAt").notNull(), 21 | updatedAt: timestamp("updatedAt").notNull(), 22 | ipAddress: text("ipAddress"), 23 | userAgent: text("userAgent"), 24 | userId: text("userId") 25 | .notNull() 26 | .references(() => user.id, { onDelete: "cascade" }), 27 | }); 28 | 29 | export const account = pgTable("account", { 30 | id: text("id").primaryKey(), 31 | accountId: text("accountId").notNull(), 32 | providerId: text("providerId").notNull(), 33 | userId: text("userId") 34 | .notNull() 35 | .references(() => user.id, { onDelete: "cascade" }), 36 | accessToken: text("accessToken"), 37 | refreshToken: text("refreshToken"), 38 | idToken: text("idToken"), 39 | accessTokenExpiresAt: timestamp("accessTokenExpiresAt"), 40 | refreshTokenExpiresAt: timestamp("refreshTokenExpiresAt"), 41 | scope: text("scope"), 42 | password: text("password"), 43 | createdAt: timestamp("createdAt").notNull(), 44 | updatedAt: timestamp("updatedAt").notNull(), 45 | }); 46 | 47 | export const verification = pgTable("verification", { 48 | id: text("id").primaryKey(), 49 | identifier: text("identifier").notNull(), 50 | value: text("value").notNull(), 51 | expiresAt: timestamp("expiresAt").notNull(), 52 | createdAt: timestamp("createdAt"), 53 | updatedAt: timestamp("updatedAt"), 54 | }); 55 | 56 | // Game data tables 57 | export const gameResults = pgTable("gameResults", { 58 | id: text("id").primaryKey().$defaultFn(() => nanoid()), 59 | userId: text("userId") 60 | .references(() => user.id, { onDelete: "cascade" }), 61 | wpm: integer("wpm").notNull(), 62 | accuracy: integer("accuracy").notNull(), 63 | duration: integer("duration").notNull(), // in seconds 64 | textExcerpt: text("textExcerpt").notNull(), 65 | wpmHistory: jsonb("wpmHistory").$type>(), // Array of {time: seconds, wpm: number} 66 | createdAt: timestamp("createdAt").notNull().defaultNow(), 67 | }, (table) => ({ 68 | // Database-level constraints to prevent invalid data 69 | wpmCheck: check("wpm_check", sql`${table.wpm} >= 0 AND ${table.wpm} <= 350`), 70 | accuracyCheck: check("accuracy_check", sql`${table.accuracy} >= 0 AND ${table.accuracy} <= 100`), 71 | durationCheck: check("duration_check", sql`${table.duration} >= 0 AND ${table.duration} <= 300`), 72 | })); 73 | 74 | export const shareableResults = pgTable("shareableResults", { 75 | id: text("id").primaryKey().$defaultFn(() => nanoid()), 76 | shortId: text("shortId").notNull().unique(), 77 | gameResultId: text("gameResultId") 78 | .references(() => gameResults.id, { onDelete: "cascade" }) 79 | .notNull(), 80 | createdAt: timestamp("createdAt").notNull().defaultNow(), 81 | }); 82 | 83 | // Relations 84 | export const userRelations = relations(user, ({ many }) => ({ 85 | gameResults: many(gameResults), 86 | })); 87 | 88 | export const gameResultsRelations = relations(gameResults, ({ one }) => ({ 89 | user: one(user, { 90 | fields: [gameResults.userId], 91 | references: [user.id], 92 | }), 93 | })); 94 | 95 | -------------------------------------------------------------------------------- /app/profile/page.tsx: -------------------------------------------------------------------------------- 1 | import { requireAuth } from "@/lib/auth-server"; 2 | import { db } from "@/lib/db"; 3 | import { gameResults } from "@/lib/db/schema"; 4 | import { eq } from "drizzle-orm"; 5 | import { Navigation } from "@/components/navigation"; 6 | import { BottomNav } from "@/components/bottom-nav"; 7 | 8 | async function getUserStats(userId: string) { 9 | const allResults = await db.query.gameResults.findMany({ 10 | where: eq(gameResults.userId, userId), 11 | orderBy: (gameResults, { desc }) => [desc(gameResults.createdAt)], 12 | }); 13 | 14 | if (allResults.length === 0) { 15 | return { 16 | totalGames: 0, 17 | averageWpm: 0, 18 | bestWpm: 0, 19 | averageAccuracy: 0, 20 | }; 21 | } 22 | 23 | const totalWpm = allResults.reduce((sum, result) => sum + result.wpm, 0); 24 | const totalAccuracy = allResults.reduce( 25 | (sum, result) => sum + result.accuracy, 26 | 0 27 | ); 28 | const bestWpm = Math.max(...allResults.map((r) => r.wpm)); 29 | 30 | return { 31 | totalGames: allResults.length, 32 | averageWpm: Math.round(totalWpm / allResults.length), 33 | bestWpm, 34 | averageAccuracy: Math.round(totalAccuracy / allResults.length), 35 | }; 36 | } 37 | 38 | async function StatsCard() { 39 | const session = await requireAuth(); 40 | const stats = await getUserStats(session.userId); 41 | 42 | return ( 43 |
44 |
45 | {stats.bestWpm} 46 | BEST 47 |
48 |
49 | {stats.averageWpm} 50 | AVG 51 |
52 |
53 | ); 54 | } 55 | 56 | async function GameHistory() { 57 | const session = await requireAuth(); 58 | const allResults = await db.query.gameResults.findMany({ 59 | where: eq(gameResults.userId, session.userId), 60 | orderBy: (gameResults, { desc }) => [desc(gameResults.createdAt)], 61 | limit: 20, 62 | }); 63 | 64 | if (allResults.length === 0) { 65 | return ( 66 |
67 | No games played yet. Start typing to see your history! 68 |
69 | ); 70 | } 71 | 72 | return ( 73 |
74 | {allResults.map((result) => ( 75 |
79 |
80 |
81 | {result.wpm} 82 | WPM 83 |
84 | {result.accuracy}% 85 |
86 |
87 | {new Date(result.createdAt).toLocaleDateString()} 88 |
89 |
90 | ))} 91 |
92 | ); 93 | } 94 | 95 | export default async function ProfilePage() { 96 | await requireAuth(); 97 | 98 | return ( 99 |
100 | 101 | 102 |
103 | 104 |
105 | 106 |
107 |
108 |
109 | ); 110 | } 111 | 112 | -------------------------------------------------------------------------------- /lib/use-keyboard-sounds.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useCallback, useState } from 'react'; 2 | import { Howl } from 'howler'; 3 | 4 | interface KeyboardSoundsOptions { 5 | initialEnabled?: boolean; 6 | volume?: number; 7 | } 8 | 9 | interface SoundMap { 10 | [key: string]: Howl; 11 | } 12 | 13 | export function useKeyboardSounds({ initialEnabled = true, volume = 0.9 }: KeyboardSoundsOptions = {}) { 14 | const [enabled, setEnabled] = useState(initialEnabled); 15 | const pressSoundsRef = useRef({}); 16 | const releaseSoundsRef = useRef({}); 17 | const genericPressSoundsRef = useRef([]); 18 | const isInitializedRef = useRef(false); 19 | 20 | useEffect(() => { 21 | if (isInitializedRef.current) return; 22 | 23 | // Press sounds 24 | pressSoundsRef.current = { 25 | ' ': new Howl({ src: ['/sounds/press/SPACE.mp3'], volume, preload: true, html5: true }), 26 | 'Backspace': new Howl({ src: ['/sounds/press/BACKSPACE.mp3'], volume, preload: true, html5: true }), 27 | 'Enter': new Howl({ src: ['/sounds/press/ENTER.mp3'], volume, preload: true, html5: true }), 28 | }; 29 | 30 | // Generic press sounds (for regular character keys) 31 | genericPressSoundsRef.current = [ 32 | new Howl({ src: ['/sounds/press/GENERIC_R0.mp3'], volume, preload: true, html5: true }), 33 | new Howl({ src: ['/sounds/press/GENERIC_R1.mp3'], volume, preload: true, html5: true }), 34 | new Howl({ src: ['/sounds/press/GENERIC_R2.mp3'], volume, preload: true, html5: true }), 35 | new Howl({ src: ['/sounds/press/GENERIC_R3.mp3'], volume, preload: true, html5: true }), 36 | new Howl({ src: ['/sounds/press/GENERIC_R4.mp3'], volume, preload: true, html5: true }), 37 | ]; 38 | 39 | // Release sounds 40 | releaseSoundsRef.current = { 41 | ' ': new Howl({ src: ['/sounds/release/SPACE.mp3'], volume, preload: true, html5: true }), 42 | 'Backspace': new Howl({ src: ['/sounds/release/BACKSPACE.mp3'], volume, preload: true, html5: true }), 43 | 'Enter': new Howl({ src: ['/sounds/release/ENTER.mp3'], volume, preload: true, html5: true }), 44 | 'generic': new Howl({ src: ['/sounds/release/GENERIC.mp3'], volume, preload: true, html5: true }), 45 | }; 46 | 47 | isInitializedRef.current = true; 48 | 49 | return () => { 50 | Object.values(pressSoundsRef.current).forEach((sound) => sound.unload()); 51 | Object.values(releaseSoundsRef.current).forEach((sound) => sound.unload()); 52 | genericPressSoundsRef.current.forEach((sound) => sound.unload()); 53 | isInitializedRef.current = false; 54 | }; 55 | }, [volume]); 56 | 57 | const playPressSound = useCallback((key: string) => { 58 | if (!enabled) return; 59 | 60 | // Check for specific key sounds 61 | const specificSound = pressSoundsRef.current[key]; 62 | if (specificSound) { 63 | specificSound.play(); 64 | } else if (genericPressSoundsRef.current.length > 0) { 65 | // Use random generic sound for regular keys 66 | const randomIndex = Math.floor(Math.random() * genericPressSoundsRef.current.length); 67 | genericPressSoundsRef.current[randomIndex].play(); 68 | } 69 | }, [enabled]); 70 | 71 | const playReleaseSound = useCallback((key: string) => { 72 | if (!enabled) return; 73 | 74 | // Check for specific key sounds 75 | const specificSound = releaseSoundsRef.current[key]; 76 | if (specificSound) { 77 | specificSound.play(); 78 | } else { 79 | // Use generic release sound for regular keys 80 | releaseSoundsRef.current['generic']?.play(); 81 | } 82 | }, [enabled]); 83 | 84 | const toggleSound = useCallback(() => { 85 | setEnabled((prev) => { 86 | const newEnabled = !prev; 87 | // Play a sound when enabling 88 | if (newEnabled && genericPressSoundsRef.current.length > 0) { 89 | const randomIndex = Math.floor(Math.random() * genericPressSoundsRef.current.length); 90 | genericPressSoundsRef.current[randomIndex].play(); 91 | } 92 | return newEnabled; 93 | }); 94 | }, []); 95 | 96 | return { playPressSound, playReleaseSound, enabled, toggleSound }; 97 | } 98 | 99 | -------------------------------------------------------------------------------- /components/ui/use-toast.ts: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | 5 | import type { ToastActionElement, ToastProps } from "@/components/ui/toast" 6 | 7 | const TOAST_LIMIT = 1 8 | const TOAST_REMOVE_DELAY = 1000000 9 | 10 | type ToasterToast = ToastProps & { 11 | id: string 12 | title?: React.ReactNode 13 | description?: React.ReactNode 14 | action?: ToastActionElement 15 | } 16 | 17 | const actionTypes = { 18 | ADD_TOAST: "ADD_TOAST", 19 | UPDATE_TOAST: "UPDATE_TOAST", 20 | DISMISS_TOAST: "DISMISS_TOAST", 21 | REMOVE_TOAST: "REMOVE_TOAST", 22 | } as const 23 | 24 | let count = 0 25 | 26 | function genId() { 27 | count = (count + 1) % Number.MAX_SAFE_INTEGER 28 | return count.toString() 29 | } 30 | 31 | type ActionType = typeof actionTypes 32 | 33 | type Action = 34 | | { 35 | type: ActionType["ADD_TOAST"] 36 | toast: ToasterToast 37 | } 38 | | { 39 | type: ActionType["UPDATE_TOAST"] 40 | toast: Partial 41 | } 42 | | { 43 | type: ActionType["DISMISS_TOAST"] 44 | toastId?: ToasterToast["id"] 45 | } 46 | | { 47 | type: ActionType["REMOVE_TOAST"] 48 | toastId?: ToasterToast["id"] 49 | } 50 | 51 | interface State { 52 | toasts: ToasterToast[] 53 | } 54 | 55 | const toastTimeouts = new Map>() 56 | 57 | const addToRemoveQueue = (toastId: string) => { 58 | if (toastTimeouts.has(toastId)) { 59 | return 60 | } 61 | 62 | const timeout = setTimeout(() => { 63 | toastTimeouts.delete(toastId) 64 | dispatch({ 65 | type: "REMOVE_TOAST", 66 | toastId: toastId, 67 | }) 68 | }, TOAST_REMOVE_DELAY) 69 | 70 | toastTimeouts.set(toastId, timeout) 71 | } 72 | 73 | export const reducer = (state: State, action: Action): State => { 74 | switch (action.type) { 75 | case "ADD_TOAST": 76 | return { 77 | ...state, 78 | toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), 79 | } 80 | 81 | case "UPDATE_TOAST": 82 | return { 83 | ...state, 84 | toasts: state.toasts.map((t) => 85 | t.id === action.toast.id ? { ...t, ...action.toast } : t 86 | ), 87 | } 88 | 89 | case "DISMISS_TOAST": { 90 | const { toastId } = action 91 | 92 | if (toastId) { 93 | addToRemoveQueue(toastId) 94 | } else { 95 | state.toasts.forEach((toast) => { 96 | addToRemoveQueue(toast.id) 97 | }) 98 | } 99 | 100 | return { 101 | ...state, 102 | toasts: state.toasts.map((t) => 103 | t.id === toastId || toastId === undefined 104 | ? { 105 | ...t, 106 | open: false, 107 | } 108 | : t 109 | ), 110 | } 111 | } 112 | case "REMOVE_TOAST": 113 | if (action.toastId === undefined) { 114 | return { 115 | ...state, 116 | toasts: [], 117 | } 118 | } 119 | return { 120 | ...state, 121 | toasts: state.toasts.filter((t) => t.id !== action.toastId), 122 | } 123 | } 124 | } 125 | 126 | const listeners: Array<(state: State) => void> = [] 127 | 128 | let memoryState: State = { toasts: [] } 129 | 130 | function dispatch(action: Action) { 131 | memoryState = reducer(memoryState, action) 132 | listeners.forEach((listener) => { 133 | listener(memoryState) 134 | }) 135 | } 136 | 137 | type Toast = Omit 138 | 139 | function toast({ ...props }: Toast) { 140 | const id = genId() 141 | 142 | const update = (props: ToasterToast) => 143 | dispatch({ 144 | type: "UPDATE_TOAST", 145 | toast: { ...props, id }, 146 | }) 147 | const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }) 148 | 149 | dispatch({ 150 | type: "ADD_TOAST", 151 | toast: { 152 | ...props, 153 | id, 154 | open: true, 155 | onOpenChange: (open) => { 156 | if (!open) dismiss() 157 | }, 158 | }, 159 | }) 160 | 161 | return { 162 | id: id, 163 | dismiss, 164 | update, 165 | } 166 | } 167 | 168 | function useToast() { 169 | const [state, setState] = React.useState(memoryState) 170 | 171 | React.useEffect(() => { 172 | listeners.push(setState) 173 | return () => { 174 | const index = listeners.indexOf(setState) 175 | if (index > -1) { 176 | listeners.splice(index, 1) 177 | } 178 | } 179 | }, [state]) 180 | 181 | return { 182 | ...state, 183 | toast, 184 | dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), 185 | } 186 | } 187 | 188 | export { useToast, toast } 189 | 190 | -------------------------------------------------------------------------------- /app/s/[shortId]/opengraph-image.tsx: -------------------------------------------------------------------------------- 1 | import { readFile } from 'node:fs/promises'; 2 | import { join } from 'node:path'; 3 | import { ImageResponse } from 'next/og'; 4 | import { db } from '@/lib/db'; 5 | import { shareableResults, gameResults, user } from '@/lib/db/schema'; 6 | import { eq } from 'drizzle-orm'; 7 | 8 | export const runtime = 'nodejs'; 9 | export const alt = 'Typing Test Result'; 10 | export const size = { 11 | width: 1200, 12 | height: 630, 13 | }; 14 | export const contentType = 'image/png'; 15 | 16 | export default async function Image({ 17 | params, 18 | }: { 19 | params: Promise<{ shortId: string }>; 20 | }) { 21 | const { shortId } = await params; 22 | 23 | const shareable = await db.query.shareableResults.findFirst({ 24 | where: eq(shareableResults.shortId, shortId), 25 | }); 26 | 27 | if (!shareable) { 28 | return new ImageResponse( 29 | ( 30 |
42 | Result not found 43 |
44 | ), 45 | { ...size } 46 | ); 47 | } 48 | 49 | const gameResult = await db.query.gameResults.findFirst({ 50 | where: eq(gameResults.id, shareable.gameResultId), 51 | }); 52 | 53 | if (!gameResult) { 54 | return new ImageResponse( 55 | ( 56 |
68 | Result not found 69 |
70 | ), 71 | { ...size } 72 | ); 73 | } 74 | 75 | let userImage: string | null = null; 76 | if (gameResult.userId) { 77 | const userData = await db.query.user.findFirst({ 78 | where: eq(user.id, gameResult.userId), 79 | }); 80 | userImage = userData?.image || null; 81 | } 82 | 83 | const fontData = await readFile( 84 | join(process.cwd(), 'public/fonts/CursorGothic-Regular.ttf') 85 | ); 86 | 87 | return new ImageResponse( 88 | ( 89 |
102 |
110 |
120 | {gameResult.wpm} 121 | WPM 122 |
123 |
133 | {gameResult.accuracy}% 134 | ACC 135 |
136 |
137 | 138 | {userImage && ( 139 |
147 | User avatar 156 |
157 | )} 158 |
159 | ), 160 | { 161 | ...size, 162 | fonts: [ 163 | { 164 | name: 'CursorGothic', 165 | data: fontData, 166 | style: 'normal', 167 | weight: 400, 168 | }, 169 | ], 170 | } 171 | ); 172 | } 173 | 174 | -------------------------------------------------------------------------------- /app/s/[shortId]/page.tsx: -------------------------------------------------------------------------------- 1 | import { db } from "@/lib/db"; 2 | import { shareableResults, gameResults } from "@/lib/db/schema"; 3 | import { eq } from "drizzle-orm"; 4 | import { Navigation } from "@/components/navigation"; 5 | import type { Metadata } from "next"; 6 | import Link from "next/link"; 7 | import { WPMChartWrapper } from "@/components/wpm-chart-wrapper"; 8 | 9 | export async function generateMetadata({ 10 | params, 11 | }: { 12 | params: Promise<{ shortId: string }>; 13 | }): Promise { 14 | const { shortId } = await params; 15 | const shareable = await db.query.shareableResults.findFirst({ 16 | where: eq(shareableResults.shortId, shortId), 17 | }); 18 | 19 | if (!shareable) { 20 | return { 21 | title: "Result not found", 22 | }; 23 | } 24 | 25 | const gameResult = await db.query.gameResults.findFirst({ 26 | where: eq(gameResults.id, shareable.gameResultId), 27 | with: { 28 | user: true, 29 | }, 30 | }); 31 | 32 | if (!gameResult) { 33 | return { 34 | title: "Result not found", 35 | }; 36 | } 37 | 38 | return { 39 | title: `${gameResult.wpm} WPM • ${gameResult.accuracy}% Accuracy`, 40 | description: `Check out this typing test result: ${gameResult.wpm} WPM with ${gameResult.accuracy}% accuracy`, 41 | openGraph: { 42 | title: `${gameResult.wpm} WPM • ${gameResult.accuracy}% Accuracy`, 43 | description: `Check out this typing test result: ${gameResult.wpm} WPM with ${gameResult.accuracy}% accuracy`, 44 | type: "website", 45 | }, 46 | twitter: { 47 | card: "summary_large_image", 48 | title: `${gameResult.wpm} WPM • ${gameResult.accuracy}% Accuracy`, 49 | description: `Check out this typing test result: ${gameResult.wpm} WPM with ${gameResult.accuracy}% accuracy`, 50 | }, 51 | }; 52 | } 53 | 54 | export default async function SharedResultPage({ 55 | params, 56 | }: { 57 | params: Promise<{ shortId: string }>; 58 | }) { 59 | const { shortId } = await params; 60 | const shareable = await db.query.shareableResults.findFirst({ 61 | where: eq(shareableResults.shortId, shortId), 62 | }); 63 | 64 | if (!shareable) { 65 | return ( 66 |
67 | 68 |
69 |
70 |

Result not found

71 |

72 | This result may have expired or doesn't exist. 73 |

74 |
75 |
76 |
77 | ); 78 | } 79 | 80 | const gameResult = await db.query.gameResults.findFirst({ 81 | where: eq(gameResults.id, shareable.gameResultId), 82 | with: { 83 | user: true, 84 | }, 85 | }); 86 | 87 | if (!gameResult) { 88 | return ( 89 |
90 | 91 |
92 |
93 |

Result not found

94 |
95 |
96 |
97 | ); 98 | } 99 | 100 | return ( 101 |
102 | 103 |
104 |
105 |
106 | {gameResult.wpm} 107 | WPM 108 |
109 |
110 | {gameResult.accuracy}% 111 | ACC 112 |
113 |
114 | 115 | {gameResult.wpmHistory && gameResult.wpmHistory.length > 0 && ( 116 |
117 | 118 |
119 | )} 120 | 121 |
122 | 126 | Play again 127 | 128 | 129 | Shared on {new Date(gameResult.createdAt).toLocaleDateString()} 130 | {gameResult.user && ` by ${gameResult.user.name}`} 131 | 132 |
133 |
134 |
135 | ); 136 | } 137 | 138 | -------------------------------------------------------------------------------- /components/ui/toast.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as ToastPrimitives from "@radix-ui/react-toast" 5 | import { cva, type VariantProps } from "class-variance-authority" 6 | import { X } from "lucide-react" 7 | 8 | import { cn } from "@/lib/utils" 9 | 10 | const ToastProvider = ToastPrimitives.Provider 11 | 12 | const ToastViewport = React.forwardRef< 13 | React.ElementRef, 14 | React.ComponentPropsWithoutRef 15 | >(({ className, ...props }, ref) => ( 16 | 24 | )) 25 | ToastViewport.displayName = ToastPrimitives.Viewport.displayName 26 | 27 | const toastVariants = cva( 28 | "group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full", 29 | { 30 | variants: { 31 | variant: { 32 | default: "border bg-background text-foreground", 33 | destructive: 34 | "destructive group border-destructive bg-destructive text-destructive-foreground", 35 | }, 36 | }, 37 | defaultVariants: { 38 | variant: "default", 39 | }, 40 | } 41 | ) 42 | 43 | const Toast = React.forwardRef< 44 | React.ElementRef, 45 | React.ComponentPropsWithoutRef & 46 | VariantProps 47 | >(({ className, variant, ...props }, ref) => { 48 | return ( 49 | 54 | ) 55 | }) 56 | Toast.displayName = ToastPrimitives.Root.displayName 57 | 58 | const ToastAction = React.forwardRef< 59 | React.ElementRef, 60 | React.ComponentPropsWithoutRef 61 | >(({ className, ...props }, ref) => ( 62 | 70 | )) 71 | ToastAction.displayName = ToastPrimitives.Action.displayName 72 | 73 | const ToastClose = React.forwardRef< 74 | React.ElementRef, 75 | React.ComponentPropsWithoutRef 76 | >(({ className, ...props }, ref) => ( 77 | 86 | 87 | 88 | )) 89 | ToastClose.displayName = ToastPrimitives.Close.displayName 90 | 91 | const ToastTitle = React.forwardRef< 92 | React.ElementRef, 93 | React.ComponentPropsWithoutRef 94 | >(({ className, ...props }, ref) => ( 95 | 100 | )) 101 | ToastTitle.displayName = ToastPrimitives.Title.displayName 102 | 103 | const ToastDescription = React.forwardRef< 104 | React.ElementRef, 105 | React.ComponentPropsWithoutRef 106 | >(({ className, ...props }, ref) => ( 107 | 112 | )) 113 | ToastDescription.displayName = ToastPrimitives.Description.displayName 114 | 115 | type ToastProps = React.ComponentPropsWithoutRef 116 | 117 | type ToastActionElement = React.ReactElement 118 | 119 | export { 120 | type ToastProps, 121 | type ToastActionElement, 122 | ToastProvider, 123 | ToastViewport, 124 | Toast, 125 | ToastTitle, 126 | ToastDescription, 127 | ToastClose, 128 | ToastAction, 129 | } 130 | 131 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # anytype 2 | 3 | A minimal typing simulator game built with Next.js, featuring real-time WPM tracking, leaderboards, and shareable results. 4 | 5 | ## Features 6 | 7 | - **30-second typing test** or complete the text to finish 8 | - **Real-time WPM tracking** with live updates every 100ms 9 | - **WPM history charts** showing performance over time 10 | - **Race mode** with ghost cursor showing the top leaderboard entry 11 | - **Keyboard sound effects** (toggleable) using Howler.js 12 | - **Leaderboard** displaying top 10 players by WPM 13 | - **User profiles** with best WPM, average WPM, and game history 14 | - **Shareable results** with unique short URLs and OpenGraph images 15 | - **Google OAuth** authentication via Better Auth 16 | - **Dark/Light theme** support with system preference detection 17 | - **Custom font** (CursorGothic) for enhanced typography 18 | 19 | ## Setup 20 | 21 | ### Prerequisites 22 | 23 | - Node.js 20+ 24 | - PostgreSQL database 25 | - pnpm (or npm/yarn) 26 | 27 | ### Installation 28 | 29 | 1. Install dependencies: 30 | 31 | ```bash 32 | pnpm install 33 | ``` 34 | 35 | 2. Set up environment variables in `.env.local`: 36 | 37 | ```env 38 | DATABASE_URL="your-postgres-connection-string" 39 | GOOGLE_CLIENT_ID="your-google-client-id" 40 | GOOGLE_CLIENT_SECRET="your-google-client-secret" 41 | NEXT_PUBLIC_BASE_URL="http://localhost:3000" 42 | BETTER_AUTH_SECRET="your-auth-secret" 43 | BETTER_AUTH_URL="http://localhost:3000" 44 | ``` 45 | 46 | 3. Generate and run database migrations: 47 | 48 | ```bash 49 | # Generate Better Auth schema (if needed) 50 | npx @better-auth/cli generate 51 | 52 | # Generate Drizzle migrations 53 | pnpm db:generate 54 | 55 | # Apply migrations 56 | pnpm db:migrate 57 | 58 | # Or push schema directly (development) 59 | pnpm db:push 60 | ``` 61 | 62 | 4. Run the development server: 63 | 64 | ```bash 65 | pnpm dev 66 | ``` 67 | 68 | Visit `http://localhost:3000` to start typing. 69 | 70 | ## Tech Stack 71 | 72 | - **Next.js 16** (App Router) - React framework with server components 73 | - **React 19** - UI library 74 | - **TypeScript** - Type safety 75 | - **Drizzle ORM** - Type-safe database queries 76 | - **Better Auth** - Authentication with OAuth support 77 | - **PostgreSQL** - Database (Neon) 78 | - **Tailwind CSS 4** - Utility-first styling 79 | - **Recharts** - Data visualization for WPM charts 80 | - **Howler.js** - Audio engine for keyboard sounds 81 | - **Sonner** - Toast notifications 82 | - **Next Themes** - Theme management 83 | - **Nanoid** - Short ID generation for shareable links 84 | 85 | ## Project Structure 86 | 87 | ``` 88 | ├── app/ 89 | │ ├── actions/ 90 | │ │ └── share.ts # Server action for saving shareable results 91 | │ ├── api/ 92 | │ │ ├── auth/[...all]/ # Better Auth API routes 93 | │ │ └── leaderboard/top/ # API endpoint for top player 94 | │ ├── leaderboard/ 95 | │ │ ├── page.tsx # Leaderboard page 96 | │ │ └── opengraph-image.tsx # OG image generation 97 | │ ├── profile/ 98 | │ │ └── page.tsx # User profile with stats 99 | │ ├── s/[shortId]/ 100 | │ │ ├── page.tsx # Shared result viewer 101 | │ │ └── opengraph-image.tsx # OG image for shared results 102 | │ ├── layout.tsx # Root layout with theme provider 103 | │ └── page.tsx # Main game page 104 | ├── components/ 105 | │ ├── ui/ # Reusable UI components 106 | │ ├── navigation.tsx # Top navigation bar 107 | │ ├── bottom-nav.tsx # Bottom navigation 108 | │ ├── typing-game.tsx # Main game component 109 | │ ├── wpm-chart.tsx # WPM history chart 110 | │ └── theme-provider.tsx # Theme provider 111 | ├── lib/ 112 | │ ├── db/ 113 | │ │ ├── schema.ts # Drizzle schema definitions 114 | │ │ └── index.ts # Database client 115 | │ ├── auth.ts # Better Auth configuration 116 | │ ├── auth-client.ts # Client-side auth utilities 117 | │ ├── auth-server.ts # Server-side auth utilities 118 | │ ├── excerpts.ts # Text excerpts for typing practice 119 | │ └── use-keyboard-sounds.ts # Keyboard sound effects hook 120 | └── drizzle.config.ts # Drizzle configuration 121 | ``` 122 | 123 | ## Scripts 124 | 125 | - `pnpm dev` - Start development server 126 | - `pnpm build` - Build for production 127 | - `pnpm start` - Start production server 128 | - `pnpm lint` - Run ESLint 129 | - `pnpm db:generate` - Generate database migrations 130 | - `pnpm db:migrate` - Apply migrations 131 | - `pnpm db:push` - Push schema changes (dev only) 132 | - `pnpm db:studio` - Open Drizzle Studio 133 | 134 | ## Key Features Explained 135 | 136 | ### Game Mechanics 137 | 138 | - Timer starts when you type the first character 139 | - Game ends after 30 seconds or when text is completed 140 | - WPM calculated as: `(correct characters / 5) / minutes` 141 | - Accuracy shown as percentage of correct characters 142 | 143 | ### Race Mode 144 | 145 | - Enable the flag icon to see a ghost cursor showing the top leaderboard player's speed 146 | - Helps visualize how you're performing relative to the best player 147 | 148 | ### Sharing Results 149 | 150 | - Click "Share" after completing a game to get a unique short URL 151 | - Shared links include WPM history charts if available 152 | - OpenGraph images are automatically generated for social sharing 153 | 154 | ## Database Schema 155 | 156 | - `user` - User accounts (Better Auth) 157 | - `session` - User sessions (Better Auth) 158 | - `gameResults` - Stored game results with WPM, accuracy, duration, and history 159 | - `shareableResults` - Maps short IDs to game results 160 | -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | @import "tw-animate-css"; 3 | 4 | @custom-variant dark (&:is(.dark *)); 5 | 6 | @theme inline { 7 | --color-background: var(--background); 8 | --color-foreground: var(--foreground); 9 | --color-foreground-2: var(--foreground-2); 10 | --font-sans: var(--font-cursor-sans); 11 | --font-mono: var(--font-cursor-sans); 12 | --font-cursor-sans: var(--font-cursor-sans); 13 | --color-ring: var(--ring); 14 | --color-input: var(--input); 15 | --color-border: var(--border); 16 | --color-destructive: var(--destructive); 17 | --color-accent-foreground: var(--accent-foreground); 18 | --color-accent: var(--accent); 19 | --color-muted-foreground: var(--muted-foreground); 20 | --color-muted: var(--muted); 21 | --color-secondary-foreground: var(--secondary-foreground); 22 | --color-secondary: var(--secondary); 23 | --color-primary-foreground: var(--primary-foreground); 24 | --color-primary: var(--primary); 25 | --color-popover-foreground: var(--popover-foreground); 26 | --color-popover: var(--popover); 27 | --color-card-foreground: var(--card-foreground); 28 | --color-card: var(--card); 29 | --radius-sm: calc(var(--radius) - 4px); 30 | --radius-md: calc(var(--radius) - 2px); 31 | --radius-lg: var(--radius); 32 | --radius-xl: calc(var(--radius) + 4px); 33 | 34 | --font-weight-thin: 400; 35 | --font-weight-extralight: 400; 36 | --font-weight-light: 400; 37 | --font-weight-normal: 400; 38 | --font-weight-medium: 400; 39 | --font-weight-semibold: 700; 40 | --font-weight-bold: 700; 41 | --font-weight-extrabold: 700; 42 | --font-weight-black: 700; 43 | 44 | /* Typography System */ 45 | --font-size-small: 13.125px; 46 | --font-size-base: 15px; 47 | --font-size-base-plus: 18px; 48 | --font-size-medium: 20.625px; 49 | --font-size-large: 33.75px; 50 | --font-size-xlarge: 48.75px; 51 | --font-size-xxlarge: 67.5px; 52 | 53 | --line-height-small: 140%; 54 | --line-height-base: 140%; 55 | --line-height-medium: 130%; 56 | --line-height-large: 120%; 57 | --line-height-xlarge: 115%; 58 | --line-height-xxlarge: 110%; 59 | 60 | --tracking-small: 0.01em; 61 | --tracking-base: 0.005em; 62 | --tracking-medium: -0.005em; 63 | --tracking-large: -0.02em; 64 | --tracking-xlarge: -0.025em; 65 | --tracking-xxlarge: -0.03em; 66 | } 67 | 68 | :root { 69 | --radius: 0.625rem; 70 | 71 | /* Default theme (Light theme fallback) */ 72 | --background: #f7f7f4; 73 | --foreground: #26251e; 74 | 75 | --card: #f0efea; 76 | --card-foreground: #26251e; 77 | 78 | --popover: #f0efea; 79 | --popover-foreground: #26251e; 80 | 81 | /* Brand / accent */ 82 | --primary: #eb5600; 83 | --primary-foreground: #ffffff; 84 | 85 | /* Subtle surfaces */ 86 | --secondary: #ebeae5; 87 | --secondary-foreground: #26251e; 88 | 89 | --muted: #ebeae5; 90 | --muted-foreground: #7a7974; 91 | 92 | --accent: #ebeae5; 93 | --accent-foreground: #26251e; 94 | 95 | --destructive: #eb5600; 96 | --destructive-foreground: #14120b; 97 | 98 | /* Strokes */ 99 | --border: #e2e2df; 100 | --input: #e2e2df; 101 | --ring: #504f49; 102 | 103 | --foreground-2: #3b3a33; 104 | --prose-bullets: rgba(38, 37, 30, 0.4); 105 | } 106 | 107 | .dark { 108 | /* Dark theme */ 109 | --background: #14120b; 110 | --foreground: #edecec; 111 | 112 | --card: #1b1913; 113 | --card-foreground: #edecec; 114 | 115 | --popover: #1b1913; 116 | --popover-foreground: #edecec; 117 | 118 | --primary: #eb5600; 119 | --primary-foreground: #14120b; 120 | 121 | /* Subtle surfaces */ 122 | --secondary: #201e18; 123 | --secondary-foreground: #edecec; 124 | 125 | --muted: #201e18; 126 | --muted-foreground: #969592; 127 | 128 | --accent: #201e18; 129 | --accent-foreground: #edecec; 130 | 131 | --destructive: #eb5600; 132 | --destructive-foreground: #14120b; 133 | 134 | --border: #2a2822; 135 | --input: #2a2822; 136 | --ring: #c2c0bf; 137 | 138 | --foreground-2: #d7d6d6; 139 | --prose-bullets: rgba(237, 236, 236, 0.4); 140 | } 141 | 142 | @layer base { 143 | * { 144 | @apply border-border outline-ring/50; 145 | } 146 | 147 | body { 148 | @apply bg-background text-foreground; 149 | font-family: var(--font-cursor-sans); 150 | font-synthesis: none; 151 | } 152 | 153 | h1, h2, h3, h4, h5, h6 { 154 | font-family: var(--font-cursor-sans); 155 | font-synthesis: none; 156 | font-weight: 400; 157 | -webkit-font-smoothing: subpixel-antialiased; 158 | } 159 | } 160 | 161 | @layer utilities { 162 | /* Cursor blink animation matching macOS timing */ 163 | @keyframes cursor-blink { 164 | 0%, 49% { 165 | opacity: 1; 166 | } 167 | 50%, 100% { 168 | opacity: 0; 169 | } 170 | } 171 | 172 | .animate-cursor-blink { 173 | animation: cursor-blink 1060ms steps(1, end) infinite; 174 | } 175 | 176 | /* Typography utility classes */ 177 | .text-small { 178 | font-size: var(--font-size-small); 179 | line-height: var(--line-height-small); 180 | letter-spacing: var(--tracking-small); 181 | } 182 | 183 | .text-small-bold { 184 | font-size: var(--font-size-small); 185 | line-height: var(--line-height-small); 186 | letter-spacing: var(--tracking-small); 187 | font-weight: 700; 188 | } 189 | 190 | .text-base { 191 | font-size: var(--font-size-base); 192 | line-height: var(--line-height-base); 193 | letter-spacing: var(--tracking-base); 194 | } 195 | 196 | .text-base-bold { 197 | font-size: var(--font-size-base); 198 | line-height: var(--line-height-base); 199 | letter-spacing: var(--tracking-base); 200 | font-weight: 700; 201 | } 202 | 203 | .text-medium { 204 | font-size: var(--font-size-medium); 205 | line-height: var(--line-height-medium); 206 | letter-spacing: var(--tracking-medium); 207 | } 208 | 209 | .text-large { 210 | font-size: var(--font-size-large); 211 | line-height: var(--line-height-large); 212 | letter-spacing: var(--tracking-large); 213 | -webkit-font-smoothing: subpixel-antialiased; 214 | } 215 | 216 | .text-xlarge { 217 | font-size: var(--font-size-xlarge); 218 | line-height: var(--line-height-xlarge); 219 | letter-spacing: var(--tracking-xlarge); 220 | -webkit-font-smoothing: subpixel-antialiased; 221 | } 222 | 223 | .text-xxlarge { 224 | font-size: var(--font-size-xxlarge); 225 | line-height: var(--line-height-xxlarge); 226 | letter-spacing: var(--tracking-xxlarge); 227 | -webkit-font-smoothing: subpixel-antialiased; 228 | } 229 | 230 | /* Enhanced typography */ 231 | body { 232 | text-rendering: optimizeLegibility; 233 | font-optical-sizing: auto; 234 | font-feature-settings: 235 | 'kern' 1, 236 | 'liga' 1, 237 | 'calt' 1, 238 | 'case' 1; 239 | } 240 | 241 | p, 242 | li, 243 | blockquote, 244 | h1, 245 | h2, 246 | h3, 247 | h4, 248 | h5, 249 | h6 { 250 | font-feature-settings: 251 | 'kern' 1, 252 | 'liga' 1, 253 | 'calt' 1, 254 | 'case' 1; 255 | } 256 | } 257 | 258 | button { 259 | cursor: pointer; 260 | } 261 | -------------------------------------------------------------------------------- /app/opengraph-image.tsx: -------------------------------------------------------------------------------- 1 | import { readFile } from 'node:fs/promises'; 2 | import { join } from 'node:path'; 3 | import { ImageResponse } from 'next/og'; 4 | import { db } from '@/lib/db'; 5 | import { gameResults, user } from '@/lib/db/schema'; 6 | import { desc, eq } from 'drizzle-orm'; 7 | 8 | export const runtime = 'nodejs'; 9 | export const alt = 'Leaderboard - Top Scores'; 10 | export const size = { 11 | width: 1200, 12 | height: 630, 13 | }; 14 | export const contentType = 'image/png'; 15 | 16 | async function getTopThreePlayers() { 17 | try { 18 | const results = await db 19 | .select({ 20 | wpm: gameResults.wpm, 21 | accuracy: gameResults.accuracy, 22 | duration: gameResults.duration, 23 | playerName: user.name, 24 | }) 25 | .from(gameResults) 26 | .leftJoin(user, eq(gameResults.userId, user.id)) 27 | .orderBy(desc(gameResults.wpm)) 28 | .limit(3); 29 | 30 | return results; 31 | } catch (error) { 32 | console.error("Error fetching top players:", error); 33 | return []; 34 | } 35 | } 36 | 37 | export default async function Image() { 38 | const topPlayers = await getTopThreePlayers(); 39 | 40 | const player1Name = topPlayers[0]?.playerName || 'Anonymous'; 41 | const player1Stats = `${topPlayers[0]?.accuracy || 0}% accuracy • ${topPlayers[0]?.duration || 0}s`; 42 | const player1Wpm = String(topPlayers[0]?.wpm || 0); 43 | 44 | const player2Name = topPlayers[1]?.playerName || 'Anonymous'; 45 | const player2Stats = `${topPlayers[1]?.accuracy || 0}% accuracy • ${topPlayers[1]?.duration || 0}s`; 46 | const player2Wpm = String(topPlayers[1]?.wpm || 0); 47 | 48 | const player3Name = topPlayers[2]?.playerName || 'Anonymous'; 49 | const player3Stats = `${topPlayers[2]?.accuracy || 0}% accuracy • ${topPlayers[2]?.duration || 0}s`; 50 | const player3Wpm = String(topPlayers[2]?.wpm || 0); 51 | 52 | const fontData = await readFile( 53 | join(process.cwd(), 'public/fonts/CursorGothic-Regular.ttf') 54 | ); 55 | 56 | return new ImageResponse( 57 | ( 58 |
71 |
81 | LEADERBOARD 82 |
83 | 84 |
92 |
101 |
110 | 🏆 111 |
112 |
120 |
127 | {player1Name} 128 |
129 |
135 | {player1Stats} 136 |
137 |
138 |
146 | {player1Wpm} 147 |
148 |
149 | 150 |
159 |
168 | 🥈 169 |
170 |
178 |
185 | {player2Name} 186 |
187 |
193 | {player2Stats} 194 |
195 |
196 |
204 | {player2Wpm} 205 |
206 |
207 | 208 |
215 |
224 | 🥉 225 |
226 |
234 |
241 | {player3Name} 242 |
243 |
249 | {player3Stats} 250 |
251 |
252 |
260 | {player3Wpm} 261 |
262 |
263 |
264 |
265 | ), 266 | { 267 | ...size, 268 | fonts: [ 269 | { 270 | name: 'CursorGothic', 271 | data: fontData, 272 | style: 'normal', 273 | weight: 400, 274 | }, 275 | ], 276 | } 277 | ); 278 | } 279 | -------------------------------------------------------------------------------- /app/leaderboard/opengraph-image.tsx: -------------------------------------------------------------------------------- 1 | import { readFile } from 'node:fs/promises'; 2 | import { join } from 'node:path'; 3 | import { ImageResponse } from 'next/og'; 4 | import { db } from '@/lib/db'; 5 | import { gameResults, user } from '@/lib/db/schema'; 6 | import { desc, eq } from 'drizzle-orm'; 7 | 8 | export const runtime = 'nodejs'; 9 | export const alt = 'Leaderboard - Top Scores'; 10 | export const size = { 11 | width: 1200, 12 | height: 630, 13 | }; 14 | export const contentType = 'image/png'; 15 | 16 | async function getTopThreePlayers() { 17 | try { 18 | const results = await db 19 | .select({ 20 | wpm: gameResults.wpm, 21 | accuracy: gameResults.accuracy, 22 | duration: gameResults.duration, 23 | playerName: user.name, 24 | }) 25 | .from(gameResults) 26 | .leftJoin(user, eq(gameResults.userId, user.id)) 27 | .orderBy(desc(gameResults.wpm)) 28 | .limit(3); 29 | 30 | return results; 31 | } catch (error) { 32 | console.error("Error fetching top players:", error); 33 | return []; 34 | } 35 | } 36 | 37 | export default async function Image() { 38 | const topPlayers = await getTopThreePlayers(); 39 | 40 | const player1Name = topPlayers[0]?.playerName || 'Anonymous'; 41 | const player1Stats = `${topPlayers[0]?.accuracy || 0}% accuracy • ${topPlayers[0]?.duration || 0}s`; 42 | const player1Wpm = String(topPlayers[0]?.wpm || 0); 43 | 44 | const player2Name = topPlayers[1]?.playerName || 'Anonymous'; 45 | const player2Stats = `${topPlayers[1]?.accuracy || 0}% accuracy • ${topPlayers[1]?.duration || 0}s`; 46 | const player2Wpm = String(topPlayers[1]?.wpm || 0); 47 | 48 | const player3Name = topPlayers[2]?.playerName || 'Anonymous'; 49 | const player3Stats = `${topPlayers[2]?.accuracy || 0}% accuracy • ${topPlayers[2]?.duration || 0}s`; 50 | const player3Wpm = String(topPlayers[2]?.wpm || 0); 51 | 52 | const fontData = await readFile( 53 | join(process.cwd(), 'public/fonts/CursorGothic-Regular.ttf') 54 | ); 55 | 56 | return new ImageResponse( 57 | ( 58 |
71 |
81 | LEADERBOARD 82 |
83 | 84 |
92 |
101 |
110 | 🏆 111 |
112 |
120 |
127 | {player1Name} 128 |
129 |
135 | {player1Stats} 136 |
137 |
138 |
146 | {player1Wpm} 147 |
148 |
149 | 150 |
159 |
168 | 🥈 169 |
170 |
178 |
185 | {player2Name} 186 |
187 |
193 | {player2Stats} 194 |
195 |
196 |
204 | {player2Wpm} 205 |
206 |
207 | 208 |
215 |
224 | 🥉 225 |
226 |
234 |
241 | {player3Name} 242 |
243 |
249 | {player3Stats} 250 |
251 |
252 |
260 | {player3Wpm} 261 |
262 |
263 |
264 |
265 | ), 266 | { 267 | ...size, 268 | fonts: [ 269 | { 270 | name: 'CursorGothic', 271 | data: fontData, 272 | style: 'normal', 273 | weight: 400, 274 | }, 275 | ], 276 | } 277 | ); 278 | } 279 | -------------------------------------------------------------------------------- /drizzle/meta/0000_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "070027fe-e8b2-416b-bfe1-69402b124c1a", 3 | "prevId": "00000000-0000-0000-0000-000000000000", 4 | "version": "7", 5 | "dialect": "postgresql", 6 | "tables": { 7 | "public.account": { 8 | "name": "account", 9 | "schema": "", 10 | "columns": { 11 | "id": { 12 | "name": "id", 13 | "type": "text", 14 | "primaryKey": true, 15 | "notNull": true 16 | }, 17 | "accountId": { 18 | "name": "accountId", 19 | "type": "text", 20 | "primaryKey": false, 21 | "notNull": true 22 | }, 23 | "providerId": { 24 | "name": "providerId", 25 | "type": "text", 26 | "primaryKey": false, 27 | "notNull": true 28 | }, 29 | "userId": { 30 | "name": "userId", 31 | "type": "text", 32 | "primaryKey": false, 33 | "notNull": true 34 | }, 35 | "accessToken": { 36 | "name": "accessToken", 37 | "type": "text", 38 | "primaryKey": false, 39 | "notNull": false 40 | }, 41 | "refreshToken": { 42 | "name": "refreshToken", 43 | "type": "text", 44 | "primaryKey": false, 45 | "notNull": false 46 | }, 47 | "idToken": { 48 | "name": "idToken", 49 | "type": "text", 50 | "primaryKey": false, 51 | "notNull": false 52 | }, 53 | "accessTokenExpiresAt": { 54 | "name": "accessTokenExpiresAt", 55 | "type": "timestamp", 56 | "primaryKey": false, 57 | "notNull": false 58 | }, 59 | "refreshTokenExpiresAt": { 60 | "name": "refreshTokenExpiresAt", 61 | "type": "timestamp", 62 | "primaryKey": false, 63 | "notNull": false 64 | }, 65 | "scope": { 66 | "name": "scope", 67 | "type": "text", 68 | "primaryKey": false, 69 | "notNull": false 70 | }, 71 | "password": { 72 | "name": "password", 73 | "type": "text", 74 | "primaryKey": false, 75 | "notNull": false 76 | }, 77 | "createdAt": { 78 | "name": "createdAt", 79 | "type": "timestamp", 80 | "primaryKey": false, 81 | "notNull": true 82 | }, 83 | "updatedAt": { 84 | "name": "updatedAt", 85 | "type": "timestamp", 86 | "primaryKey": false, 87 | "notNull": true 88 | } 89 | }, 90 | "indexes": {}, 91 | "foreignKeys": { 92 | "account_userId_user_id_fk": { 93 | "name": "account_userId_user_id_fk", 94 | "tableFrom": "account", 95 | "tableTo": "user", 96 | "columnsFrom": [ 97 | "userId" 98 | ], 99 | "columnsTo": [ 100 | "id" 101 | ], 102 | "onDelete": "cascade", 103 | "onUpdate": "no action" 104 | } 105 | }, 106 | "compositePrimaryKeys": {}, 107 | "uniqueConstraints": {}, 108 | "policies": {}, 109 | "checkConstraints": {}, 110 | "isRLSEnabled": false 111 | }, 112 | "public.gameResults": { 113 | "name": "gameResults", 114 | "schema": "", 115 | "columns": { 116 | "id": { 117 | "name": "id", 118 | "type": "text", 119 | "primaryKey": true, 120 | "notNull": true 121 | }, 122 | "userId": { 123 | "name": "userId", 124 | "type": "text", 125 | "primaryKey": false, 126 | "notNull": true 127 | }, 128 | "wpm": { 129 | "name": "wpm", 130 | "type": "integer", 131 | "primaryKey": false, 132 | "notNull": true 133 | }, 134 | "accuracy": { 135 | "name": "accuracy", 136 | "type": "integer", 137 | "primaryKey": false, 138 | "notNull": true 139 | }, 140 | "duration": { 141 | "name": "duration", 142 | "type": "integer", 143 | "primaryKey": false, 144 | "notNull": true 145 | }, 146 | "textExcerpt": { 147 | "name": "textExcerpt", 148 | "type": "text", 149 | "primaryKey": false, 150 | "notNull": true 151 | }, 152 | "createdAt": { 153 | "name": "createdAt", 154 | "type": "timestamp", 155 | "primaryKey": false, 156 | "notNull": true, 157 | "default": "now()" 158 | } 159 | }, 160 | "indexes": {}, 161 | "foreignKeys": { 162 | "gameResults_userId_user_id_fk": { 163 | "name": "gameResults_userId_user_id_fk", 164 | "tableFrom": "gameResults", 165 | "tableTo": "user", 166 | "columnsFrom": [ 167 | "userId" 168 | ], 169 | "columnsTo": [ 170 | "id" 171 | ], 172 | "onDelete": "cascade", 173 | "onUpdate": "no action" 174 | } 175 | }, 176 | "compositePrimaryKeys": {}, 177 | "uniqueConstraints": {}, 178 | "policies": {}, 179 | "checkConstraints": {}, 180 | "isRLSEnabled": false 181 | }, 182 | "public.session": { 183 | "name": "session", 184 | "schema": "", 185 | "columns": { 186 | "id": { 187 | "name": "id", 188 | "type": "text", 189 | "primaryKey": true, 190 | "notNull": true 191 | }, 192 | "expiresAt": { 193 | "name": "expiresAt", 194 | "type": "timestamp", 195 | "primaryKey": false, 196 | "notNull": true 197 | }, 198 | "token": { 199 | "name": "token", 200 | "type": "text", 201 | "primaryKey": false, 202 | "notNull": true 203 | }, 204 | "createdAt": { 205 | "name": "createdAt", 206 | "type": "timestamp", 207 | "primaryKey": false, 208 | "notNull": true 209 | }, 210 | "updatedAt": { 211 | "name": "updatedAt", 212 | "type": "timestamp", 213 | "primaryKey": false, 214 | "notNull": true 215 | }, 216 | "ipAddress": { 217 | "name": "ipAddress", 218 | "type": "text", 219 | "primaryKey": false, 220 | "notNull": false 221 | }, 222 | "userAgent": { 223 | "name": "userAgent", 224 | "type": "text", 225 | "primaryKey": false, 226 | "notNull": false 227 | }, 228 | "userId": { 229 | "name": "userId", 230 | "type": "text", 231 | "primaryKey": false, 232 | "notNull": true 233 | } 234 | }, 235 | "indexes": {}, 236 | "foreignKeys": { 237 | "session_userId_user_id_fk": { 238 | "name": "session_userId_user_id_fk", 239 | "tableFrom": "session", 240 | "tableTo": "user", 241 | "columnsFrom": [ 242 | "userId" 243 | ], 244 | "columnsTo": [ 245 | "id" 246 | ], 247 | "onDelete": "cascade", 248 | "onUpdate": "no action" 249 | } 250 | }, 251 | "compositePrimaryKeys": {}, 252 | "uniqueConstraints": { 253 | "session_token_unique": { 254 | "name": "session_token_unique", 255 | "nullsNotDistinct": false, 256 | "columns": [ 257 | "token" 258 | ] 259 | } 260 | }, 261 | "policies": {}, 262 | "checkConstraints": {}, 263 | "isRLSEnabled": false 264 | }, 265 | "public.shareableResults": { 266 | "name": "shareableResults", 267 | "schema": "", 268 | "columns": { 269 | "id": { 270 | "name": "id", 271 | "type": "text", 272 | "primaryKey": true, 273 | "notNull": true 274 | }, 275 | "shortId": { 276 | "name": "shortId", 277 | "type": "text", 278 | "primaryKey": false, 279 | "notNull": true 280 | }, 281 | "gameResultId": { 282 | "name": "gameResultId", 283 | "type": "text", 284 | "primaryKey": false, 285 | "notNull": true 286 | }, 287 | "createdAt": { 288 | "name": "createdAt", 289 | "type": "timestamp", 290 | "primaryKey": false, 291 | "notNull": true, 292 | "default": "now()" 293 | } 294 | }, 295 | "indexes": {}, 296 | "foreignKeys": { 297 | "shareableResults_gameResultId_gameResults_id_fk": { 298 | "name": "shareableResults_gameResultId_gameResults_id_fk", 299 | "tableFrom": "shareableResults", 300 | "tableTo": "gameResults", 301 | "columnsFrom": [ 302 | "gameResultId" 303 | ], 304 | "columnsTo": [ 305 | "id" 306 | ], 307 | "onDelete": "cascade", 308 | "onUpdate": "no action" 309 | } 310 | }, 311 | "compositePrimaryKeys": {}, 312 | "uniqueConstraints": { 313 | "shareableResults_shortId_unique": { 314 | "name": "shareableResults_shortId_unique", 315 | "nullsNotDistinct": false, 316 | "columns": [ 317 | "shortId" 318 | ] 319 | } 320 | }, 321 | "policies": {}, 322 | "checkConstraints": {}, 323 | "isRLSEnabled": false 324 | }, 325 | "public.user": { 326 | "name": "user", 327 | "schema": "", 328 | "columns": { 329 | "id": { 330 | "name": "id", 331 | "type": "text", 332 | "primaryKey": true, 333 | "notNull": true 334 | }, 335 | "name": { 336 | "name": "name", 337 | "type": "text", 338 | "primaryKey": false, 339 | "notNull": true 340 | }, 341 | "email": { 342 | "name": "email", 343 | "type": "text", 344 | "primaryKey": false, 345 | "notNull": true 346 | }, 347 | "emailVerified": { 348 | "name": "emailVerified", 349 | "type": "boolean", 350 | "primaryKey": false, 351 | "notNull": true 352 | }, 353 | "image": { 354 | "name": "image", 355 | "type": "text", 356 | "primaryKey": false, 357 | "notNull": false 358 | }, 359 | "createdAt": { 360 | "name": "createdAt", 361 | "type": "timestamp", 362 | "primaryKey": false, 363 | "notNull": true 364 | }, 365 | "updatedAt": { 366 | "name": "updatedAt", 367 | "type": "timestamp", 368 | "primaryKey": false, 369 | "notNull": true 370 | } 371 | }, 372 | "indexes": {}, 373 | "foreignKeys": {}, 374 | "compositePrimaryKeys": {}, 375 | "uniqueConstraints": { 376 | "user_email_unique": { 377 | "name": "user_email_unique", 378 | "nullsNotDistinct": false, 379 | "columns": [ 380 | "email" 381 | ] 382 | } 383 | }, 384 | "policies": {}, 385 | "checkConstraints": {}, 386 | "isRLSEnabled": false 387 | }, 388 | "public.verification": { 389 | "name": "verification", 390 | "schema": "", 391 | "columns": { 392 | "id": { 393 | "name": "id", 394 | "type": "text", 395 | "primaryKey": true, 396 | "notNull": true 397 | }, 398 | "identifier": { 399 | "name": "identifier", 400 | "type": "text", 401 | "primaryKey": false, 402 | "notNull": true 403 | }, 404 | "value": { 405 | "name": "value", 406 | "type": "text", 407 | "primaryKey": false, 408 | "notNull": true 409 | }, 410 | "expiresAt": { 411 | "name": "expiresAt", 412 | "type": "timestamp", 413 | "primaryKey": false, 414 | "notNull": true 415 | }, 416 | "createdAt": { 417 | "name": "createdAt", 418 | "type": "timestamp", 419 | "primaryKey": false, 420 | "notNull": false 421 | }, 422 | "updatedAt": { 423 | "name": "updatedAt", 424 | "type": "timestamp", 425 | "primaryKey": false, 426 | "notNull": false 427 | } 428 | }, 429 | "indexes": {}, 430 | "foreignKeys": {}, 431 | "compositePrimaryKeys": {}, 432 | "uniqueConstraints": {}, 433 | "policies": {}, 434 | "checkConstraints": {}, 435 | "isRLSEnabled": false 436 | } 437 | }, 438 | "enums": {}, 439 | "schemas": {}, 440 | "sequences": {}, 441 | "roles": {}, 442 | "policies": {}, 443 | "views": {}, 444 | "_meta": { 445 | "columns": {}, 446 | "schemas": {}, 447 | "tables": {} 448 | } 449 | } -------------------------------------------------------------------------------- /drizzle/meta/0001_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "49c6ca43-86f3-4d11-9289-4a5bc3e6d33a", 3 | "prevId": "070027fe-e8b2-416b-bfe1-69402b124c1a", 4 | "version": "7", 5 | "dialect": "postgresql", 6 | "tables": { 7 | "public.account": { 8 | "name": "account", 9 | "schema": "", 10 | "columns": { 11 | "id": { 12 | "name": "id", 13 | "type": "text", 14 | "primaryKey": true, 15 | "notNull": true 16 | }, 17 | "accountId": { 18 | "name": "accountId", 19 | "type": "text", 20 | "primaryKey": false, 21 | "notNull": true 22 | }, 23 | "providerId": { 24 | "name": "providerId", 25 | "type": "text", 26 | "primaryKey": false, 27 | "notNull": true 28 | }, 29 | "userId": { 30 | "name": "userId", 31 | "type": "text", 32 | "primaryKey": false, 33 | "notNull": true 34 | }, 35 | "accessToken": { 36 | "name": "accessToken", 37 | "type": "text", 38 | "primaryKey": false, 39 | "notNull": false 40 | }, 41 | "refreshToken": { 42 | "name": "refreshToken", 43 | "type": "text", 44 | "primaryKey": false, 45 | "notNull": false 46 | }, 47 | "idToken": { 48 | "name": "idToken", 49 | "type": "text", 50 | "primaryKey": false, 51 | "notNull": false 52 | }, 53 | "accessTokenExpiresAt": { 54 | "name": "accessTokenExpiresAt", 55 | "type": "timestamp", 56 | "primaryKey": false, 57 | "notNull": false 58 | }, 59 | "refreshTokenExpiresAt": { 60 | "name": "refreshTokenExpiresAt", 61 | "type": "timestamp", 62 | "primaryKey": false, 63 | "notNull": false 64 | }, 65 | "scope": { 66 | "name": "scope", 67 | "type": "text", 68 | "primaryKey": false, 69 | "notNull": false 70 | }, 71 | "password": { 72 | "name": "password", 73 | "type": "text", 74 | "primaryKey": false, 75 | "notNull": false 76 | }, 77 | "createdAt": { 78 | "name": "createdAt", 79 | "type": "timestamp", 80 | "primaryKey": false, 81 | "notNull": true 82 | }, 83 | "updatedAt": { 84 | "name": "updatedAt", 85 | "type": "timestamp", 86 | "primaryKey": false, 87 | "notNull": true 88 | } 89 | }, 90 | "indexes": {}, 91 | "foreignKeys": { 92 | "account_userId_user_id_fk": { 93 | "name": "account_userId_user_id_fk", 94 | "tableFrom": "account", 95 | "tableTo": "user", 96 | "columnsFrom": [ 97 | "userId" 98 | ], 99 | "columnsTo": [ 100 | "id" 101 | ], 102 | "onDelete": "cascade", 103 | "onUpdate": "no action" 104 | } 105 | }, 106 | "compositePrimaryKeys": {}, 107 | "uniqueConstraints": {}, 108 | "policies": {}, 109 | "checkConstraints": {}, 110 | "isRLSEnabled": false 111 | }, 112 | "public.gameResults": { 113 | "name": "gameResults", 114 | "schema": "", 115 | "columns": { 116 | "id": { 117 | "name": "id", 118 | "type": "text", 119 | "primaryKey": true, 120 | "notNull": true 121 | }, 122 | "userId": { 123 | "name": "userId", 124 | "type": "text", 125 | "primaryKey": false, 126 | "notNull": false 127 | }, 128 | "wpm": { 129 | "name": "wpm", 130 | "type": "integer", 131 | "primaryKey": false, 132 | "notNull": true 133 | }, 134 | "accuracy": { 135 | "name": "accuracy", 136 | "type": "integer", 137 | "primaryKey": false, 138 | "notNull": true 139 | }, 140 | "duration": { 141 | "name": "duration", 142 | "type": "integer", 143 | "primaryKey": false, 144 | "notNull": true 145 | }, 146 | "textExcerpt": { 147 | "name": "textExcerpt", 148 | "type": "text", 149 | "primaryKey": false, 150 | "notNull": true 151 | }, 152 | "createdAt": { 153 | "name": "createdAt", 154 | "type": "timestamp", 155 | "primaryKey": false, 156 | "notNull": true, 157 | "default": "now()" 158 | } 159 | }, 160 | "indexes": {}, 161 | "foreignKeys": { 162 | "gameResults_userId_user_id_fk": { 163 | "name": "gameResults_userId_user_id_fk", 164 | "tableFrom": "gameResults", 165 | "tableTo": "user", 166 | "columnsFrom": [ 167 | "userId" 168 | ], 169 | "columnsTo": [ 170 | "id" 171 | ], 172 | "onDelete": "cascade", 173 | "onUpdate": "no action" 174 | } 175 | }, 176 | "compositePrimaryKeys": {}, 177 | "uniqueConstraints": {}, 178 | "policies": {}, 179 | "checkConstraints": {}, 180 | "isRLSEnabled": false 181 | }, 182 | "public.session": { 183 | "name": "session", 184 | "schema": "", 185 | "columns": { 186 | "id": { 187 | "name": "id", 188 | "type": "text", 189 | "primaryKey": true, 190 | "notNull": true 191 | }, 192 | "expiresAt": { 193 | "name": "expiresAt", 194 | "type": "timestamp", 195 | "primaryKey": false, 196 | "notNull": true 197 | }, 198 | "token": { 199 | "name": "token", 200 | "type": "text", 201 | "primaryKey": false, 202 | "notNull": true 203 | }, 204 | "createdAt": { 205 | "name": "createdAt", 206 | "type": "timestamp", 207 | "primaryKey": false, 208 | "notNull": true 209 | }, 210 | "updatedAt": { 211 | "name": "updatedAt", 212 | "type": "timestamp", 213 | "primaryKey": false, 214 | "notNull": true 215 | }, 216 | "ipAddress": { 217 | "name": "ipAddress", 218 | "type": "text", 219 | "primaryKey": false, 220 | "notNull": false 221 | }, 222 | "userAgent": { 223 | "name": "userAgent", 224 | "type": "text", 225 | "primaryKey": false, 226 | "notNull": false 227 | }, 228 | "userId": { 229 | "name": "userId", 230 | "type": "text", 231 | "primaryKey": false, 232 | "notNull": true 233 | } 234 | }, 235 | "indexes": {}, 236 | "foreignKeys": { 237 | "session_userId_user_id_fk": { 238 | "name": "session_userId_user_id_fk", 239 | "tableFrom": "session", 240 | "tableTo": "user", 241 | "columnsFrom": [ 242 | "userId" 243 | ], 244 | "columnsTo": [ 245 | "id" 246 | ], 247 | "onDelete": "cascade", 248 | "onUpdate": "no action" 249 | } 250 | }, 251 | "compositePrimaryKeys": {}, 252 | "uniqueConstraints": { 253 | "session_token_unique": { 254 | "name": "session_token_unique", 255 | "nullsNotDistinct": false, 256 | "columns": [ 257 | "token" 258 | ] 259 | } 260 | }, 261 | "policies": {}, 262 | "checkConstraints": {}, 263 | "isRLSEnabled": false 264 | }, 265 | "public.shareableResults": { 266 | "name": "shareableResults", 267 | "schema": "", 268 | "columns": { 269 | "id": { 270 | "name": "id", 271 | "type": "text", 272 | "primaryKey": true, 273 | "notNull": true 274 | }, 275 | "shortId": { 276 | "name": "shortId", 277 | "type": "text", 278 | "primaryKey": false, 279 | "notNull": true 280 | }, 281 | "gameResultId": { 282 | "name": "gameResultId", 283 | "type": "text", 284 | "primaryKey": false, 285 | "notNull": true 286 | }, 287 | "createdAt": { 288 | "name": "createdAt", 289 | "type": "timestamp", 290 | "primaryKey": false, 291 | "notNull": true, 292 | "default": "now()" 293 | } 294 | }, 295 | "indexes": {}, 296 | "foreignKeys": { 297 | "shareableResults_gameResultId_gameResults_id_fk": { 298 | "name": "shareableResults_gameResultId_gameResults_id_fk", 299 | "tableFrom": "shareableResults", 300 | "tableTo": "gameResults", 301 | "columnsFrom": [ 302 | "gameResultId" 303 | ], 304 | "columnsTo": [ 305 | "id" 306 | ], 307 | "onDelete": "cascade", 308 | "onUpdate": "no action" 309 | } 310 | }, 311 | "compositePrimaryKeys": {}, 312 | "uniqueConstraints": { 313 | "shareableResults_shortId_unique": { 314 | "name": "shareableResults_shortId_unique", 315 | "nullsNotDistinct": false, 316 | "columns": [ 317 | "shortId" 318 | ] 319 | } 320 | }, 321 | "policies": {}, 322 | "checkConstraints": {}, 323 | "isRLSEnabled": false 324 | }, 325 | "public.user": { 326 | "name": "user", 327 | "schema": "", 328 | "columns": { 329 | "id": { 330 | "name": "id", 331 | "type": "text", 332 | "primaryKey": true, 333 | "notNull": true 334 | }, 335 | "name": { 336 | "name": "name", 337 | "type": "text", 338 | "primaryKey": false, 339 | "notNull": true 340 | }, 341 | "email": { 342 | "name": "email", 343 | "type": "text", 344 | "primaryKey": false, 345 | "notNull": true 346 | }, 347 | "emailVerified": { 348 | "name": "emailVerified", 349 | "type": "boolean", 350 | "primaryKey": false, 351 | "notNull": true 352 | }, 353 | "image": { 354 | "name": "image", 355 | "type": "text", 356 | "primaryKey": false, 357 | "notNull": false 358 | }, 359 | "createdAt": { 360 | "name": "createdAt", 361 | "type": "timestamp", 362 | "primaryKey": false, 363 | "notNull": true 364 | }, 365 | "updatedAt": { 366 | "name": "updatedAt", 367 | "type": "timestamp", 368 | "primaryKey": false, 369 | "notNull": true 370 | } 371 | }, 372 | "indexes": {}, 373 | "foreignKeys": {}, 374 | "compositePrimaryKeys": {}, 375 | "uniqueConstraints": { 376 | "user_email_unique": { 377 | "name": "user_email_unique", 378 | "nullsNotDistinct": false, 379 | "columns": [ 380 | "email" 381 | ] 382 | } 383 | }, 384 | "policies": {}, 385 | "checkConstraints": {}, 386 | "isRLSEnabled": false 387 | }, 388 | "public.verification": { 389 | "name": "verification", 390 | "schema": "", 391 | "columns": { 392 | "id": { 393 | "name": "id", 394 | "type": "text", 395 | "primaryKey": true, 396 | "notNull": true 397 | }, 398 | "identifier": { 399 | "name": "identifier", 400 | "type": "text", 401 | "primaryKey": false, 402 | "notNull": true 403 | }, 404 | "value": { 405 | "name": "value", 406 | "type": "text", 407 | "primaryKey": false, 408 | "notNull": true 409 | }, 410 | "expiresAt": { 411 | "name": "expiresAt", 412 | "type": "timestamp", 413 | "primaryKey": false, 414 | "notNull": true 415 | }, 416 | "createdAt": { 417 | "name": "createdAt", 418 | "type": "timestamp", 419 | "primaryKey": false, 420 | "notNull": false 421 | }, 422 | "updatedAt": { 423 | "name": "updatedAt", 424 | "type": "timestamp", 425 | "primaryKey": false, 426 | "notNull": false 427 | } 428 | }, 429 | "indexes": {}, 430 | "foreignKeys": {}, 431 | "compositePrimaryKeys": {}, 432 | "uniqueConstraints": {}, 433 | "policies": {}, 434 | "checkConstraints": {}, 435 | "isRLSEnabled": false 436 | } 437 | }, 438 | "enums": {}, 439 | "schemas": {}, 440 | "sequences": {}, 441 | "roles": {}, 442 | "policies": {}, 443 | "views": {}, 444 | "_meta": { 445 | "columns": {}, 446 | "schemas": {}, 447 | "tables": {} 448 | } 449 | } -------------------------------------------------------------------------------- /drizzle/meta/0002_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "20d9a84f-646e-4c49-b1ac-915a812d27e8", 3 | "prevId": "49c6ca43-86f3-4d11-9289-4a5bc3e6d33a", 4 | "version": "7", 5 | "dialect": "postgresql", 6 | "tables": { 7 | "public.account": { 8 | "name": "account", 9 | "schema": "", 10 | "columns": { 11 | "id": { 12 | "name": "id", 13 | "type": "text", 14 | "primaryKey": true, 15 | "notNull": true 16 | }, 17 | "accountId": { 18 | "name": "accountId", 19 | "type": "text", 20 | "primaryKey": false, 21 | "notNull": true 22 | }, 23 | "providerId": { 24 | "name": "providerId", 25 | "type": "text", 26 | "primaryKey": false, 27 | "notNull": true 28 | }, 29 | "userId": { 30 | "name": "userId", 31 | "type": "text", 32 | "primaryKey": false, 33 | "notNull": true 34 | }, 35 | "accessToken": { 36 | "name": "accessToken", 37 | "type": "text", 38 | "primaryKey": false, 39 | "notNull": false 40 | }, 41 | "refreshToken": { 42 | "name": "refreshToken", 43 | "type": "text", 44 | "primaryKey": false, 45 | "notNull": false 46 | }, 47 | "idToken": { 48 | "name": "idToken", 49 | "type": "text", 50 | "primaryKey": false, 51 | "notNull": false 52 | }, 53 | "accessTokenExpiresAt": { 54 | "name": "accessTokenExpiresAt", 55 | "type": "timestamp", 56 | "primaryKey": false, 57 | "notNull": false 58 | }, 59 | "refreshTokenExpiresAt": { 60 | "name": "refreshTokenExpiresAt", 61 | "type": "timestamp", 62 | "primaryKey": false, 63 | "notNull": false 64 | }, 65 | "scope": { 66 | "name": "scope", 67 | "type": "text", 68 | "primaryKey": false, 69 | "notNull": false 70 | }, 71 | "password": { 72 | "name": "password", 73 | "type": "text", 74 | "primaryKey": false, 75 | "notNull": false 76 | }, 77 | "createdAt": { 78 | "name": "createdAt", 79 | "type": "timestamp", 80 | "primaryKey": false, 81 | "notNull": true 82 | }, 83 | "updatedAt": { 84 | "name": "updatedAt", 85 | "type": "timestamp", 86 | "primaryKey": false, 87 | "notNull": true 88 | } 89 | }, 90 | "indexes": {}, 91 | "foreignKeys": { 92 | "account_userId_user_id_fk": { 93 | "name": "account_userId_user_id_fk", 94 | "tableFrom": "account", 95 | "tableTo": "user", 96 | "columnsFrom": [ 97 | "userId" 98 | ], 99 | "columnsTo": [ 100 | "id" 101 | ], 102 | "onDelete": "cascade", 103 | "onUpdate": "no action" 104 | } 105 | }, 106 | "compositePrimaryKeys": {}, 107 | "uniqueConstraints": {}, 108 | "policies": {}, 109 | "checkConstraints": {}, 110 | "isRLSEnabled": false 111 | }, 112 | "public.gameResults": { 113 | "name": "gameResults", 114 | "schema": "", 115 | "columns": { 116 | "id": { 117 | "name": "id", 118 | "type": "text", 119 | "primaryKey": true, 120 | "notNull": true 121 | }, 122 | "userId": { 123 | "name": "userId", 124 | "type": "text", 125 | "primaryKey": false, 126 | "notNull": false 127 | }, 128 | "wpm": { 129 | "name": "wpm", 130 | "type": "integer", 131 | "primaryKey": false, 132 | "notNull": true 133 | }, 134 | "accuracy": { 135 | "name": "accuracy", 136 | "type": "integer", 137 | "primaryKey": false, 138 | "notNull": true 139 | }, 140 | "duration": { 141 | "name": "duration", 142 | "type": "integer", 143 | "primaryKey": false, 144 | "notNull": true 145 | }, 146 | "textExcerpt": { 147 | "name": "textExcerpt", 148 | "type": "text", 149 | "primaryKey": false, 150 | "notNull": true 151 | }, 152 | "createdAt": { 153 | "name": "createdAt", 154 | "type": "timestamp", 155 | "primaryKey": false, 156 | "notNull": true, 157 | "default": "now()" 158 | } 159 | }, 160 | "indexes": {}, 161 | "foreignKeys": { 162 | "gameResults_userId_user_id_fk": { 163 | "name": "gameResults_userId_user_id_fk", 164 | "tableFrom": "gameResults", 165 | "tableTo": "user", 166 | "columnsFrom": [ 167 | "userId" 168 | ], 169 | "columnsTo": [ 170 | "id" 171 | ], 172 | "onDelete": "cascade", 173 | "onUpdate": "no action" 174 | } 175 | }, 176 | "compositePrimaryKeys": {}, 177 | "uniqueConstraints": {}, 178 | "policies": {}, 179 | "checkConstraints": { 180 | "wpm_check": { 181 | "name": "wpm_check", 182 | "value": "\"gameResults\".\"wpm\" >= 0 AND \"gameResults\".\"wpm\" <= 250" 183 | }, 184 | "accuracy_check": { 185 | "name": "accuracy_check", 186 | "value": "\"gameResults\".\"accuracy\" >= 0 AND \"gameResults\".\"accuracy\" <= 100" 187 | }, 188 | "duration_check": { 189 | "name": "duration_check", 190 | "value": "\"gameResults\".\"duration\" >= 0 AND \"gameResults\".\"duration\" <= 300" 191 | } 192 | }, 193 | "isRLSEnabled": false 194 | }, 195 | "public.session": { 196 | "name": "session", 197 | "schema": "", 198 | "columns": { 199 | "id": { 200 | "name": "id", 201 | "type": "text", 202 | "primaryKey": true, 203 | "notNull": true 204 | }, 205 | "expiresAt": { 206 | "name": "expiresAt", 207 | "type": "timestamp", 208 | "primaryKey": false, 209 | "notNull": true 210 | }, 211 | "token": { 212 | "name": "token", 213 | "type": "text", 214 | "primaryKey": false, 215 | "notNull": true 216 | }, 217 | "createdAt": { 218 | "name": "createdAt", 219 | "type": "timestamp", 220 | "primaryKey": false, 221 | "notNull": true 222 | }, 223 | "updatedAt": { 224 | "name": "updatedAt", 225 | "type": "timestamp", 226 | "primaryKey": false, 227 | "notNull": true 228 | }, 229 | "ipAddress": { 230 | "name": "ipAddress", 231 | "type": "text", 232 | "primaryKey": false, 233 | "notNull": false 234 | }, 235 | "userAgent": { 236 | "name": "userAgent", 237 | "type": "text", 238 | "primaryKey": false, 239 | "notNull": false 240 | }, 241 | "userId": { 242 | "name": "userId", 243 | "type": "text", 244 | "primaryKey": false, 245 | "notNull": true 246 | } 247 | }, 248 | "indexes": {}, 249 | "foreignKeys": { 250 | "session_userId_user_id_fk": { 251 | "name": "session_userId_user_id_fk", 252 | "tableFrom": "session", 253 | "tableTo": "user", 254 | "columnsFrom": [ 255 | "userId" 256 | ], 257 | "columnsTo": [ 258 | "id" 259 | ], 260 | "onDelete": "cascade", 261 | "onUpdate": "no action" 262 | } 263 | }, 264 | "compositePrimaryKeys": {}, 265 | "uniqueConstraints": { 266 | "session_token_unique": { 267 | "name": "session_token_unique", 268 | "nullsNotDistinct": false, 269 | "columns": [ 270 | "token" 271 | ] 272 | } 273 | }, 274 | "policies": {}, 275 | "checkConstraints": {}, 276 | "isRLSEnabled": false 277 | }, 278 | "public.shareableResults": { 279 | "name": "shareableResults", 280 | "schema": "", 281 | "columns": { 282 | "id": { 283 | "name": "id", 284 | "type": "text", 285 | "primaryKey": true, 286 | "notNull": true 287 | }, 288 | "shortId": { 289 | "name": "shortId", 290 | "type": "text", 291 | "primaryKey": false, 292 | "notNull": true 293 | }, 294 | "gameResultId": { 295 | "name": "gameResultId", 296 | "type": "text", 297 | "primaryKey": false, 298 | "notNull": true 299 | }, 300 | "createdAt": { 301 | "name": "createdAt", 302 | "type": "timestamp", 303 | "primaryKey": false, 304 | "notNull": true, 305 | "default": "now()" 306 | } 307 | }, 308 | "indexes": {}, 309 | "foreignKeys": { 310 | "shareableResults_gameResultId_gameResults_id_fk": { 311 | "name": "shareableResults_gameResultId_gameResults_id_fk", 312 | "tableFrom": "shareableResults", 313 | "tableTo": "gameResults", 314 | "columnsFrom": [ 315 | "gameResultId" 316 | ], 317 | "columnsTo": [ 318 | "id" 319 | ], 320 | "onDelete": "cascade", 321 | "onUpdate": "no action" 322 | } 323 | }, 324 | "compositePrimaryKeys": {}, 325 | "uniqueConstraints": { 326 | "shareableResults_shortId_unique": { 327 | "name": "shareableResults_shortId_unique", 328 | "nullsNotDistinct": false, 329 | "columns": [ 330 | "shortId" 331 | ] 332 | } 333 | }, 334 | "policies": {}, 335 | "checkConstraints": {}, 336 | "isRLSEnabled": false 337 | }, 338 | "public.user": { 339 | "name": "user", 340 | "schema": "", 341 | "columns": { 342 | "id": { 343 | "name": "id", 344 | "type": "text", 345 | "primaryKey": true, 346 | "notNull": true 347 | }, 348 | "name": { 349 | "name": "name", 350 | "type": "text", 351 | "primaryKey": false, 352 | "notNull": true 353 | }, 354 | "email": { 355 | "name": "email", 356 | "type": "text", 357 | "primaryKey": false, 358 | "notNull": true 359 | }, 360 | "emailVerified": { 361 | "name": "emailVerified", 362 | "type": "boolean", 363 | "primaryKey": false, 364 | "notNull": true 365 | }, 366 | "image": { 367 | "name": "image", 368 | "type": "text", 369 | "primaryKey": false, 370 | "notNull": false 371 | }, 372 | "createdAt": { 373 | "name": "createdAt", 374 | "type": "timestamp", 375 | "primaryKey": false, 376 | "notNull": true 377 | }, 378 | "updatedAt": { 379 | "name": "updatedAt", 380 | "type": "timestamp", 381 | "primaryKey": false, 382 | "notNull": true 383 | } 384 | }, 385 | "indexes": {}, 386 | "foreignKeys": {}, 387 | "compositePrimaryKeys": {}, 388 | "uniqueConstraints": { 389 | "user_email_unique": { 390 | "name": "user_email_unique", 391 | "nullsNotDistinct": false, 392 | "columns": [ 393 | "email" 394 | ] 395 | } 396 | }, 397 | "policies": {}, 398 | "checkConstraints": {}, 399 | "isRLSEnabled": false 400 | }, 401 | "public.verification": { 402 | "name": "verification", 403 | "schema": "", 404 | "columns": { 405 | "id": { 406 | "name": "id", 407 | "type": "text", 408 | "primaryKey": true, 409 | "notNull": true 410 | }, 411 | "identifier": { 412 | "name": "identifier", 413 | "type": "text", 414 | "primaryKey": false, 415 | "notNull": true 416 | }, 417 | "value": { 418 | "name": "value", 419 | "type": "text", 420 | "primaryKey": false, 421 | "notNull": true 422 | }, 423 | "expiresAt": { 424 | "name": "expiresAt", 425 | "type": "timestamp", 426 | "primaryKey": false, 427 | "notNull": true 428 | }, 429 | "createdAt": { 430 | "name": "createdAt", 431 | "type": "timestamp", 432 | "primaryKey": false, 433 | "notNull": false 434 | }, 435 | "updatedAt": { 436 | "name": "updatedAt", 437 | "type": "timestamp", 438 | "primaryKey": false, 439 | "notNull": false 440 | } 441 | }, 442 | "indexes": {}, 443 | "foreignKeys": {}, 444 | "compositePrimaryKeys": {}, 445 | "uniqueConstraints": {}, 446 | "policies": {}, 447 | "checkConstraints": {}, 448 | "isRLSEnabled": false 449 | } 450 | }, 451 | "enums": {}, 452 | "schemas": {}, 453 | "sequences": {}, 454 | "roles": {}, 455 | "policies": {}, 456 | "views": {}, 457 | "_meta": { 458 | "columns": {}, 459 | "schemas": {}, 460 | "tables": {} 461 | } 462 | } -------------------------------------------------------------------------------- /drizzle/meta/0003_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "6259a83d-f2de-4e67-9308-78fe172fdab8", 3 | "prevId": "20d9a84f-646e-4c49-b1ac-915a812d27e8", 4 | "version": "7", 5 | "dialect": "postgresql", 6 | "tables": { 7 | "public.account": { 8 | "name": "account", 9 | "schema": "", 10 | "columns": { 11 | "id": { 12 | "name": "id", 13 | "type": "text", 14 | "primaryKey": true, 15 | "notNull": true 16 | }, 17 | "accountId": { 18 | "name": "accountId", 19 | "type": "text", 20 | "primaryKey": false, 21 | "notNull": true 22 | }, 23 | "providerId": { 24 | "name": "providerId", 25 | "type": "text", 26 | "primaryKey": false, 27 | "notNull": true 28 | }, 29 | "userId": { 30 | "name": "userId", 31 | "type": "text", 32 | "primaryKey": false, 33 | "notNull": true 34 | }, 35 | "accessToken": { 36 | "name": "accessToken", 37 | "type": "text", 38 | "primaryKey": false, 39 | "notNull": false 40 | }, 41 | "refreshToken": { 42 | "name": "refreshToken", 43 | "type": "text", 44 | "primaryKey": false, 45 | "notNull": false 46 | }, 47 | "idToken": { 48 | "name": "idToken", 49 | "type": "text", 50 | "primaryKey": false, 51 | "notNull": false 52 | }, 53 | "accessTokenExpiresAt": { 54 | "name": "accessTokenExpiresAt", 55 | "type": "timestamp", 56 | "primaryKey": false, 57 | "notNull": false 58 | }, 59 | "refreshTokenExpiresAt": { 60 | "name": "refreshTokenExpiresAt", 61 | "type": "timestamp", 62 | "primaryKey": false, 63 | "notNull": false 64 | }, 65 | "scope": { 66 | "name": "scope", 67 | "type": "text", 68 | "primaryKey": false, 69 | "notNull": false 70 | }, 71 | "password": { 72 | "name": "password", 73 | "type": "text", 74 | "primaryKey": false, 75 | "notNull": false 76 | }, 77 | "createdAt": { 78 | "name": "createdAt", 79 | "type": "timestamp", 80 | "primaryKey": false, 81 | "notNull": true 82 | }, 83 | "updatedAt": { 84 | "name": "updatedAt", 85 | "type": "timestamp", 86 | "primaryKey": false, 87 | "notNull": true 88 | } 89 | }, 90 | "indexes": {}, 91 | "foreignKeys": { 92 | "account_userId_user_id_fk": { 93 | "name": "account_userId_user_id_fk", 94 | "tableFrom": "account", 95 | "tableTo": "user", 96 | "columnsFrom": [ 97 | "userId" 98 | ], 99 | "columnsTo": [ 100 | "id" 101 | ], 102 | "onDelete": "cascade", 103 | "onUpdate": "no action" 104 | } 105 | }, 106 | "compositePrimaryKeys": {}, 107 | "uniqueConstraints": {}, 108 | "policies": {}, 109 | "checkConstraints": {}, 110 | "isRLSEnabled": false 111 | }, 112 | "public.gameResults": { 113 | "name": "gameResults", 114 | "schema": "", 115 | "columns": { 116 | "id": { 117 | "name": "id", 118 | "type": "text", 119 | "primaryKey": true, 120 | "notNull": true 121 | }, 122 | "userId": { 123 | "name": "userId", 124 | "type": "text", 125 | "primaryKey": false, 126 | "notNull": false 127 | }, 128 | "wpm": { 129 | "name": "wpm", 130 | "type": "integer", 131 | "primaryKey": false, 132 | "notNull": true 133 | }, 134 | "accuracy": { 135 | "name": "accuracy", 136 | "type": "integer", 137 | "primaryKey": false, 138 | "notNull": true 139 | }, 140 | "duration": { 141 | "name": "duration", 142 | "type": "integer", 143 | "primaryKey": false, 144 | "notNull": true 145 | }, 146 | "textExcerpt": { 147 | "name": "textExcerpt", 148 | "type": "text", 149 | "primaryKey": false, 150 | "notNull": true 151 | }, 152 | "createdAt": { 153 | "name": "createdAt", 154 | "type": "timestamp", 155 | "primaryKey": false, 156 | "notNull": true, 157 | "default": "now()" 158 | } 159 | }, 160 | "indexes": {}, 161 | "foreignKeys": { 162 | "gameResults_userId_user_id_fk": { 163 | "name": "gameResults_userId_user_id_fk", 164 | "tableFrom": "gameResults", 165 | "tableTo": "user", 166 | "columnsFrom": [ 167 | "userId" 168 | ], 169 | "columnsTo": [ 170 | "id" 171 | ], 172 | "onDelete": "cascade", 173 | "onUpdate": "no action" 174 | } 175 | }, 176 | "compositePrimaryKeys": {}, 177 | "uniqueConstraints": {}, 178 | "policies": {}, 179 | "checkConstraints": { 180 | "wpm_check": { 181 | "name": "wpm_check", 182 | "value": "\"gameResults\".\"wpm\" >= 0 AND \"gameResults\".\"wpm\" <= 350" 183 | }, 184 | "accuracy_check": { 185 | "name": "accuracy_check", 186 | "value": "\"gameResults\".\"accuracy\" >= 0 AND \"gameResults\".\"accuracy\" <= 100" 187 | }, 188 | "duration_check": { 189 | "name": "duration_check", 190 | "value": "\"gameResults\".\"duration\" >= 0 AND \"gameResults\".\"duration\" <= 300" 191 | } 192 | }, 193 | "isRLSEnabled": false 194 | }, 195 | "public.session": { 196 | "name": "session", 197 | "schema": "", 198 | "columns": { 199 | "id": { 200 | "name": "id", 201 | "type": "text", 202 | "primaryKey": true, 203 | "notNull": true 204 | }, 205 | "expiresAt": { 206 | "name": "expiresAt", 207 | "type": "timestamp", 208 | "primaryKey": false, 209 | "notNull": true 210 | }, 211 | "token": { 212 | "name": "token", 213 | "type": "text", 214 | "primaryKey": false, 215 | "notNull": true 216 | }, 217 | "createdAt": { 218 | "name": "createdAt", 219 | "type": "timestamp", 220 | "primaryKey": false, 221 | "notNull": true 222 | }, 223 | "updatedAt": { 224 | "name": "updatedAt", 225 | "type": "timestamp", 226 | "primaryKey": false, 227 | "notNull": true 228 | }, 229 | "ipAddress": { 230 | "name": "ipAddress", 231 | "type": "text", 232 | "primaryKey": false, 233 | "notNull": false 234 | }, 235 | "userAgent": { 236 | "name": "userAgent", 237 | "type": "text", 238 | "primaryKey": false, 239 | "notNull": false 240 | }, 241 | "userId": { 242 | "name": "userId", 243 | "type": "text", 244 | "primaryKey": false, 245 | "notNull": true 246 | } 247 | }, 248 | "indexes": {}, 249 | "foreignKeys": { 250 | "session_userId_user_id_fk": { 251 | "name": "session_userId_user_id_fk", 252 | "tableFrom": "session", 253 | "tableTo": "user", 254 | "columnsFrom": [ 255 | "userId" 256 | ], 257 | "columnsTo": [ 258 | "id" 259 | ], 260 | "onDelete": "cascade", 261 | "onUpdate": "no action" 262 | } 263 | }, 264 | "compositePrimaryKeys": {}, 265 | "uniqueConstraints": { 266 | "session_token_unique": { 267 | "name": "session_token_unique", 268 | "nullsNotDistinct": false, 269 | "columns": [ 270 | "token" 271 | ] 272 | } 273 | }, 274 | "policies": {}, 275 | "checkConstraints": {}, 276 | "isRLSEnabled": false 277 | }, 278 | "public.shareableResults": { 279 | "name": "shareableResults", 280 | "schema": "", 281 | "columns": { 282 | "id": { 283 | "name": "id", 284 | "type": "text", 285 | "primaryKey": true, 286 | "notNull": true 287 | }, 288 | "shortId": { 289 | "name": "shortId", 290 | "type": "text", 291 | "primaryKey": false, 292 | "notNull": true 293 | }, 294 | "gameResultId": { 295 | "name": "gameResultId", 296 | "type": "text", 297 | "primaryKey": false, 298 | "notNull": true 299 | }, 300 | "createdAt": { 301 | "name": "createdAt", 302 | "type": "timestamp", 303 | "primaryKey": false, 304 | "notNull": true, 305 | "default": "now()" 306 | } 307 | }, 308 | "indexes": {}, 309 | "foreignKeys": { 310 | "shareableResults_gameResultId_gameResults_id_fk": { 311 | "name": "shareableResults_gameResultId_gameResults_id_fk", 312 | "tableFrom": "shareableResults", 313 | "tableTo": "gameResults", 314 | "columnsFrom": [ 315 | "gameResultId" 316 | ], 317 | "columnsTo": [ 318 | "id" 319 | ], 320 | "onDelete": "cascade", 321 | "onUpdate": "no action" 322 | } 323 | }, 324 | "compositePrimaryKeys": {}, 325 | "uniqueConstraints": { 326 | "shareableResults_shortId_unique": { 327 | "name": "shareableResults_shortId_unique", 328 | "nullsNotDistinct": false, 329 | "columns": [ 330 | "shortId" 331 | ] 332 | } 333 | }, 334 | "policies": {}, 335 | "checkConstraints": {}, 336 | "isRLSEnabled": false 337 | }, 338 | "public.user": { 339 | "name": "user", 340 | "schema": "", 341 | "columns": { 342 | "id": { 343 | "name": "id", 344 | "type": "text", 345 | "primaryKey": true, 346 | "notNull": true 347 | }, 348 | "name": { 349 | "name": "name", 350 | "type": "text", 351 | "primaryKey": false, 352 | "notNull": true 353 | }, 354 | "email": { 355 | "name": "email", 356 | "type": "text", 357 | "primaryKey": false, 358 | "notNull": true 359 | }, 360 | "emailVerified": { 361 | "name": "emailVerified", 362 | "type": "boolean", 363 | "primaryKey": false, 364 | "notNull": true 365 | }, 366 | "image": { 367 | "name": "image", 368 | "type": "text", 369 | "primaryKey": false, 370 | "notNull": false 371 | }, 372 | "createdAt": { 373 | "name": "createdAt", 374 | "type": "timestamp", 375 | "primaryKey": false, 376 | "notNull": true 377 | }, 378 | "updatedAt": { 379 | "name": "updatedAt", 380 | "type": "timestamp", 381 | "primaryKey": false, 382 | "notNull": true 383 | } 384 | }, 385 | "indexes": {}, 386 | "foreignKeys": {}, 387 | "compositePrimaryKeys": {}, 388 | "uniqueConstraints": { 389 | "user_email_unique": { 390 | "name": "user_email_unique", 391 | "nullsNotDistinct": false, 392 | "columns": [ 393 | "email" 394 | ] 395 | } 396 | }, 397 | "policies": {}, 398 | "checkConstraints": {}, 399 | "isRLSEnabled": false 400 | }, 401 | "public.verification": { 402 | "name": "verification", 403 | "schema": "", 404 | "columns": { 405 | "id": { 406 | "name": "id", 407 | "type": "text", 408 | "primaryKey": true, 409 | "notNull": true 410 | }, 411 | "identifier": { 412 | "name": "identifier", 413 | "type": "text", 414 | "primaryKey": false, 415 | "notNull": true 416 | }, 417 | "value": { 418 | "name": "value", 419 | "type": "text", 420 | "primaryKey": false, 421 | "notNull": true 422 | }, 423 | "expiresAt": { 424 | "name": "expiresAt", 425 | "type": "timestamp", 426 | "primaryKey": false, 427 | "notNull": true 428 | }, 429 | "createdAt": { 430 | "name": "createdAt", 431 | "type": "timestamp", 432 | "primaryKey": false, 433 | "notNull": false 434 | }, 435 | "updatedAt": { 436 | "name": "updatedAt", 437 | "type": "timestamp", 438 | "primaryKey": false, 439 | "notNull": false 440 | } 441 | }, 442 | "indexes": {}, 443 | "foreignKeys": {}, 444 | "compositePrimaryKeys": {}, 445 | "uniqueConstraints": {}, 446 | "policies": {}, 447 | "checkConstraints": {}, 448 | "isRLSEnabled": false 449 | } 450 | }, 451 | "enums": {}, 452 | "schemas": {}, 453 | "sequences": {}, 454 | "roles": {}, 455 | "policies": {}, 456 | "views": {}, 457 | "_meta": { 458 | "columns": {}, 459 | "schemas": {}, 460 | "tables": {} 461 | } 462 | } -------------------------------------------------------------------------------- /drizzle/meta/0004_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "dedfe2c7-0a0f-47ca-a32d-8eade556b96a", 3 | "prevId": "6259a83d-f2de-4e67-9308-78fe172fdab8", 4 | "version": "7", 5 | "dialect": "postgresql", 6 | "tables": { 7 | "public.account": { 8 | "name": "account", 9 | "schema": "", 10 | "columns": { 11 | "id": { 12 | "name": "id", 13 | "type": "text", 14 | "primaryKey": true, 15 | "notNull": true 16 | }, 17 | "accountId": { 18 | "name": "accountId", 19 | "type": "text", 20 | "primaryKey": false, 21 | "notNull": true 22 | }, 23 | "providerId": { 24 | "name": "providerId", 25 | "type": "text", 26 | "primaryKey": false, 27 | "notNull": true 28 | }, 29 | "userId": { 30 | "name": "userId", 31 | "type": "text", 32 | "primaryKey": false, 33 | "notNull": true 34 | }, 35 | "accessToken": { 36 | "name": "accessToken", 37 | "type": "text", 38 | "primaryKey": false, 39 | "notNull": false 40 | }, 41 | "refreshToken": { 42 | "name": "refreshToken", 43 | "type": "text", 44 | "primaryKey": false, 45 | "notNull": false 46 | }, 47 | "idToken": { 48 | "name": "idToken", 49 | "type": "text", 50 | "primaryKey": false, 51 | "notNull": false 52 | }, 53 | "accessTokenExpiresAt": { 54 | "name": "accessTokenExpiresAt", 55 | "type": "timestamp", 56 | "primaryKey": false, 57 | "notNull": false 58 | }, 59 | "refreshTokenExpiresAt": { 60 | "name": "refreshTokenExpiresAt", 61 | "type": "timestamp", 62 | "primaryKey": false, 63 | "notNull": false 64 | }, 65 | "scope": { 66 | "name": "scope", 67 | "type": "text", 68 | "primaryKey": false, 69 | "notNull": false 70 | }, 71 | "password": { 72 | "name": "password", 73 | "type": "text", 74 | "primaryKey": false, 75 | "notNull": false 76 | }, 77 | "createdAt": { 78 | "name": "createdAt", 79 | "type": "timestamp", 80 | "primaryKey": false, 81 | "notNull": true 82 | }, 83 | "updatedAt": { 84 | "name": "updatedAt", 85 | "type": "timestamp", 86 | "primaryKey": false, 87 | "notNull": true 88 | } 89 | }, 90 | "indexes": {}, 91 | "foreignKeys": { 92 | "account_userId_user_id_fk": { 93 | "name": "account_userId_user_id_fk", 94 | "tableFrom": "account", 95 | "tableTo": "user", 96 | "columnsFrom": [ 97 | "userId" 98 | ], 99 | "columnsTo": [ 100 | "id" 101 | ], 102 | "onDelete": "cascade", 103 | "onUpdate": "no action" 104 | } 105 | }, 106 | "compositePrimaryKeys": {}, 107 | "uniqueConstraints": {}, 108 | "policies": {}, 109 | "checkConstraints": {}, 110 | "isRLSEnabled": false 111 | }, 112 | "public.gameResults": { 113 | "name": "gameResults", 114 | "schema": "", 115 | "columns": { 116 | "id": { 117 | "name": "id", 118 | "type": "text", 119 | "primaryKey": true, 120 | "notNull": true 121 | }, 122 | "userId": { 123 | "name": "userId", 124 | "type": "text", 125 | "primaryKey": false, 126 | "notNull": false 127 | }, 128 | "wpm": { 129 | "name": "wpm", 130 | "type": "integer", 131 | "primaryKey": false, 132 | "notNull": true 133 | }, 134 | "accuracy": { 135 | "name": "accuracy", 136 | "type": "integer", 137 | "primaryKey": false, 138 | "notNull": true 139 | }, 140 | "duration": { 141 | "name": "duration", 142 | "type": "integer", 143 | "primaryKey": false, 144 | "notNull": true 145 | }, 146 | "textExcerpt": { 147 | "name": "textExcerpt", 148 | "type": "text", 149 | "primaryKey": false, 150 | "notNull": true 151 | }, 152 | "wpmHistory": { 153 | "name": "wpmHistory", 154 | "type": "jsonb", 155 | "primaryKey": false, 156 | "notNull": false 157 | }, 158 | "createdAt": { 159 | "name": "createdAt", 160 | "type": "timestamp", 161 | "primaryKey": false, 162 | "notNull": true, 163 | "default": "now()" 164 | } 165 | }, 166 | "indexes": {}, 167 | "foreignKeys": { 168 | "gameResults_userId_user_id_fk": { 169 | "name": "gameResults_userId_user_id_fk", 170 | "tableFrom": "gameResults", 171 | "tableTo": "user", 172 | "columnsFrom": [ 173 | "userId" 174 | ], 175 | "columnsTo": [ 176 | "id" 177 | ], 178 | "onDelete": "cascade", 179 | "onUpdate": "no action" 180 | } 181 | }, 182 | "compositePrimaryKeys": {}, 183 | "uniqueConstraints": {}, 184 | "policies": {}, 185 | "checkConstraints": { 186 | "wpm_check": { 187 | "name": "wpm_check", 188 | "value": "\"gameResults\".\"wpm\" >= 0 AND \"gameResults\".\"wpm\" <= 350" 189 | }, 190 | "accuracy_check": { 191 | "name": "accuracy_check", 192 | "value": "\"gameResults\".\"accuracy\" >= 0 AND \"gameResults\".\"accuracy\" <= 100" 193 | }, 194 | "duration_check": { 195 | "name": "duration_check", 196 | "value": "\"gameResults\".\"duration\" >= 0 AND \"gameResults\".\"duration\" <= 300" 197 | } 198 | }, 199 | "isRLSEnabled": false 200 | }, 201 | "public.session": { 202 | "name": "session", 203 | "schema": "", 204 | "columns": { 205 | "id": { 206 | "name": "id", 207 | "type": "text", 208 | "primaryKey": true, 209 | "notNull": true 210 | }, 211 | "expiresAt": { 212 | "name": "expiresAt", 213 | "type": "timestamp", 214 | "primaryKey": false, 215 | "notNull": true 216 | }, 217 | "token": { 218 | "name": "token", 219 | "type": "text", 220 | "primaryKey": false, 221 | "notNull": true 222 | }, 223 | "createdAt": { 224 | "name": "createdAt", 225 | "type": "timestamp", 226 | "primaryKey": false, 227 | "notNull": true 228 | }, 229 | "updatedAt": { 230 | "name": "updatedAt", 231 | "type": "timestamp", 232 | "primaryKey": false, 233 | "notNull": true 234 | }, 235 | "ipAddress": { 236 | "name": "ipAddress", 237 | "type": "text", 238 | "primaryKey": false, 239 | "notNull": false 240 | }, 241 | "userAgent": { 242 | "name": "userAgent", 243 | "type": "text", 244 | "primaryKey": false, 245 | "notNull": false 246 | }, 247 | "userId": { 248 | "name": "userId", 249 | "type": "text", 250 | "primaryKey": false, 251 | "notNull": true 252 | } 253 | }, 254 | "indexes": {}, 255 | "foreignKeys": { 256 | "session_userId_user_id_fk": { 257 | "name": "session_userId_user_id_fk", 258 | "tableFrom": "session", 259 | "tableTo": "user", 260 | "columnsFrom": [ 261 | "userId" 262 | ], 263 | "columnsTo": [ 264 | "id" 265 | ], 266 | "onDelete": "cascade", 267 | "onUpdate": "no action" 268 | } 269 | }, 270 | "compositePrimaryKeys": {}, 271 | "uniqueConstraints": { 272 | "session_token_unique": { 273 | "name": "session_token_unique", 274 | "nullsNotDistinct": false, 275 | "columns": [ 276 | "token" 277 | ] 278 | } 279 | }, 280 | "policies": {}, 281 | "checkConstraints": {}, 282 | "isRLSEnabled": false 283 | }, 284 | "public.shareableResults": { 285 | "name": "shareableResults", 286 | "schema": "", 287 | "columns": { 288 | "id": { 289 | "name": "id", 290 | "type": "text", 291 | "primaryKey": true, 292 | "notNull": true 293 | }, 294 | "shortId": { 295 | "name": "shortId", 296 | "type": "text", 297 | "primaryKey": false, 298 | "notNull": true 299 | }, 300 | "gameResultId": { 301 | "name": "gameResultId", 302 | "type": "text", 303 | "primaryKey": false, 304 | "notNull": true 305 | }, 306 | "createdAt": { 307 | "name": "createdAt", 308 | "type": "timestamp", 309 | "primaryKey": false, 310 | "notNull": true, 311 | "default": "now()" 312 | } 313 | }, 314 | "indexes": {}, 315 | "foreignKeys": { 316 | "shareableResults_gameResultId_gameResults_id_fk": { 317 | "name": "shareableResults_gameResultId_gameResults_id_fk", 318 | "tableFrom": "shareableResults", 319 | "tableTo": "gameResults", 320 | "columnsFrom": [ 321 | "gameResultId" 322 | ], 323 | "columnsTo": [ 324 | "id" 325 | ], 326 | "onDelete": "cascade", 327 | "onUpdate": "no action" 328 | } 329 | }, 330 | "compositePrimaryKeys": {}, 331 | "uniqueConstraints": { 332 | "shareableResults_shortId_unique": { 333 | "name": "shareableResults_shortId_unique", 334 | "nullsNotDistinct": false, 335 | "columns": [ 336 | "shortId" 337 | ] 338 | } 339 | }, 340 | "policies": {}, 341 | "checkConstraints": {}, 342 | "isRLSEnabled": false 343 | }, 344 | "public.user": { 345 | "name": "user", 346 | "schema": "", 347 | "columns": { 348 | "id": { 349 | "name": "id", 350 | "type": "text", 351 | "primaryKey": true, 352 | "notNull": true 353 | }, 354 | "name": { 355 | "name": "name", 356 | "type": "text", 357 | "primaryKey": false, 358 | "notNull": true 359 | }, 360 | "email": { 361 | "name": "email", 362 | "type": "text", 363 | "primaryKey": false, 364 | "notNull": true 365 | }, 366 | "emailVerified": { 367 | "name": "emailVerified", 368 | "type": "boolean", 369 | "primaryKey": false, 370 | "notNull": true 371 | }, 372 | "image": { 373 | "name": "image", 374 | "type": "text", 375 | "primaryKey": false, 376 | "notNull": false 377 | }, 378 | "createdAt": { 379 | "name": "createdAt", 380 | "type": "timestamp", 381 | "primaryKey": false, 382 | "notNull": true 383 | }, 384 | "updatedAt": { 385 | "name": "updatedAt", 386 | "type": "timestamp", 387 | "primaryKey": false, 388 | "notNull": true 389 | } 390 | }, 391 | "indexes": {}, 392 | "foreignKeys": {}, 393 | "compositePrimaryKeys": {}, 394 | "uniqueConstraints": { 395 | "user_email_unique": { 396 | "name": "user_email_unique", 397 | "nullsNotDistinct": false, 398 | "columns": [ 399 | "email" 400 | ] 401 | } 402 | }, 403 | "policies": {}, 404 | "checkConstraints": {}, 405 | "isRLSEnabled": false 406 | }, 407 | "public.verification": { 408 | "name": "verification", 409 | "schema": "", 410 | "columns": { 411 | "id": { 412 | "name": "id", 413 | "type": "text", 414 | "primaryKey": true, 415 | "notNull": true 416 | }, 417 | "identifier": { 418 | "name": "identifier", 419 | "type": "text", 420 | "primaryKey": false, 421 | "notNull": true 422 | }, 423 | "value": { 424 | "name": "value", 425 | "type": "text", 426 | "primaryKey": false, 427 | "notNull": true 428 | }, 429 | "expiresAt": { 430 | "name": "expiresAt", 431 | "type": "timestamp", 432 | "primaryKey": false, 433 | "notNull": true 434 | }, 435 | "createdAt": { 436 | "name": "createdAt", 437 | "type": "timestamp", 438 | "primaryKey": false, 439 | "notNull": false 440 | }, 441 | "updatedAt": { 442 | "name": "updatedAt", 443 | "type": "timestamp", 444 | "primaryKey": false, 445 | "notNull": false 446 | } 447 | }, 448 | "indexes": {}, 449 | "foreignKeys": {}, 450 | "compositePrimaryKeys": {}, 451 | "uniqueConstraints": {}, 452 | "policies": {}, 453 | "checkConstraints": {}, 454 | "isRLSEnabled": false 455 | } 456 | }, 457 | "enums": {}, 458 | "schemas": {}, 459 | "sequences": {}, 460 | "roles": {}, 461 | "policies": {}, 462 | "views": {}, 463 | "_meta": { 464 | "columns": {}, 465 | "schemas": {}, 466 | "tables": {} 467 | } 468 | } -------------------------------------------------------------------------------- /components/typing-game.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState, useEffect, useLayoutEffect, useCallback, useRef } from "react"; 4 | import { getRandomExcerpt } from "@/lib/excerpts"; 5 | import { copy } from "clipboard"; 6 | import { nanoid } from "nanoid"; 7 | import { toast } from "sonner"; 8 | import { shareGameResult } from "@/app/actions/share"; 9 | import { useKeyboardSounds } from "@/lib/use-keyboard-sounds"; 10 | import { Volume2, VolumeX, Medal, Flag } from "lucide-react"; 11 | import Link from "next/link"; 12 | 13 | interface GameState { 14 | text: string; 15 | userInput: string; 16 | startTime: number | null; 17 | timer: number; 18 | isGameActive: boolean; 19 | isGameFinished: boolean; 20 | finalWPM: number; 21 | finalAccuracy: number; 22 | } 23 | 24 | interface GameResult { 25 | wpm: number; 26 | accuracy: number; 27 | duration: number; 28 | wpmHistory?: Array<{ time: number; wpm: number }>; 29 | } 30 | 31 | interface TypingGameProps { 32 | onGameFinish?: (result: GameResult) => void; 33 | } 34 | 35 | export function TypingGame({ onGameFinish }: TypingGameProps) { 36 | const [state, setState] = useState({ 37 | text: "", // Initialize with empty string to avoid hydration mismatch 38 | userInput: "", 39 | startTime: null, 40 | timer: 30, 41 | isGameActive: false, 42 | isGameFinished: false, 43 | finalWPM: 0, 44 | finalAccuracy: 0, 45 | }); 46 | 47 | const inputRef = useRef(null); 48 | const textContainerRef = useRef(null); 49 | const [cursorPosition, setCursorPosition] = useState<{ left: number | string; top: number }>({ left: "-2", top: 2 }); 50 | const [isCursorMoving, setIsCursorMoving] = useState(false); 51 | const cursorMoveTimeoutRef = useRef(null); 52 | const [currentWPM, setCurrentWPM] = useState(0); 53 | const [wpmHistory, setWpmHistory] = useState>([]); 54 | 55 | // Race mode 56 | const [raceModeEnabled, setRaceModeEnabled] = useState(false); 57 | const [topEntry, setTopEntry] = useState<{ wpm: number; playerName: string | null } | null>(null); 58 | const [ghostCursorPosition, setGhostCursorPosition] = useState<{ left: number | string; top: number }>({ left: "-2", top: 2 }); 59 | 60 | // Keyboard sounds 61 | const { playPressSound, playReleaseSound, enabled: soundEnabled, toggleSound } = useKeyboardSounds({ initialEnabled: true, volume: 0.9 }); 62 | 63 | // Helper function to calculate correct characters 64 | const getCorrectChars = useCallback((userInput: string, text: string): number => { 65 | return userInput 66 | .split("") 67 | .filter((char, index) => char === text[index]).length; 68 | }, []); 69 | 70 | useEffect(() => { 71 | // Set random excerpt only on client side to avoid hydration mismatch 72 | setState((prev) => ({ 73 | ...prev, 74 | text: getRandomExcerpt(), 75 | })); 76 | // Ensure input is focused on mount 77 | inputRef.current?.focus(); 78 | }, []); 79 | 80 | // Fetch top leaderboard entry when race mode is enabled 81 | useEffect(() => { 82 | if (raceModeEnabled) { 83 | fetch("/api/leaderboard/top") 84 | .then((res) => res.json()) 85 | .then((data) => { 86 | setTopEntry(data); 87 | }) 88 | .catch((error) => { 89 | console.error("Error fetching top entry:", error); 90 | }); 91 | } 92 | }, [raceModeEnabled]); 93 | 94 | // Track cursor movement state 95 | useEffect(() => { 96 | // Cursor is moving 97 | setIsCursorMoving(true); 98 | 99 | // Clear existing timeout 100 | if (cursorMoveTimeoutRef.current) { 101 | clearTimeout(cursorMoveTimeoutRef.current); 102 | } 103 | 104 | // Set cursor to stopped after 150ms of no movement 105 | cursorMoveTimeoutRef.current = setTimeout(() => { 106 | setIsCursorMoving(false); 107 | }, 150); 108 | 109 | return () => { 110 | if (cursorMoveTimeoutRef.current) { 111 | clearTimeout(cursorMoveTimeoutRef.current); 112 | } 113 | }; 114 | }, [cursorPosition]); 115 | 116 | // Update cursor position when userInput changes 117 | // useLayoutEffect is appropriate here because we're measuring DOM layout 118 | // and need to update synchronously to prevent visual flickering 119 | useLayoutEffect(() => { 120 | if (!textContainerRef.current) return; 121 | 122 | const container = textContainerRef.current; 123 | const spans = container.querySelectorAll('span[data-char]'); 124 | 125 | if (spans.length === 0) return; 126 | 127 | const currentIndex = state.userInput.length; 128 | 129 | if (currentIndex === 0) { 130 | // Cursor at the beginning 131 | setCursorPosition({ left: "-2", top: 0 }); 132 | } else if (currentIndex < spans.length) { 133 | // Position cursor at the current character 134 | const targetSpan = spans[currentIndex] as HTMLElement; 135 | const rect = targetSpan.getBoundingClientRect(); 136 | const containerRect = container.getBoundingClientRect(); 137 | 138 | setCursorPosition({ 139 | left: rect.left - containerRect.left, 140 | top: rect.top - containerRect.top, 141 | }); 142 | } else { 143 | // Cursor at the end 144 | const lastSpan = spans[spans.length - 1] as HTMLElement; 145 | const rect = lastSpan.getBoundingClientRect(); 146 | const containerRect = container.getBoundingClientRect(); 147 | 148 | setCursorPosition({ 149 | left: rect.right - containerRect.left, 150 | top: rect.top - containerRect.top, 151 | }); 152 | } 153 | }, [state.userInput, state.text]); 154 | 155 | // Update ghost cursor position (similar to regular cursor) 156 | useLayoutEffect(() => { 157 | if (!textContainerRef.current || !raceModeEnabled || !topEntry) return; 158 | 159 | const container = textContainerRef.current; 160 | const spans = container.querySelectorAll('span[data-char]'); 161 | 162 | if (spans.length === 0) return; 163 | 164 | // Ghost cursor tracks a virtual "input" at the speed of top entry 165 | // We'll update this based on game time 166 | const updateGhostCursor = () => { 167 | if (!state.isGameActive || !state.startTime) return; 168 | 169 | const elapsedMs = Date.now() - state.startTime; 170 | const wpm = topEntry.wpm; 171 | // Characters per second = (wpm * 5) / 60 172 | const charsPerSecond = (wpm * 5) / 60; 173 | const charactersTyped = Math.floor(elapsedMs / 1000 * charsPerSecond); 174 | 175 | if (charactersTyped === 0) { 176 | setGhostCursorPosition({ left: "-2", top: 0 }); 177 | } else if (charactersTyped < spans.length) { 178 | const targetSpan = spans[charactersTyped] as HTMLElement; 179 | const rect = targetSpan.getBoundingClientRect(); 180 | const containerRect = container.getBoundingClientRect(); 181 | 182 | setGhostCursorPosition({ 183 | left: rect.left - containerRect.left, 184 | top: rect.top - containerRect.top, 185 | }); 186 | } else { 187 | const lastSpan = spans[spans.length - 1] as HTMLElement; 188 | const rect = lastSpan.getBoundingClientRect(); 189 | const containerRect = container.getBoundingClientRect(); 190 | 191 | setGhostCursorPosition({ 192 | left: rect.right - containerRect.left, 193 | top: rect.top - containerRect.top, 194 | }); 195 | } 196 | }; 197 | 198 | const interval = setInterval(updateGhostCursor, 50); 199 | updateGhostCursor(); // Initial call 200 | 201 | return () => clearInterval(interval); 202 | }, [state.isGameActive, state.startTime, state.text, raceModeEnabled, topEntry]); 203 | 204 | const handleInput = useCallback( 205 | (e: React.ChangeEvent) => { 206 | const value = e.target.value; 207 | console.log("Input changed:", value); 208 | 209 | setState((prev) => { 210 | const isFirstChar = prev.userInput === "" && value !== ""; 211 | console.log("Is first char:", isFirstChar, "Game active:", prev.isGameActive); 212 | 213 | if (isFirstChar && !prev.isGameActive) { 214 | console.log("Starting game!"); 215 | return { 216 | ...prev, 217 | isGameActive: true, 218 | startTime: Date.now(), 219 | userInput: value, 220 | }; 221 | } 222 | 223 | return { 224 | ...prev, 225 | userInput: value, 226 | }; 227 | }); 228 | }, 229 | [] 230 | ); 231 | 232 | useEffect(() => { 233 | let interval: NodeJS.Timeout | undefined; 234 | if (state.isGameActive && state.timer > 0) { 235 | interval = setInterval(() => { 236 | setState((prev) => ({ 237 | ...prev, 238 | timer: prev.timer - 1, 239 | })); 240 | }, 1000); 241 | } 242 | return () => { 243 | if (interval) clearInterval(interval); 244 | }; 245 | }, [state.isGameActive, state.timer]); 246 | 247 | // Calculate WPM continuously during gameplay and store history 248 | useEffect(() => { 249 | if (state.isGameActive && state.startTime) { 250 | const calculateWPM = () => { 251 | const elapsedSeconds = (Date.now() - state.startTime!) / 1000; 252 | const elapsedMinutes = elapsedSeconds / 60; 253 | if (elapsedMinutes > 0) { 254 | const correctChars = getCorrectChars(state.userInput, state.text); 255 | const wpm = Math.min(Math.round((correctChars / 5) / elapsedMinutes), 999); 256 | setCurrentWPM(wpm); 257 | 258 | // Store WPM history (round time to nearest second) 259 | const timeSeconds = Math.floor(elapsedSeconds); 260 | setWpmHistory((prev) => { 261 | // Only add if this second hasn't been recorded yet, or update the latest entry for the same second 262 | const lastEntry = prev[prev.length - 1]; 263 | if (lastEntry && lastEntry.time === timeSeconds) { 264 | // Update the latest entry 265 | return [...prev.slice(0, -1), { time: timeSeconds, wpm }]; 266 | } else { 267 | // Add new entry 268 | return [...prev, { time: timeSeconds, wpm }]; 269 | } 270 | }); 271 | } 272 | }; 273 | 274 | const interval = setInterval(calculateWPM, 100); // Update more frequently for smoother tracking 275 | calculateWPM(); // Calculate immediately 276 | 277 | return () => clearInterval(interval); 278 | } else if (!state.isGameActive) { 279 | // Reset history when game is not active 280 | setWpmHistory([]); 281 | } 282 | }, [state.isGameActive, state.startTime, state.userInput, state.text, getCorrectChars]); 283 | 284 | useEffect(() => { 285 | if (state.timer === 0 && state.isGameActive && !state.isGameFinished) { 286 | const calculateResults = () => { 287 | const endTime = Date.now(); 288 | const duration = Math.floor((endTime - (state.startTime || endTime)) / 1000); 289 | const typedChars = state.userInput.length; 290 | const correctChars = getCorrectChars(state.userInput, state.text); 291 | const accuracy = typedChars > 0 ? Math.round((correctChars / typedChars) * 100) : 0; 292 | const wpm = duration > 0 ? Math.min(Math.round((correctChars / 5) / (duration / 60)), 999) : 0; 293 | 294 | return { wpm, accuracy, duration, wpmHistory }; 295 | }; 296 | 297 | const results = calculateResults(); 298 | // Use setTimeout to avoid synchronous setState in effect 299 | setTimeout(() => { 300 | setState((prev) => ({ 301 | ...prev, 302 | isGameFinished: true, 303 | finalWPM: results.wpm, 304 | finalAccuracy: results.accuracy, 305 | })); 306 | onGameFinish?.(results); 307 | }, 0); 308 | } 309 | }, [state.timer, state.isGameActive, state.isGameFinished, state.userInput, state.text, state.startTime, onGameFinish, getCorrectChars, wpmHistory]); 310 | 311 | useEffect(() => { 312 | // Finish game when user completes the excerpt 313 | if (state.userInput.length === state.text.length && state.isGameActive && !state.isGameFinished) { 314 | const calculateResults = () => { 315 | const endTime = Date.now(); 316 | const duration = Math.floor((endTime - (state.startTime || endTime)) / 1000); 317 | const typedChars = state.userInput.length; 318 | const correctChars = getCorrectChars(state.userInput, state.text); 319 | const accuracy = typedChars > 0 ? Math.round((correctChars / typedChars) * 100) : 0; 320 | const wpm = duration > 0 ? Math.min(Math.round((correctChars / 5) / (duration / 60)), 999) : 0; 321 | 322 | return { wpm, accuracy, duration, wpmHistory }; 323 | }; 324 | 325 | const results = calculateResults(); 326 | setTimeout(() => { 327 | setState((prev) => ({ 328 | ...prev, 329 | isGameFinished: true, 330 | finalWPM: results.wpm, 331 | finalAccuracy: results.accuracy, 332 | })); 333 | onGameFinish?.(results); 334 | }, 0); 335 | } 336 | }, [state.userInput, state.text, state.isGameActive, state.isGameFinished, state.startTime, onGameFinish, getCorrectChars, wpmHistory]); 337 | 338 | const handleRestart = () => { 339 | setState({ 340 | text: getRandomExcerpt(), 341 | userInput: "", 342 | startTime: null, 343 | timer: 30, 344 | isGameActive: false, 345 | isGameFinished: false, 346 | finalWPM: 0, 347 | finalAccuracy: 0, 348 | }); 349 | setCurrentWPM(0); 350 | setWpmHistory([]); 351 | setGhostCursorPosition({ left: "-2", top: 2 }); 352 | inputRef.current?.focus(); 353 | }; 354 | 355 | const handleShare = async () => { 356 | const id = nanoid(8); 357 | 358 | // Optimistically copy to clipboard and show success 359 | const shareUrl = `${window.location.origin}/s/${id}`; 360 | await copy(shareUrl); 361 | toast.success("Link copied to clipboard!"); 362 | 363 | // Save to database in the background 364 | try { 365 | await shareGameResult({ 366 | shortId: id, 367 | wpm: state.finalWPM, 368 | accuracy: state.finalAccuracy, 369 | duration: 30 - state.timer, 370 | wpmHistory: wpmHistory.length > 0 ? wpmHistory : undefined, 371 | }); 372 | } catch { 373 | toast.error("Failed to save results"); 374 | } 375 | }; 376 | 377 | const handleKeyDown = (e: React.KeyboardEvent) => { 378 | if (e.key === "Tab") { 379 | e.preventDefault(); 380 | } 381 | if (e.key === "Escape") { 382 | e.preventDefault(); 383 | handleRestart(); 384 | return; 385 | } 386 | 387 | // Play press sound for printable characters, backspace, enter, and space 388 | // Only play on initial press, not on key repeat 389 | if (!e.repeat && (e.key.length === 1 || e.key === "Backspace" || e.key === "Enter")) { 390 | playPressSound(e.key); 391 | } 392 | }; 393 | 394 | const handleKeyUp = (e: React.KeyboardEvent) => { 395 | // Play release sound for printable characters, backspace, enter, and space 396 | if (e.key.length === 1 || e.key === "Backspace" || e.key === "Enter") { 397 | playReleaseSound(e.key); 398 | } 399 | }; 400 | 401 | const handleClick = () => { 402 | inputRef.current?.focus(); 403 | }; 404 | 405 | return ( 406 |
410 |
411 |
412 |
418 | {state.text.split("").map((char, index) => { 419 | const userChar = state.userInput[index]; 420 | let className = "text-muted-foreground/40"; // Less opacity on non-typed text 421 | 422 | if (userChar) { 423 | className = userChar === char ? "text-foreground" : "text-orange-500"; // Black for correct, orange for errors 424 | } 425 | 426 | return ( 427 | 428 | {char} 429 | 430 | ); 431 | })} 432 |
433 | 434 | {/* Animated cursor */} 435 |
447 | 448 | {/* Ghost cursor (race mode) */} 449 | {raceModeEnabled && topEntry && state.isGameActive && !state.isGameFinished && ( 450 |
458 | {/* Player name label above cursor */} 459 |
460 |
461 | {topEntry.playerName || "Anonymous"} 462 |
463 |
464 | {/* Purple cursor line */} 465 |
466 |
467 | )} 468 |
469 |
470 | 471 | 483 | 484 | {/* Timer/Share, WPM, and Restart grouped together - always rendered to reserve space */} 485 |
486 | {state.isGameFinished ? ( 487 | 494 | ) : ( 495 | {state.timer || 30} 496 | )} 497 | 498 | {state.isGameFinished ? state.finalWPM : currentWPM} WPM 499 | 500 | 507 |
508 | 509 | {/* Leaderboard button - bottom right */} 510 | 515 | 516 | 517 | 518 | {/* Race flag button - bottom right */} 519 | 534 | 535 | {/* Sound toggle button - bottom right */} 536 | 551 |
552 | ); 553 | } 554 | 555 | --------------------------------------------------------------------------------