From 6cd804ff93d992d5ff1fe24bfbfbf5c3917be94c Mon Sep 17 00:00:00 2001 From: Mattia Cerutti Date: Fri, 21 Nov 2025 21:31:16 +0100 Subject: [PATCH 1/5] feat: implement snippet sharing --- src/app/game/page.tsx | 18 ++++++++-- src/features/game/components/game-view.tsx | 35 ++++++++++++++----- src/features/game/hooks/useGameSnippets.ts | 15 +++++++- src/features/shared/types/snippet.ts | 1 + .../repositories/snippet.repository.server.ts | 32 +++++++++++++++-- ...ppets.server.ts => get-snippets.server.ts} | 18 +++++++++- src/server/trpc/routers/snippet.ts | 18 +++++++++- 7 files changed, 121 insertions(+), 16 deletions(-) rename src/features/snippets/services/{get-random-snippets.server.ts => get-snippets.server.ts} (69%) diff --git a/src/app/game/page.tsx b/src/app/game/page.tsx index 9dcaf79..5deb3cb 100644 --- a/src/app/game/page.tsx +++ b/src/app/game/page.tsx @@ -12,6 +12,7 @@ import {GameStatus} from "@/features/game/types/game-status"; import {buildClientSnippets} from "@/features/snippets/services/build-client-snippets.client"; import type {ILanguage} from "@/features/shared/types/language"; import {AutoClosingMode} from "@/features/settings/types/autoclosing-mode"; +import {useSearchParams} from "next/navigation"; function Home() { const status = useGameStore((state) => state.status); @@ -24,6 +25,9 @@ function Home() { const [elapsedTime, setElapsedTime] = useState(0); + const searchParams = useSearchParams(); + const snippetIdFromUrl = searchParams.get("snippet"); + const hiddenInputRef = useRef(null); const onTick = (elapsed: number) => { @@ -37,7 +41,7 @@ function Home() { }; const {startStopwatch, stopStopwatch, resetStopwatch, getTime} = useStopwatch({onTick, onInterval: pushPositionSample}); - const {availableLanguages, error, isNextButtonLocked, activateLanguage, changeSnippet} = useGameSnippets(); + const {availableLanguages, error, isNextButtonLocked, activateLanguage, activateLanguageWithSnippet, changeSnippet} = useGameSnippets(); const handleResetSnippet = () => { resetStopwatch(); @@ -76,10 +80,20 @@ function Home() { activateLanguage(language); }); + const activateLanguageWithSnippetEvent = useEffectEvent((snippetId: string) => { + resetStopwatch(); + activateLanguageWithSnippet(snippetId); + }); + // Initialize game on available languages load useEffect(() => { if (!availableLanguages) return; + if (snippetIdFromUrl) { + activateLanguageWithSnippetEvent(snippetIdFromUrl); + return; + } + const selectedLanguage = useSettingsStore.getState().selectedLanguage; const langId = selectedLanguage?.id ?? Object.keys(availableLanguages)[0]; if (!langId) return; @@ -88,7 +102,7 @@ function Home() { if (!languageToUse) return; activateLanguageEvent(languageToUse); - }, [availableLanguages]); + }, [availableLanguages, snippetIdFromUrl]); // Every time autoclosing mode changes, reparse all snippets useEffect(() => { diff --git a/src/features/game/components/game-view.tsx b/src/features/game/components/game-view.tsx index d804acf..4f653de 100644 --- a/src/features/game/components/game-view.tsx +++ b/src/features/game/components/game-view.tsx @@ -7,6 +7,7 @@ import {useGameStore} from "../state/game-store"; import {useState} from "react"; import SettingsModal from "@/features/settings/components/modal"; import {IoSettingsSharp} from "react-icons/io5"; +import {ImShare2} from "react-icons/im"; import useSettingsStore from "@/features/settings/stores/settings-store"; interface IGameViewProps { @@ -53,25 +54,41 @@ function GameView(props: IGameViewProps) { onGameFinished(); }; + console.log({currentSnippet}); + return ( <> setIsSettingsModalOpen(false)} />
Caps Lock is on
-
-
+
+
{humanizeTime(elapsedTime)}
- +
+ + +
diff --git a/src/features/game/hooks/useGameSnippets.ts b/src/features/game/hooks/useGameSnippets.ts index 10051d3..3670fda 100644 --- a/src/features/game/hooks/useGameSnippets.ts +++ b/src/features/game/hooks/useGameSnippets.ts @@ -25,8 +25,9 @@ export function useGameSnippets() { const {data: availableLanguages, isError: languagesError} = api.snippet.languages.useQuery(); const {error: randomError, ...fetchRandomSnippets} = api.snippet.random.useMutation(); + const {error: byIdError, ...fetchSnippetById} = api.snippet.byId.useMutation(); - const error = languagesError || randomError; + const error = languagesError || randomError || byIdError; const fetchSnippetsForLanguage = async (language: ILanguage): Promise => { const autoClosingEnabled = autoClosingMode !== AutoClosingMode.DISABLED; @@ -45,6 +46,17 @@ export function useGameSnippets() { initialize(language, snippets); }; + const activateLanguageWithSnippet = async (snippetId: string) => { + const snippet = await fetchSnippetById.mutateAsync({snippetId}); + const language = availableLanguages![snippet.languageId]; + const snippetsQueue = await fetchRandomSnippets.mutateAsync({languageId: language.id}); + + const rawSnippets = [snippet, ...snippetsQueue.filter((s) => s.id !== snippet.id)]; + const snippets = buildClientSnippets(rawSnippets, autoClosingMode !== AutoClosingMode.DISABLED); + + initialize(language, snippets); + }; + const changeSnippet = async () => { const snippetQueue = getSnippetQueue(); @@ -80,6 +92,7 @@ export function useGameSnippets() { return { availableLanguages, activateLanguage, + activateLanguageWithSnippet, changeSnippet, error, isNextButtonLocked, diff --git a/src/features/shared/types/snippet.ts b/src/features/shared/types/snippet.ts index 779fc57..34c7e04 100644 --- a/src/features/shared/types/snippet.ts +++ b/src/features/shared/types/snippet.ts @@ -3,6 +3,7 @@ import {ICharacter} from "@/features/shared/types/character"; export type IParsedSnippet = ICharacter[]; export interface IRawSnippet { + id: string; content: string; disabledRanges: {startIndex: number; endIndex: number}[]; } diff --git a/src/features/snippets/infrastructure/repositories/snippet.repository.server.ts b/src/features/snippets/infrastructure/repositories/snippet.repository.server.ts index 7503a86..982bd7f 100644 --- a/src/features/snippets/infrastructure/repositories/snippet.repository.server.ts +++ b/src/features/snippets/infrastructure/repositories/snippet.repository.server.ts @@ -1,7 +1,6 @@ import {prisma} from "@/server/prisma"; -import {Snippet} from "@prisma/client"; -export async function findRandomSnippets(languageId: string, quantity: number): Promise[]> { +export async function findRandomSnippets(languageId: string, quantity: number) { const snippets = await prisma.snippet.findManyRandom(quantity, { select: { id: true, @@ -18,3 +17,32 @@ export async function findRandomSnippets(languageId: string, quantity: number): return snippets; } + +export async function findSnippetById(snippetId: string) { + const snippet = await prisma.snippet.findUnique({ + where: { + id: snippetId, + }, + select: { + id: true, + content: true, + fileVersion: { + select: { + file: { + select: { + languageId: true, + }, + }, + }, + }, + }, + }); + + if (!snippet) return null; + + return { + id: snippet.id, + content: snippet.content, + languageId: snippet.fileVersion.file.languageId, + }; +} diff --git a/src/features/snippets/services/get-random-snippets.server.ts b/src/features/snippets/services/get-snippets.server.ts similarity index 69% rename from src/features/snippets/services/get-random-snippets.server.ts rename to src/features/snippets/services/get-snippets.server.ts index c737be5..1608732 100644 --- a/src/features/snippets/services/get-random-snippets.server.ts +++ b/src/features/snippets/services/get-snippets.server.ts @@ -1,6 +1,6 @@ import {IRawSnippet} from "@/features/shared/types/snippet"; import {filterSnippets} from "@/features/snippets/logic/filter"; -import {findRandomSnippets} from "@/features/snippets/infrastructure/repositories/snippet.repository.server"; +import {findRandomSnippets, findSnippetById} from "@/features/snippets/infrastructure/repositories/snippet.repository.server"; import {extractAutoCompleteDisabledRanges} from "@/features/snippets/logic/parsing/snippet-parser.server"; import {MAX_GET_SNIPPETS_ATTEMPTS, MIN_SNIPPETS_NUMBER, SNIPPETS_RETRIEVED_PER_QUERY} from "@/features/snippets/config/snippets.server"; @@ -17,6 +17,7 @@ export async function getRandomSnippets(languageId: string): Promise Math.random() - 0.5); } + +export async function getSnippetById(snippetId: string): Promise<(IRawSnippet & {languageId: string}) | null> { + const snippet = await findSnippetById(snippetId); + + if (!snippet) return null; + + const disabledRanges = extractAutoCompleteDisabledRanges(snippet.content, snippet.languageId); + + return { + id: snippet.id, + content: snippet.content, + disabledRanges, + languageId: snippet.languageId, + }; +} diff --git a/src/server/trpc/routers/snippet.ts b/src/server/trpc/routers/snippet.ts index b5a6274..9c691a5 100644 --- a/src/server/trpc/routers/snippet.ts +++ b/src/server/trpc/routers/snippet.ts @@ -1,7 +1,7 @@ import {TRPCError} from "@trpc/server"; import {z} from "zod"; import {createTRPCRouter, publicProcedure} from "@/server/trpc/trpc"; -import {getRandomSnippets} from "@/features/snippets/services/get-random-snippets.server"; +import {getRandomSnippets, getSnippetById} from "@/features/snippets/services/get-snippets.server"; import {doesLanguageExist, getLanguages} from "@/features/snippets/infrastructure/repositories/language.repository.server"; export const snippetRouter = createTRPCRouter({ @@ -24,4 +24,20 @@ export const snippetRouter = createTRPCRouter({ return getRandomSnippets(languageId); }), + byId: publicProcedure + .input( + z.object({ + snippetId: z.uuid(), + }) + ) + .mutation(async ({input}) => { + const snippetId = input.snippetId; + const snippet = await getSnippetById(snippetId); + + if (!snippet) { + throw new TRPCError({code: "NOT_FOUND", message: "Snippet not found"}); + } + + return snippet; + }), }); From 7910c5549209d1a559045a4d2245e03a4057ab37 Mon Sep 17 00:00:00 2001 From: Mattia Cerutti Date: Fri, 21 Nov 2025 23:41:17 +0100 Subject: [PATCH 2/5] feat: add invalid snippet error handling --- package-lock.json | 12 ++++++++++++ package.json | 1 + src/app/game/layout.tsx | 12 ++++++++++++ src/app/game/page.tsx | 14 +++++++------- src/features/game/hooks/useGameSnippets.ts | 20 ++++++++++++++++---- src/utils.ts | 20 ++++++++++++++++++++ 6 files changed, 68 insertions(+), 11 deletions(-) create mode 100644 src/utils.ts diff --git a/package-lock.json b/package-lock.json index 8ba67af..c537124 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "react-icons": "^5.5.0", "recharts": "^3.2.1", "rxjs": "^7.8.2", + "sonner": "^2.0.7", "tree-sitter": "0.21.1", "tree-sitter-c": "0.20.0", "tree-sitter-c-sharp": "0.23.1", @@ -4334,6 +4335,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -7764,6 +7766,16 @@ "simple-concat": "^1.0.0" } }, + "node_modules/sonner": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", + "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", diff --git a/package.json b/package.json index 425a5b0..0160353 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "react-icons": "^5.5.0", "recharts": "^3.2.1", "rxjs": "^7.8.2", + "sonner": "^2.0.7", "tree-sitter": "0.21.1", "tree-sitter-c": "0.20.0", "tree-sitter-c-sharp": "0.23.1", diff --git a/src/app/game/layout.tsx b/src/app/game/layout.tsx index 3110884..0d90f13 100644 --- a/src/app/game/layout.tsx +++ b/src/app/game/layout.tsx @@ -2,10 +2,22 @@ import React from "react"; import Link from "next/link"; import {FaGithub} from "react-icons/fa"; import ThemeToggle from "@/components/theme-toggle"; +import {Toaster} from "sonner"; function GameLayout({children}: {children: React.ReactNode}) { return (
+
{children}

Website doesn't yet support mobile. Please visit using a desktop device.

diff --git a/src/app/game/page.tsx b/src/app/game/page.tsx index 5deb3cb..f83cd9f 100644 --- a/src/app/game/page.tsx +++ b/src/app/game/page.tsx @@ -80,20 +80,15 @@ function Home() { activateLanguage(language); }); - const activateLanguageWithSnippetEvent = useEffectEvent((snippetId: string) => { + const activateLanguageWithSnippetEvent = useEffectEvent((snippetId: string, defaultLanguage: ILanguage) => { resetStopwatch(); - activateLanguageWithSnippet(snippetId); + activateLanguageWithSnippet(snippetId, defaultLanguage); }); // Initialize game on available languages load useEffect(() => { if (!availableLanguages) return; - if (snippetIdFromUrl) { - activateLanguageWithSnippetEvent(snippetIdFromUrl); - return; - } - const selectedLanguage = useSettingsStore.getState().selectedLanguage; const langId = selectedLanguage?.id ?? Object.keys(availableLanguages)[0]; if (!langId) return; @@ -101,6 +96,11 @@ function Home() { const languageToUse = availableLanguages[langId]; if (!languageToUse) return; + if (snippetIdFromUrl) { + activateLanguageWithSnippetEvent(snippetIdFromUrl, languageToUse); + return; + } + activateLanguageEvent(languageToUse); }, [availableLanguages, snippetIdFromUrl]); diff --git a/src/features/game/hooks/useGameSnippets.ts b/src/features/game/hooks/useGameSnippets.ts index 3670fda..f08b4eb 100644 --- a/src/features/game/hooks/useGameSnippets.ts +++ b/src/features/game/hooks/useGameSnippets.ts @@ -8,6 +8,8 @@ import {IClientSnippet} from "@/features/shared/types/snippet"; import useSettingsStore from "@/features/settings/stores/settings-store"; import {AutoClosingMode} from "@/features/settings/types/autoclosing-mode"; import {buildClientSnippets} from "@/features/snippets/services/build-client-snippets.client"; +import {toast} from "sonner"; +import {tryCatch} from "@/utils"; export function useGameSnippets() { const language = useGameStore((state) => state.language); @@ -25,9 +27,11 @@ export function useGameSnippets() { const {data: availableLanguages, isError: languagesError} = api.snippet.languages.useQuery(); const {error: randomError, ...fetchRandomSnippets} = api.snippet.random.useMutation(); - const {error: byIdError, ...fetchSnippetById} = api.snippet.byId.useMutation(); + const fetchSnippetById = api.snippet.byId.useMutation({ + retry: false, + }); - const error = languagesError || randomError || byIdError; + const error = languagesError || randomError; const fetchSnippetsForLanguage = async (language: ILanguage): Promise => { const autoClosingEnabled = autoClosingMode !== AutoClosingMode.DISABLED; @@ -46,8 +50,16 @@ export function useGameSnippets() { initialize(language, snippets); }; - const activateLanguageWithSnippet = async (snippetId: string) => { - const snippet = await fetchSnippetById.mutateAsync({snippetId}); + const activateLanguageWithSnippet = async (snippetId: string, defaultLanguage: ILanguage) => { + const [snippet, error] = await tryCatch(fetchSnippetById.mutateAsync({snippetId})); + if (error) { + await activateLanguage(defaultLanguage); + toast("Snippet not found", { + description: "The snippet you requested does not exist.", + }); + return; + } + const language = availableLanguages![snippet.languageId]; const snippetsQueue = await fetchRandomSnippets.mutateAsync({languageId: language.id}); diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..23b2371 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,20 @@ +interface ISuccess extends Array { + 0: T; + 1: null; +} + +interface IFailure extends Array { + 0: null; + 1: E; +} + +type Result = ISuccess | IFailure; + +export async function tryCatch(promise: Promise): Promise> { + try { + const data = await promise; + return [data, null]; + } catch (error) { + return [null, error as E]; + } +} From 5667615fa61dcea7a5f74173c2ebcebf7b5ea531 Mon Sep 17 00:00:00 2001 From: Mattia Cerutti Date: Sun, 23 Nov 2025 19:17:30 +0100 Subject: [PATCH 3/5] feat: add success toast to share snippet button --- src/features/game/components/game-view.tsx | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/features/game/components/game-view.tsx b/src/features/game/components/game-view.tsx index 4f653de..14d1c4f 100644 --- a/src/features/game/components/game-view.tsx +++ b/src/features/game/components/game-view.tsx @@ -9,6 +9,7 @@ import SettingsModal from "@/features/settings/components/modal"; import {IoSettingsSharp} from "react-icons/io5"; import {ImShare2} from "react-icons/im"; import useSettingsStore from "@/features/settings/stores/settings-store"; +import {toast} from "sonner"; interface IGameViewProps { onGameFinished: () => void; @@ -54,6 +55,13 @@ function GameView(props: IGameViewProps) { onGameFinished(); }; + const handleShareSnippet = () => { + const url = new URL(window.location.href); + url.searchParams.set("snippet", currentSnippet.rawSnippet.id); + navigator.clipboard.writeText(url.toString()); + toast.success("Snippet link copied to clipboard!"); + }; + console.log({currentSnippet}); return ( @@ -71,12 +79,7 @@ function GameView(props: IGameViewProps) {