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 |
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 | 
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 |
--------------------------------------------------------------------------------
|