diff --git a/backend/__tests__/__integration__/dal/user.spec.ts b/backend/__tests__/__integration__/dal/user.spec.ts index ba0ecdd469a3..a93ff9c87952 100644 --- a/backend/__tests__/__integration__/dal/user.spec.ts +++ b/backend/__tests__/__integration__/dal/user.spec.ts @@ -1,12 +1,12 @@ -import { describe, it, expect, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; +import { CustomThemeColors } from "@monkeytype/schemas/configs"; +import { PersonalBest, PersonalBests } from "@monkeytype/schemas/shared"; +import { MonkeyMail, ResultFilters } from "@monkeytype/schemas/users"; +import { ObjectId } from "mongodb"; import * as UserDAL from "../../../src/dal/user"; -import * as UserTestData from "../../__testData__/users"; import { createConnection as createFriend } from "../../__testData__/connections"; -import { ObjectId } from "mongodb"; -import { MonkeyMail, ResultFilters } from "@monkeytype/schemas/users"; -import { PersonalBest, PersonalBests } from "@monkeytype/schemas/shared"; -import { CustomThemeColors } from "@monkeytype/schemas/configs"; +import * as UserTestData from "../../__testData__/users"; const mockPersonalBest: PersonalBest = { acc: 1, @@ -1122,6 +1122,55 @@ describe("UserDal", () => { expect(year2024[93]).toEqual(2); }); }); + + describe("getUser", () => { + it("should get with missing personalBests", async () => { + //GIVEN + let user = await UserTestData.createUser({ personalBests: undefined }); + + //WHEN + const read = await UserDAL.getUser(user.uid, "read"); + + expect(read.personalBests).toEqual({ + custom: {}, + quote: {}, + time: {}, + words: {}, + zen: {}, + }); + }); + }); + + describe("getUserByName", () => { + it("should get with missing personalBests", async () => { + //GIVEN + let user = await UserTestData.createUser({ personalBests: undefined }); + + //WHEN + const read = await UserDAL.getUserByName(user.name, "read"); + + expect(read.personalBests).toEqual({ + custom: {}, + quote: {}, + time: {}, + words: {}, + zen: {}, + }); + }); + }); + + describe("getPersonalBests", () => { + it("should get with missing personalBests", async () => { + //GIVEN + let user = await UserTestData.createUser({ personalBests: undefined }); + + //WHEN + const read = await UserDAL.getPersonalBests(user.uid, "time", "15"); + + expect(read).toBeUndefined(); + }); + }); + describe("getPartialUser", () => { it("should throw for unknown user", async () => { await expect(async () => @@ -1156,6 +1205,24 @@ describe("UserDal", () => { }, }); }); + it("should get with missing personalBests", async () => { + //GIVEN + let user = await UserTestData.createUser({ personalBests: undefined }); + + //WHEN + const read = await UserDAL.getPartialUser(user.uid, "read", [ + "uid", + "personalBests", + ]); + + expect(read.personalBests).toEqual({ + custom: {}, + quote: {}, + time: {}, + words: {}, + zen: {}, + }); + }); }); describe("updateEmail", () => { it("throws for nonexisting user", async () => { diff --git a/backend/src/dal/user.ts b/backend/src/dal/user.ts index c9d3fcfcc5ef..8349bf8bb674 100644 --- a/backend/src/dal/user.ts +++ b/backend/src/dal/user.ts @@ -27,7 +27,12 @@ import { CountByYearAndDay, Friend, } from "@monkeytype/schemas/users"; -import { Mode, Mode2, PersonalBest } from "@monkeytype/schemas/shared"; +import { + Mode, + Mode2, + PersonalBest, + PersonalBests, +} from "@monkeytype/schemas/shared"; import { addImportantLog } from "./logs"; import { Result as ResultType } from "@monkeytype/schemas/results"; import { Configuration } from "@monkeytype/schemas/configuration"; @@ -246,7 +251,7 @@ export async function updateEmail( export async function getUser(uid: string, stack: string): Promise { const user = await getUsersCollection().findOne({ uid }); if (!user) throw new MonkeyError(404, "User not found", stack); - return user; + return migrateUser(user); } /** @@ -263,10 +268,16 @@ export async function getPartialUser( fields: K[], ): Promise> { const projection = new Map(fields.map((it) => [it, 1])); - const results = await getUsersCollection().findOne({ uid }, { projection }); - if (results === null) throw new MonkeyError(404, "User not found", stack); + const partialUser = await getUsersCollection().findOne( + { uid }, + { projection }, + ); + if (partialUser === null) throw new MonkeyError(404, "User not found", stack); - return results; + if (fields.includes("personalBests" as K)) { + return migrateUser(partialUser); + } + return partialUser; } export async function findByName(name: string): Promise { @@ -294,7 +305,7 @@ export async function getUserByName( ): Promise { const user = await findByName(name); if (!user) throw new MonkeyError(404, "User not found", stack); - return user; + return migrateUser(user); } export async function isDiscordIdAvailable( @@ -1359,3 +1370,15 @@ export async function getFriends(uid: string): Promise { ], ); } + +function migrateUser(user: T): T { + user.personalBests ??= { + time: {}, + words: {}, + quote: {}, + zen: {}, + custom: {}, + }; + + return user; +} diff --git a/frontend/__tests__/utils/ip-addresses.spec.ts b/frontend/__tests__/utils/ip-addresses.spec.ts index 3dbf07f950c7..46cb8b15f8e8 100644 --- a/frontend/__tests__/utils/ip-addresses.spec.ts +++ b/frontend/__tests__/utils/ip-addresses.spec.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from "vitest"; import * as IpAddresses from "../../src/ts/utils/ip-addresses"; -const IP_GENERATE_COUNT = 1000; +const IP_GENERATE_COUNT = 50; describe("IP Addresses", () => { describe("Compressing IPv6", () => { @@ -37,25 +37,17 @@ describe("IP Addresses", () => { describe("Generating IPv4", () => { it("should generate valid IPv4 addresses", () => { - // We generate a set number of ip addresses dictated by the constant for (let i = 0; i < IP_GENERATE_COUNT; i++) { const ipAddress = IpAddresses.getRandomIPv4address(); - const splitIpAddress = ipAddress.split("."); + const parts = ipAddress.split("."); - expect(splitIpAddress.length, "Make sure there are four parts").toEqual( - 4, - ); + expect(parts).toHaveLength(4); - for (let j = 0; j < 4; j++) { - const currentNumber = Number(splitIpAddress[j]); - expect( - currentNumber, - "Each part of an IPv4 should be >= 0", - ).toBeGreaterThanOrEqual(0); - expect( - currentNumber, - "Each part of an IPv4 should be <= 255", - ).toBeLessThanOrEqual(255); + for (const part of parts) { + const num = Number(part); + expect(num).toBeGreaterThanOrEqual(0); + expect(num).toBeLessThanOrEqual(255); + expect(Number.isInteger(num)).toBe(true); } } }); @@ -65,37 +57,18 @@ describe("IP Addresses", () => { it("should generate valid IPv6 addresses", () => { for (let i = 0; i < IP_GENERATE_COUNT; i++) { const ipAddress = IpAddresses.getRandomIPv6address(); - const splitIpAddress = ipAddress.split(":"); + const parts = ipAddress.split(":"); - expect( - splitIpAddress.length, - "Make sure there are eight parts", - ).toEqual(8); + expect(parts).toHaveLength(8); - for (let j = 0; j < 8; j++) { - const currentPart = splitIpAddress[j] as string; - expect( - currentPart.length, - "Each part of an IPv6 should be between 1 and 4 characters", - ).toBeGreaterThanOrEqual(1); - expect( - currentPart.length, - "Each part of an IPv6 should be between 1 and 4 characters", - ).toBeLessThanOrEqual(4); + for (const part of parts) { + expect(part.length).toBeGreaterThanOrEqual(1); + expect(part.length).toBeLessThanOrEqual(4); - const currentNumber = parseInt(currentPart, 16); - expect( - currentNumber, - "Each part of an IPv6 should be a valid hexadecimal number", - ).not.toBeNaN(); - expect( - currentNumber, - "Each part of an IPv6 should be >= 0", - ).toBeGreaterThanOrEqual(0); - expect( - currentNumber, - "Each part of an IPv6 should be <= 65535", - ).toBeLessThanOrEqual(65535); + const num = parseInt(part, 16); + expect(num).not.toBeNaN(); + expect(num).toBeGreaterThanOrEqual(0); + expect(num).toBeLessThanOrEqual(0xffff); } } }); diff --git a/frontend/src/404.html b/frontend/src/404.html index e6f49e41220c..ac3020f2ead8 100644 --- a/frontend/src/404.html +++ b/frontend/src/404.html @@ -63,6 +63,6 @@

- + diff --git a/frontend/src/email-handler.html b/frontend/src/email-handler.html index 6eb5728b37fa..324965b0087c 100644 --- a/frontend/src/email-handler.html +++ b/frontend/src/email-handler.html @@ -4,11 +4,7 @@ Email Handler | Monkeytype - - - + Privacy Policy | Monkeytype - - - + diff --git a/frontend/src/security-policy.html b/frontend/src/security-policy.html index 41432356f486..08c60504ec3c 100644 --- a/frontend/src/security-policy.html +++ b/frontend/src/security-policy.html @@ -4,11 +4,7 @@ Security Policy | Monkeytype - - - + diff --git a/frontend/src/styles/standalone.scss b/frontend/src/styles/standalone.scss new file mode 100644 index 000000000000..4e1289cacbce --- /dev/null +++ b/frontend/src/styles/standalone.scss @@ -0,0 +1,13 @@ +@import "balloon-css/balloon.min.css"; +:root { + --bg-color: #323437; + --main-color: #e2b714; + --caret-color: #e2b714; + --sub-color: #646669; + --sub-alt-color: #2c2e31; + --text-color: #d1d0c5; + --error-color: #ca4754; + --error-extra-color: #7e2a33; + --colorful-error-color: #ca4754; + --colorful-error-extra-color: #7e2a33; +} diff --git a/frontend/src/terms-of-service.html b/frontend/src/terms-of-service.html index 4d29f5d3fd54..16f729bb4b63 100644 --- a/frontend/src/terms-of-service.html +++ b/frontend/src/terms-of-service.html @@ -4,11 +4,7 @@ Terms of Service | Monkeytype - - - + diff --git a/frontend/src/ts/components/utils/TailwindMediaQueryDebugger.tsx b/frontend/src/ts/components/utils/TailwindMediaQueryDebugger.tsx index 0ad30def93d8..53da2f95869e 100644 --- a/frontend/src/ts/components/utils/TailwindMediaQueryDebugger.tsx +++ b/frontend/src/ts/components/utils/TailwindMediaQueryDebugger.tsx @@ -1,11 +1,12 @@ -import { JSXElement, Show } from "solid-js"; +import { JSXElement, Match, Show, Switch } from "solid-js"; +import { bp } from "../../signals/breakpoints"; import { isDevEnvironment } from "../../utils/misc"; export function TailwindMediaQueryDebugger(): JSXElement { return ( -
+
@@ -13,6 +14,17 @@ export function TailwindMediaQueryDebugger(): JSXElement {
xxs
+
+ / + + 2xl + xl + lg + md + sm + xs + +
); diff --git a/frontend/src/ts/elements/account/pb-tables.ts b/frontend/src/ts/elements/account/pb-tables.ts index 8a56e2f74333..55b46cccf875 100644 --- a/frontend/src/ts/elements/account/pb-tables.ts +++ b/frontend/src/ts/elements/account/pb-tables.ts @@ -3,9 +3,12 @@ import { format as dateFormat } from "date-fns/format"; import Format from "../../utils/format"; import { Mode2, PersonalBests } from "@monkeytype/schemas/shared"; import { StringNumber } from "@monkeytype/schemas/util"; +import { qs } from "../../utils/dom"; function clearTables(isProfile: boolean): void { - const source = isProfile ? "Profile" : "Account"; + const targetElement = isProfile + ? qs(".pageProfile .profile") + : qs(".pageAccount .profile"); const showAllButton = `
`; - $(`.page${source} .profile .pbsTime`).append(text + showAllButton); + targetElement?.qs(".pbsTime")?.appendHtml(text + showAllButton); text = ""; wordMode2s.forEach((mode2) => { text += buildPbHtml(personalBests, "words", mode2); }); - $(`.page${source} .profile .pbsWords`).append(text + showAllButton); + targetElement?.qs(".pbsWords")?.appendHtml(text + showAllButton); } function buildPbHtml( diff --git a/frontend/src/ts/hooks/useTailwindBreakpoints.ts b/frontend/src/ts/hooks/useTailwindBreakpoints.ts deleted file mode 100644 index c155b2fbcc16..000000000000 --- a/frontend/src/ts/hooks/useTailwindBreakpoints.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { Accessor, createSignal, onCleanup, onMount } from "solid-js"; -import { debounce } from "throttle-debounce"; - -type Breakpoints = { - xxs: boolean; - xs: boolean; - sm: boolean; - md: boolean; - lg: boolean; - xl: boolean; - "2xl": boolean; -}; - -export function useTailwindBreakpoints( - debounceMs = 125, -): Accessor { - const [breakpoints, setBreakpoints] = createSignal( - undefined, - ); - - const updateBreakpoints = (): void => { - const styles = getComputedStyle(document.documentElement); - - const breakpoints = { - xxs: parseInt(styles.getPropertyValue("--breakpoint-xxs")), - xs: parseInt(styles.getPropertyValue("--breakpoint-xs")), - sm: parseInt(styles.getPropertyValue("--breakpoint-sm")), - md: parseInt(styles.getPropertyValue("--breakpoint-md")), - lg: parseInt(styles.getPropertyValue("--breakpoint-lg")), - xl: parseInt(styles.getPropertyValue("--breakpoint-xl")), - "2xl": parseInt(styles.getPropertyValue("--breakpoint-2xl")), - }; - - const currentWidth = window.innerWidth; - - setBreakpoints({ - xxs: true, - xs: currentWidth >= breakpoints.xs, - sm: currentWidth >= breakpoints.sm, - md: currentWidth >= breakpoints.md, - lg: currentWidth >= breakpoints.lg, - xl: currentWidth >= breakpoints.xl, - "2xl": currentWidth >= breakpoints["2xl"], - }); - }; - - const debouncedUpdate = debounce(debounceMs, updateBreakpoints); - - onMount(() => { - updateBreakpoints(); - window.addEventListener("resize", debouncedUpdate); - }); - - onCleanup(() => { - window.removeEventListener("resize", debouncedUpdate); - }); - - return breakpoints; -} diff --git a/frontend/src/ts/pages/account.ts b/frontend/src/ts/pages/account.ts index 87ad92d7d22a..a80d2f66d7f3 100644 --- a/frontend/src/ts/pages/account.ts +++ b/frontend/src/ts/pages/account.ts @@ -1253,7 +1253,7 @@ export const page = new Page({ historyTable ??= new SortedTableWithLimit>({ limit: 10, - table: ".pageAccount .content .history table", + table: qsr(".pageAccount .content .history table"), data: filteredResults, buildRow: (val) => { return buildResultRow(val); diff --git a/frontend/src/ts/pages/friends.ts b/frontend/src/ts/pages/friends.ts index 5e7704f027db..0d3157f26899 100644 --- a/frontend/src/ts/pages/friends.ts +++ b/frontend/src/ts/pages/friends.ts @@ -223,7 +223,7 @@ function updateFriends(): void { if (friendsTable === undefined) { friendsTable = new SortedTable({ - table: ".pageFriends .friends table", + table: qsr(".pageFriends .friends table"), data: friendsList, buildRow: buildFriendRow, persistence: new LocalStorageWithSchema({ diff --git a/frontend/src/ts/signals/breakpoints.ts b/frontend/src/ts/signals/breakpoints.ts new file mode 100644 index 000000000000..343ece2d6089 --- /dev/null +++ b/frontend/src/ts/signals/breakpoints.ts @@ -0,0 +1,53 @@ +import { Accessor, createSignal, onCleanup } from "solid-js"; +import { debounce } from "throttle-debounce"; + +type BreakpointKeys = "xxl" | "xl" | "lg" | "md" | "sm" | "xs" | "xxs"; +type Breakpoints = Record; + +const styles = getComputedStyle(document.documentElement); +const tw: Record = { + xxs: parseInt(styles.getPropertyValue("--breakpoint-xxs")), + xs: parseInt(styles.getPropertyValue("--breakpoint-xs")), + sm: parseInt(styles.getPropertyValue("--breakpoint-sm")), + md: parseInt(styles.getPropertyValue("--breakpoint-md")), + lg: parseInt(styles.getPropertyValue("--breakpoint-lg")), + xl: parseInt(styles.getPropertyValue("--breakpoint-xl")), + xxl: parseInt(styles.getPropertyValue("--breakpoint-2xl")), +}; + +export const bp = createBreakpoints(tw); + +function createBreakpoints( + breakpoints: Record, +): Accessor { + const queries = Object.fromEntries( + Object.entries(breakpoints).map(([key, px]) => [ + key, + window.matchMedia(`(min-width: ${px}px)`), + ]), + ); + + const [matches, setMatches] = createSignal( + Object.fromEntries(Object.entries(queries).map(([k, q]) => [k, q.matches])), + ); + + const update = debounce(125, () => + setMatches( + Object.fromEntries( + Object.entries(queries).map(([k, q]) => [k, q.matches]), + ), + ), + ); + + for (const q of Object.values(queries)) { + q.addEventListener("change", update); + } + + onCleanup(() => { + for (const q of Object.values(queries)) { + q.removeEventListener("change", update); + } + }); + + return matches as Accessor>; +} diff --git a/frontend/src/ts/utils/dom.ts b/frontend/src/ts/utils/dom.ts index f9774b6e69c5..0a1f31de73d9 100644 --- a/frontend/src/ts/utils/dom.ts +++ b/frontend/src/ts/utils/dom.ts @@ -517,11 +517,34 @@ export class ElementWithUtils { /** * Append a child element */ - append(element: HTMLElement | ElementWithUtils): this { - if (element instanceof ElementWithUtils) { - this.native.appendChild(element.native); + append( + elementOrElements: + | HTMLElement + | ElementWithUtils + | HTMLElement[] + | ElementsWithUtils + | ElementWithUtils[], + ): this { + if (elementOrElements instanceof ElementsWithUtils) { + this.native.append(...elementOrElements.native); + return this; + } + + if (Array.isArray(elementOrElements)) { + for (const element of elementOrElements) { + if (element instanceof ElementWithUtils) { + this.native.append(element.native); + } else { + this.native.append(element); + } + } + return this; + } + + if (elementOrElements instanceof ElementWithUtils) { + this.native.appendChild(elementOrElements.native); } else { - this.native.append(element); + this.native.append(elementOrElements); } return this; } @@ -996,6 +1019,16 @@ export class ElementsWithUtils< } return this; } + + /** + * Append HTML string to all elements in the array + */ + appendHtml(htmlString: string): this { + for (const item of this) { + item.appendHtml(htmlString); + } + return this; + } } function checkUniqueSelector( diff --git a/frontend/src/ts/utils/misc.ts b/frontend/src/ts/utils/misc.ts index 102d85279395..988e343e32a5 100644 --- a/frontend/src/ts/utils/misc.ts +++ b/frontend/src/ts/utils/misc.ts @@ -734,7 +734,7 @@ export function debounceUntilResolved( } export function triggerResize(): void { - $(window).trigger("resize"); + window.dispatchEvent(new Event("resize")); } export type RequiredProperties = Omit & diff --git a/frontend/src/ts/utils/sorted-table.ts b/frontend/src/ts/utils/sorted-table.ts index 4c37d33db9c9..061eb9f42b52 100644 --- a/frontend/src/ts/utils/sorted-table.ts +++ b/frontend/src/ts/utils/sorted-table.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { ElementWithUtils } from "./dom"; export const SortSchema = z.object({ property: z.string(), @@ -12,7 +13,7 @@ type Persistence = { }; type SortedTableOptions = { - table: string; + table: ElementWithUtils; data?: T[]; buildRow: (entry: T) => HTMLTableRowElement; } & ( @@ -22,16 +23,13 @@ type SortedTableOptions = { export class SortedTable { protected data: { source: T; element?: HTMLTableRowElement }[] = []; - private table: JQuery; + private table: ElementWithUtils; private buildRow: (entry: T) => HTMLTableRowElement; private sort?: Sort; private persistence?: Persistence; constructor(options: SortedTableOptions) { - this.table = $(options.table); - if (this.table === undefined) { - throw new Error(`No element found for ${options.table}`); - } + this.table = options.table; this.buildRow = options.buildRow; @@ -49,10 +47,10 @@ export class SortedTable { } //init headers - for (const col of this.table.find(`td[data-sort-property]`)) { - col.classList.add("sortable"); + for (const col of this.table.qsa(`td[data-sort-property]`)) { + col.addClass("sortable"); col.setAttribute("type", "button"); - col.onclick = (e: MouseEvent) => { + col.on("click", (e: MouseEvent) => { const target = e.currentTarget as HTMLElement; const property = target.dataset["sortProperty"] as string; const defaultDirection = @@ -69,7 +67,7 @@ export class SortedTable { this.setSort(updatedSort); this.updateBody(); - }; + }); } } @@ -89,12 +87,12 @@ export class SortedTable { const { property, descending } = this.sort; // Removes styling from previous sorting requests: - this.table.find("thead td").removeClass("headerSorted"); - this.table.find("thead td").children("i").remove(); + this.table.qsa("thead td").removeClass("headerSorted"); + this.table.qsa("thead td > i").remove(); this.table - .find(`thead td[data-sort-property="${property}"]`) + .qsa(`thead td[data-sort-property="${property}"]`) .addClass("headerSorted") - .append( + .appendHtml( ``, @@ -124,9 +122,9 @@ export class SortedTable { }); } public updateBody(): void { - const body = this.table.find("tbody"); - body.empty(); - body.append( + const body = this.table.qs("tbody"); + body?.empty(); + body?.append( this.getData().map((data) => { data.element ??= this.buildRow(data.source); return data.element;