;
12 | safeGetSession: () => Promise<{ session: Session | null; user: User | null }>;
13 | session: Session | null;
14 | user: User | null;
15 | }
16 | interface PageData {
17 | session: Session | null;
18 | flash?: { type: "success" | "error"; message: string };
19 | }
20 | // interface PageState {}
21 | // interface Platform {}
22 | }
23 | }
24 |
25 | export {};
26 |
--------------------------------------------------------------------------------
/src/app.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | %sveltekit.head%
8 |
9 |
10 | %sveltekit.body%
11 |
12 |
13 |
--------------------------------------------------------------------------------
/src/globals.css:
--------------------------------------------------------------------------------
1 | @import url("https://fonts.googleapis.com/css2?family=Lato:ital,wght@0,100;0,300;0,400;0,700;0,900;1,100;1,300;1,400;1,700;1,900&display=swap");
2 | @import url("https://fonts.googleapis.com/css2?family=Fira+Code:wght@300..700&display=swap");
3 |
4 | @tailwind base;
5 | @tailwind components;
6 | @tailwind utilities;
7 |
8 | :root {
9 | --ff-general: "Lato", sans-serif;
10 | --ff-code: "Fira Code", monospace;
11 |
12 | --color-background-400: 32, 35, 44;
13 | --color-background-500: 37, 41, 50;
14 | --color-background-600: 52, 55, 69;
15 |
16 | --color-foreground-neutral: 255, 255, 255;
17 | --color-foreground-red: 224, 108, 117;
18 | --color-foreground-green: 152, 195, 121;
19 | --color-foreground-blue: 97, 175, 239;
20 |
21 | background-color: rgb(var(--color-background-500));
22 | color: rgb(var(--color-foreground-neutral));
23 |
24 | color-scheme: dark;
25 | }
26 |
27 | *,
28 | *::before,
29 | *::after {
30 | box-sizing: border-box;
31 | }
32 |
33 | html {
34 | transition: all 0.125s linear;
35 | }
36 |
37 | body {
38 | margin: 0;
39 | padding: 0;
40 | min-height: 100vh;
41 | font-family: var(--ff-code);
42 | }
43 |
44 | #app {
45 | padding-block: 2rem;
46 | min-height: 100vh;
47 | display: flex;
48 | flex-direction: column;
49 | row-gap: 1rem;
50 | margin-inline: auto;
51 | }
52 |
53 | code {
54 | font-family: var(--ff-code);
55 | }
56 |
--------------------------------------------------------------------------------
/src/hooks.server.ts:
--------------------------------------------------------------------------------
1 | import { createServerClient } from "@supabase/ssr";
2 | import { type Handle, redirect } from "@sveltejs/kit";
3 | import { sequence } from "@sveltejs/kit/hooks";
4 |
5 | import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from "$env/static/public";
6 |
7 | const supabase: Handle = async ({ event, resolve }) => {
8 | /**
9 | * Creates a Supabase client specific to this server request.
10 | *
11 | * The Supabase client gets the Auth token from the request cookies.
12 | */
13 | event.locals.supabase = createServerClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
14 | cookies: {
15 | getAll: () => event.cookies.getAll(),
16 | /**
17 | * SvelteKit's cookies API requires `path` to be explicitly set in
18 | * the cookie options. Setting `path` to `/` replicates previous/
19 | * standard behavior.
20 | */
21 | setAll: (cookiesToSet) => {
22 | cookiesToSet.forEach(({ name, value, options }) => {
23 | event.cookies.set(name, value, { ...options, path: "/" });
24 | });
25 | }
26 | }
27 | });
28 |
29 | if ("suppressGetSessionWarning" in event.locals.supabase.auth) {
30 | // @ts-expect-error - suppressGetSessionWarning is not part of the official API
31 | event.locals.supabase.auth.suppressGetSessionWarning = true;
32 | } else {
33 | console.warn(
34 | "SupabaseAuthClient#suppressGetSessionWarning was removed. See https://github.com/supabase/auth-js/issues/888."
35 | );
36 | }
37 |
38 | /**
39 | * Unlike `supabase.auth.getSession()`, which returns the session _without_
40 | * validating the JWT, this function also calls `getUser()` to validate the
41 | * JWT before returning the session.
42 | */
43 | event.locals.safeGetSession = async () => {
44 | const {
45 | data: { session }
46 | } = await event.locals.supabase.auth.getSession();
47 | if (!session) {
48 | return { session: null, user: null };
49 | }
50 |
51 | const {
52 | data: { user },
53 | error
54 | } = await event.locals.supabase.auth.getUser();
55 | if (error) {
56 | // JWT validation has failed
57 | return { session: null, user: null };
58 | }
59 |
60 | return { session, user };
61 | };
62 |
63 | return resolve(event, {
64 | filterSerializedResponseHeaders(name) {
65 | /**
66 | * Supabase libraries use the `content-range` and `x-supabase-api-version`
67 | * headers, so we need to tell SvelteKit to pass it through.
68 | */
69 | return name === "content-range" || name === "x-supabase-api-version";
70 | }
71 | });
72 | };
73 |
74 | const authGuard: Handle = async ({ event, resolve }) => {
75 | const { session, user } = await event.locals.safeGetSession();
76 | event.locals.session = session;
77 | event.locals.user = user;
78 |
79 | if (!event.locals.session && event.url.pathname.startsWith("/private")) {
80 | redirect(303, "/auth");
81 | }
82 |
83 | if (event.locals.session && event.url.pathname === "/auth") {
84 | redirect(303, "/private");
85 | }
86 |
87 | return resolve(event);
88 | };
89 |
90 | export const handle: Handle = sequence(supabase, authGuard);
91 |
--------------------------------------------------------------------------------
/src/lib/components/dropdown.svelte:
--------------------------------------------------------------------------------
1 |
25 |
26 |
30 |
(isDropdownOpen = true)}
34 | on:keydown={() => (isDropdownOpen = true)}
35 | aria-pressed="false"
36 | tabindex="0"
37 | >
38 | {dropdownOptions[selectedOption]}
39 |
40 |
41 |
46 | {#each dropdownOptions as option, i}
47 | {
49 | selectedOption = i;
50 | isDropdownOpen = false;
51 | }}
52 | class="px-2 text-left"
53 | >
54 | {option}
55 | {#if selectedOption === i}
56 |
57 | {/if}
58 |
59 | {/each}
60 |
61 |
62 |
--------------------------------------------------------------------------------
/src/lib/components/editor.svelte:
--------------------------------------------------------------------------------
1 |
210 |
211 | {#if !loaded}
212 |
213 |
214 |
215 | {/if}
216 |
217 |
218 |
219 |
223 |
Tip: You can reset tests by using :q
224 |
225 |
--------------------------------------------------------------------------------
/src/lib/components/footer.svelte:
--------------------------------------------------------------------------------
1 |
11 |
--------------------------------------------------------------------------------
/src/lib/components/help-popup.svelte:
--------------------------------------------------------------------------------
1 |
6 |
7 |
12 | Help❓
13 |
14 | A brief guide to the tests in vimaroo
. Start by selecting a test, then
15 | locate and delete the specified line using dd
to start the test. If you need to
16 | exit early, use :q
. Follow the instructions and complete each test using Vim
17 | motions!
18 |
19 |
20 |
21 |
Horizontal ⬅️➡️
22 |
23 | Remove the special character ('*', '#', '@', etc.) from the sequence of words. You can move
24 | between words with w
(go one word forward) or b
(go one word
25 | backward). You can also use f
/F
to go directly to that character,
26 | or ^
to go to the beginning of the line, and $
to go to the end of
27 | the line.
28 |
29 |
30 |
31 |
Containers 🫙
32 |
33 | Delete the inside contents of the container. You can do this with the di
34 | command followed by the opening character of the container ("delete inside" the container). For
35 | example, use di"
to delete everything inside the double quotes.
36 |
37 |
38 |
39 |
Lines ⬆️⬇️
40 |
41 | Delete the line with the sentence (you can ONLY delete that single line). Move up and down
42 | with k
and j
, but relative line jumping can be helpful as well
43 | (e.g., 6j
will go down 6 lines).
44 |
45 |
46 |
47 |
Movement ⬅️⬇️⬆️➡️
48 |
49 | Move within the dot grid with h
j
k
l
50 | and remove the special character ('*', '#', '@', etc.) using x
. If it's faster,
51 | you can also search for the character itself using /
.
52 |
53 |
54 |
55 |
Mixed 🥗
56 |
57 | All tests combined into one! Any of the tests above will be randomly chosen for each round
58 | (horizontal, containers, lines, or movement tests).
59 |
60 |
61 |
62 |
63 |
--------------------------------------------------------------------------------
/src/lib/components/login.svelte:
--------------------------------------------------------------------------------
1 |
7 |
8 |
9 | User Login
10 | Continue with one of the following social login providers
11 |
12 |
30 |
31 |
--------------------------------------------------------------------------------
/src/lib/components/navbar.svelte:
--------------------------------------------------------------------------------
1 |
28 |
29 |
30 |
36 |
37 |
(isHelpOpen = true)}>
38 |
39 |
40 |
(isSettingsOpen = true)}>
41 |
42 |
43 | {#if profile}
44 |
45 |
(isDropdownOpen = !isDropdownOpen)}>
46 |
47 |
48 |
69 |
70 | {:else}
71 |
(isLoginOpen = true)}>
72 |
73 |
74 | {/if}
75 |
76 |
77 |
78 |
91 |
--------------------------------------------------------------------------------
/src/lib/components/popover.svelte:
--------------------------------------------------------------------------------
1 |
15 |
16 | {#key isOpen}
17 |
25 | {#if !locked}
26 |
27 |
28 |
29 | {/if}
30 |
31 |
32 |
33 |
43 | {/key}
44 |
45 |
51 |
--------------------------------------------------------------------------------
/src/lib/components/settings-popup.svelte:
--------------------------------------------------------------------------------
1 |
32 |
33 |
34 | Editor Settings
35 |
36 |
37 |
38 |
Font Size
39 |
Change the editor font size
40 |
41 |
42 |
43 |
44 |
45 |
ASCII Logo
46 |
Enable the ASCII logo inside the editor
47 |
48 |
49 |
50 |
51 |
52 |
Word Wrap
53 |
Enable word wrapping for the editor
54 |
55 |
56 |
57 |
58 |
59 |
Relative Lines
60 |
Enable relative lines for the editor
61 |
62 |
66 |
67 |
68 |
69 |
--------------------------------------------------------------------------------
/src/lib/components/spinner.svelte:
--------------------------------------------------------------------------------
1 |
8 |
12 |
16 |
17 |
--------------------------------------------------------------------------------
/src/lib/components/test-settings.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 |
12 |
13 |
16 | {#each testOptions as testMode, i}
17 | selectedTestIndex.set(i)}
21 | >
22 | {testMode}
23 |
24 | {/each}
25 |
26 |
27 | {#each modeOptions as modeOption, i}
28 | selectedModeIndex.set(i)}
32 | >
33 | {modeOption}
34 |
35 | {/each}
36 |
37 | {#if ["time", "rounds"].includes(modeOptions[$selectedModeIndex])}
38 |
39 | {#if modeOptions[$selectedModeIndex] === "time"}
40 | {#each timeOptions as timeOption, i}
41 | ($selectedTimeIndex = i)}
45 | >
46 | {timeOption}
47 |
48 | {/each}
49 | {/if}
50 | {#if modeOptions[$selectedModeIndex] === "rounds"}
51 | {#each roundOptions as roundOption, i}
52 | ($selectedRoundsIndex = i)}
56 | >
57 | {roundOption}
58 |
59 | {/each}
60 | {/if}
61 |
62 | {/if}
63 |
64 |
65 |
66 |
67 |
68 | {#each testOptions as testMode, i}
69 | selectedTestIndex.set(i)}
73 | >
74 | {testMode}
75 |
76 | {/each}
77 |
78 |
83 | {#each modeOptions as modeOption, i}
84 | selectedModeIndex.set(i)}
88 | >
89 | {modeOption}
90 |
91 | {/each}
92 |
93 | {#if ["time", "rounds"].includes(modeOptions[$selectedModeIndex])}
94 |
95 | {#if modeOptions[$selectedModeIndex] === "time"}
96 | {#each timeOptions as timeOption, i}
97 | ($selectedTimeIndex = i)}
101 | >
102 | {timeOption}
103 |
104 | {/each}
105 | {/if}
106 | {#if modeOptions[$selectedModeIndex] === "rounds"}
107 | {#each roundOptions as roundOption, i}
108 | ($selectedRoundsIndex = i)}
112 | >
113 | {roundOption}
114 |
115 | {/each}
116 | {/if}
117 |
118 | {/if}
119 |
120 |
--------------------------------------------------------------------------------
/src/lib/db/stats.ts:
--------------------------------------------------------------------------------
1 | import type { Database } from "$lib/types/supabase";
2 |
3 | type UserStats = Database["public"]["Tables"]["user_stats"]["Row"];
4 | type TestStats = {
5 | testName: string;
6 | testsCompleted: number | null;
7 | dps: number | null;
8 | accuracy: number | null;
9 | };
10 |
11 | function calculateStats(
12 | testName: string,
13 | testsCompleted: number | null,
14 | deletionsTotal: number | null,
15 | deletionsCorrect: number | null,
16 | totalTime: number | null
17 | ): TestStats {
18 | const dps = deletionsCorrect != null && totalTime != null ? deletionsCorrect / totalTime : null;
19 | const accuracy =
20 | deletionsCorrect != null && deletionsTotal != null ? deletionsCorrect / deletionsTotal : null;
21 |
22 | return {
23 | testName,
24 | testsCompleted,
25 | dps,
26 | accuracy
27 | };
28 | }
29 |
30 | export function getTestStats(stats: UserStats) {
31 | const testTypes = [
32 | {
33 | testName: "horizontal",
34 | testsCompleted: stats.horizontal_tests_completed,
35 | deletionsTotal: stats.horizontal_deletions_total,
36 | deletionsCorrect: stats.horizontal_deletions_correct,
37 | totalTime: stats.horizontal_total_time
38 | },
39 | {
40 | testName: "containers",
41 | testsCompleted: stats.containers_tests_completed,
42 | deletionsTotal: stats.containers_deletions_total,
43 | deletionsCorrect: stats.containers_deletions_correct,
44 | totalTime: stats.containers_total_time
45 | },
46 | {
47 | testName: "lines",
48 | testsCompleted: stats.lines_tests_completed,
49 | deletionsTotal: stats.lines_deletions_total,
50 | deletionsCorrect: stats.lines_deletions_correct,
51 | totalTime: stats.lines_total_time
52 | },
53 | {
54 | testName: "movement",
55 | testsCompleted: stats.movement_tests_completed,
56 | deletionsTotal: stats.movement_deletions_total,
57 | deletionsCorrect: stats.movement_deletions_correct,
58 | totalTime: stats.movement_total_time
59 | },
60 | {
61 | testName: "mixed",
62 | testsCompleted: stats.mixed_tests_completed,
63 | deletionsTotal: stats.mixed_deletions_total,
64 | deletionsCorrect: stats.mixed_deletions_correct,
65 | totalTime: stats.mixed_total_time
66 | }
67 | ];
68 |
69 | const testStats: TestStats[] = [];
70 |
71 | testTypes.forEach((test) => {
72 | testStats.push(
73 | calculateStats(
74 | test.testName,
75 | test.testsCompleted,
76 | test.deletionsTotal,
77 | test.deletionsCorrect,
78 | test.totalTime
79 | )
80 | );
81 | });
82 |
83 | return testStats;
84 | }
85 |
--------------------------------------------------------------------------------
/src/lib/db/update.ts:
--------------------------------------------------------------------------------
1 | export async function incrementTestsStarted() {
2 | try {
3 | const response = await fetch("/api/stats/increment", {
4 | method: "POST",
5 | headers: {
6 | "Content-Type": "application/json"
7 | }
8 | });
9 | if (response.ok) {
10 | console.log("Number of user tests incremented.");
11 | } else {
12 | console.error("Failed to increment tests:", await response.text());
13 | }
14 | } catch (error) {
15 | console.error(error);
16 | }
17 | }
18 |
19 | export async function updateStats(
20 | testName: string,
21 | deletionsCorrect: number,
22 | deletionsTotal: number,
23 | totalTime: number
24 | ) {
25 | try {
26 | const response = await fetch("api/stats/update", {
27 | method: "POST",
28 | headers: {
29 | "Content-Type": "applications/json"
30 | },
31 | body: JSON.stringify({ testName, deletionsCorrect, deletionsTotal, totalTime })
32 | });
33 |
34 | if (response.ok) {
35 | console.log("User stats updated successfully.");
36 | } else {
37 | console.error("Failed to update database:", await response.text());
38 | }
39 | } catch (error) {
40 | console.error(error);
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/src/lib/editor/ascii.ts:
--------------------------------------------------------------------------------
1 | export const ASCII_LOGO = `
2 |
3 | @%%%%%%%%%%%%%%%##%% %%%%%%%%%%%%% =====
4 | @ @%#############%#++*#%@%#############% =========
5 | #@#*=-----------=*#+==+#%+------------=* ===--======
6 | #@%*+=---------=+**=--=+#*+=---------=+# ==-:--======
7 | @ @@%*=--------*#*==--==+#+=--------=+++======= ==----=======
8 | @@*=--------**+=--=+++=--------=============== =--=====+===
9 | @@*=--------**+===++=--------=================== ======*#+====*#
10 | @@*=--------**+++*+--------=========+======================+====+*##
11 | @@*=--------*#*++=-------============++=======================+*++*
12 | %##%*=--------*#+=--------=++===========+=======================+*+=
13 | #*++#*=--------==--------=+*+============++====+============
14 | %*++**=----------------=+**=====+========++===+============
15 | %#**#*=-------------==++*+=====+=========+++==+===+======
16 | %##%*=------------===++======+=========+++++ ++==++++
17 | @@*=-----------+**+++====++==++=====+++++ ====++++
18 | @@*=---------=+*+======+++===+====++++++ =====+++
19 | @@*=-------=+*+===++++==++=-=====+++*+ ++++
20 | @@*=------+***+=-++++==+**=-+===++++*
21 | @@*=----=*#*+++-=+++=-*##*=+**===+++++
22 | @@#*+=+*#%%##*++++*+++#%#++*##*====++++
23 | @%%%%%%%@ %%#*+==+*##%@#*+###*+====++++
24 | %##**##% ====++++
25 | %%%% ====++++
26 | ===
27 |
28 | `;
29 |
--------------------------------------------------------------------------------
/src/lib/editor/monaco.ts:
--------------------------------------------------------------------------------
1 | import * as monaco from "monaco-editor";
2 | // @ts-ignore (No types support for monaco-vim yet)
3 | import { initVimMode, VimMode } from "monaco-vim";
4 |
5 | // Import the workers in a production-safe way.
6 | // This is different than in Monaco's documentation for Vite,
7 | // but avoids a weird error ("Unexpected usage") at runtime
8 | import editorWorker from "monaco-editor/esm/vs/editor/editor.worker?worker";
9 |
10 | self.MonacoEnvironment = {
11 | getWorker: () => {
12 | return new editorWorker();
13 | }
14 | };
15 |
16 | export default { monaco, initVimMode, VimMode };
17 |
--------------------------------------------------------------------------------
/src/lib/editor/theme.ts:
--------------------------------------------------------------------------------
1 | import type * as Monaco from "monaco-editor/esm/vs/editor/editor.api";
2 |
3 | export const editorTheme: Monaco.editor.IStandaloneThemeData = {
4 | base: "vs-dark",
5 | inherit: true,
6 | rules: [],
7 | colors: {
8 | "editor.foreground": "#FFFFFF",
9 | "editor.background": "#20232C",
10 | "editor.selectionBackground": "#2E3A45",
11 | "editor.lineHighlightBackground": "#2E3A45",
12 | "editorCursor.foreground": "#D8DEE9",
13 | "editorWhitespace.foreground": "#434C5ECC"
14 | }
15 | };
16 |
--------------------------------------------------------------------------------
/src/lib/stores/persistent.ts:
--------------------------------------------------------------------------------
1 | import { browser } from "$app/environment";
2 | import { writable, type Writable } from "svelte/store";
3 |
4 | /**
5 | * Set a cookie with a key, value, and optional expiration period in days.
6 | */
7 | function setCookie(key: string, value: string, days: number) {
8 | if (browser) {
9 | const date = new Date();
10 | date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000);
11 | const expires = `expires=${date.toUTCString()}`;
12 | document.cookie = `${key}=${encodeURIComponent(value)};${expires};path=/`;
13 | }
14 | }
15 |
16 | /**
17 | * Get a cookie by key.
18 | */
19 | function getCookie(key: string): string | null {
20 | if (browser) {
21 | const name = `${key}=`;
22 | const decodedCookie = decodeURIComponent(document.cookie);
23 | const cookieArray = decodedCookie.split(";");
24 | for (let i = 0; i < cookieArray.length; i++) {
25 | let cookie = cookieArray[i].trim();
26 | if (cookie.indexOf(name) === 0) {
27 | return cookie.substring(name.length, cookie.length);
28 | }
29 | }
30 | }
31 | return null;
32 | }
33 |
34 | /**
35 | * Create a persistent svelte store using cookies.
36 | * @param key cookie key
37 | * @param initialValue store initial value
38 | * @returns a writable store
39 | */
40 | export function createPersistentStore(key: string, initialValue: T) {
41 | let storedValue = initialValue;
42 | if (browser) {
43 | const cookieValue = getCookie(key);
44 | if (cookieValue != null) {
45 | storedValue = JSON.parse(cookieValue);
46 | }
47 | }
48 | return writable(storedValue);
49 | }
50 |
51 | /**
52 | * Subscribes to stores and syncs them in cookies.
53 | * @param stores records with cookie key and the store itself as value
54 | * @returns unsubscriber for all stores
55 | */
56 | export function syncStoresToCookies(stores: Record>, days: number = 365) {
57 | const unsubscribers = Object.entries(stores).map(([key, store]) => {
58 | return store.subscribe((value) => {
59 | if (browser) {
60 | setCookie(key, JSON.stringify(value), days);
61 | }
62 | });
63 | });
64 | return () => {
65 | unsubscribers.forEach((unsubscribe) => {
66 | unsubscribe();
67 | });
68 | };
69 | }
70 |
--------------------------------------------------------------------------------
/src/lib/stores/settings/settings.ts:
--------------------------------------------------------------------------------
1 | import { createPersistentStore } from "../persistent";
2 |
3 | export const ASCII_OPTION_KEY = "ascii-option";
4 | export const FONT_SIZE_OPTION_KEY = "font-size-option";
5 | export const WORD_WRAP_OPTION_KEY = "word-wrap-option";
6 | export const RELATIVE_LINES_OPTION_KEY = "relative-lines-option";
7 |
8 | // Options for each of the editor customization settings
9 | export const fontSizeOptions = [12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24];
10 | export const enableAsciiLogoOptions = ["Yes", "No"];
11 | export const enableWordWrapOptions = ["Yes", "No"];
12 | export const enableRelativeLinesOptions = ["Yes", "No"];
13 |
14 | const DEFAULT_FONT_SIZE_INDEX = fontSizeOptions.indexOf(16);
15 | const DEFAULT_ASCII_OPTION_INDEX = enableAsciiLogoOptions.indexOf("Yes");
16 | const DEFAULT_WORD_WRAP_OPTION_INDEX = enableWordWrapOptions.indexOf("No");
17 | const DEFAULT_RELATIVE_LINES_OPTION_INDEX = enableRelativeLinesOptions.indexOf("Yes");
18 |
19 | // Create the editor setting stores
20 | export const fontSize = createPersistentStore(
21 | FONT_SIZE_OPTION_KEY,
22 | DEFAULT_FONT_SIZE_INDEX
23 | );
24 | export const asciiLogoEnabled = createPersistentStore(
25 | ASCII_OPTION_KEY,
26 | DEFAULT_ASCII_OPTION_INDEX
27 | );
28 | export const wordWrapEnabled = createPersistentStore(
29 | WORD_WRAP_OPTION_KEY,
30 | DEFAULT_WORD_WRAP_OPTION_INDEX
31 | );
32 | export const relativeLinesEnabled = createPersistentStore(
33 | RELATIVE_LINES_OPTION_KEY,
34 | DEFAULT_RELATIVE_LINES_OPTION_INDEX
35 | );
36 |
--------------------------------------------------------------------------------
/src/lib/stores/test/options.ts:
--------------------------------------------------------------------------------
1 | import { createPersistentStore } from "../persistent";
2 |
3 | export const TEST_INDEX_KEY = "test-index";
4 | export const MODE_INDEX_KEY = "mode-index";
5 | export const TIME_INDEX_KEY = "time-index";
6 | export const ROUNDS_INDEX_KEY = "rounds-index";
7 |
8 | const DEFAULT_INDEX = 0;
9 |
10 | // Create the test, mode, time, and rounds index stores
11 | export const selectedTestIndex = createPersistentStore(TEST_INDEX_KEY, DEFAULT_INDEX);
12 | export const selectedModeIndex = createPersistentStore(MODE_INDEX_KEY, DEFAULT_INDEX);
13 | export const selectedTimeIndex = createPersistentStore(TIME_INDEX_KEY, DEFAULT_INDEX);
14 | export const selectedRoundsIndex = createPersistentStore(ROUNDS_INDEX_KEY, DEFAULT_INDEX);
15 |
--------------------------------------------------------------------------------
/src/lib/stores/test/rounds.ts:
--------------------------------------------------------------------------------
1 | import { writable } from "svelte/store";
2 |
3 | function createRoundsStore() {
4 | const { subscribe, set, update } = writable(0);
5 |
6 | const setRounds = (rounds: number) => {
7 | set(rounds);
8 | };
9 |
10 | const updateRounds = () => {
11 | update((rounds) => {
12 | return rounds - 1;
13 | });
14 | };
15 |
16 | return {
17 | subscribe,
18 | set,
19 | setRounds,
20 | updateRounds
21 | };
22 | }
23 |
24 | export const rounds = createRoundsStore();
25 |
--------------------------------------------------------------------------------
/src/lib/stores/test/scores.ts:
--------------------------------------------------------------------------------
1 | import { writable } from "svelte/store";
2 |
3 | function createScoresStore() {
4 | const { subscribe, set, update } = writable<[number, number]>([0, 0]);
5 |
6 | const incrementScore = () => {
7 | update(([prevScore, total]) => [prevScore + 1, total]);
8 | };
9 |
10 | const incrementTotal = () => {
11 | update(([prevScore, totalScore]) => [prevScore, totalScore + 1]);
12 | };
13 |
14 | const reset = () => {
15 | set([0, 0]);
16 | };
17 |
18 | return {
19 | subscribe,
20 | set,
21 | update,
22 | incrementScore,
23 | incrementTotal,
24 | reset
25 | };
26 | }
27 |
28 | export const scores = createScoresStore();
29 |
--------------------------------------------------------------------------------
/src/lib/stores/test/status.ts:
--------------------------------------------------------------------------------
1 | import { writable } from "svelte/store";
2 |
3 | export const testStarted = writable(false);
4 | export const testOver = writable(false);
5 |
--------------------------------------------------------------------------------
/src/lib/stores/test/timer.ts:
--------------------------------------------------------------------------------
1 | import type * as Monaco from "monaco-editor/esm/vs/editor/editor.api";
2 | import { writable } from "svelte/store";
3 |
4 | function createTimerStore(initialValue: number = 15) {
5 | const { subscribe, set, update } = writable(initialValue);
6 | let intervalId: number;
7 |
8 | // Starts the timer using setInterval, clear when it reaches 0
9 | const start = (editor: Monaco.editor.IStandaloneCodeEditor) => {
10 | clear(); // Ensure no duplicate intervals
11 | intervalId = window.setInterval(() => {
12 | update((n) => {
13 | n -= 1;
14 | if (n === 0) {
15 | clear();
16 | set(n);
17 | editor.setValue("");
18 | return n;
19 | }
20 | return n;
21 | });
22 | }, 1000);
23 | };
24 | const clear = () => {
25 | if (intervalId) clearInterval(intervalId);
26 | };
27 | const setTimer = (value: number) => {
28 | set(value);
29 | };
30 | return {
31 | subscribe,
32 | start,
33 | clear,
34 | setTimer
35 | };
36 | }
37 |
38 | export const timer = createTimerStore();
39 |
--------------------------------------------------------------------------------
/src/lib/test/constants.ts:
--------------------------------------------------------------------------------
1 | export const BEGIN_TEST_LINE = "Delete this line to begin the test!";
2 |
3 | export const EXTRA_WORDS = [
4 | "aar",
5 | "bar",
6 | "car",
7 | "dar",
8 | "ear",
9 | "far",
10 | "gar",
11 | "har",
12 | "iar",
13 | "jar",
14 | "kar",
15 | "lar",
16 | "mar",
17 | "nar",
18 | "oar",
19 | "par",
20 | "qar",
21 | "rar",
22 | "sar",
23 | "tar",
24 | "uar",
25 | "var",
26 | "war",
27 | "xar",
28 | "yar",
29 | "zar"
30 | ];
31 | export const EXTRA_SENTENCES = [
32 | "The greatest glory in living lies not in never falling, but in rising every time we fall.",
33 | "The way to get started is to quit talking and begin doing.",
34 | "Your time is limited, so don't waste it living someone else's life.",
35 | "If life were predictable it would cease to be life, and be without flavor.",
36 | "If you look at what you have in life, you'll always have more.",
37 | "If you set your goals ridiculously high and it's a failure, you will fail above everyone else's success.",
38 | "Life is what happens when you're busy making other plans.",
39 | "Spread love everywhere you go. Let no one ever come to you without leaving happier.",
40 | "When you reach the end of your rope, tie a knot in it and hang on.",
41 | "Always remember that you are absolutely unique. Just like everyone else.",
42 | "Don't judge each day by the harvest you reap but by the seeds that you plant.",
43 | "The future belongs to those who believe in the beauty of their dreams.",
44 | "Tell me and I forget. Teach me and I remember. Involve me and I learn.",
45 | "The best and most beautiful things in the world cannot be seen or even touched - they must be felt with the heart.",
46 | "It is during our darkest moments that we must focus to see the light.",
47 | "Whoever is happy will make others happy too.",
48 | "Do not go where the path may lead, go instead where there is no path and leave a trail.",
49 | "You will face many defeats in life, but never let yourself be defeated.",
50 | "The greatest glory in living lies not in never falling, but in rising every time we fall.",
51 | "In the end, it's not the years in your life that count. It's the life in your years.",
52 | "Never let the fear of striking out keep you from playing the test.",
53 | "Life is either a daring adventure or nothing at all.",
54 | "Many of life's failures are people who did not realize how close they were to success when they gave up.",
55 | "You have within you right now, everything you need to deal with whatever the world can throw at you.",
56 | "Believe you can and you're halfway there.",
57 | "What we achieve inwardly will change outer reality.",
58 | "Change your thoughts and you change your world.",
59 | "The only limit to our realization of tomorrow will be our doubts of today.",
60 | "The purpose of our lives is to be happy.",
61 | "Life is what happens when you're busy making other plans.",
62 | "Get busy living or get busy dying.",
63 | "You only live once, but if you do it right, once is enough.",
64 | "Many of life's failures are people who did not realize how close they were to success when they gave up.",
65 | "If you want to live a happy life, tie it to a goal, not to people or things.",
66 | "Never let the fear of striking out keep you from playing the test.",
67 | "Money and success don’t change people; they merely amplify what is already there.",
68 | "Your time is limited, so don’t waste it living someone else’s life.",
69 | "Not how long, but how well you have lived is the main thing.",
70 | "If life were predictable it would cease to be life and be without flavor.",
71 | "The whole secret of a successful life is to find out what is one’s destiny to do, and then do it.",
72 | "In order to write about life first you must live it.",
73 | "The big lesson in life, baby, is never be scared of anyone or anything.",
74 | "Sing like no one’s listening, love like you’ve never been hurt, dance like nobody’s watching, and live like it’s heaven on earth.",
75 | "Curiosity about life in all of its aspects, I think, is still the secret of great creative people.",
76 | "Life is not a problem to be solved, but a reality to be experienced.",
77 | "The unexamined life is not worth living.",
78 | "Turn your wounds into wisdom.",
79 | "The way I see it, if you want the rainbow, you gotta put up with the rain.",
80 | "Do all the good you can, for all the people you can, in all the ways you can, as long as you can."
81 | ];
82 |
83 | export const EXTRA_DELETE_SENTENCES = [
84 | "DELETE ME",
85 | "Can you get to me?",
86 | "You are not good enough",
87 | "Stop trying so hard",
88 | "Give up",
89 | "Get out of here",
90 | "You don't use vim btw",
91 | "You are too weak for me",
92 | "Too slow",
93 | "You will never win"
94 | ];
95 |
96 | export const EXTRA_SYMBOLS = ["#", "*", "+", "%", "@", "$", "-"];
97 |
--------------------------------------------------------------------------------
/src/lib/test/options.ts:
--------------------------------------------------------------------------------
1 | import { TestType } from "$lib/types/test";
2 |
3 | const testOptions = Object.values(TestType);
4 |
5 | const modeOptions = ["time", "rounds", "zen"];
6 | // These options exist under modeOptions
7 | // E.g., options for "time" include 15, 30, 60, and 120 seconds
8 | const timeOptions = [15, 30, 60, 120];
9 | const roundOptions = [10, 25, 50, 100];
10 |
11 | export { testOptions, modeOptions, timeOptions, roundOptions };
12 |
--------------------------------------------------------------------------------
/src/lib/test/tests/containers.ts:
--------------------------------------------------------------------------------
1 | import { TestType, type ContainersTest } from "$lib/types/test";
2 | import { EXTRA_SENTENCES } from "../constants";
3 |
4 | export const containersTest: ContainersTest = {
5 | type: TestType.CONTAINERS,
6 | prompt: "Delete the contents of the containers.",
7 | tip: "Tip: use di to delete inside a specific container.",
8 | textBuffer: ["[", "DELETE_ME", "]"],
9 | joinCharacter: "",
10 | condition: (currentBuffer: string) => {
11 | const wrapper =
12 | containersTest.textBuffer[0] +
13 | containersTest.textBuffer[containersTest.textBuffer.length - 1];
14 | return currentBuffer.includes(wrapper);
15 | },
16 | updateBuffer: () => {
17 | const containerTypes = ["[]", "{}", "()", "''", '""'];
18 | const containerType = containerTypes[Math.floor(Math.random() * containerTypes.length)];
19 |
20 | containersTest.textBuffer[0] = containerType[0];
21 | containersTest.textBuffer[containersTest.textBuffer.length - 1] = containerType[1];
22 | containersTest.textBuffer[1] =
23 | EXTRA_SENTENCES[Math.floor(Math.random() * EXTRA_SENTENCES.length)];
24 | }
25 | };
26 |
--------------------------------------------------------------------------------
/src/lib/test/tests/horizontal.ts:
--------------------------------------------------------------------------------
1 | import { TestType, type HorizontalTest } from "$lib/types/test";
2 | import { EXTRA_SYMBOLS, EXTRA_WORDS } from "../constants";
3 |
4 | export const horizontalTest: HorizontalTest = {
5 | type: TestType.HORIZONTAL,
6 | targetCharacter: EXTRA_SYMBOLS[0],
7 | populateWord: EXTRA_WORDS[Math.floor(Math.random() * EXTRA_WORDS.length)],
8 | targetPosition: 0,
9 | prompt: "Remove the special character ('*', '#', '@', etc.) from a sequence of words.",
10 | tip: "Tip: use w/b to move between words, ^/_/$ for extremes, or f/F to find characters.",
11 | textBuffer: new Array(10).fill(EXTRA_WORDS[1]),
12 | joinCharacter: " ",
13 | condition: (currentBuffer: string) => {
14 | if (currentBuffer.length === 0) return false;
15 | if (horizontalTest.type !== TestType.HORIZONTAL) return false;
16 |
17 | return currentBuffer.split(" ").join("") === horizontalTest.populateWord.repeat(10);
18 | },
19 | updateBuffer: () => {
20 | if (horizontalTest.type !== TestType.HORIZONTAL) return;
21 |
22 | // Select a new random character and position to be inserted into textBuffer
23 | horizontalTest.targetCharacter =
24 | EXTRA_SYMBOLS[Math.floor(Math.random() * EXTRA_SYMBOLS.length)];
25 | horizontalTest.targetPosition = Math.floor(Math.random() * horizontalTest.textBuffer.length);
26 |
27 | // Update the position of the new character
28 | horizontalTest.textBuffer = Array(10).fill(horizontalTest.populateWord);
29 | const targetPosition = horizontalTest.targetPosition;
30 | const targetCharacter = horizontalTest.targetCharacter;
31 | horizontalTest.textBuffer.splice(targetPosition, 0, targetCharacter);
32 | }
33 | };
34 |
--------------------------------------------------------------------------------
/src/lib/test/tests/index.ts:
--------------------------------------------------------------------------------
1 | import { TestType } from "$lib/types/test";
2 | import type { Test } from "$lib/types/test";
3 | import { horizontalTest } from "./horizontal";
4 | import { containersTest } from "./containers";
5 | import { linesTest } from "./lines";
6 | import { movementTest } from "./movement";
7 | import { mixedTest } from "./mixed";
8 |
9 | export function handleTestModeChange(testMode: string): Test {
10 | switch (testMode) {
11 | case TestType.HORIZONTAL:
12 | return horizontalTest;
13 | case TestType.CONTAINERS:
14 | return containersTest;
15 | case TestType.LINES:
16 | return linesTest;
17 | case TestType.MOVEMENT:
18 | return movementTest;
19 | case TestType.MIXED:
20 | return mixedTest;
21 | }
22 | return horizontalTest;
23 | }
24 |
--------------------------------------------------------------------------------
/src/lib/test/tests/lines.ts:
--------------------------------------------------------------------------------
1 | import { TestType, type LinesTest } from "$lib/types/test";
2 | import { EXTRA_DELETE_SENTENCES } from "../constants";
3 |
4 | export const linesTest: LinesTest = {
5 | type: TestType.LINES,
6 | targetLine: EXTRA_DELETE_SENTENCES[1],
7 | targetPosition: 0,
8 | prompt: "Remove the sentences from the buffer (you can ONLY delete the sentence itself).",
9 | tip: "Tip: j and k are fast, but relative line jumping can be faster.",
10 | textBuffer: new Array(10).fill(".".repeat(EXTRA_DELETE_SENTENCES[1].length)),
11 | joinCharacter: "\n",
12 | condition: (currentBuffer: string) => {
13 | if (currentBuffer.length === 0) return false;
14 | if (linesTest.type !== TestType.LINES) return false;
15 |
16 | const parsedBuffer = currentBuffer.split("\n").join("");
17 | const rowLength = linesTest.textBuffer.length;
18 | const columnLength = linesTest.targetLine.length;
19 | return parsedBuffer === ".".repeat((rowLength - 1) * columnLength);
20 | },
21 | updateBuffer: () => {
22 | if (linesTest.type !== TestType.LINES) return;
23 |
24 | linesTest.targetPosition = Math.floor(Math.random() * linesTest.textBuffer.length);
25 | const randomTarget =
26 | EXTRA_DELETE_SENTENCES[Math.floor(Math.random() * EXTRA_DELETE_SENTENCES.length)];
27 | linesTest.targetLine = randomTarget;
28 | linesTest.textBuffer = Array(10).fill(".".repeat(linesTest.targetLine.length));
29 | linesTest.textBuffer[linesTest.targetPosition] = randomTarget;
30 | }
31 | };
32 |
--------------------------------------------------------------------------------
/src/lib/test/tests/mixed.ts:
--------------------------------------------------------------------------------
1 | import { TestType, type MixedTest, type Test } from "$lib/types/test";
2 | import { containersTest } from "./containers";
3 | import { linesTest } from "./lines";
4 | import { movementTest } from "./movement";
5 | import { horizontalTest } from "./horizontal";
6 |
7 | export let mixedTest: Test = {
8 | type: TestType.MIXED,
9 | targetLine: "",
10 | populateWord: "",
11 | targetCharacter: "",
12 | populateCharacter: "",
13 | targetPosition: 0,
14 | prompt: "A combination of all other tests (words, containers, lines, movement) into one.",
15 | tip: "Tip: Good luck, it's pretty hard.",
16 | textBuffer: [],
17 | joinCharacter: "",
18 | condition: () => false,
19 | updateBuffer: () => {
20 | const testTypes = [TestType.HORIZONTAL, TestType.CONTAINERS, TestType.LINES, TestType.MOVEMENT];
21 | const randomTest = testTypes[Math.floor(Math.random() * testTypes.length)];
22 | const savedUpdatedBuffer = mixedTest.updateBuffer;
23 | switch (randomTest) {
24 | case TestType.HORIZONTAL:
25 | horizontalTest.updateBuffer();
26 | mixedTest.type = horizontalTest.type;
27 | if (mixedTest.type !== TestType.HORIZONTAL) break;
28 |
29 | mixedTest.targetCharacter = horizontalTest.targetCharacter;
30 | mixedTest.populateWord = horizontalTest.populateWord;
31 | mixedTest.targetPosition = horizontalTest.targetPosition;
32 | mixedTest.textBuffer = horizontalTest.textBuffer;
33 | mixedTest.joinCharacter = horizontalTest.joinCharacter;
34 | mixedTest.condition = horizontalTest.condition;
35 | break;
36 | case TestType.CONTAINERS:
37 | containersTest.updateBuffer();
38 | mixedTest.type = containersTest.type;
39 | if (mixedTest.type !== TestType.CONTAINERS) break;
40 |
41 | mixedTest.textBuffer = containersTest.textBuffer;
42 | mixedTest.joinCharacter = containersTest.joinCharacter;
43 | mixedTest.condition = containersTest.condition;
44 | break;
45 | case TestType.LINES:
46 | linesTest.updateBuffer();
47 | mixedTest.type = linesTest.type;
48 | if (mixedTest.type !== TestType.LINES) break;
49 |
50 | mixedTest.targetLine = linesTest.targetLine;
51 | mixedTest.targetPosition = linesTest.targetPosition;
52 | mixedTest.textBuffer = linesTest.textBuffer;
53 | mixedTest.joinCharacter = linesTest.joinCharacter;
54 | mixedTest.condition = linesTest.condition;
55 | break;
56 | case TestType.MOVEMENT:
57 | movementTest.updateBuffer();
58 | mixedTest.type = movementTest.type;
59 | if (mixedTest.type !== TestType.MOVEMENT) break;
60 |
61 | mixedTest.targetCharacter = movementTest.targetCharacter;
62 | mixedTest.populateCharacter = movementTest.populateCharacter;
63 | mixedTest.targetPosition = movementTest.targetPosition;
64 | mixedTest.textBuffer = movementTest.textBuffer;
65 | mixedTest.joinCharacter = movementTest.joinCharacter;
66 | mixedTest.condition = movementTest.condition;
67 | break;
68 | }
69 | mixedTest.updateBuffer = savedUpdatedBuffer;
70 | }
71 | } satisfies MixedTest;
72 |
--------------------------------------------------------------------------------
/src/lib/test/tests/movement.ts:
--------------------------------------------------------------------------------
1 | import { TestType, type MovementTest } from "$lib/types/test";
2 | import { EXTRA_SYMBOLS } from "../constants";
3 |
4 | export const movementTest: MovementTest = {
5 | type: TestType.MOVEMENT,
6 | targetCharacter: EXTRA_SYMBOLS[0],
7 | populateCharacter: EXTRA_SYMBOLS[1],
8 | targetPosition: 0,
9 | prompt: "Remove the special character ('*', '#', '@', etc.) from the buffer.",
10 | tip: "Tip: move around using hjkl keys or use `/` as well.",
11 | textBuffer: new Array(8).fill(".........."),
12 | joinCharacter: "\n",
13 | condition: (currentBuffer: string) => {
14 | if (movementTest.type !== TestType.MOVEMENT) return false;
15 |
16 | const parsedBuffer = currentBuffer.split("\n").join("");
17 | const rowLength = movementTest.textBuffer.length;
18 | const columnLength = movementTest.textBuffer[0].length;
19 | return parsedBuffer === movementTest.populateCharacter.repeat(rowLength * columnLength - 1);
20 | },
21 | updateBuffer: () => {
22 | if (movementTest.type !== TestType.MOVEMENT) return;
23 |
24 | const rowLength = movementTest.textBuffer.length;
25 | const columnLength = movementTest.textBuffer[0].length;
26 |
27 | // Populate the text buffer with dots
28 | movementTest.populateCharacter = ".";
29 | movementTest.textBuffer = Array(rowLength).fill(
30 | movementTest.populateCharacter.repeat(columnLength)
31 | );
32 |
33 | // Randomly select a new target position
34 | const randomPosition = Math.random() * rowLength * columnLength;
35 | movementTest.targetPosition = Math.floor(randomPosition);
36 |
37 | // Change new target position to a new random target character
38 | const row = Math.floor(movementTest.targetPosition / columnLength);
39 | const column = Math.floor(movementTest.targetPosition % columnLength);
40 | do {
41 | movementTest.targetCharacter =
42 | EXTRA_SYMBOLS[Math.floor(Math.random() * EXTRA_SYMBOLS.length)];
43 | } while (movementTest.populateCharacter === movementTest.targetCharacter);
44 |
45 | let targetRow: string | string[] = [...movementTest.textBuffer[row]];
46 | targetRow[column] = movementTest.targetCharacter;
47 | targetRow = targetRow.join("");
48 | movementTest.textBuffer[row] = targetRow;
49 | }
50 | };
51 |
--------------------------------------------------------------------------------
/src/lib/types/profile.ts:
--------------------------------------------------------------------------------
1 | export type UserProfile = {
2 | avatar_url: string | null;
3 | created_at: string;
4 | email: string | null;
5 | id: string;
6 | username: string | null;
7 | };
8 |
--------------------------------------------------------------------------------
/src/lib/types/supabase.ts:
--------------------------------------------------------------------------------
1 | export type Json = string | number | boolean | null | { [key: string]: Json | undefined } | Json[];
2 |
3 | export type Database = {
4 | public: {
5 | Tables: {
6 | profiles: {
7 | Row: {
8 | avatar_url: string | null;
9 | created_at: string;
10 | email: string | null;
11 | id: string;
12 | username: string | null;
13 | };
14 | Insert: {
15 | avatar_url?: string | null;
16 | created_at?: string;
17 | email?: string | null;
18 | id: string;
19 | username?: string | null;
20 | };
21 | Update: {
22 | avatar_url?: string | null;
23 | created_at?: string;
24 | email?: string | null;
25 | id?: string;
26 | username?: string | null;
27 | };
28 | Relationships: [
29 | {
30 | foreignKeyName: "profiles_id_fkey";
31 | columns: ["id"];
32 | isOneToOne: true;
33 | referencedRelation: "users";
34 | referencedColumns: ["id"];
35 | }
36 | ];
37 | };
38 | user_stats: {
39 | Row: {
40 | containers_accuracy: number | null;
41 | containers_deletions_correct: number | null;
42 | containers_deletions_total: number | null;
43 | containers_tests_completed: number | null;
44 | containers_total_time: number | null;
45 | created_at: string;
46 | horizontal_accuracy: number | null;
47 | horizontal_deletions_correct: number | null;
48 | horizontal_deletions_total: number | null;
49 | horizontal_tests_completed: number | null;
50 | horizontal_total_time: number | null;
51 | id: string;
52 | lines_accuracy: number | null;
53 | lines_deletions_correct: number | null;
54 | lines_deletions_total: number | null;
55 | lines_tests_completed: number | null;
56 | lines_total_time: number | null;
57 | mixed_accuracy: number | null;
58 | mixed_deletions_correct: number | null;
59 | mixed_deletions_total: number | null;
60 | mixed_tests_completed: number | null;
61 | mixed_total_time: number | null;
62 | movement_accuracy: number | null;
63 | movement_deletions_correct: number | null;
64 | movement_deletions_total: number | null;
65 | movement_tests_completed: number | null;
66 | movement_total_time: number | null;
67 | tests_completed: number | null;
68 | tests_started: number | null;
69 | user_id: string | null;
70 | };
71 | Insert: {
72 | containers_accuracy?: number | null;
73 | containers_deletions_correct?: number | null;
74 | containers_deletions_total?: number | null;
75 | containers_tests_completed?: number | null;
76 | containers_total_time?: number | null;
77 | created_at?: string;
78 | horizontal_accuracy?: number | null;
79 | horizontal_deletions_correct?: number | null;
80 | horizontal_deletions_total?: number | null;
81 | horizontal_tests_completed?: number | null;
82 | horizontal_total_time?: number | null;
83 | id?: string;
84 | lines_accuracy?: number | null;
85 | lines_deletions_correct?: number | null;
86 | lines_deletions_total?: number | null;
87 | lines_tests_completed?: number | null;
88 | lines_total_time?: number | null;
89 | mixed_accuracy?: number | null;
90 | mixed_deletions_correct?: number | null;
91 | mixed_deletions_total?: number | null;
92 | mixed_tests_completed?: number | null;
93 | mixed_total_time?: number | null;
94 | movement_accuracy?: number | null;
95 | movement_deletions_correct?: number | null;
96 | movement_deletions_total?: number | null;
97 | movement_tests_completed?: number | null;
98 | movement_total_time?: number | null;
99 | tests_completed?: number | null;
100 | tests_started?: number | null;
101 | user_id?: string | null;
102 | };
103 | Update: {
104 | containers_accuracy?: number | null;
105 | containers_deletions_correct?: number | null;
106 | containers_deletions_total?: number | null;
107 | containers_tests_completed?: number | null;
108 | containers_total_time?: number | null;
109 | created_at?: string;
110 | horizontal_accuracy?: number | null;
111 | horizontal_deletions_correct?: number | null;
112 | horizontal_deletions_total?: number | null;
113 | horizontal_tests_completed?: number | null;
114 | horizontal_total_time?: number | null;
115 | id?: string;
116 | lines_accuracy?: number | null;
117 | lines_deletions_correct?: number | null;
118 | lines_deletions_total?: number | null;
119 | lines_tests_completed?: number | null;
120 | lines_total_time?: number | null;
121 | mixed_accuracy?: number | null;
122 | mixed_deletions_correct?: number | null;
123 | mixed_deletions_total?: number | null;
124 | mixed_tests_completed?: number | null;
125 | mixed_total_time?: number | null;
126 | movement_accuracy?: number | null;
127 | movement_deletions_correct?: number | null;
128 | movement_deletions_total?: number | null;
129 | movement_tests_completed?: number | null;
130 | movement_total_time?: number | null;
131 | tests_completed?: number | null;
132 | tests_started?: number | null;
133 | user_id?: string | null;
134 | };
135 | Relationships: [
136 | {
137 | foreignKeyName: "user_stats_user_id_fkey";
138 | columns: ["user_id"];
139 | isOneToOne: false;
140 | referencedRelation: "profiles";
141 | referencedColumns: ["id"];
142 | }
143 | ];
144 | };
145 | };
146 | Views: {
147 | [_ in never]: never;
148 | };
149 | Functions: {
150 | [_ in never]: never;
151 | };
152 | Enums: {
153 | [_ in never]: never;
154 | };
155 | CompositeTypes: {
156 | [_ in never]: never;
157 | };
158 | };
159 | };
160 |
161 | type PublicSchema = Database[Extract];
162 |
163 | export type Tables<
164 | PublicTableNameOrOptions extends
165 | | keyof (PublicSchema["Tables"] & PublicSchema["Views"])
166 | | { schema: keyof Database },
167 | TableName extends PublicTableNameOrOptions extends { schema: keyof Database }
168 | ? keyof (Database[PublicTableNameOrOptions["schema"]]["Tables"] &
169 | Database[PublicTableNameOrOptions["schema"]]["Views"])
170 | : never = never
171 | > = PublicTableNameOrOptions extends { schema: keyof Database }
172 | ? (Database[PublicTableNameOrOptions["schema"]]["Tables"] &
173 | Database[PublicTableNameOrOptions["schema"]]["Views"])[TableName] extends {
174 | Row: infer R;
175 | }
176 | ? R
177 | : never
178 | : PublicTableNameOrOptions extends keyof (PublicSchema["Tables"] & PublicSchema["Views"])
179 | ? (PublicSchema["Tables"] & PublicSchema["Views"])[PublicTableNameOrOptions] extends {
180 | Row: infer R;
181 | }
182 | ? R
183 | : never
184 | : never;
185 |
186 | export type TablesInsert<
187 | PublicTableNameOrOptions extends keyof PublicSchema["Tables"] | { schema: keyof Database },
188 | TableName extends PublicTableNameOrOptions extends { schema: keyof Database }
189 | ? keyof Database[PublicTableNameOrOptions["schema"]]["Tables"]
190 | : never = never
191 | > = PublicTableNameOrOptions extends { schema: keyof Database }
192 | ? Database[PublicTableNameOrOptions["schema"]]["Tables"][TableName] extends {
193 | Insert: infer I;
194 | }
195 | ? I
196 | : never
197 | : PublicTableNameOrOptions extends keyof PublicSchema["Tables"]
198 | ? PublicSchema["Tables"][PublicTableNameOrOptions] extends {
199 | Insert: infer I;
200 | }
201 | ? I
202 | : never
203 | : never;
204 |
205 | export type TablesUpdate<
206 | PublicTableNameOrOptions extends keyof PublicSchema["Tables"] | { schema: keyof Database },
207 | TableName extends PublicTableNameOrOptions extends { schema: keyof Database }
208 | ? keyof Database[PublicTableNameOrOptions["schema"]]["Tables"]
209 | : never = never
210 | > = PublicTableNameOrOptions extends { schema: keyof Database }
211 | ? Database[PublicTableNameOrOptions["schema"]]["Tables"][TableName] extends {
212 | Update: infer U;
213 | }
214 | ? U
215 | : never
216 | : PublicTableNameOrOptions extends keyof PublicSchema["Tables"]
217 | ? PublicSchema["Tables"][PublicTableNameOrOptions] extends {
218 | Update: infer U;
219 | }
220 | ? U
221 | : never
222 | : never;
223 |
224 | export type Enums<
225 | PublicEnumNameOrOptions extends keyof PublicSchema["Enums"] | { schema: keyof Database },
226 | EnumName extends PublicEnumNameOrOptions extends { schema: keyof Database }
227 | ? keyof Database[PublicEnumNameOrOptions["schema"]]["Enums"]
228 | : never = never
229 | > = PublicEnumNameOrOptions extends { schema: keyof Database }
230 | ? Database[PublicEnumNameOrOptions["schema"]]["Enums"][EnumName]
231 | : PublicEnumNameOrOptions extends keyof PublicSchema["Enums"]
232 | ? PublicSchema["Enums"][PublicEnumNameOrOptions]
233 | : never;
234 |
--------------------------------------------------------------------------------
/src/lib/types/test.ts:
--------------------------------------------------------------------------------
1 | type TypeMode = {
2 | type: string;
3 | variances: number[];
4 | };
5 |
6 | enum TestType {
7 | HORIZONTAL = "horizontal",
8 | CONTAINERS = "containers",
9 | LINES = "lines",
10 | MOVEMENT = "movement",
11 | MIXED = "mixed"
12 | }
13 |
14 | type BaseTest = {
15 | prompt: string;
16 | tip?: string;
17 | textBuffer: string[];
18 | joinCharacter: string;
19 | condition: (currentBuffer: string) => boolean;
20 | updateBuffer: () => void;
21 | };
22 |
23 | interface HorizontalTest extends BaseTest {
24 | type: TestType.HORIZONTAL;
25 | targetCharacter: string;
26 | populateWord: string;
27 | targetPosition: number;
28 | }
29 |
30 | interface ContainersTest extends BaseTest {
31 | type: TestType.CONTAINERS;
32 | }
33 |
34 | interface LinesTest extends BaseTest {
35 | type: TestType.LINES;
36 | targetLine: string;
37 | targetPosition: number;
38 | }
39 |
40 | interface MovementTest extends BaseTest {
41 | type: TestType.MOVEMENT;
42 | targetCharacter: string;
43 | populateCharacter: string;
44 | targetPosition: number;
45 | }
46 |
47 | interface MixedTest extends BaseTest {
48 | type: TestType.MIXED;
49 | targetLine: string;
50 | populateWord: string;
51 | targetCharacter: string;
52 | populateCharacter: string;
53 | targetPosition: number;
54 | }
55 |
56 | type Test = HorizontalTest | ContainersTest | LinesTest | MovementTest | MixedTest;
57 |
58 | export { TestType };
59 | export type {
60 | TypeMode,
61 | BaseTest,
62 | HorizontalTest,
63 | ContainersTest,
64 | LinesTest,
65 | MovementTest,
66 | MixedTest,
67 | Test
68 | };
69 |
--------------------------------------------------------------------------------
/src/routes/+error.svelte:
--------------------------------------------------------------------------------
1 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 | {$page.status} |
18 |
19 | {$page.error?.message}
20 |
21 |
22 |
25 |
26 |
--------------------------------------------------------------------------------
/src/routes/+layout.server.ts:
--------------------------------------------------------------------------------
1 | import type { LayoutServerLoad } from "./$types";
2 | import { error, redirect } from "@sveltejs/kit";
3 | import { loadFlash } from "sveltekit-flash-message/server";
4 |
5 | export const load: LayoutServerLoad = loadFlash(
6 | async ({ url, locals: { safeGetSession, supabase }, cookies }) => {
7 | const { session, user } = await safeGetSession();
8 |
9 | if (!session || !user) {
10 | return {
11 | session: null,
12 | user: null,
13 | profile: null,
14 | cookies: cookies.getAll()
15 | };
16 | }
17 |
18 | const userProfileQuery = await supabase
19 | .from("profiles")
20 | .select("*")
21 | .eq("id", user.id)
22 | .maybeSingle();
23 |
24 | if (userProfileQuery.error) {
25 | error(500, { message: userProfileQuery.error.message });
26 | }
27 |
28 | const userProfile = userProfileQuery.data;
29 |
30 | if (userProfile) {
31 | if (userProfile.username == null && !url.href.endsWith("account/create")) {
32 | redirect(303, "/account/create");
33 | }
34 |
35 | return {
36 | session,
37 | user,
38 | profile: userProfile,
39 | cookies: cookies.getAll()
40 | };
41 | }
42 |
43 | const insertNewProfileQuery = await supabase
44 | .from("profiles")
45 | .insert({
46 | id: user.id,
47 | email: user.email,
48 | avatar_url: user.user_metadata.avatar_url
49 | })
50 | .select("*")
51 | .single();
52 |
53 | if (insertNewProfileQuery.error || !insertNewProfileQuery.data) {
54 | error(500, { message: insertNewProfileQuery.error.message });
55 | }
56 | redirect(303, "/account/create");
57 | }
58 | );
59 |
--------------------------------------------------------------------------------
/src/routes/+layout.svelte:
--------------------------------------------------------------------------------
1 |
31 |
32 |
33 | Vimaroo | Practice your VIM skills
34 |
35 |
36 |
37 | {#key data.url}
38 |
39 | {#if $navigating}
40 |
41 |
42 |
43 | {:else}
44 |
45 |
46 |
47 | {/if}
48 |
49 | {/key}
50 |
51 |
52 |
53 |
54 |
55 |
--------------------------------------------------------------------------------
/src/routes/+layout.ts:
--------------------------------------------------------------------------------
1 | import { createBrowserClient, createServerClient, isBrowser } from "@supabase/ssr";
2 | import { PUBLIC_SUPABASE_ANON_KEY, PUBLIC_SUPABASE_URL } from "$env/static/public";
3 | import type { LayoutLoad } from "./$types";
4 |
5 | export const load: LayoutLoad = async ({ data, depends, fetch, url }) => {
6 | /**
7 | * Declare a dependency so the layout can be invalidated, for example, on
8 | * session refresh.
9 | */
10 | depends("supabase:auth");
11 |
12 | const supabase = isBrowser()
13 | ? createBrowserClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
14 | global: {
15 | fetch
16 | }
17 | })
18 | : createServerClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
19 | global: {
20 | fetch
21 | },
22 | cookies: {
23 | getAll() {
24 | return data.cookies;
25 | }
26 | }
27 | });
28 |
29 | const profile = data.profile;
30 |
31 | /**
32 | * It's fine to use `getSession` here, because on the client, `getSession` is
33 | * safe, and on the server, it reads `session` from the `LayoutData`, which
34 | * safely checked the session using `safeGetSession`.
35 | */
36 | const {
37 | data: { session }
38 | } = await supabase.auth.getSession();
39 |
40 | const {
41 | data: { user }
42 | } = await supabase.auth.getUser();
43 |
44 | return { session, supabase, user, profile, url: url.pathname };
45 | };
46 |
--------------------------------------------------------------------------------
/src/routes/+page.svelte:
--------------------------------------------------------------------------------
1 |
86 |
87 | {#if $flash && playFlashAnimation}
88 |
92 |
93 | {#if $flash.type === "success"}
94 |
99 | {:else}
100 |
105 | {/if}
106 |
107 | {$flash.message}
108 | ($flash = undefined)}>
109 |
110 |
111 |
112 | {/if}
113 |
114 |
115 | {#if !$testStarted || $testOver}
116 | {#if transitionReady}
117 |
118 |
119 |
120 | {/if}
121 | {:else}
122 |
123 | {#if ["time", "rounds"].includes(modeOptions[$selectedModeIndex])}
124 |
125 | {#if modeOptions[$selectedModeIndex] === "time"}
126 | {$timer}
127 | {:else}
128 | {$rounds}
129 | {/if}
130 |
131 |
132 | {$scores[0]} / {$scores[1]}
133 |
134 | {/if}
135 |
136 | {/if}
137 |
138 |
139 | {#key [testMode, typeMode, testTypeAmount, $asciiLogoEnabled, $fontSize, $wordWrapEnabled, $relativeLinesEnabled]}
140 |
141 | {/key}
142 |
143 |
144 |
--------------------------------------------------------------------------------
/src/routes/about/+page.svelte:
--------------------------------------------------------------------------------
1 |
2 |
3 | About vimaroo
4 |
5 |
6 |
7 | I'm Tomas and I love using Vim. This website was created with the intent of making it easy to
8 | practice Vim keybinds with a set of motion-focused test. It was inspired by ThePrimeagen's
13 | vim-be-good
14 |
15 | Neovim plugin and
16 | Monkeytype .
21 |
22 |
23 | How can I support this project?
24 |
25 | This project is free (and will continue to be free), but if you would like to support the
26 | project, you can drop a GitHub star ⭐ in the project's
27 |
28 |
33 | repository . Thank you!
35 |
36 |
37 | Encoutered a bug?
38 |
39 | You can submit bug reports in the project's
40 |
45 | GitHub repository
46 |
47 |
48 |
49 |
50 | Developed with 🔥 by
51 | Chom. @ 2024
52 |
53 |
54 |
--------------------------------------------------------------------------------
/src/routes/account/create/+page.server.ts:
--------------------------------------------------------------------------------
1 | import { error, fail, redirect, type Actions } from "@sveltejs/kit";
2 | import type { PageServerLoad } from "./$types";
3 |
4 | export const load: PageServerLoad = async ({ locals: { supabase, safeGetSession } }) => {
5 | const { session, user } = await safeGetSession();
6 | if (!session || !user) {
7 | error(400, "User not authenticated");
8 | }
9 |
10 | const profileQuery = await supabase
11 | .from("profiles")
12 | .select("username")
13 | .eq("id", user.id)
14 | .single();
15 |
16 | if (profileQuery.error) {
17 | error(500, { message: profileQuery.error.message });
18 | }
19 |
20 | const profile = profileQuery.data;
21 | if (profile && profile.username != null) {
22 | redirect(303, "/");
23 | }
24 | };
25 |
26 | export const actions: Actions = {
27 | createAccount: async ({ request, locals: { supabase, safeGetSession } }) => {
28 | const { session, user } = await safeGetSession();
29 |
30 | if (!session || !user) {
31 | return fail(400, { message: "Cannot create username without being logged in" });
32 | }
33 |
34 | const data = await request.formData();
35 | const username = data.get("username");
36 |
37 | if (!username || !username.toString()) {
38 | return fail(400, { message: "Username not provided" });
39 | }
40 |
41 | if (username.toString().length < 3 || username.toString().length > 32) {
42 | return fail(400, { message: "Username should be between 3-32 characters" });
43 | }
44 |
45 | const usernameQuery = await supabase
46 | .from("profiles")
47 | .select("*", { count: "exact" })
48 | .eq("username", username.toString());
49 |
50 | if (usernameQuery.error) {
51 | return fail(500, { message: "Something went wrong, please try again." });
52 | }
53 | if (usernameQuery.count) {
54 | return fail(400, { message: "Username already selected" });
55 | }
56 |
57 | const updateUsernameQuery = await supabase
58 | .from("profiles")
59 | .update({
60 | username: username.toString()
61 | })
62 | .eq("id", user.id);
63 |
64 | if (updateUsernameQuery.error) {
65 | error(500, { message: updateUsernameQuery.error.message });
66 | }
67 |
68 | // Create user stats
69 | const createUserStatsQuery = await supabase.from("user_stats").insert({
70 | user_id: user.id
71 | });
72 |
73 | if (createUserStatsQuery.error) {
74 | error(500, { message: createUserStatsQuery.error.message });
75 | }
76 |
77 | redirect(303, "/");
78 | }
79 | };
80 |
--------------------------------------------------------------------------------
/src/routes/account/create/+page.svelte:
--------------------------------------------------------------------------------
1 |
18 |
19 |
20 | Account Username
21 | Please provide a username to continue
22 |
48 |
49 |
--------------------------------------------------------------------------------
/src/routes/account/settings/+page.server.ts:
--------------------------------------------------------------------------------
1 | import { error, fail, redirect } from "@sveltejs/kit";
2 | import type { Actions, PageServerLoad } from "./$types";
3 | import { setFlash } from "sveltekit-flash-message/server";
4 |
5 | export const load: PageServerLoad = async ({ locals: { safeGetSession } }) => {
6 | const { user, session } = await safeGetSession();
7 | if (!user || !session) {
8 | redirect(303, "/");
9 | }
10 | };
11 |
12 | export const actions: Actions = {
13 | updateUsername: async ({ request, cookies, locals: { supabase, safeGetSession } }) => {
14 | const { user, session } = await safeGetSession();
15 | if (!user || !session) {
16 | error(401, "Unauthorized");
17 | }
18 |
19 | const data = await request.formData();
20 | const username = data.get("update-username");
21 |
22 | if (!username || !username.toString()) {
23 | setFlash({ type: "error", message: "Username not provided" }, cookies);
24 | return fail(400, { message: "Username not provided" });
25 | }
26 | if (username.toString().length < 3 || username.toString().length > 32) {
27 | setFlash({ type: "error", message: "Username should be between 3-32 characters" }, cookies);
28 | return fail(400, { message: "Username should be between 3-32 characters" });
29 | }
30 |
31 | const usernameQuery = await supabase
32 | .from("profiles")
33 | .select("*", { count: "exact" })
34 | .eq("username", username.toString());
35 |
36 | if (usernameQuery.error) {
37 | setFlash({ type: "error", message: "Something went wrong, please try again." }, cookies);
38 | return fail(500, { message: "Something went wrong, please try again." });
39 | }
40 | if (usernameQuery.count) {
41 | setFlash({ type: "error", message: "Username already selected." }, cookies);
42 | return fail(400, { message: "Username already selected." });
43 | }
44 |
45 | const updateUsernameQuery = await supabase
46 | .from("profiles")
47 | .update({
48 | username: username.toString()
49 | })
50 | .eq("id", user.id);
51 | if (updateUsernameQuery.error) {
52 | setFlash({ type: "error", message: updateUsernameQuery.error.message }, cookies);
53 | fail(500, { message: updateUsernameQuery.error.message });
54 | }
55 | setFlash({ type: "success", message: "Username updated successfully." }, cookies);
56 | },
57 | resetStats: async ({ cookies, locals: { supabase, safeGetSession } }) => {
58 | const { user, session } = await safeGetSession();
59 | if (!user || !session) {
60 | error(401, "Unauthorized");
61 | }
62 |
63 | const deleteStatsQuery = await supabase.from("user_stats").delete().eq("user_id", user.id);
64 | if (deleteStatsQuery.error) {
65 | setFlash({ type: "error", message: deleteStatsQuery.error.message }, cookies);
66 | fail(500, { message: deleteStatsQuery.error.message });
67 | }
68 | const resetStatsQuery = await supabase.from("user_stats").insert({ user_id: user.id });
69 | if (resetStatsQuery.error) {
70 | setFlash({ type: "error", message: resetStatsQuery.error.message }, cookies);
71 | fail(500, { message: resetStatsQuery.error.message });
72 | }
73 | setFlash({ type: "success", message: "Stats resetted successfully." }, cookies);
74 | },
75 | deleteAccount: async ({ fetch, cookies, locals: { supabase, safeGetSession } }) => {
76 | const { user, session } = await safeGetSession();
77 | if (!user || !session) {
78 | error(401, "Unauthorized");
79 | }
80 | // We can just delete from the profiles table since it will cascade
81 | // and delete the associated record in the user_stats table as well
82 | const deleteAccountQuery = await supabase.from("profiles").delete().eq("id", user.id);
83 | if (deleteAccountQuery.error) {
84 | setFlash({ type: "error", message: "Something went wrong, please try again." }, cookies);
85 | fail(500, { message: deleteAccountQuery.error.message });
86 | }
87 | // Sign out the user
88 | const response = await fetch("/api/logout", {
89 | method: "POST",
90 | headers: {
91 | "Content-Type": "application/json"
92 | }
93 | });
94 |
95 | if (!response.ok) {
96 | console.error("Failed to log out user.");
97 | }
98 | }
99 | };
100 |
--------------------------------------------------------------------------------
/src/routes/account/settings/+page.svelte:
--------------------------------------------------------------------------------
1 |
43 |
44 | {#if $flash && playFlashAnimation}
45 |
49 |
50 | {#if $flash.type === "success"}
51 |
56 | {:else}
57 |
62 | {/if}
63 |
64 | {$flash.message}
65 | ($flash = undefined)}>
66 |
67 |
68 |
69 | {/if}
70 |
71 |
72 | {#if formLoading}
73 |
74 |
75 |
76 | {:else}
77 | Account Settings
78 |
79 |
80 |
Change username
81 |
95 |
96 |
97 | Danger Zone
98 |
99 |
100 |
101 |
Reset user statistics
102 | (isResetStatsOpen = true)}
104 | class="rounded-lg border-2 border-foreground-red bg-background-500 p-2
105 | font-semibold text-foreground-red transition hover:brightness-110"
106 | >
107 | Reset User Stats
108 |
109 |
110 |
111 |
112 |
113 |
114 |
Delete vimaroo account
115 | (isDeleteOpen = true)}
117 | class="rounded-lg border-2 border-foreground-red bg-foreground-red p-2
118 | font-semibold text-foreground-neutral transition hover:brightness-110"
119 | >
120 | Delete Account
121 |
122 |
123 |
124 | {/if}
125 |
126 |
127 |
128 |
148 |
149 |
150 |
151 |
171 |
172 |
--------------------------------------------------------------------------------
/src/routes/api/login/+page.server.ts:
--------------------------------------------------------------------------------
1 | import type { Provider } from "@supabase/supabase-js";
2 | import type { Actions } from "./$types";
3 | import { fail, redirect } from "@sveltejs/kit";
4 |
5 | const OAUTH_PROVIDERS = ["google", "github"];
6 |
7 | export const actions: Actions = {
8 | login: async ({ url, locals: { supabase } }) => {
9 | const provider = url.searchParams.get("provider") as Provider;
10 | if (!provider) {
11 | return fail(422, { message: "Unable to process provider" });
12 | }
13 |
14 | if (!OAUTH_PROVIDERS.includes(provider)) {
15 | return fail(400, { message: "Provider not supported" });
16 | }
17 | const signInQuery = await supabase.auth.signInWithOAuth({
18 | provider: provider
19 | });
20 |
21 | if (signInQuery.error) {
22 | console.error(signInQuery.error.message);
23 | return fail(400, { message: "Something went wrong" });
24 | }
25 |
26 | redirect(303, signInQuery.data.url);
27 | }
28 | };
29 |
--------------------------------------------------------------------------------
/src/routes/api/logout/+server.ts:
--------------------------------------------------------------------------------
1 | import { error, redirect, type RequestHandler } from "@sveltejs/kit";
2 |
3 | export const POST: RequestHandler = async ({ locals: { supabase, safeGetSession } }) => {
4 | const session = await safeGetSession();
5 | if (!session) {
6 | error(401, "Unauthorized");
7 | }
8 |
9 | const { error: err } = await supabase.auth.signOut();
10 |
11 | if (err) {
12 | error(500, "Something went wrong signing out");
13 | }
14 |
15 | redirect(303, "/");
16 | };
17 |
--------------------------------------------------------------------------------
/src/routes/api/stats/increment/+server.ts:
--------------------------------------------------------------------------------
1 | import { json, type RequestHandler } from "@sveltejs/kit";
2 |
3 | export const POST: RequestHandler = async ({ request, locals: { supabase, safeGetSession } }) => {
4 | if (request.method === "OPTIONS") {
5 | return new Response("");
6 | }
7 |
8 | const { session, user } = await safeGetSession();
9 | if (!user || !session) {
10 | return json({ success: false, message: "Unauthorized" }, { status: 400 });
11 | }
12 |
13 | const testsStartedQuery = await supabase
14 | .from("user_stats")
15 | .select("tests_started")
16 | .eq("user_id", user.id)
17 | .single();
18 |
19 | if (testsStartedQuery.error) {
20 | return json({ success: false, message: testsStartedQuery.error.message }, { status: 500 });
21 | }
22 |
23 | const testsStarted = testsStartedQuery.data.tests_started;
24 |
25 | const updateTestsStartedQuery = await supabase
26 | .from("user_stats")
27 | .update({ tests_started: testsStarted != null ? testsStarted + 1 : 1 })
28 | .eq("user_id", user.id);
29 |
30 | if (updateTestsStartedQuery.error) {
31 | return json({ success: false, error: updateTestsStartedQuery.error.message }, { status: 500 });
32 | }
33 |
34 | return json({ success: true });
35 | };
36 |
--------------------------------------------------------------------------------
/src/routes/api/stats/update/+server.ts:
--------------------------------------------------------------------------------
1 | import { json, type RequestHandler } from "@sveltejs/kit";
2 |
3 | const horizontalStatsColumns =
4 | "horizontal_tests_completed, horizontal_deletions_total, horizontal_deletions_correct, horizontal_total_time";
5 | const containersStatsColumns =
6 | "containers_tests_completed, containers_deletions_total, containers_deletions_correct, containers_total_time";
7 | const linesStatsColumns =
8 | "lines_tests_completed, lines_deletions_total, lines_deletions_correct, lines_total_time";
9 | const movementStatsColumns =
10 | "movement_tests_completed, movement_deletions_total, movement_deletions_correct, movement_total_time";
11 | const mixedStatsColumns =
12 | "mixed_tests_completed, mixed_deletions_total, mixed_deletions_correct, mixed_total_time";
13 |
14 | export const POST: RequestHandler = async ({ request, locals: { supabase, safeGetSession } }) => {
15 | if (request.method === "OPTIONS") {
16 | return new Response("");
17 | }
18 |
19 | const { session, user } = await safeGetSession();
20 | if (!user || !session) {
21 | return json({ success: false, message: "Unauthorized" }, { status: 400 });
22 | }
23 |
24 | const { testName, deletionsCorrect, deletionsTotal, totalTime } = await request.json();
25 |
26 | if (
27 | typeof testName != "string" ||
28 | typeof deletionsCorrect !== "number" ||
29 | typeof deletionsTotal !== "number" ||
30 | typeof totalTime !== "number"
31 | ) {
32 | return json({ success: false, error: "Invalid input data." }, { status: 500 });
33 | }
34 |
35 | const testsTotalQuery = await supabase
36 | .from("user_stats")
37 | .select("tests_started, tests_completed")
38 | .eq("user_id", user.id)
39 | .single();
40 |
41 | if (testsTotalQuery.error || !testsTotalQuery.data) {
42 | return json({ success: false, error: testsTotalQuery.error.message }, { status: 500 });
43 | }
44 |
45 | const testsStarted = testsTotalQuery.data.tests_started;
46 | const testsCompleted = testsTotalQuery.data.tests_completed;
47 |
48 | const updateTestsTotalQuery = await supabase
49 | .from("user_stats")
50 | .update({
51 | tests_started: testsStarted ? testsStarted + 1 : 1,
52 | tests_completed: testsCompleted ? testsCompleted + 1 : 1
53 | })
54 | .eq("user_id", user.id);
55 |
56 | if (updateTestsTotalQuery.error) {
57 | return json({ success: false, error: updateTestsTotalQuery.error.message }, { status: 500 });
58 | }
59 |
60 | switch (testName) {
61 | case "horizontal":
62 | const horizontalStats = await supabase
63 | .from("user_stats")
64 | .select(horizontalStatsColumns)
65 | .eq("user_id", user.id)
66 | .single();
67 |
68 | if (horizontalStats.error) {
69 | return json({ success: false, error: horizontalStats.error.message }, { status: 500 });
70 | }
71 | if (!horizontalStats.data) {
72 | return json({ sucess: false, error: "Failed retrieving user stats." }, { status: 500 });
73 | }
74 |
75 | const horizontalTestsCompleted = horizontalStats.data.horizontal_tests_completed;
76 | const horizontalDeletionsCorrect = horizontalStats.data.horizontal_deletions_correct;
77 | const horizontalDeletionsTotal = horizontalStats.data.horizontal_deletions_total;
78 | const horizontalTotalTime = horizontalStats.data.horizontal_total_time;
79 |
80 | const updateHorizontalStatsResult = await supabase
81 | .from("user_stats")
82 | .update({
83 | horizontal_tests_completed: horizontalTestsCompleted ? horizontalTestsCompleted + 1 : 1,
84 | horizontal_deletions_correct: horizontalDeletionsCorrect
85 | ? horizontalDeletionsCorrect + deletionsCorrect
86 | : deletionsCorrect,
87 | horizontal_deletions_total: horizontalDeletionsTotal
88 | ? horizontalDeletionsTotal + deletionsTotal
89 | : deletionsTotal,
90 | horizontal_total_time: horizontalTotalTime ? horizontalTotalTime + totalTime : totalTime
91 | })
92 | .eq("user_id", user.id);
93 |
94 | if (updateHorizontalStatsResult.error) {
95 | return json(
96 | { sucess: false, error: updateHorizontalStatsResult.error.message },
97 | { status: 500 }
98 | );
99 | }
100 |
101 | break;
102 | case "containers":
103 | const containersStats = await supabase
104 | .from("user_stats")
105 | .select(containersStatsColumns)
106 | .eq("user_id", user.id)
107 | .single();
108 |
109 | if (containersStats.error) {
110 | return json({ success: false, error: containersStats.error.message }, { status: 500 });
111 | }
112 | if (!containersStats.data) {
113 | return json({ sucess: false, error: "Failed retrieving user stats." }, { status: 500 });
114 | }
115 |
116 | const containersTestsCompleted = containersStats.data.containers_tests_completed;
117 | const containersDeletionsCorrect = containersStats.data.containers_deletions_correct;
118 | const containersDeletionsTotal = containersStats.data.containers_deletions_total;
119 | const containersTotalTime = containersStats.data.containers_total_time;
120 |
121 | const updateContainersStatsResult = await supabase
122 | .from("user_stats")
123 | .update({
124 | containers_tests_completed: containersTestsCompleted ? containersTestsCompleted + 1 : 1,
125 | containers_deletions_correct: containersDeletionsCorrect
126 | ? containersDeletionsCorrect + deletionsCorrect
127 | : deletionsCorrect,
128 | containers_deletions_total: containersDeletionsTotal
129 | ? containersDeletionsTotal + deletionsTotal
130 | : deletionsTotal,
131 | containers_total_time: containersTotalTime ? containersTotalTime + totalTime : totalTime
132 | })
133 | .eq("user_id", user.id);
134 |
135 | if (updateContainersStatsResult.error) {
136 | return json(
137 | { sucess: false, error: updateContainersStatsResult.error.message },
138 | { status: 500 }
139 | );
140 | }
141 |
142 | break;
143 | case "lines":
144 | const linesStats = await supabase
145 | .from("user_stats")
146 | .select(linesStatsColumns)
147 | .eq("user_id", user.id)
148 | .single();
149 |
150 | if (linesStats.error) {
151 | return json({ success: false, error: linesStats.error.message }, { status: 500 });
152 | }
153 | if (!linesStats.data) {
154 | return json({ sucess: false, error: "Failed retrieving user stats." }, { status: 500 });
155 | }
156 |
157 | const linesTestsCompleted = linesStats.data.lines_tests_completed;
158 | const linesDeletionsCorrect = linesStats.data.lines_deletions_correct;
159 | const linesDeletionsTotal = linesStats.data.lines_deletions_total;
160 | const linesTotalTime = linesStats.data.lines_total_time;
161 |
162 | const updateLinesStatsResult = await supabase
163 | .from("user_stats")
164 | .update({
165 | lines_tests_completed: linesTestsCompleted ? linesTestsCompleted + 1 : 1,
166 | lines_deletions_correct: linesDeletionsCorrect
167 | ? linesDeletionsCorrect + deletionsCorrect
168 | : deletionsCorrect,
169 | lines_deletions_total: linesDeletionsTotal
170 | ? linesDeletionsTotal + deletionsTotal
171 | : deletionsTotal,
172 | lines_total_time: linesTotalTime ? linesTotalTime + totalTime : totalTime
173 | })
174 | .eq("user_id", user.id);
175 |
176 | if (updateLinesStatsResult.error) {
177 | return json(
178 | { sucess: false, error: updateLinesStatsResult.error.message },
179 | { status: 500 }
180 | );
181 | }
182 |
183 | break;
184 | case "movement":
185 | const movementStats = await supabase
186 | .from("user_stats")
187 | .select(movementStatsColumns)
188 | .eq("user_id", user.id)
189 | .single();
190 |
191 | if (movementStats.error) {
192 | return json({ success: false, error: movementStats.error.message }, { status: 500 });
193 | }
194 | if (!movementStats.data) {
195 | return json({ sucess: false, error: "Failed retrieving user stats." }, { status: 500 });
196 | }
197 |
198 | const movementTestsCompleted = movementStats.data.movement_tests_completed;
199 | const movementDeletionsCorrect = movementStats.data.movement_deletions_correct;
200 | const movementDeletionsTotal = movementStats.data.movement_deletions_total;
201 | const movementTotalTime = movementStats.data.movement_total_time;
202 |
203 | const updateMovementStatsResult = await supabase
204 | .from("user_stats")
205 | .update({
206 | movement_tests_completed: movementTestsCompleted ? movementTestsCompleted + 1 : 1,
207 | movement_deletions_correct: movementDeletionsCorrect
208 | ? movementDeletionsCorrect + deletionsCorrect
209 | : deletionsCorrect,
210 | movement_deletions_total: movementDeletionsTotal
211 | ? movementDeletionsTotal + deletionsTotal
212 | : deletionsTotal,
213 | movement_total_time: movementTotalTime ? movementTotalTime + totalTime : totalTime
214 | })
215 | .eq("user_id", user.id);
216 |
217 | if (updateMovementStatsResult.error) {
218 | return json(
219 | { sucess: false, error: updateMovementStatsResult.error.message },
220 | { status: 500 }
221 | );
222 | }
223 |
224 | break;
225 | case "mixed":
226 | const mixedStats = await supabase
227 | .from("user_stats")
228 | .select(mixedStatsColumns)
229 | .eq("user_id", user.id)
230 | .single();
231 |
232 | if (mixedStats.error) {
233 | return json({ success: false, error: mixedStats.error.message }, { status: 500 });
234 | }
235 | if (!mixedStats.data) {
236 | return json({ sucess: false, error: "Failed retrieving user stats." }, { status: 500 });
237 | }
238 |
239 | const mixedTestsCompleted = mixedStats.data.mixed_tests_completed;
240 | const mixedDeletionsCorrect = mixedStats.data.mixed_deletions_correct;
241 | const mixedDeletionsTotal = mixedStats.data.mixed_deletions_total;
242 | const mixedTotalTime = mixedStats.data.mixed_total_time;
243 |
244 | const updateMixedStatsResult = await supabase
245 | .from("user_stats")
246 | .update({
247 | mixed_tests_completed: mixedTestsCompleted ? mixedTestsCompleted + 1 : 1,
248 | mixed_deletions_correct: mixedDeletionsCorrect
249 | ? mixedDeletionsCorrect + deletionsCorrect
250 | : deletionsCorrect,
251 | mixed_deletions_total: mixedDeletionsTotal
252 | ? mixedDeletionsTotal + deletionsTotal
253 | : deletionsTotal,
254 | mixed_total_time: mixedTotalTime ? mixedTotalTime + totalTime : totalTime
255 | })
256 | .eq("user_id", user.id);
257 |
258 | if (updateMixedStatsResult.error) {
259 | return json(
260 | { sucess: false, error: updateMixedStatsResult.error.message },
261 | { status: 500 }
262 | );
263 | }
264 |
265 | break;
266 | default:
267 | return json({ sucess: false, error: "Invalid test" }, { status: 400 });
268 | }
269 |
270 | return json({ success: true });
271 | };
272 |
--------------------------------------------------------------------------------
/src/routes/auth/callback/+server.ts:
--------------------------------------------------------------------------------
1 | import { redirect } from "@sveltejs/kit";
2 | import type { RequestEvent } from "./$types";
3 |
4 | export const GET = async (event: RequestEvent) => {
5 | const {
6 | url,
7 | locals: { supabase }
8 | } = event;
9 | const code = url.searchParams.get("code") as string;
10 | const next = url.searchParams.get("next") ?? "/";
11 |
12 | if (code) {
13 | const { error } = await supabase.auth.exchangeCodeForSession(code);
14 | if (!error) {
15 | throw redirect(303, `/${next.slice(1)}`);
16 | }
17 | }
18 |
19 | // return the user to an error page with instructions
20 | throw redirect(303, "/auth/auth-code-error");
21 | };
22 |
--------------------------------------------------------------------------------
/src/routes/privacy/+page.svelte:
--------------------------------------------------------------------------------
1 |
2 | Vimaroo Privacy Policy
3 | Effective Date: August 30, 2024
4 |
5 | Thank you for using vimaroo! Your privacy is important to us. This policy explains how we handle
6 | your information when you use this application.
7 |
8 |
9 | 1. Information We Collect
10 | We collect your:
11 |
12 | Email
13 | Username
14 | Photo URL (based on the OAuth provider)
15 | How many tests you started
16 | Your site settings
17 |
18 |
19 | 2. How We Use Your Information
20 |
21 |
22 | Authentication: We use your OAuth information solely to authenticate your identity
23 | when you log in to vimaroo.
24 |
25 |
26 | User statistics: We keep track of the number of tests you started, deletions per
27 | second (dps), and test accuracy. This information is viewable by the user.
28 |
29 |
30 | Site settings: We persist your editor settings across browser sessions.
31 |
32 |
33 |
34 | 2. The Information We Share
35 |
36 | Aside of displaying user profiles/statistics on our website, we do not share your information
37 | with any third parties.
38 |
39 |
40 | 3. Contact Us
41 |
42 | Questions? Reach out at tomasoh@csu.fullerton.edu .
47 |
48 |
49 |
--------------------------------------------------------------------------------
/src/routes/profile/[user]/+page.server.ts:
--------------------------------------------------------------------------------
1 | import { error } from "@sveltejs/kit";
2 | import type { PageServerLoad } from "./$types";
3 | import { getTestStats } from "$lib/db/stats";
4 |
5 | export const load: PageServerLoad = async ({ url, locals: { supabase } }) => {
6 | const username = url.toString().substring(url.toString().lastIndexOf("/") + 1);
7 | const profileQuery = await supabase
8 | .from("profiles")
9 | .select("*")
10 | .eq("username", username)
11 | .maybeSingle();
12 |
13 | if (profileQuery.error) {
14 | error(500, { message: profileQuery.error.message });
15 | }
16 |
17 | const profile = profileQuery.data;
18 | if (!profile) {
19 | error(404, "Not found");
20 | }
21 |
22 | const userStatsQuery = await supabase
23 | .from("user_stats")
24 | .select("*")
25 | .eq("user_id", profile.id)
26 | .single();
27 |
28 | if (userStatsQuery.error) {
29 | error(500, { message: userStatsQuery.error.message });
30 | }
31 |
32 | const stats = userStatsQuery.data;
33 |
34 | const overallStats = {
35 | testsStarted: stats.tests_started ?? 0,
36 | testsCompleted: stats.tests_completed ?? 0
37 | };
38 |
39 | const testStats = getTestStats(stats);
40 |
41 | return {
42 | profile: profile,
43 | overallStats: overallStats,
44 | testStats: testStats
45 | };
46 | };
47 |
--------------------------------------------------------------------------------
/src/routes/profile/[user]/+page.svelte:
--------------------------------------------------------------------------------
1 |
17 |
18 |
19 |
20 |
21 |
22 |
23 | {profile.username}
24 | Joined in: {createdAt}
25 |
26 |
27 |
30 |
31 | Tests started:
32 | {overallStats.testsStarted}
33 |
34 |
35 | Tests completed:
36 | {overallStats.testsCompleted}
37 |
38 |
39 |
40 |
41 |
42 | {#each testStats as test}
43 |
44 |
{test.testName}:
45 |
46 |
47 | {test.testsCompleted ?? "-"}
48 | tests
49 |
50 |
51 | {test.dps?.toFixed(2) ?? "-"}
54 | dps
55 |
56 |
57 | {test.accuracy != null ? (test.accuracy * 100).toFixed(1) + "%" : "-"}
60 | accuracy
61 |
62 |
63 |
64 | {/each}
65 |
66 |
67 |
--------------------------------------------------------------------------------
/static/vimaroo-demo.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tomasohCHOM/vimaroo/d745a0ebaa67dcaafe7a2a82683a01d965d80f0c/static/vimaroo-demo.gif
--------------------------------------------------------------------------------
/svelte.config.js:
--------------------------------------------------------------------------------
1 | import adapter from "@sveltejs/adapter-auto";
2 | import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
3 |
4 | /** @type {import('@sveltejs/kit').Config} */
5 | const config = {
6 | // Consult https://kit.svelte.dev/docs/integrations#preprocessors
7 | // for more information about preprocessors
8 | preprocess: vitePreprocess(),
9 |
10 | kit: {
11 | // adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list.
12 | // If your environment is not supported, or you settled on a specific environment, switch out the adapter.
13 | // See https://kit.svelte.dev/docs/adapters for more information about adapters.
14 | adapter: adapter()
15 | }
16 | };
17 |
18 | export default config;
19 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | export default {
3 | content: ["./src/**/*.{html,js,svelte,ts}"],
4 | theme: {
5 | extend: {
6 | colors: {
7 | "background-400": "rgb(var(--color-background-400))",
8 | "background-500": "rgb(var(--color-background-500))",
9 | "background-600": "rgb(var(--color-background-600))",
10 |
11 | "foreground-neutral": "rgb(var(--color-foreground-neutral))",
12 | "foreground-red": "rgb(var(--color-foreground-red))",
13 | "foreground-green": "rgb(var(--color-foreground-green))",
14 | "foreground-blue": "rgb(var(--color-foreground-blue))"
15 | },
16 | fontFamily: {
17 | general: "var(--ff-general)",
18 | code: "var(--ff-code)"
19 | }
20 | }
21 | },
22 | corePlugins: {
23 | listStyleType: true
24 | }
25 | };
26 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./.svelte-kit/tsconfig.json",
3 | "compilerOptions": {
4 | "allowJs": true,
5 | "checkJs": true,
6 | "esModuleInterop": true,
7 | "forceConsistentCasingInFileNames": true,
8 | "resolveJsonModule": true,
9 | "skipLibCheck": true,
10 | "sourceMap": true,
11 | "strict": true,
12 | "moduleResolution": "bundler"
13 | }
14 | // Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias
15 | // except $lib which is handled by https://kit.svelte.dev/docs/configuration#files
16 | //
17 | // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
18 | // from the referenced tsconfig.json - TypeScript does not merge them in
19 | }
20 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { sveltekit } from "@sveltejs/kit/vite";
2 | import { defineConfig } from "vite";
3 |
4 | export default defineConfig({
5 | plugins: [sveltekit()]
6 | });
7 |
--------------------------------------------------------------------------------