Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
18 changes: 16 additions & 2 deletions src/app/game/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,27 @@
import React from "react";
import React, {Suspense} from "react";
import Link from "next/link";
import {FaGithub} from "react-icons/fa";
import ThemeToggle from "@/components/theme-toggle";
import {Toaster} from "sonner";
Comment thread
mattiacerutti marked this conversation as resolved.

function GameLayout({children}: {children: React.ReactNode}) {
return (
<div className="flex min-h-screen w-screen flex-col">
<Toaster
position="bottom-right"
richColors
visibleToasts={1}
toastOptions={{
classNames: {
toast: "!bg-(--color-surface) !text-(--color-foreground) !border-none",
title: "!font-bold !text-sm",
},
}}
/>
<main className="flex w-full flex-1 flex-col items-center justify-center">
<div className="max-xl:hidden">{children}</div>
<div className="max-xl:hidden">
<Suspense>{children}</Suspense>
</div>
<p className="px-10 text-center text-lg xl:hidden">Website doesn&apos;t yet support mobile. Please visit using a desktop device.</p>
</main>
<footer className="flex items-center justify-center py-4 text-sm text-(--color-muted)">
Expand Down
18 changes: 16 additions & 2 deletions src/app/game/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -24,6 +25,9 @@ function Home() {

const [elapsedTime, setElapsedTime] = useState(0);

const searchParams = useSearchParams();
const snippetIdFromUrl = searchParams.get("snippet");

const hiddenInputRef = useRef<HTMLInputElement | null>(null);

const onTick = (elapsed: number) => {
Expand All @@ -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();
Expand Down Expand Up @@ -76,6 +80,11 @@ function Home() {
activateLanguage(language);
});

const activateLanguageWithSnippetEvent = useEffectEvent((snippetId: string, defaultLanguage: ILanguage) => {
resetStopwatch();
activateLanguageWithSnippet(snippetId, defaultLanguage);
});

// Initialize game on available languages load
useEffect(() => {
if (!availableLanguages) return;
Expand All @@ -87,8 +96,13 @@ function Home() {
const languageToUse = availableLanguages[langId];
if (!languageToUse) return;

if (snippetIdFromUrl) {
activateLanguageWithSnippetEvent(snippetIdFromUrl, languageToUse);
return;
}

activateLanguageEvent(languageToUse);
}, [availableLanguages]);
}, [availableLanguages, snippetIdFromUrl]);

// Every time autoclosing mode changes, reparse all snippets
useEffect(() => {
Expand Down
41 changes: 32 additions & 9 deletions src/features/game/components/game-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ 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";
import {toast} from "sonner";

interface IGameViewProps {
onGameFinished: () => void;
Expand Down Expand Up @@ -53,25 +55,46 @@ function GameView(props: IGameViewProps) {
onGameFinished();
};

const handleShareSnippet = async () => {
const url = new URL(window.location.href);
url.searchParams.set("snippet", currentSnippet.rawSnippet.id);
try {
await navigator.clipboard.writeText(url.toString());
toast.success("Snippet link copied to clipboard!");
} catch {
toast.error("Failed to copy snippet link to clipboard.");
return;
}
};

return (
<>
<SettingsModal isOpen={isSettingsModalOpen} closeModal={() => setIsSettingsModalOpen(false)} />
<div className="flex flex-col items-center justify-center">
<div className={`relative bottom-8 text-(--color-danger) ${!isCapsLockOn && "opacity-0"} text-2xl font-bold`}>Caps Lock is on</div>
<div className="flex flex-col items-center justify-center gap-10">
<div className="flex w-full flex-row">
<div className="flex flex-col items-center justify-end gap-10">
<div className="grid w-full grid-cols-[1fr_auto_1fr] items-center before:content-['']">
<div className="flex h-full flex-1 justify-center">
<div className="h-fit w-fit min-w-20 rounded-md bg-(--color-surface) px-4 py-2 text-center align-middle font-medium text-(--color-foreground)">
{humanizeTime(elapsedTime)}
</div>
</div>
<button
className="aspect-square h-auto w-fit rounded-md bg-(--color-accent) px-4 py-2 text-center font-medium text-(--color-accent-contrast) shadow-sm enabled:hover:bg-(--color-accent-hover) disabled:cursor-not-allowed disabled:opacity-20"
onClick={() => setIsSettingsModalOpen(true)}
disabled={status !== GameStatus.READY}
>
<IoSettingsSharp />
</button>
<div className="flex gap-2 justify-self-end">
<button
className="aspect-square h-auto w-fit rounded-md bg-(--color-accent) px-4 py-2 text-center font-medium text-(--color-accent-contrast) shadow-sm enabled:hover:bg-(--color-accent-hover) disabled:cursor-not-allowed disabled:opacity-20"
onClick={handleShareSnippet}
disabled={status !== GameStatus.READY}
>
<ImShare2 />
</button>
<button
className="aspect-square h-auto w-fit rounded-md bg-(--color-accent) px-4 py-2 text-center font-medium text-(--color-accent-contrast) shadow-sm enabled:hover:bg-(--color-accent-hover) disabled:cursor-not-allowed disabled:opacity-20"
onClick={() => setIsSettingsModalOpen(true)}
disabled={status !== GameStatus.READY}
>
<IoSettingsSharp />
</button>
</div>
</div>
<div className="relative h-fit w-fit">
<TypingArea onGameFinished={handleSnippetFinished} />
Expand Down
25 changes: 25 additions & 0 deletions src/features/game/hooks/useGameSnippets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -25,6 +27,9 @@ export function useGameSnippets() {

const {data: availableLanguages, isError: languagesError} = api.snippet.languages.useQuery();
const {error: randomError, ...fetchRandomSnippets} = api.snippet.random.useMutation();
const fetchSnippetById = api.snippet.byId.useMutation({
retry: false,
});

const error = languagesError || randomError;

Expand All @@ -45,6 +50,25 @@ export function useGameSnippets() {
initialize(language, snippets);
};

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});

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();

Expand Down Expand Up @@ -80,6 +104,7 @@ export function useGameSnippets() {
return {
availableLanguages,
activateLanguage,
activateLanguageWithSnippet,
changeSnippet,
error,
isNextButtonLocked,
Expand Down
1 change: 1 addition & 0 deletions src/features/shared/types/snippet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}[];
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import {prisma} from "@/server/prisma";
import {Snippet} from "@prisma/client";

export async function findRandomSnippets(languageId: string, quantity: number): Promise<Pick<Snippet, "id" | "content">[]> {
export async function findRandomSnippets(languageId: string, quantity: number) {
const snippets = await prisma.snippet.findManyRandom(quantity, {
select: {
id: true,
Expand All @@ -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,
};
}
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -17,6 +17,7 @@ export async function getRandomSnippets(languageId: string): Promise<IRawSnippet
const disabledRanges = extractAutoCompleteDisabledRanges(snippet.content, languageId);

return {
id: snippet.id,
content: snippet.content,
disabledRanges,
};
Expand All @@ -33,3 +34,18 @@ export async function getRandomSnippets(languageId: string): Promise<IRawSnippet

return snippets.sort(() => 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,
};
}
18 changes: 17 additions & 1 deletion src/server/trpc/routers/snippet.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand All @@ -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;
}),
Comment thread
mattiacerutti marked this conversation as resolved.
});
20 changes: 20 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
interface ISuccess<T> extends Array<T | null> {
0: T;
1: null;
}

interface IFailure<E> extends Array<E | null> {
0: null;
1: E;
}

type Result<T, E = Error> = ISuccess<T> | IFailure<E>;

export async function tryCatch<T, E = Error>(promise: Promise<T>): Promise<Result<T, E>> {
try {
const data = await promise;
return [data, null];
} catch (error) {
return [null, error as E];
}
}
Loading