diff --git a/package-lock.json b/package-lock.json index 743d4c5..8ba67af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,10 @@ "dependencies": { "@davisvaughan/tree-sitter-r": "^1.2.0", "@prisma/client": "^6.5.0", + "@tanstack/react-query": "^5.90.10", + "@trpc/client": "^11.7.1", + "@trpc/react-query": "^11.7.1", + "@trpc/server": "^11.7.1", "@types/umami-browser": "^2.18.0", "axios": "^1.8.4", "axios-cache-interceptor": "^1.7.0", @@ -38,6 +42,7 @@ "tree-sitter-lua": "2.1.3", "tree-sitter-python": "0.20.0", "tree-sitter-typescript": "0.23.2", + "zod": "^4.1.12", "zustand": "^5.0.8" }, "devDependencies": { @@ -2065,6 +2070,77 @@ "tailwindcss": "4.1.17" } }, + "node_modules/@tanstack/query-core": { + "version": "5.90.10", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.10.tgz", + "integrity": "sha512-EhZVFu9rl7GfRNuJLJ3Y7wtbTnENsvzp+YpcAV7kCYiXni1v8qZh++lpw4ch4rrwC0u/EZRnBHIehzCGzwXDSQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.90.10", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.10.tgz", + "integrity": "sha512-BKLss9Y8PQ9IUjPYQiv3/Zmlx92uxffUOX8ZZNoQlCIZBJPT5M+GOMQj7xislvVQ6l1BstBjcX0XB/aHfFYVNw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@tanstack/query-core": "5.90.10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@trpc/client": { + "version": "11.7.1", + "resolved": "https://registry.npmjs.org/@trpc/client/-/client-11.7.1.tgz", + "integrity": "sha512-uOnAjElKI892/U6aQMcBHYs3x7mme3Cvv1F87ytBL56rBvs7+DyK7r43zgaXKf13+GtPEI6ex5xjVUfyDW8XcQ==", + "funding": [ + "https://trpc.io/sponsor" + ], + "license": "MIT", + "peer": true, + "peerDependencies": { + "@trpc/server": "11.7.1", + "typescript": ">=5.7.2" + } + }, + "node_modules/@trpc/react-query": { + "version": "11.7.1", + "resolved": "https://registry.npmjs.org/@trpc/react-query/-/react-query-11.7.1.tgz", + "integrity": "sha512-dEHDjIqSTzO8nLlCbtiFBMBwhbSkK1QP7aYVo3nP3sYBna0b+iCtrPXdxVPCSopr9/aIqDTEh+dMRZa7yBgjfQ==", + "funding": [ + "https://trpc.io/sponsor" + ], + "license": "MIT", + "peerDependencies": { + "@tanstack/react-query": "^5.80.3", + "@trpc/client": "11.7.1", + "@trpc/server": "11.7.1", + "react": ">=18.2.0", + "react-dom": ">=18.2.0", + "typescript": ">=5.7.2" + } + }, + "node_modules/@trpc/server": { + "version": "11.7.1", + "resolved": "https://registry.npmjs.org/@trpc/server/-/server-11.7.1.tgz", + "integrity": "sha512-N3U8LNLIP4g9C7LJ/sLkjuPHwqlvE3bnspzC4DEFVdvx2+usbn70P80E3wj5cjOTLhmhRiwJCSXhlB+MHfGeCw==", + "funding": [ + "https://trpc.io/sponsor" + ], + "license": "MIT", + "peer": true, + "peerDependencies": { + "typescript": ">=5.7.2" + } + }, "node_modules/@types/d3-array": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", @@ -4258,7 +4334,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -8398,7 +8473,6 @@ "version": "5.8.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz", "integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==", - "devOptional": true, "license": "Apache-2.0", "peer": true, "bin": { @@ -8804,7 +8878,6 @@ "version": "4.1.12", "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", - "dev": true, "license": "MIT", "peer": true, "funding": { diff --git a/package.json b/package.json index e0bd155..425a5b0 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,10 @@ "dependencies": { "@davisvaughan/tree-sitter-r": "^1.2.0", "@prisma/client": "^6.5.0", + "@tanstack/react-query": "^5.90.10", + "@trpc/client": "^11.7.1", + "@trpc/react-query": "^11.7.1", + "@trpc/server": "^11.7.1", "@types/umami-browser": "^2.18.0", "axios": "^1.8.4", "axios-cache-interceptor": "^1.7.0", @@ -46,6 +50,7 @@ "tree-sitter-lua": "2.1.3", "tree-sitter-python": "0.20.0", "tree-sitter-typescript": "0.23.2", + "zod": "^4.1.12", "zustand": "^5.0.8" }, "devDependencies": { diff --git a/prisma/seed/files.ts b/prisma/seed/files.ts index f46d9e5..6782edc 100644 --- a/prisma/seed/files.ts +++ b/prisma/seed/files.ts @@ -1,4 +1,4 @@ -import {ILanguage} from "@/shared/types/language"; +import {ILanguage} from "@/features/shared/types/language"; import {PrismaClient} from "@prisma/client"; import {callGitHubApi} from "@lib/github"; import {SUPPORTED_LANGUAGES} from "./languages"; diff --git a/prisma/seed/languages.ts b/prisma/seed/languages.ts index db0d518..f15b289 100644 --- a/prisma/seed/languages.ts +++ b/prisma/seed/languages.ts @@ -1,4 +1,4 @@ -import {ILanguage} from "@/shared/types/language"; +import {ILanguage} from "@/features/shared/types/language"; import {PrismaClient} from "@prisma/client"; export const SUPPORTED_LANGUAGES: ILanguage[] = [ diff --git a/src/app/api/snippets/languages/route.ts b/src/app/api/snippets/languages/route.ts deleted file mode 100644 index 9fb67c3..0000000 --- a/src/app/api/snippets/languages/route.ts +++ /dev/null @@ -1,11 +0,0 @@ -import {rateLimit} from "@/core/middleware/rate-limiter"; -import {getLanguages} from "@/features/snippets/infrastructure/repositories/language.repository.server"; -import {NextRequest, NextResponse} from "next/server"; - -export async function GET(request: NextRequest) { - const resRateLimit = rateLimit(request); - if (resRateLimit) return resRateLimit; - - const languages = await getLanguages(); - return NextResponse.json(languages); -} diff --git a/src/app/api/snippets/random/route.ts b/src/app/api/snippets/random/route.ts deleted file mode 100644 index 42ff19b..0000000 --- a/src/app/api/snippets/random/route.ts +++ /dev/null @@ -1,32 +0,0 @@ -import {rateLimit} from "@/core/middleware/rate-limiter"; -import {doesLanguageExist} from "@/features/snippets/infrastructure/repositories/language.repository.server"; -import {getRandomSnippets} from "@/features/snippets/services/get-random-snippets.server"; -import {NextRequest, NextResponse} from "next/server"; - -export async function GET(request: NextRequest) { - const resRateLimit = rateLimit(request); - if (resRateLimit) return resRateLimit; - - const {searchParams} = new URL(request.url); - let languageId = searchParams.get("language"); - - if (!languageId) { - return NextResponse.json({error: "Language not provided"}, {status: 400}); - } - - languageId = languageId.toLowerCase(); - - // Check if language provided exists - const languageExists = await doesLanguageExist(languageId); - if (!languageExists) { - return NextResponse.json({error: "Language not found"}, {status: 404}); - } - - try { - const snippets = await getRandomSnippets(languageId); - return NextResponse.json(snippets); - } catch (error) { - console.error("Error fetching random snippets:", error); - return NextResponse.json({error: "Failed to fetch random snippets"}, {status: 500}); - } -} diff --git a/src/app/api/trpc/[trpc]/route.ts b/src/app/api/trpc/[trpc]/route.ts new file mode 100644 index 0000000..1db8009 --- /dev/null +++ b/src/app/api/trpc/[trpc]/route.ts @@ -0,0 +1,14 @@ +import {fetchRequestHandler} from "@trpc/server/adapters/fetch"; +import {NextRequest} from "next/server"; +import {appRouter} from "@/server/trpc/routers"; +import {createTRPCContext} from "@/server/trpc/context"; + +const handler = (req: NextRequest) => + fetchRequestHandler({ + endpoint: "/api/trpc", + req, + router: appRouter, + createContext: () => createTRPCContext({req}), + }); + +export {handler as GET, handler as POST}; diff --git a/src/app/game/layout.tsx b/src/app/game/layout.tsx index 91f26b3..3110884 100644 --- a/src/app/game/layout.tsx +++ b/src/app/game/layout.tsx @@ -1,7 +1,7 @@ import React from "react"; import Link from "next/link"; import {FaGithub} from "react-icons/fa"; -import ThemeToggle from "@/shared/components/theme-toggle"; +import ThemeToggle from "@/components/theme-toggle"; function GameLayout({children}: {children: React.ReactNode}) { return ( diff --git a/src/app/game/page.tsx b/src/app/game/page.tsx index e054188..201e452 100644 --- a/src/app/game/page.tsx +++ b/src/app/game/page.tsx @@ -1,104 +1,44 @@ "use client"; -import {useEffect, useRef, useState} from "react"; -import {buildClientSnippet, getRandomCodeSnippets} from "@/features/snippets/services/get-random-snippets.client"; -import {fetchLanguages} from "@/features/snippets/infrastructure/adapters/snippet-fetch.client"; +import {useEffect, useRef, useState, useEffectEvent} from "react"; import useStopwatch from "@/features/game/hooks/useStopwatch"; -import {GameStatus} from "@/features/game/types/game-status"; -import {REFRESH_BUTTON_MIN_DELAY} from "@/features/game/config/game"; import {useGameStore} from "@/features/game/state/game-store"; import useSettingsStore from "@/features/settings/stores/settings-store"; import GameView from "@/features/game/components/game-view"; import EndgameView from "@/features/game/components/endgame-view"; -import type {ILanguage} from "@/shared/types/language"; -import type {ISnippet} from "@/shared/types/snippet"; -import {AutoClosingMode} from "@/features/settings/types/autoclosing-mode"; import {track} from "@/features/game/logic/track"; +import {useGameSnippets} from "@/features/game/hooks/useGameSnippets"; +import {GameStatus} from "@/features/game/types/game-status"; +import {buildClientSnippet} from "@/features/snippets/services/get-random-snippets.client"; +import type {ISnippet as IClientSnippet} from "@/features/shared/types/snippet"; +import type {ILanguage} from "@/features/shared/types/language"; +import {AutoClosingMode} from "@/features/settings/types/autoclosing-mode"; function Home() { const status = useGameStore((state) => state.status); - const language = useGameStore((state) => state.language); - const currentSnippet = useGameStore((state) => state.currentSnippet); const initialize = useGameStore((state) => state.initialize); - const addSnippetsToQueue = useGameStore((state) => state.addSnippetsToQueue); - const goToNextSnippet = useGameStore((state) => state.goToNextSnippet); const resetCurrentSnippet = useGameStore((state) => state.resetCurrentSnippet); const setStatus = useGameStore((state) => state.setStatus); const registerPositionSample = useGameStore((state) => state.registerPositionSample); - const getSnippetQueue = useGameStore((state) => state.getSnippetQueue); const autoClosingMode = useSettingsStore((state) => state.autoClosingMode); - const setSelectedLanguage = useSettingsStore((state) => state.setSelectedLanguage); const [elapsedTime, setElapsedTime] = useState(0); const hiddenInputRef = useRef(null); - const onTick = (elapsedTime: number) => { - setElapsedTime(elapsedTime); + const onTick = (elapsed: number) => { + setElapsedTime(elapsed); }; - const pushPositionSample = (elapsedTime: number) => { + const pushPositionSample = (elapsed: number) => { const userPosition = useGameStore.getState().userPosition; if (userPosition === null) return; - registerPositionSample(elapsedTime, userPosition); + registerPositionSample(elapsed, userPosition); }; const {startStopwatch, stopStopwatch, resetStopwatch, getTime} = useStopwatch({onTick, onInterval: pushPositionSample}); - - const [isNextButtonLocked, setIsNextButtonLocked] = useState(false); - - const backgroundFetchPromise = useRef | null>(null); - const [availableLanguages, setAvailableLanguages] = useState<{[key: string]: ILanguage} | null>(null); - - const setSnippets = async (selectedLanguage: ILanguage) => { - setSelectedLanguage(selectedLanguage); - resetStopwatch(); - const autoClosingMode = useSettingsStore.getState().autoClosingMode; - const snippets = await getRandomCodeSnippets(selectedLanguage.id, autoClosingMode !== AutoClosingMode.DISABLED); - initialize(selectedLanguage, snippets); - }; - - const backgroundGetSnippets = async () => { - if (!language) return; - const autoClosingMode = useSettingsStore.getState().autoClosingMode; - const snippets = await getRandomCodeSnippets(language!.id, autoClosingMode !== AutoClosingMode.DISABLED); - - addSnippetsToQueue(snippets); - }; - - const goToNextSnippetWithPrefetch = async () => { - if (status !== GameStatus.PLAYING && status !== GameStatus.READY && status !== GameStatus.FINISHED) return; - - const snippetQueue = getSnippetQueue(); - - const startTime = Date.now(); - setIsNextButtonLocked(true); - - if (snippetQueue.length === 0) { - setStatus(GameStatus.LOADING); - - if (backgroundFetchPromise.current) { - await backgroundFetchPromise.current; - backgroundFetchPromise.current = null; - } else { - await setSnippets(language!); - setIsNextButtonLocked(false); - return; - } - } else if (snippetQueue.length <= 3 && !backgroundFetchPromise.current) { - backgroundFetchPromise.current = backgroundGetSnippets(); - } - - const elapsedTime = Date.now() - startTime; - const remainingTime = Math.max(REFRESH_BUTTON_MIN_DELAY - elapsedTime, 0); - - goToNextSnippet(); - - setTimeout(() => { - setIsNextButtonLocked(false); - }, remainingTime); - }; + const {availableLanguages, error, isNextButtonLocked, activateLanguage, changeSnippet} = useGameSnippets(); const handleResetSnippet = () => { resetStopwatch(); @@ -118,65 +58,78 @@ function Home() { track("GAME_END"); }; - const handleChangeSnippet = async () => { + const handleSnippetChange = async () => { setStatus(GameStatus.LOADING); - await goToNextSnippetWithPrefetch(); resetStopwatch(); + await changeSnippet(); hiddenInputRef.current?.focus(); track("SNIPPET_CHANGE"); }; const handleChangeLanguage = async (selectedLanguage: ILanguage) => { - await setSnippets(selectedLanguage); + resetStopwatch(); + activateLanguage(selectedLanguage); track("LANGUAGE_CHANGE", {language: selectedLanguage.name}); }; + const activateLanguageEvent = useEffectEvent((language: ILanguage) => { + resetStopwatch(); + activateLanguage(language); + }); + + // Initialize game on available languages load useEffect(() => { - const initializeGame = async () => { - const languages = await fetchLanguages(); - setAvailableLanguages(languages); + if (!availableLanguages) return; - const selectedLanguage = useSettingsStore.getState().selectedLanguage; + const selectedLanguage = useSettingsStore.getState().selectedLanguage; + const langId = selectedLanguage?.id ?? Object.keys(availableLanguages)[0]; + if (!langId) return; - const langId = selectedLanguage?.id ?? Object.keys(languages)[0]; + const languageToUse = availableLanguages[langId]; + if (!languageToUse) return; - await setSnippets(languages[langId]); - }; - initializeGame(); - }, [setSnippets]); + activateLanguageEvent(languageToUse); + }, [availableLanguages]); - const reparseExistingSnippets = async () => { + // Every time autoclosing mode changes, reparse all snippets + useEffect(() => { const gameState = useGameStore.getState(); const activeLanguage = gameState.language; const activeSnippet = gameState.currentSnippet; if (!activeLanguage || !activeSnippet) return; - const snippetsToReparse = [activeSnippet, ...gameState.getSnippetQueue()]; - setStatus(GameStatus.LOADING); resetStopwatch(); + const snippetsToReparse = [activeSnippet, ...gameState.getSnippetQueue()]; const reparsedSnippets = snippetsToReparse .map((snippet) => buildClientSnippet(snippet.rawSnippet, autoClosingMode !== AutoClosingMode.DISABLED)) - .filter((snippet): snippet is ISnippet => snippet !== null); - - if (reparsedSnippets.length === 0) { - await setSnippets(activeLanguage); - return; - } + .filter((snippet): snippet is IClientSnippet => snippet !== null); initialize(activeLanguage, reparsedSnippets); - }; + }, [autoClosingMode, initialize, resetStopwatch, setStatus]); - useEffect(() => { - reparseExistingSnippets(); - }, [autoClosingMode, reparseExistingSnippets]); + if (status === GameStatus.FINISHED) { + return ; + } - if (status === GameStatus.FINISHED && currentSnippet) { - return ; + if (error) { + return ( +
+

There was an error! :(

+

+ Something went wrong while getting your game ready, try again later.
+ If the issue persists, please  + + open an issue + +  on Github. +

+
+ ); } - if (status === GameStatus.LOADING || !availableLanguages || !currentSnippet || !language) { + if (status === GameStatus.LOADING) { return
Loading...
; } @@ -184,11 +137,11 @@ function Home() { diff --git a/src/app/layout.tsx b/src/app/layout.tsx index b69d265..dc15f91 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,7 @@ import Script from "next/script"; import "./globals.css"; -import ThemeProvider from "@/shared/components/theme-provider"; +import ThemeProvider from "@/components/providers/theme-provider"; +import {TRPCReactProvider} from "@/components/providers/trpc-provider"; export default function RootLayout({ children, @@ -11,7 +12,9 @@ export default function RootLayout({ {process.env.NODE_ENV === "production" &&