mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-01 07:37:55 -05:00
Move app to apps/web-react in pnpm workspace layout
This commit is contained in:
206
apps/web-react/app/utils/Test.ts
Normal file
206
apps/web-react/app/utils/Test.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import type {
|
||||
ActionFunctionArgs,
|
||||
LoaderFunctionArgs,
|
||||
Params,
|
||||
} from "react-router";
|
||||
import { expect } from "vitest";
|
||||
import type { z } from "zod";
|
||||
import { REGULAR_USER_TEST_ID } from "~/db/seed/constants";
|
||||
import { actAs } from "~/db/seed/core/actAs";
|
||||
import { ADMIN_ID } from "~/features/admin/admin-constants";
|
||||
import { SESSION_KEY } from "~/features/auth/core/authenticator.server";
|
||||
import { authSessionStorage } from "~/features/auth/core/session.server";
|
||||
import {
|
||||
getUserFromRequest,
|
||||
userAsyncLocalStorage,
|
||||
} from "~/features/auth/core/user-context.server";
|
||||
import { logger } from "./logger";
|
||||
|
||||
/**
|
||||
* The user a wrapped action/loader call runs as: one of the pinned seed users,
|
||||
* or any user's id — scenario tests' users are participants and staff the test
|
||||
* itself created, not fixed ids.
|
||||
*/
|
||||
export type TestUser = "admin" | "regular" | number;
|
||||
|
||||
export function arrayContainsSameItems<T>(arr1: T[], arr2: T[]) {
|
||||
return (
|
||||
arr1.length === arr2.length && arr1.every((item) => arr2.includes(item))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs `fn` inside the user AsyncLocalStorage store so that repository functions
|
||||
* resolving the actor via `actorId()` / `actorIdOrNull()` see the user as the acting
|
||||
* one. Use in direct repository unit tests, which run outside a request.
|
||||
*
|
||||
* An id is all it takes: repositories read the actor solely through `actorId()`.
|
||||
*/
|
||||
export function withUserId<T>(id: number, fn: () => T): T {
|
||||
return actAs(id, fn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs `fn` inside a user AsyncLocalStorage store with no acting user, mirroring an
|
||||
* anonymous visitor's request. Repository functions resolving the actor via
|
||||
* `actorIdOrNull()` then see `null`, as they would inside a real request context.
|
||||
*/
|
||||
export function withNoUser<T>(fn: () => T): T {
|
||||
return userAsyncLocalStorage.run({ user: undefined }, fn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps an action function to provide a strongly-typed, reusable handler for executing actions
|
||||
* in unit tests as if it was a normal function. The returned function allows you to pass
|
||||
* parameters that match the schema defined by the action, and it simulates a request with
|
||||
* authentication headers based on the provided user type.
|
||||
*
|
||||
* @example
|
||||
* import { someAction } from "../actions/some.action.server";
|
||||
*
|
||||
* const someAction = wrappedAction<typeof someActionSchema>({ action });
|
||||
*/
|
||||
export function wrappedAction<T extends z.ZodTypeAny>({
|
||||
action,
|
||||
/** Is this action submitted as json (via SendouForm) */
|
||||
isJsonSubmission = false,
|
||||
}: {
|
||||
action: (args: ActionFunctionArgs) => any;
|
||||
isJsonSubmission?: boolean;
|
||||
}) {
|
||||
return async (
|
||||
args: z.infer<T>,
|
||||
{ user, params = {} }: { user?: TestUser; params?: Params<string> } = {},
|
||||
) => {
|
||||
const body = isJsonSubmission
|
||||
? JSON.stringify(args)
|
||||
: new URLSearchParams(args as any);
|
||||
const request = new Request("http://app.com/path", {
|
||||
method: "POST",
|
||||
body,
|
||||
headers: [
|
||||
...(await authHeader(user)),
|
||||
[
|
||||
"Content-Type",
|
||||
isJsonSubmission
|
||||
? "application/json"
|
||||
: "application/x-www-form-urlencoded",
|
||||
],
|
||||
],
|
||||
});
|
||||
|
||||
const userFromRequest = await getUserFromRequest(
|
||||
request,
|
||||
new URL(request.url),
|
||||
);
|
||||
|
||||
return userAsyncLocalStorage.run({ user: userFromRequest }, async () => {
|
||||
try {
|
||||
const response = await action({
|
||||
request,
|
||||
context: {} as any,
|
||||
params,
|
||||
pattern: "",
|
||||
url: new URL(request.url),
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (thrown) {
|
||||
// we only log errors in vitest for failed tests so this is okay (more context)
|
||||
logger.error("Error in wrappedAction:", thrown);
|
||||
|
||||
if (thrown instanceof Response) {
|
||||
// it was a redirect
|
||||
if (thrown.status === 302) return thrown;
|
||||
|
||||
throw new Error(`Response thrown with status code: ${thrown.status}`);
|
||||
}
|
||||
|
||||
throw thrown;
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function wrappedLoader<T>({
|
||||
loader,
|
||||
}: {
|
||||
loader: (args: LoaderFunctionArgs) => any;
|
||||
}) {
|
||||
return async ({
|
||||
user,
|
||||
params = {},
|
||||
url = "/path",
|
||||
}: {
|
||||
user?: TestUser;
|
||||
params?: Params<string>;
|
||||
/** Path with its search params, built with the route's search params definition. */
|
||||
url?: string;
|
||||
} = {}) => {
|
||||
const request = new Request(new URL(url, "http://app.com"), {
|
||||
method: "GET",
|
||||
headers: [
|
||||
...(await authHeader(user)),
|
||||
["Content-Type", "application/x-www-form-urlencoded"],
|
||||
],
|
||||
});
|
||||
|
||||
const userFromRequest = await getUserFromRequest(
|
||||
request,
|
||||
new URL(request.url),
|
||||
);
|
||||
|
||||
return userAsyncLocalStorage.run({ user: userFromRequest }, async () => {
|
||||
try {
|
||||
const data = await loader({
|
||||
request,
|
||||
params,
|
||||
context: {} as any,
|
||||
pattern: "",
|
||||
url: new URL(request.url),
|
||||
});
|
||||
|
||||
return data as T;
|
||||
} catch (thrown) {
|
||||
if (thrown instanceof Response) {
|
||||
throw new Error(`Response thrown with status code: ${thrown.status}`);
|
||||
}
|
||||
|
||||
throw thrown;
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given response errored out (with a toast message, via `errorToastIfFalsy(cond)` call)
|
||||
*
|
||||
* @param response - The HTTP response object to check.
|
||||
* @param message - Optional. The expected error toast message shown to the user.
|
||||
*/
|
||||
export function assertResponseErrored(response: Response, message?: string) {
|
||||
if (!response) {
|
||||
throw new Error(`Expected a Response, got: ${response}`);
|
||||
}
|
||||
|
||||
expect(response.headers.get("Location")).toContain("?__error=");
|
||||
if (message) {
|
||||
expect(response.headers.get("Location")).toContain(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function authHeader(user?: TestUser): Promise<[string, string][]> {
|
||||
if (user === undefined) return [];
|
||||
|
||||
const session = await authSessionStorage.getSession();
|
||||
|
||||
session.set(SESSION_KEY, testUserId(user));
|
||||
|
||||
return [["Cookie", await authSessionStorage.commitSession(session)]];
|
||||
}
|
||||
|
||||
function testUserId(user: Exclude<TestUser, undefined>): number {
|
||||
if (typeof user === "number") return user;
|
||||
|
||||
return user === "admin" ? ADMIN_ID : REGULAR_USER_TEST_ID;
|
||||
}
|
||||
25
apps/web-react/app/utils/action-schemas.ts
Normal file
25
apps/web-react/app/utils/action-schemas.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { z } from "zod";
|
||||
|
||||
/** `_action` literals of an action schema (union or single branch). */
|
||||
export type ActionsOf<TSchema extends z.ZodTypeAny> =
|
||||
z.infer<TSchema> extends {
|
||||
_action: infer TAction extends string;
|
||||
}
|
||||
? TAction
|
||||
: never;
|
||||
|
||||
/** Non-`_action` fields of the schema branch matching the given action, in parsed types. */
|
||||
export type FieldsOf<
|
||||
TSchema extends z.ZodTypeAny,
|
||||
TAction extends string,
|
||||
> = Omit<Extract<z.infer<TSchema>, { _action: TAction }>, "_action">;
|
||||
|
||||
/** Serializes a parsed field value into its form data representation. */
|
||||
export function serializeFieldValue(value: unknown) {
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
70
apps/web-react/app/utils/arrays.test.ts
Normal file
70
apps/web-react/app/utils/arrays.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { diff, flatZip, mostPopularArrayElement } from "./arrays";
|
||||
|
||||
describe("diff", () => {
|
||||
test.each([
|
||||
[
|
||||
[1, 2, 3],
|
||||
[2, 3, 4, 4],
|
||||
[4, 4],
|
||||
],
|
||||
[[1, 2, 3], [], []],
|
||||
[[], [1, 2, 3], [1, 2, 3]],
|
||||
[
|
||||
[1, 2, 2, 3],
|
||||
[2, 2, 3, 3, 4],
|
||||
[3, 4],
|
||||
],
|
||||
[[1, 2, 3], [1, 2, 3], []],
|
||||
])("%j vs %j leaves %j", (arr1, arr2, expected) => {
|
||||
expect(diff(arr1, arr2)).toEqual(expected);
|
||||
});
|
||||
|
||||
test("does not overflow the stack for very large counts", () => {
|
||||
const arr2 = new Array(200_000).fill(1);
|
||||
|
||||
expect(diff([], arr2)).toHaveLength(200_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mostPopularArrayElement", () => {
|
||||
test.each([
|
||||
[[1, 2, 2, 3, 3, 3, 4], 3],
|
||||
[["a", "b", "b", "c", "a", "b"], "b"],
|
||||
// the first of the tied elements wins
|
||||
[[1, 2, 2, 1], 1],
|
||||
[["only"], "only"],
|
||||
[[], null],
|
||||
] as [(string | number)[], string | number | null][])(
|
||||
"%j is most popularly %j",
|
||||
(arr, expected) => {
|
||||
expect(mostPopularArrayElement(arr)).toBe(expected);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("flatZip", () => {
|
||||
test.each([
|
||||
[
|
||||
[1, 2, 3],
|
||||
["a", "b", "c"],
|
||||
[1, "a", 2, "b", 3, "c"],
|
||||
],
|
||||
[
|
||||
[1, 2],
|
||||
["a", "b", "c"],
|
||||
[1, "a", 2, "b", "c"],
|
||||
],
|
||||
[
|
||||
[1, 2, 3, 4],
|
||||
["a", "b"],
|
||||
[1, "a", 2, "b", 3, 4],
|
||||
],
|
||||
[[], ["a", "b"], ["a", "b"]],
|
||||
[[1, 2], [], [1, 2]],
|
||||
[[], [], []],
|
||||
[[1], ["a"], [1, "a"]],
|
||||
])("zips %j and %j into %j", (arr1, arr2, expected) => {
|
||||
expect(flatZip(arr1, arr2)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
110
apps/web-react/app/utils/arrays.ts
Normal file
110
apps/web-react/app/utils/arrays.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
// TODO: when more examples of permissions profile difference between
|
||||
// this implementation and one that takes arrays
|
||||
|
||||
// (not all arrays need to necessarily run but they need to be defined)
|
||||
export function allTruthy(arr: unknown[]) {
|
||||
return arr.every(Boolean);
|
||||
}
|
||||
|
||||
export function normalizeFormFieldArray(
|
||||
value: undefined | null | string | string[],
|
||||
): string[] {
|
||||
return value == null ? [] : typeof value === "string" ? [value] : value;
|
||||
}
|
||||
|
||||
export function nullFilledArray(size: number): null[] {
|
||||
return new Array(size).fill(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the average of an array of numbers. If the array is empty, returns null.
|
||||
*
|
||||
* @param values - An array of numbers to calculate the average of.
|
||||
* @returns The average of the numbers in the array, or null if the array is empty.
|
||||
*/
|
||||
export function nullifyingAvg(values: number[]) {
|
||||
if (values.length === 0) return null;
|
||||
return values.reduce((acc, cur) => acc + cur, 0) / values.length;
|
||||
}
|
||||
|
||||
function countElements<T>(arr: T[]): Map<T, number> {
|
||||
const counts = new Map<T, number>();
|
||||
|
||||
for (const element of arr) {
|
||||
const count = counts.get(element) ?? 0;
|
||||
counts.set(element, count + 1);
|
||||
}
|
||||
|
||||
return counts;
|
||||
}
|
||||
|
||||
/** Returns list of elements that are in arr2 but not in arr1. Supports duplicates */
|
||||
export function diff<T extends string | number>(arr1: T[], arr2: T[]): T[] {
|
||||
const arr1Counts = countElements(arr1);
|
||||
const arr2Counts = countElements(arr2);
|
||||
|
||||
const diff = new Map<T, number>();
|
||||
|
||||
for (const [element, count] of arr2Counts) {
|
||||
const diffCount = Math.max(count - (arr1Counts.get(element) ?? 0), 0);
|
||||
diff.set(element, diffCount);
|
||||
}
|
||||
|
||||
const result: T[] = [];
|
||||
|
||||
for (const [element, count] of diff) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
result.push(element);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function mostPopularArrayElement<T>(arr: T[]): T | null {
|
||||
if (arr.length === 0) return null;
|
||||
|
||||
const counts = countElements(arr);
|
||||
let mostPopularElement: T | null = null;
|
||||
let maxCount = 0;
|
||||
|
||||
for (const [element, count] of counts) {
|
||||
if (count > maxCount) {
|
||||
maxCount = count;
|
||||
mostPopularElement = element;
|
||||
}
|
||||
}
|
||||
|
||||
return mostPopularElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely zips two arrays together by alternating elements. If arrays have different lengths,
|
||||
* zips as much as possible, then appends remaining elements from the longer array.
|
||||
*
|
||||
* @param arr1 - The first array
|
||||
* @param arr2 - The second array
|
||||
* @returns Alternating elements from both arrays: [arr1[0], arr2[0], arr1[1], arr2[1], ...]
|
||||
* followed by any remaining elements from the longer array
|
||||
*
|
||||
* @example
|
||||
* zipSafe([1, 2], ['a', 'b']) // [1, 'a', 2, 'b']
|
||||
* zipSafe([1, 2], ['a', 'b', 'c']) // [1, 'a', 2, 'b', 'c']
|
||||
* zipSafe([1, 2, 3], ['a', 'b']) // [1, 'a', 2, 'b', 3]
|
||||
*/
|
||||
export function flatZip<T, U>(arr1: T[], arr2: U[]): Array<T | U> {
|
||||
const result: Array<T | U> = [];
|
||||
const minLength = Math.min(arr1.length, arr2.length);
|
||||
|
||||
for (let i = 0; i < minLength; i++) {
|
||||
result.push(arr1[i], arr2[i]);
|
||||
}
|
||||
|
||||
if (arr1.length > minLength) {
|
||||
result.push(...arr1.slice(minLength));
|
||||
} else if (arr2.length > minLength) {
|
||||
result.push(...arr2.slice(minLength));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
33
apps/web-react/app/utils/cache.server.ts
Normal file
33
apps/web-react/app/utils/cache.server.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { CacheEntry } from "@epic-web/cachified";
|
||||
import { ServerConfig } from "~/config.server";
|
||||
import { LRUCache } from "~/modules/cache";
|
||||
|
||||
declare global {
|
||||
// This preserves the LRU cache during development
|
||||
var __lruCache: LRUCache<string, CacheEntry<unknown>> | undefined;
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noAssignInExpressions: trick to only create one
|
||||
export const cache = (global.__lruCache = global.__lruCache
|
||||
? global.__lruCache
|
||||
: new LRUCache<string, CacheEntry<unknown>>({ max: 5000 }));
|
||||
|
||||
export const ttl = (ms: number) => (ServerConfig.disableCache ? 0 : ms);
|
||||
|
||||
export function syncCached<T>(key: string, getFreshValue: () => T) {
|
||||
if (cache.has(key)) {
|
||||
return cache.get(key) as T;
|
||||
}
|
||||
|
||||
const value = getFreshValue();
|
||||
cache.set(key, value as any);
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
export const IN_MILLISECONDS = {
|
||||
HALF_HOUR: 30 * 60 * 1000,
|
||||
ONE_HOUR: 60 * 60 * 1000,
|
||||
TWO_HOURS: 2 * 60 * 60 * 1000,
|
||||
TWO_DAYS: 2 * 24 * 60 * 60 * 1000,
|
||||
};
|
||||
63
apps/web-react/app/utils/compression.test.ts
Normal file
63
apps/web-react/app/utils/compression.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { compressToBase64, decompressFromBase64 } from "./compression";
|
||||
|
||||
describe("compressToBase64 & decompressFromBase64", () => {
|
||||
test("round-trips a string", () => {
|
||||
const value = JSON.stringify({ name: "trophy", mesh: [1, 2, 3] });
|
||||
|
||||
expect(decompressFromBase64(compressToBase64(value))).toBe(value);
|
||||
});
|
||||
|
||||
test("decompresses raw deflate produced by another implementation", () => {
|
||||
const compressed = "q1bKS8xNVbJSKinKL8ioVNJRyk0tzlCyijbUMdIxjq0FAA==";
|
||||
|
||||
expect(decompressFromBase64(compressed)).toBe(
|
||||
JSON.stringify({ name: "trophy", mesh: [1, 2, 3] }),
|
||||
);
|
||||
});
|
||||
|
||||
test("round-trips unicode content", () => {
|
||||
const value = "tröphy \u{1f3c6} テスト";
|
||||
|
||||
expect(decompressFromBase64(compressToBase64(value))).toBe(value);
|
||||
});
|
||||
|
||||
test("round-trips with the url safe alphabet", () => {
|
||||
const value = "a".repeat(1000) + JSON.stringify({ b: [4, 5, 6] });
|
||||
const compressed = compressToBase64(value, { urlSafe: true });
|
||||
|
||||
expect(compressed).not.toMatch(/[+/=]/);
|
||||
expect(decompressFromBase64(compressed)).toBe(value);
|
||||
});
|
||||
|
||||
test("returns null for corrupt input", () => {
|
||||
expect(decompressFromBase64("!!!not base64!!!")).toBeNull();
|
||||
expect(decompressFromBase64(btoa("not deflate data"))).toBeNull();
|
||||
expect(decompressFromBase64("")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for truncated input", () => {
|
||||
const compressed = compressToBase64("some longer input to compress");
|
||||
|
||||
expect(decompressFromBase64(compressed.slice(0, 4))).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for a value inflating past maxDecompressedBytes", () => {
|
||||
const bomb = compressToBase64("a".repeat(10 * 1024 * 1024));
|
||||
|
||||
expect(bomb.length).toBeLessThan(64 * 1024);
|
||||
expect(
|
||||
decompressFromBase64(bomb, { maxDecompressedBytes: 64 * 1024 }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test("allows a value exactly at maxDecompressedBytes", () => {
|
||||
const value = "a".repeat(1024);
|
||||
|
||||
expect(
|
||||
decompressFromBase64(compressToBase64(value), {
|
||||
maxDecompressedBytes: 1024,
|
||||
}),
|
||||
).toBe(value);
|
||||
});
|
||||
});
|
||||
76
apps/web-react/app/utils/compression.ts
Normal file
76
apps/web-react/app/utils/compression.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { deflateSync, Inflate, strToU8 } from "fflate";
|
||||
|
||||
/**
|
||||
* Compresses a string with raw deflate and encodes the result as base64.
|
||||
* With `urlSafe` the output uses the URL-safe base64 alphabet without padding.
|
||||
*/
|
||||
export function compressToBase64(
|
||||
value: string,
|
||||
options?: { urlSafe?: boolean },
|
||||
) {
|
||||
const bytes = deflateSync(strToU8(value), { level: 9 });
|
||||
let binary = "";
|
||||
|
||||
for (const byte of bytes) {
|
||||
binary += String.fromCharCode(byte);
|
||||
}
|
||||
|
||||
const base64 = btoa(binary);
|
||||
if (!options?.urlSafe) return base64;
|
||||
|
||||
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Decompresses a base64 encoded (standard or URL-safe alphabet) raw deflate
|
||||
* string. Returns `null` if the input is corrupt or, when
|
||||
* `maxDecompressedBytes` is given, if it inflates past that limit (a
|
||||
* decompression bomb guard for attacker controlled input).
|
||||
*/
|
||||
export function decompressFromBase64(
|
||||
compressed: string,
|
||||
options?: { maxDecompressedBytes?: number },
|
||||
) {
|
||||
const maxDecompressedBytes =
|
||||
options?.maxDecompressedBytes ?? Number.POSITIVE_INFINITY;
|
||||
|
||||
try {
|
||||
const base64 = compressed.replace(/-/g, "+").replace(/_/g, "/");
|
||||
|
||||
const chunks: Array<Uint8Array> = [];
|
||||
let decompressedBytes = 0;
|
||||
const inflator = new Inflate((chunk) => {
|
||||
decompressedBytes += chunk.length;
|
||||
if (decompressedBytes > maxDecompressedBytes) {
|
||||
throw new Error("Decompressed value over the maximum size");
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
|
||||
inflator.push(
|
||||
Uint8Array.from(atob(base64), (c) => c.charCodeAt(0)),
|
||||
true,
|
||||
);
|
||||
|
||||
const value = new TextDecoder().decode(concatChunks(chunks));
|
||||
|
||||
if (!value) return null;
|
||||
|
||||
return value;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function concatChunks(chunks: Array<Uint8Array>) {
|
||||
const totalBytes = chunks.reduce((total, chunk) => total + chunk.length, 0);
|
||||
const result = new Uint8Array(totalBytes);
|
||||
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
result.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
86
apps/web-react/app/utils/dates.test.ts
Normal file
86
apps/web-react/app/utils/dates.test.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
||||
import {
|
||||
getDateAtNextFullHour,
|
||||
weekNumberToDate,
|
||||
weekNumberToDateRange,
|
||||
} from "./dates";
|
||||
|
||||
describe("getDateAtNextFullHour", () => {
|
||||
test("returns a date sitting exactly on a full hour (no leftover minutes/seconds/milliseconds)", () => {
|
||||
const result = getDateAtNextFullHour(new Date(2024, 0, 1, 14, 30, 0, 500));
|
||||
|
||||
expect(result).toEqual(new Date(2024, 0, 1, 15, 0, 0, 0));
|
||||
});
|
||||
|
||||
test("advances to the next hour when only seconds/milliseconds are past the hour", () => {
|
||||
const result = getDateAtNextFullHour(new Date(2024, 0, 1, 14, 0, 30, 0));
|
||||
|
||||
expect(result).toEqual(new Date(2024, 0, 1, 15, 0, 0, 0));
|
||||
});
|
||||
|
||||
test("keeps the same hour when already exactly on a full hour", () => {
|
||||
const result = getDateAtNextFullHour(new Date(2024, 0, 1, 14, 0, 0, 0));
|
||||
|
||||
expect(result).toEqual(new Date(2024, 0, 1, 14, 0, 0, 0));
|
||||
});
|
||||
});
|
||||
|
||||
describe("weekNumberToDate", () => {
|
||||
// Force a timezone west of UTC so the assertion is deterministic regardless
|
||||
// of where the test happens to run (the bug only manifests west of UTC).
|
||||
const originalTimezone = process.env.TZ;
|
||||
beforeAll(() => {
|
||||
process.env.TZ = "America/Los_Angeles";
|
||||
});
|
||||
afterAll(() => {
|
||||
process.env.TZ = originalTimezone;
|
||||
});
|
||||
|
||||
test("returns the Monday of the ISO week regardless of server timezone", () => {
|
||||
// ISO week 1 of 2024 starts on Monday 2024-01-01
|
||||
const start = weekNumberToDate({ week: 1, year: 2024 });
|
||||
|
||||
expect(start.toISOString().slice(0, 10)).toBe("2024-01-01");
|
||||
});
|
||||
|
||||
test("returns the Sunday of the ISO week regardless of server timezone", () => {
|
||||
// ISO week 1 of 2024 ends on Sunday 2024-01-07
|
||||
const end = weekNumberToDate({ week: 1, year: 2024, position: "end" });
|
||||
|
||||
expect(end.toISOString().slice(0, 10)).toBe("2024-01-07");
|
||||
});
|
||||
});
|
||||
|
||||
describe("weekNumberToDateRange", () => {
|
||||
// Force a timezone west of UTC observing DST so the assertion is deterministic
|
||||
// regardless of where the test happens to run.
|
||||
const originalTimezone = process.env.TZ;
|
||||
beforeAll(() => {
|
||||
process.env.TZ = "America/New_York";
|
||||
});
|
||||
afterAll(() => {
|
||||
process.env.TZ = originalTimezone;
|
||||
});
|
||||
|
||||
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
test("spans exactly seven days even when the week contains a DST transition", () => {
|
||||
// US spring-forward 2025 happened on Sunday 2025-03-09, which falls inside
|
||||
// ISO week 10 of 2025 (Mon 2025-03-03 .. Mon 2025-03-10).
|
||||
const { startTime, endTime } = weekNumberToDateRange({
|
||||
week: 10,
|
||||
year: 2025,
|
||||
});
|
||||
|
||||
expect(endTime.getTime() - startTime.getTime()).toBe(SEVEN_DAYS_MS);
|
||||
});
|
||||
|
||||
test("spans exactly seven days for an ordinary week", () => {
|
||||
const { startTime, endTime } = weekNumberToDateRange({
|
||||
week: 20,
|
||||
year: 2025,
|
||||
});
|
||||
|
||||
expect(endTime.getTime() - startTime.getTime()).toBe(SEVEN_DAYS_MS);
|
||||
});
|
||||
});
|
||||
238
apps/web-react/app/utils/dates.ts
Normal file
238
apps/web-react/app/utils/dates.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
import {
|
||||
CalendarDate,
|
||||
CalendarDateTime,
|
||||
parseDate,
|
||||
} from "@internationalized/date";
|
||||
import type { Locale } from "date-fns";
|
||||
import { formatDistanceToNow as dateFnsFormatDistanceToNow } from "date-fns";
|
||||
import { enUS } from "date-fns/locale/en-US";
|
||||
import type { MonthYear } from "~/features/plus-voting/core";
|
||||
import type { LanguageCode } from "~/modules/i18n/config";
|
||||
import { logger } from "./logger";
|
||||
import type { DayMonthYear } from "./zod";
|
||||
|
||||
// en-US ships with date-fns core as the default locale, so it costs no extra bytes
|
||||
const LOCALE_LOADERS: Record<LanguageCode, () => Promise<Locale>> = {
|
||||
da: () => import("date-fns/locale/da").then((module) => module.da),
|
||||
de: () => import("date-fns/locale/de").then((module) => module.de),
|
||||
en: () => Promise.resolve(enUS),
|
||||
"es-ES": () => import("date-fns/locale/es").then((module) => module.es),
|
||||
"es-US": () => import("date-fns/locale/es").then((module) => module.es),
|
||||
"fr-CA": () => import("date-fns/locale/fr-CA").then((module) => module.frCA),
|
||||
"fr-EU": () => import("date-fns/locale/fr").then((module) => module.fr),
|
||||
he: () => import("date-fns/locale/he").then((module) => module.he),
|
||||
it: () => import("date-fns/locale/it").then((module) => module.it),
|
||||
ja: () => import("date-fns/locale/ja").then((module) => module.ja),
|
||||
ko: () => import("date-fns/locale/ko").then((module) => module.ko),
|
||||
nl: () => import("date-fns/locale/nl").then((module) => module.nl),
|
||||
pl: () => import("date-fns/locale/pl").then((module) => module.pl),
|
||||
"pt-BR": () => import("date-fns/locale/pt-BR").then((module) => module.ptBR),
|
||||
ru: () => import("date-fns/locale/ru").then((module) => module.ru),
|
||||
zh: () => import("date-fns/locale/zh-CN").then((module) => module.zhCN),
|
||||
};
|
||||
|
||||
const loadedLocales = new Map<LanguageCode, Locale>();
|
||||
|
||||
/**
|
||||
* Loads the date-fns locale for the given language into the in-memory cache
|
||||
* used by {@link formatDistanceToNow}. Load failures are logged and result in
|
||||
* an English fallback instead of rejecting.
|
||||
*/
|
||||
export async function loadDateFnsLocale(language: LanguageCode) {
|
||||
if (loadedLocales.has(language)) return;
|
||||
|
||||
const loader = LOCALE_LOADERS[language];
|
||||
if (!loader) return;
|
||||
|
||||
try {
|
||||
loadedLocales.set(language, await loader());
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`Failed to load date-fns locale for language ${language}`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Loads every date-fns locale into the cache (meant for the server where bundle size does not matter). */
|
||||
export function loadAllDateFnsLocales() {
|
||||
return Promise.all(
|
||||
(Object.keys(LOCALE_LOADERS) as LanguageCode[]).map(loadDateFnsLocale),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats how long ago / until the given date in the given language. The
|
||||
* language's date-fns locale must be loaded first via
|
||||
* {@link loadDateFnsLocale}; otherwise falls back to English.
|
||||
*/
|
||||
export function formatDistanceToNow(
|
||||
date: Parameters<typeof dateFnsFormatDistanceToNow>[0],
|
||||
options: Omit<
|
||||
NonNullable<Parameters<typeof dateFnsFormatDistanceToNow>[1]>,
|
||||
"locale"
|
||||
> & { language: LanguageCode },
|
||||
) {
|
||||
return dateFnsFormatDistanceToNow(date, {
|
||||
...options,
|
||||
locale: loadedLocales.get(options.language) ?? enUS,
|
||||
});
|
||||
}
|
||||
|
||||
export function databaseTimestampToDate(timestamp: number) {
|
||||
return new Date(databaseTimestampToJavascriptTimestamp(timestamp));
|
||||
}
|
||||
|
||||
export function databaseTimestampToJavascriptTimestamp(timestamp: number) {
|
||||
return timestamp * 1000;
|
||||
}
|
||||
|
||||
export function dateToDatabaseTimestamp(date: Date) {
|
||||
return Math.floor(date.getTime() / 1000);
|
||||
}
|
||||
|
||||
export function databaseTimestampNow() {
|
||||
return dateToDatabaseTimestamp(new Date());
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a date represented by day, month, and year into a JavaScript Date object, noon UTC.
|
||||
*/
|
||||
export function dayMonthYearToDate({ day, month, year }: DayMonthYear) {
|
||||
return new Date(Date.UTC(year, month, day, 12));
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a JavaScript Date object into a CalendarDateTime object (used by react-aria-components).
|
||||
*/
|
||||
export function dateToDateValue(date: Date) {
|
||||
return new CalendarDateTime(
|
||||
date.getFullYear(),
|
||||
date.getMonth() + 1,
|
||||
date.getDate(),
|
||||
date.getHours(),
|
||||
date.getMinutes(),
|
||||
date.getSeconds(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a JavaScript Date object into a CalendarDate object (used by react-aria-components for date-only pickers).
|
||||
*/
|
||||
export function dateToCalendarDate(date: Date) {
|
||||
return new CalendarDate(
|
||||
date.getFullYear(),
|
||||
date.getMonth() + 1,
|
||||
date.getDate(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a date represented by day, month, and year into a DateValue object (used by react-aria-components), noon UTC.
|
||||
*/
|
||||
export function dayMonthYearToDateValue({ day, month, year }: DayMonthYear) {
|
||||
const isoString = dateToYYYYMMDD(new Date(Date.UTC(year, month, day, 12)));
|
||||
|
||||
return parseDate(isoString);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a date represented by day, month, and year into a database timestamp, noon UTC.
|
||||
*/
|
||||
export function dayMonthYearToDatabaseTimestamp(args: DayMonthYear) {
|
||||
return dateToDatabaseTimestamp(dayMonthYearToDate(args));
|
||||
}
|
||||
|
||||
// https://stackoverflow.com/a/71336659
|
||||
export function weekNumberToDate({
|
||||
week,
|
||||
year,
|
||||
position = "start",
|
||||
}: {
|
||||
week: number;
|
||||
year: number;
|
||||
/** start = Date of Monday, end = Date of Sunday */
|
||||
position?: "start" | "end";
|
||||
}) {
|
||||
const result = new Date(Date.UTC(year, 0, 4));
|
||||
|
||||
result.setUTCDate(
|
||||
result.getUTCDate() - (result.getUTCDay() || 7) + 1 + 7 * (week - 1),
|
||||
);
|
||||
if (position === "end") {
|
||||
result.setUTCDate(result.getUTCDate() + 6);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the UTC date range covering an ISO week: the Monday that starts the
|
||||
* week and the Monday that starts the following week (a 7-day span). Uses UTC
|
||||
* date arithmetic so the span is exactly 7×24h regardless of the server's
|
||||
* timezone or any DST transition that falls inside the week.
|
||||
*/
|
||||
export function weekNumberToDateRange({
|
||||
week,
|
||||
year,
|
||||
}: {
|
||||
week: number;
|
||||
year: number;
|
||||
}) {
|
||||
const startTime = weekNumberToDate({ week, year });
|
||||
|
||||
const endTime = new Date(startTime);
|
||||
endTime.setUTCDate(endTime.getUTCDate() + 7);
|
||||
|
||||
return { startTime, endTime };
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a date is valid or not.
|
||||
*
|
||||
* Returns:
|
||||
* - True if date is valid
|
||||
* - False otherwise
|
||||
*/
|
||||
export function isValidDate(date: Date) {
|
||||
return !Number.isNaN(date.getTime());
|
||||
}
|
||||
|
||||
export function getDateAtNextFullHour(date: Date) {
|
||||
const copiedDate = new Date(date.getTime());
|
||||
if (
|
||||
date.getMinutes() > 0 ||
|
||||
date.getSeconds() > 0 ||
|
||||
date.getMilliseconds() > 0
|
||||
) {
|
||||
copiedDate.setHours(date.getHours() + 1);
|
||||
copiedDate.setMinutes(0);
|
||||
}
|
||||
copiedDate.setSeconds(0);
|
||||
copiedDate.setMilliseconds(0);
|
||||
return copiedDate;
|
||||
}
|
||||
|
||||
export function dateToYYYYMMDD(date: Date) {
|
||||
return date.toISOString().split("T")[0];
|
||||
}
|
||||
|
||||
// same as datesOfMonth but contains null at the start to start with monday
|
||||
export function nullPaddedDatesOfMonth({ month, year }: MonthYear) {
|
||||
const dates = datesOfMonth({ month, year });
|
||||
const firstDay = dates[0].getUTCDay();
|
||||
const nulls = Array.from(
|
||||
{ length: firstDay === 0 ? 6 : firstDay - 1 },
|
||||
() => null,
|
||||
);
|
||||
return [...nulls, ...dates];
|
||||
}
|
||||
|
||||
function datesOfMonth({ month, year }: MonthYear) {
|
||||
const dates = [];
|
||||
const date = new Date(Date.UTC(year, month, 1));
|
||||
while (date.getUTCMonth() === month) {
|
||||
dates.push(new Date(date));
|
||||
date.setUTCDate(date.getUTCDate() + 1);
|
||||
}
|
||||
return dates;
|
||||
}
|
||||
7
apps/web-react/app/utils/e2e.ts
Normal file
7
apps/web-react/app/utils/e2e.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
// import.meta.env is undefined when Playwright bundles test code, so we need to
|
||||
// check if it exists before accessing it. There the flag comes from process.env
|
||||
// instead (set by playwright.config.ts), as app modules run in the test process too.
|
||||
export const IS_E2E_TEST_RUN =
|
||||
(typeof import.meta.env !== "undefined" &&
|
||||
import.meta.env.VITE_E2E_TEST_RUN === "true") ||
|
||||
(typeof process !== "undefined" && process.env.VITE_E2E_TEST_RUN === "true");
|
||||
20
apps/web-react/app/utils/errors.ts
Normal file
20
apps/web-react/app/utils/errors.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export class LimitReachedError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "LimitReachedError";
|
||||
}
|
||||
}
|
||||
|
||||
export class ConcurrentModificationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ConcurrentModificationError";
|
||||
}
|
||||
}
|
||||
|
||||
export class DuplicateEntryError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "DuplicateEntryError";
|
||||
}
|
||||
}
|
||||
20
apps/web-react/app/utils/fetch.ts
Normal file
20
apps/web-react/app/utils/fetch.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { logger } from "./logger";
|
||||
|
||||
// https://stackoverflow.com/a/50101022
|
||||
export async function fetchWithTimeout(
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit | undefined,
|
||||
timeout = 5000,
|
||||
) {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => {
|
||||
controller.abort();
|
||||
logger.error("Fetch timed out");
|
||||
}, timeout);
|
||||
|
||||
const response = await fetch(input, { signal: controller.signal, ...init });
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
return response;
|
||||
}
|
||||
8
apps/web-react/app/utils/git-commit.ts
Normal file
8
apps/web-react/app/utils/git-commit.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
declare const __GIT_COMMIT__: string;
|
||||
|
||||
/**
|
||||
* Commit the running bundle was built from. Inlined at build time, meaning the
|
||||
* client and the server report their own build's commit. Empty string outside
|
||||
* of deployed builds (e.g. local development).
|
||||
*/
|
||||
export const GIT_COMMIT = __GIT_COMMIT__;
|
||||
38
apps/web-react/app/utils/i18n.test.ts
Normal file
38
apps/web-react/app/utils/i18n.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { countryCodeToTranslatedName } from "./i18n";
|
||||
|
||||
describe("countryCodeToTranslatedName()", () => {
|
||||
test("returns the translated country name for a valid code", () => {
|
||||
const result = countryCodeToTranslatedName({
|
||||
countryCode: "FI",
|
||||
language: "fi",
|
||||
});
|
||||
expect(result).toBe("Suomi");
|
||||
});
|
||||
|
||||
test("returns the country name in english if the code contains a dash", () => {
|
||||
const result = countryCodeToTranslatedName({
|
||||
countryCode: "GB-WLS",
|
||||
language: "fi",
|
||||
});
|
||||
expect(result).toBe("Wales");
|
||||
});
|
||||
|
||||
test("returns the country code as is for unknown country", () => {
|
||||
const result = countryCodeToTranslatedName({
|
||||
countryCode: "UNKNOWN",
|
||||
language: "en",
|
||||
});
|
||||
|
||||
expect(result).toBe("UNKNOWN");
|
||||
});
|
||||
|
||||
test("defaults to english for unknown language", () => {
|
||||
const result = countryCodeToTranslatedName({
|
||||
countryCode: "FI",
|
||||
language: "unknown",
|
||||
});
|
||||
|
||||
expect(result).toBe("Finland");
|
||||
});
|
||||
});
|
||||
135
apps/web-react/app/utils/i18n.ts
Normal file
135
apps/web-react/app/utils/i18n.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import type { LanguageCode } from "~/modules/i18n/config";
|
||||
import type { Namespace } from "~/modules/i18n/resources.server";
|
||||
import { logger } from "./logger";
|
||||
import { assertType } from "./types";
|
||||
|
||||
// note: cannot get from resources.server.ts directly, because that is a server-only file
|
||||
const ALL_NAMESPACES = [
|
||||
"common",
|
||||
"analyzer",
|
||||
"badges",
|
||||
"builds",
|
||||
"calendar",
|
||||
"contributions",
|
||||
"faq",
|
||||
"forms",
|
||||
"game-badges",
|
||||
"game-misc",
|
||||
"gear",
|
||||
"user",
|
||||
"weapons",
|
||||
"scrims",
|
||||
"tournament",
|
||||
"team",
|
||||
"tier-list-maker",
|
||||
"vods",
|
||||
"art",
|
||||
"q",
|
||||
"lfg",
|
||||
"org",
|
||||
"front",
|
||||
"friends",
|
||||
"settings",
|
||||
"trophies",
|
||||
"params",
|
||||
"welcome",
|
||||
] as const;
|
||||
assertType<Namespace, (typeof ALL_NAMESPACES)[number]>();
|
||||
assertType<(typeof ALL_NAMESPACES)[number], Namespace>();
|
||||
|
||||
export function allI18nNamespaces() {
|
||||
return [...ALL_NAMESPACES];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the localized display name for a given ISO country code using the specified language. If the country code is unknown or the function fails for othe reason, returns the country code itself as a fallback.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* function CountryNameComponent() {
|
||||
* const { i18n } = useTranslation();
|
||||
* const countryName = countryCodeToTranslatedName({
|
||||
* countryCode: "FI",
|
||||
* language: i18n.language,
|
||||
* }); // "Suomi" in Finnish
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function countryCodeToTranslatedName({
|
||||
countryCode,
|
||||
language,
|
||||
}: {
|
||||
countryCode: string;
|
||||
language: string;
|
||||
}) {
|
||||
if (countryCode === "GB-WLS") return "Wales";
|
||||
if (countryCode === "GB-SCT") return "Scotland";
|
||||
if (countryCode === "GB-NIR") return "Northern Ireland";
|
||||
if (countryCode === "GB-ENG") return "England";
|
||||
|
||||
try {
|
||||
return (
|
||||
new Intl.DisplayNames([language], { type: "region" }).of(countryCode) ??
|
||||
countryCode
|
||||
);
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`Error getting display name for country code "${countryCode}":`,
|
||||
e,
|
||||
);
|
||||
return countryCode; // fallback to the code itself if display name fails
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordinal placement suffixes per language, keyed by the CLDR ordinal plural
|
||||
* category given by `Intl.PluralRules(language, { type: "ordinal" })`. A leading
|
||||
* `^` marks a suffix that should render as superscript. Languages without a
|
||||
* written ordinal suffix use explicit `null` – rendering no suffix is preferred
|
||||
* over borrowing the English one.
|
||||
*/
|
||||
const ORDINAL_SUFFIXES: Record<
|
||||
LanguageCode,
|
||||
Partial<Record<Intl.LDMLPluralRule, string>> | null
|
||||
> = {
|
||||
da: null,
|
||||
he: null,
|
||||
nl: null,
|
||||
en: { one: "^st", two: "^nd", few: "^rd", other: "^th" },
|
||||
"es-ES": { other: "º" },
|
||||
"es-US": { other: "^o" },
|
||||
"fr-CA": { one: "^er", other: "^ème" },
|
||||
"fr-EU": { one: "^er", other: "^ème" },
|
||||
de: { other: "^." },
|
||||
it: { many: "^o", other: "^o" },
|
||||
ja: { other: "位" },
|
||||
ko: { other: "^등" },
|
||||
pl: { other: "^." },
|
||||
"pt-BR": { other: "^º" },
|
||||
ru: { other: "^ое" },
|
||||
zh: { other: "名" },
|
||||
};
|
||||
|
||||
const pluralRulesCache = new Map<string, Intl.PluralRules>();
|
||||
|
||||
/**
|
||||
* Returns the localized ordinal suffix for a placement number in the given
|
||||
* language (e.g. `"^st"` for `1` in English). A leading `^` marks a suffix the
|
||||
* caller should render as superscript. Returns an empty string for languages
|
||||
* without a written ordinal suffix.
|
||||
*/
|
||||
export function ordinalSuffix(placement: number, language: string): string {
|
||||
const category = ordinalPluralRules(language).select(placement);
|
||||
|
||||
return ORDINAL_SUFFIXES[language as LanguageCode]?.[category] ?? "";
|
||||
}
|
||||
|
||||
function ordinalPluralRules(language: string): Intl.PluralRules {
|
||||
let rules = pluralRulesCache.get(language);
|
||||
if (!rules) {
|
||||
rules = new Intl.PluralRules(language, { type: "ordinal" });
|
||||
pluralRulesCache.set(language, rules);
|
||||
}
|
||||
|
||||
return rules;
|
||||
}
|
||||
10
apps/web-react/app/utils/id.ts
Normal file
10
apps/web-react/app/utils/id.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
export const SHORT_NANOID_LENGTH = 10;
|
||||
|
||||
/**
|
||||
* Generates a short, unique identifier string (wraps nanoid using a smaller length than the default).
|
||||
*/
|
||||
export function shortNanoid() {
|
||||
return nanoid(SHORT_NANOID_LENGTH);
|
||||
}
|
||||
12
apps/web-react/app/utils/invariant.ts
Normal file
12
apps/web-react/app/utils/invariant.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
// adapted from https://github.com/alexreardon/tiny-invariant, changed to show errors in production as well
|
||||
|
||||
export default function invariant(
|
||||
condition: any,
|
||||
message?: string,
|
||||
): asserts condition {
|
||||
if (condition) return;
|
||||
|
||||
const addition = message ? `: ${message}` : "";
|
||||
|
||||
throw new Error(`Invariant failed${addition}`);
|
||||
}
|
||||
581
apps/web-react/app/utils/kysely.server.ts
Normal file
581
apps/web-react/app/utils/kysely.server.ts
Normal file
@@ -0,0 +1,581 @@
|
||||
import {
|
||||
type AliasedRawBuilder,
|
||||
type ColumnType,
|
||||
type Expression,
|
||||
type ExpressionBuilder,
|
||||
type RawBuilder,
|
||||
sql,
|
||||
} from "kysely";
|
||||
import type {
|
||||
jsonArrayFrom as sqliteJsonArrayFrom,
|
||||
jsonBuildObject as sqliteJsonBuildObject,
|
||||
jsonObjectFrom as sqliteJsonObjectFrom,
|
||||
} from "kysely/helpers/sqlite";
|
||||
import { Config } from "~/config";
|
||||
import {
|
||||
jsonValuedNode,
|
||||
jsonValuedSelection,
|
||||
selectionOutputName,
|
||||
} from "~/db/json-selections";
|
||||
import { db } from "~/db/sql";
|
||||
import type { DB, Tables } from "~/db/tables";
|
||||
import { IS_E2E_TEST_RUN } from "./e2e";
|
||||
import { safeNumberParse } from "./number";
|
||||
|
||||
/**
|
||||
* Base query selecting the user matching a URL identifier, which can be their user id, their Discord
|
||||
* id or their custom URL. Extend it with the columns the caller needs.
|
||||
*/
|
||||
export function userByIdentifierQuery(identifier: string) {
|
||||
return db
|
||||
.selectFrom("User")
|
||||
.select("User.id")
|
||||
.where((eb) => {
|
||||
// we don't want to parse discord id's as numbers (length = 18)
|
||||
const parsedId =
|
||||
identifier.length < 10 ? safeNumberParse(identifier) : null;
|
||||
if (parsedId) {
|
||||
return eb("User.id", "=", parsedId);
|
||||
}
|
||||
|
||||
if (/^\d+$/.test(identifier)) {
|
||||
return eb("User.discordId", "=", identifier);
|
||||
}
|
||||
|
||||
return eb("User.customUrl", "=", identifier);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* SQLite expression extracting a Splatoon player's overall peak XP from the denormalized `peakXp`
|
||||
* JSON column. `"SplatoonPlayer"` must be in scope at the call site.
|
||||
*/
|
||||
export function peakXpOverallSql<T extends number | null = number | null>() {
|
||||
return sql<T>`"SplatoonPlayer"."peakXp" ->> '$.overall'`;
|
||||
}
|
||||
|
||||
type CommonUserSelectOptions = {
|
||||
alias?: string;
|
||||
prefix?: string;
|
||||
idAs?: string;
|
||||
/** For tournament scoped queries: `username` resolves to {@link tournamentUsername}. */
|
||||
inTournament?: boolean;
|
||||
};
|
||||
|
||||
type UserTableAlias<O> = O extends { alias: infer A extends string }
|
||||
? A
|
||||
: "User";
|
||||
|
||||
type PrefixedUserColumn<O, C extends string> = O extends {
|
||||
prefix: infer P extends string;
|
||||
}
|
||||
? `${P}${Capitalize<C>}`
|
||||
: C;
|
||||
|
||||
type UserIdColumn<O> = O extends { idAs: infer I extends string }
|
||||
? I
|
||||
: PrefixedUserColumn<O, "id">;
|
||||
|
||||
type CommonUserSelectResult<O> = readonly [
|
||||
`${UserTableAlias<O>}.id as ${UserIdColumn<O>}`,
|
||||
`${UserTableAlias<O>}.username as ${PrefixedUserColumn<O, "username">}`,
|
||||
`${UserTableAlias<O>}.discordId as ${PrefixedUserColumn<O, "discordId">}`,
|
||||
`${UserTableAlias<O>}.discordAvatar as ${PrefixedUserColumn<O, "discordAvatar">}`,
|
||||
`${UserTableAlias<O>}.customUrl as ${PrefixedUserColumn<O, "customUrl">}`,
|
||||
AliasedRawBuilder<string | null, PrefixedUserColumn<O, "customAvatarUrl">>,
|
||||
];
|
||||
|
||||
/**
|
||||
* Select list for the fields shared by every user representation across the app. Includes
|
||||
* `customAvatarUrl`, the full URL of the user's supporter custom avatar (resolved from
|
||||
* `User.customAvatarImgId`), or `null` when they have none. By default reads from `"User"` which
|
||||
* must be in scope at the call site; pass `alias` when the table is joined under another name
|
||||
* (`alias: "LinkedUser"`), `prefix` to prefix every output column (`prefix: "sender"` →
|
||||
* `senderId`, `senderUsername`, ...), `idAs` to rename only the id column (`idAs: "userId"`) and
|
||||
* `inTournament` to resolve `username` via {@link tournamentUsername}.
|
||||
*/
|
||||
export function commonUserSelect<const O extends CommonUserSelectOptions>(
|
||||
eb: ExpressionBuilder<DB, any>,
|
||||
options?: O,
|
||||
): CommonUserSelectResult<O> {
|
||||
const alias = options?.alias ?? "User";
|
||||
const prefix = options?.prefix;
|
||||
|
||||
const outputName = (column: string) =>
|
||||
prefix ? `${prefix}${column[0].toUpperCase()}${column.slice(1)}` : column;
|
||||
const idName = options?.idAs ?? outputName("id");
|
||||
|
||||
return [
|
||||
`${alias}.id as ${idName}`,
|
||||
options?.inTournament
|
||||
? tournamentUsername(alias).as(outputName("username"))
|
||||
: `${alias}.username as ${outputName("username")}`,
|
||||
`${alias}.discordId as ${outputName("discordId")}`,
|
||||
`${alias}.discordAvatar as ${outputName("discordAvatar")}`,
|
||||
`${alias}.customUrl as ${outputName("customUrl")}`,
|
||||
customAvatarUrl(eb, alias).as(outputName("customAvatarUrl")),
|
||||
] as unknown as CommonUserSelectResult<O>;
|
||||
}
|
||||
|
||||
/**
|
||||
* SQL expression resolving to the full URL of a user's supporter custom avatar (from
|
||||
* `User.customAvatarImgId`), or `null` when they have none. Alias it
|
||||
* (`.as("customAvatarUrl")`) when selecting it directly. Pass `alias` when the `User` table is
|
||||
* joined under another name. Prefer {@link commonUserSelect} / {@link commonUserJsonObject};
|
||||
* reach for this only when those don't fit (e.g. a hand-built `jsonBuildObject`).
|
||||
*/
|
||||
export function customAvatarUrl(
|
||||
eb: ExpressionBuilder<DB, any>,
|
||||
alias = "User",
|
||||
) {
|
||||
return concatUserSubmittedImagePrefix(
|
||||
eb
|
||||
.selectFrom("UserSubmittedImage")
|
||||
.select("UserSubmittedImage.url")
|
||||
.whereRef(
|
||||
"UserSubmittedImage.id",
|
||||
"=",
|
||||
sql.ref(`${alias}.customAvatarImgId`),
|
||||
)
|
||||
.$asScalar(),
|
||||
).$castTo<string | null>();
|
||||
}
|
||||
|
||||
export type CommonUser = Pick<
|
||||
Tables["User"],
|
||||
"id" | "username" | "discordId" | "discordAvatar" | "customUrl"
|
||||
> & { customAvatarUrl: string | null };
|
||||
|
||||
/** Represents User joined with PlusTier table */
|
||||
export type UserWithPlusTier = Tables["User"] & {
|
||||
plusTier: Tables["PlusTier"]["tier"] | null;
|
||||
};
|
||||
|
||||
const userChatNameHueRaw = sql<
|
||||
string | null
|
||||
>`IIF(COALESCE("User"."patronTier", 0) >= 2, "User"."customTheme" ->> '--_chat-h', null)`;
|
||||
|
||||
export const userChatNameHue = userChatNameHueRaw.as("chatNameHue");
|
||||
|
||||
/**
|
||||
* The {@link CommonUser} fields as a plain record of Kysely expressions, for spreading into a
|
||||
* hand-built `jsonBuildObject` alongside extra fields. Prefer {@link commonUserJsonObject} when the
|
||||
* common fields are the whole object.
|
||||
*/
|
||||
export function commonUserObjectFields(eb: ExpressionBuilder<Tables, "User">) {
|
||||
return {
|
||||
id: eb.ref("User.id"),
|
||||
username: eb.ref("User.username"),
|
||||
discordId: eb.ref("User.discordId"),
|
||||
discordAvatar: eb.ref("User.discordAvatar"),
|
||||
customUrl: eb.ref("User.customUrl"),
|
||||
customAvatarUrl: customAvatarUrl(eb),
|
||||
};
|
||||
}
|
||||
|
||||
export function commonUserJsonObject(eb: ExpressionBuilder<Tables, "User">) {
|
||||
return jsonBuildObject(commonUserObjectFields(eb));
|
||||
}
|
||||
|
||||
type ExtractedExpressionTypes<E extends Record<string, Expression<unknown>>> = {
|
||||
[K in keyof E]: E[K] extends Expression<infer T> ? T : never;
|
||||
};
|
||||
|
||||
/**
|
||||
* `json_group_array` aggregate building one object per member row from the {@link CommonUser}
|
||||
* fields plus `extras`. `"User"` must be in scope at the call site. Alias it (`.as("members")`)
|
||||
* when selecting.
|
||||
*/
|
||||
export function commonUserMembersAgg<
|
||||
E extends Record<string, Expression<unknown>>,
|
||||
>(eb: ExpressionBuilder<DB, any>, extras: E) {
|
||||
return eb.fn
|
||||
.agg("json_group_array", [
|
||||
jsonBuildObject({
|
||||
...commonUserObjectFields(
|
||||
eb as unknown as ExpressionBuilder<Tables, "User">,
|
||||
),
|
||||
...extras,
|
||||
}),
|
||||
])
|
||||
.$castTo<Array<CommonUser & ExtractedExpressionTypes<E>>>();
|
||||
}
|
||||
|
||||
const USER_SUBMITTED_IMAGE_ROOT =
|
||||
(process.env.NODE_ENV === "development" && !Config.prodMode) ||
|
||||
IS_E2E_TEST_RUN ||
|
||||
process.env.NODE_ENV === "test"
|
||||
? "http://127.0.0.1:9000/sendou"
|
||||
: "https://sendou.nyc3.cdn.digitaloceanspaces.com";
|
||||
|
||||
/**
|
||||
* Constructs a SQL expression that returns the full URL for a tournament's logo.
|
||||
* If the tournament has a custom logo (via avatarImgId), returns that logo's URL.
|
||||
* Otherwise, returns null.
|
||||
*
|
||||
* @returns A SQL expression that concatenates the image root URL with either the custom logo URL or default logo
|
||||
*/
|
||||
export function tournamentLogoOrNull(
|
||||
eb: ExpressionBuilder<Tables, "CalendarEvent">,
|
||||
) {
|
||||
return eb.fn<string | null>("iif", [
|
||||
eb("CalendarEvent.avatarImgId", "is not", null),
|
||||
eb.fn<string>("concat", [
|
||||
sql.lit(`${USER_SUBMITTED_IMAGE_ROOT}/`),
|
||||
eb
|
||||
.selectFrom("UnvalidatedUserSubmittedImage")
|
||||
.select(["UnvalidatedUserSubmittedImage.url"])
|
||||
.whereRef(
|
||||
"CalendarEvent.avatarImgId",
|
||||
"=",
|
||||
"UnvalidatedUserSubmittedImage.id",
|
||||
),
|
||||
]),
|
||||
sql`null`,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs a SQL expression that returns the full URL for a tournament's logo.
|
||||
* If the tournament has a custom logo (via avatarImgId), returns that logo's URL.
|
||||
* Otherwise, falls back to the default tournament logo.
|
||||
*
|
||||
* @returns A SQL expression that concatenates the image root URL with either the custom logo URL or default logo
|
||||
*/
|
||||
export function tournamentLogoWithDefault(
|
||||
eb: ExpressionBuilder<Tables, "CalendarEvent">,
|
||||
) {
|
||||
return concatUserSubmittedImagePrefix(
|
||||
eb.fn.coalesce(
|
||||
eb
|
||||
.selectFrom("UnvalidatedUserSubmittedImage")
|
||||
.select("UnvalidatedUserSubmittedImage.url")
|
||||
.whereRef(
|
||||
"CalendarEvent.avatarImgId",
|
||||
"=",
|
||||
"UnvalidatedUserSubmittedImage.id",
|
||||
)
|
||||
.$asScalar(),
|
||||
sql.lit(Config.tournamentDefaultLogo),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subquery resolving to the event's earliest `CalendarEventDate` start time, or `null` when it has
|
||||
* no dates. Correlates on `"CalendarEvent"."id"`. Alias it `.as("startTime")` when selecting it
|
||||
* directly. Can also be passed to `orderBy` as is.
|
||||
*/
|
||||
export function calendarEventStartTime(
|
||||
eb: ExpressionBuilder<Tables, "CalendarEvent">,
|
||||
) {
|
||||
return eb
|
||||
.selectFrom("CalendarEventDate")
|
||||
.select((eb2) => eb2.fn.min<number>("startsAt").as("startsAt"))
|
||||
.whereRef("CalendarEventDate.eventId", "=", "CalendarEvent.id");
|
||||
}
|
||||
|
||||
/**
|
||||
* Subquery counting a tournament's non-placeholder teams. Correlates on `"Tournament"."id"`.
|
||||
* Alias it `.as("teamsCount")` when selecting it directly.
|
||||
*/
|
||||
export function tournamentTeamCount(
|
||||
eb: ExpressionBuilder<Tables, "Tournament">,
|
||||
) {
|
||||
return eb
|
||||
.selectFrom("TournamentTeam")
|
||||
.select((eb2) => eb2.fn.countAll<number>().as("count"))
|
||||
.whereRef("TournamentTeam.tournamentId", "=", "Tournament.id")
|
||||
.where("TournamentTeam.isPlaceholder", "=", 0);
|
||||
}
|
||||
|
||||
/** Expression resolving to whether any of a tournament's brackets has been started. */
|
||||
function tournamentHasStarted(eb: ExpressionBuilder<DB, "Tournament">) {
|
||||
return eb.exists(
|
||||
eb
|
||||
.selectFrom("TournamentStage")
|
||||
.select("TournamentStage.id")
|
||||
.whereRef("TournamentStage.tournamentId", "=", "Tournament.id"),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subquery resolving to the non-placeholder teams of a tournament that are still relevant to it:
|
||||
* every registered team as long as no bracket has been started, only the checked in ones after
|
||||
* that. Mirrors how the tournament page itself resolves its teams, so keep the two in sync.
|
||||
* Correlates on `"Tournament"."id"`. Has no select of its own, so extend it with the aggregate the
|
||||
* caller needs. A team can have several check in rows, so aggregate with `.distinct()`, e.g.
|
||||
* `.select(({ fn }) => fn.count("TournamentTeam.id").distinct().as("count"))`.
|
||||
*/
|
||||
function tournamentCheckedInTeams(eb: ExpressionBuilder<DB, "Tournament">) {
|
||||
return eb
|
||||
.selectFrom("TournamentTeam")
|
||||
.leftJoin(
|
||||
"TournamentTeamCheckIn",
|
||||
"TournamentTeamCheckIn.tournamentTeamId",
|
||||
"TournamentTeam.id",
|
||||
)
|
||||
.whereRef("TournamentTeam.tournamentId", "=", "Tournament.id")
|
||||
.where("TournamentTeam.isPlaceholder", "=", 0)
|
||||
.where((eb2) =>
|
||||
eb2.or([
|
||||
eb2("TournamentTeamCheckIn.checkedInAt", "is not", null),
|
||||
eb2.not(tournamentHasStarted(eb)),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subquery counting the teams of {@link tournamentCheckedInTeams}. Correlates on
|
||||
* `"Tournament"."id"`. Alias it `.as("teamsCount")` when selecting it directly.
|
||||
*/
|
||||
export function tournamentTeamsCount(eb: ExpressionBuilder<DB, "Tournament">) {
|
||||
return tournamentCheckedInTeams(eb).select(({ fn }) => [
|
||||
fn.count<number>("TournamentTeam.id").distinct().as("count"),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expression resolving to a tournament's participant count: rostered players of the teams from
|
||||
* {@link tournamentCheckedInTeams} while the tournament is still ongoing, players who actually got
|
||||
* a result once it has been finalized. Correlates on `"Tournament"."id"`. Alias it
|
||||
* `.as("membersCount")` when selecting it directly.
|
||||
*/
|
||||
export function tournamentMembersCount(
|
||||
eb: ExpressionBuilder<DB, "Tournament">,
|
||||
) {
|
||||
return eb
|
||||
.case()
|
||||
.when("Tournament.isFinalized", "=", 1)
|
||||
.then(
|
||||
eb
|
||||
.selectFrom("TournamentResult")
|
||||
.whereRef("TournamentResult.tournamentId", "=", "Tournament.id")
|
||||
.select(({ fn }) => [
|
||||
fn.count<number>("TournamentResult.userId").distinct().as("count"),
|
||||
]),
|
||||
)
|
||||
.else(
|
||||
tournamentCheckedInTeams(eb)
|
||||
.innerJoin(
|
||||
"TournamentTeamMember",
|
||||
"TournamentTeamMember.tournamentTeamId",
|
||||
"TournamentTeam.id",
|
||||
)
|
||||
.select(({ fn }) => [
|
||||
fn
|
||||
.count<number>("TournamentTeamMember.userId")
|
||||
.distinct()
|
||||
.as("count"),
|
||||
]),
|
||||
)
|
||||
.end();
|
||||
}
|
||||
|
||||
/**
|
||||
* Grouped subquery picking each user's (`by: "userId"`) or team's (`by: "identifier"`) latest
|
||||
* Skill row of a season: `latestId` plus that row's `ordinal`, `matchesCount` and the `by`
|
||||
* column. Wrap it with `.selectFrom(latestSkillPerSeason(...).as("Latest"))`; extra `.where`s
|
||||
* compose before aliasing.
|
||||
*/
|
||||
export function latestSkillPerSeason<By extends "userId" | "identifier">({
|
||||
season,
|
||||
by,
|
||||
}: {
|
||||
season: number;
|
||||
by: By;
|
||||
}) {
|
||||
// The latest row per user/team is picked via SQLite's bare column rule: with a `max()`
|
||||
// aggregate the other selected columns come from the row that produced the max.
|
||||
// A self-join against a `max(id)` subquery is avoided because it lets the planner
|
||||
// pick a nested-loop plan when it misjudges the season's row count (e.g. a freshly
|
||||
// started season whose stats are dwarfed by older seasons), which made this query
|
||||
// take ~12s. This form is plan-stable regardless of stats: a single grouped scan of
|
||||
// the `skill_season_user_id_leaderboard` / `skill_season_identifier_leaderboard`
|
||||
// covering index, no temp b-tree per partition.
|
||||
return db
|
||||
.selectFrom("Skill")
|
||||
.select(({ fn }) => [
|
||||
fn.max("Skill.id").as("latestId"),
|
||||
"Skill.ordinal" as const,
|
||||
"Skill.matchesCount" as const,
|
||||
`Skill.${by}` as `Skill.${By}`,
|
||||
])
|
||||
.where("Skill.season", "=", season)
|
||||
.where(`Skill.${by}`, "is not", null)
|
||||
.groupBy(`Skill.${by}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Predicate for `Skill` rows of the user that represent a played set: either a SendouQ match or
|
||||
* a ranked tournament the user has a result in. Filters out e.g. skills of tournament teams the
|
||||
* user dropped from before results. `"Skill"` must be in scope at the call site.
|
||||
*/
|
||||
export function skillCountsAsSeasonSet(
|
||||
eb: ExpressionBuilder<DB, "Skill">,
|
||||
userId: number,
|
||||
) {
|
||||
return eb.or([
|
||||
eb("Skill.groupMatchId", "is not", null),
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom("TournamentResult")
|
||||
.select("TournamentResult.userId")
|
||||
.whereRef("TournamentResult.tournamentId", "=", "Skill.tournamentId")
|
||||
.where("TournamentResult.userId", "=", userId),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
/** Concats the file name (a bit misleadingly called `url` in the DB schema) with the root URL, giving the full URL for the image */
|
||||
export function concatUserSubmittedImagePrefix<T extends string | null>(
|
||||
expr: Expression<T>,
|
||||
) {
|
||||
// null-propagating || instead of iif(expr is not null, concat(...), null)
|
||||
// so a correlated subquery passed as expr is evaluated only once per row
|
||||
return sql<T extends null ? string | null : string>`(${sql.lit(
|
||||
`${USER_SUBMITTED_IMAGE_ROOT}/`,
|
||||
)} || ${expr})`;
|
||||
}
|
||||
|
||||
export type JSONColumnTypeNullable<
|
||||
SelectType extends object | string | number | null,
|
||||
> = ColumnType<SelectType | null, string | null, string | null>;
|
||||
|
||||
const TEN_STAR_CASE = sql<number>`case when "TenStarWeapon"."weaponSplId" is not null then 1 else 0 end`;
|
||||
|
||||
/** Match profile weapons (from UserWeaponPool) with TenStarWeapon join. Correlates on "User"."id". */
|
||||
export function matchProfileWeapons(eb: ExpressionBuilder<DB, any>) {
|
||||
return jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("UserWeaponPool")
|
||||
.leftJoin("TenStarWeapon", (join) =>
|
||||
join
|
||||
.onRef("TenStarWeapon.userId", "=", "UserWeaponPool.userId")
|
||||
.onRef(
|
||||
"TenStarWeapon.weaponSplId",
|
||||
"=",
|
||||
"UserWeaponPool.weaponSplId",
|
||||
),
|
||||
)
|
||||
.select([
|
||||
"UserWeaponPool.weaponSplId",
|
||||
"UserWeaponPool.isFavorite",
|
||||
TEN_STAR_CASE.as("isTenStar"),
|
||||
])
|
||||
.whereRef("UserWeaponPool.userId", "=", "User.id")
|
||||
.orderBy("UserWeaponPool.sortOrder", "asc"),
|
||||
);
|
||||
}
|
||||
|
||||
/** User profile weapons (from UserWeapon) with TenStarWeapon join. Correlates on "User"."id". */
|
||||
export function userProfileWeapons(eb: ExpressionBuilder<DB, any>) {
|
||||
return jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("UserWeapon")
|
||||
.leftJoin("TenStarWeapon", (join) =>
|
||||
join
|
||||
.onRef("TenStarWeapon.userId", "=", "UserWeapon.userId")
|
||||
.onRef("TenStarWeapon.weaponSplId", "=", "UserWeapon.weaponSplId"),
|
||||
)
|
||||
.select([
|
||||
"UserWeapon.weaponSplId",
|
||||
"UserWeapon.isFavorite",
|
||||
TEN_STAR_CASE.as("isTenStar"),
|
||||
])
|
||||
.whereRef("UserWeapon.userId", "=", "User.id")
|
||||
.orderBy("UserWeapon.order", "asc"),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The name a user is shown under inside tournaments: the name organizers have given them
|
||||
* (`User.tournamentName`) falling back to their `username`. Alias it (`.as("username")`) when
|
||||
* selecting it directly. Prefer `commonUserSelect(eb, { inTournament: true })`; reach for this
|
||||
* only when the query doesn't select the common user fields.
|
||||
*/
|
||||
export function tournamentUsername(alias = "User") {
|
||||
return sql<string>`coalesce(${sql.ref(`${alias}.tournamentName`)}, ${sql.ref(
|
||||
`${alias}.username`,
|
||||
)})`;
|
||||
}
|
||||
|
||||
type SelectQueryBuilderExpression<O> = Parameters<
|
||||
typeof sqliteJsonArrayFrom<O>
|
||||
>[0];
|
||||
|
||||
/**
|
||||
* Drop-in replacement for kysely's sqlite `jsonArrayFrom`. Emits the same query, except
|
||||
* JSON-valued selections (per {@link jsonValuedSelection}: JSON columns, nested json helpers) get
|
||||
* `json(...)` applied at the `json_object` argument position. SQLite's JSON subtype never
|
||||
* survives a subquery boundary, so without the re-tag such values would be embedded as
|
||||
* strings; the dialect parses each result column exactly once and relies on documents
|
||||
* arriving fully nested. Always use this over the kysely one.
|
||||
*/
|
||||
export function jsonArrayFrom<O>(
|
||||
expr: SelectQueryBuilderExpression<O>,
|
||||
): ReturnType<typeof sqliteJsonArrayFrom<O>> {
|
||||
return sql`(select coalesce(json_group_array(json_object(${sql.join(
|
||||
jsonObjectArgs(expr, "agg"),
|
||||
)})), '[]') from ${expr} as agg)` as ReturnType<
|
||||
typeof sqliteJsonArrayFrom<O>
|
||||
>;
|
||||
}
|
||||
|
||||
/** Drop-in replacement for kysely's sqlite `jsonObjectFrom`, see {@link jsonArrayFrom}. */
|
||||
export function jsonObjectFrom<O>(
|
||||
expr: SelectQueryBuilderExpression<O>,
|
||||
): ReturnType<typeof sqliteJsonObjectFrom<O>> {
|
||||
return sql`(select json_object(${sql.join(
|
||||
jsonObjectArgs(expr, "obj"),
|
||||
)}) from ${expr} as obj)` as ReturnType<typeof sqliteJsonObjectFrom<O>>;
|
||||
}
|
||||
|
||||
/** Drop-in replacement for kysely's sqlite `jsonBuildObject`, see {@link jsonArrayFrom}. */
|
||||
export function jsonBuildObject<O extends Record<string, Expression<unknown>>>(
|
||||
obj: O,
|
||||
): ReturnType<typeof sqliteJsonBuildObject<O>> {
|
||||
return sql`json_object(${sql.join(
|
||||
Object.keys(obj).flatMap((key) => [
|
||||
sql.lit(key),
|
||||
jsonValuedNode(obj[key].toOperationNode())
|
||||
? sql`json(${obj[key]})`
|
||||
: obj[key],
|
||||
]),
|
||||
)})` as ReturnType<typeof sqliteJsonBuildObject<O>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-tags a JSON-valued expression with SQLite's `json()` so it stays a nested document
|
||||
* (instead of an escaped string) inside {@link jsonBuildObject}/{@link jsonArrayFrom}.
|
||||
* Only needed for expressions the helpers can not recognize as JSON on their own, e.g. a
|
||||
* raw `IIF(...)` over a JSON column.
|
||||
*/
|
||||
export function asJson<T>(expr: Expression<T>): RawBuilder<T> {
|
||||
return sql<T>`json(${expr})`;
|
||||
}
|
||||
|
||||
function jsonObjectArgs(
|
||||
expr: SelectQueryBuilderExpression<unknown>,
|
||||
table: string,
|
||||
) {
|
||||
const args: Expression<unknown>[] = [];
|
||||
|
||||
for (const { selection } of expr.toOperationNode().selections ?? []) {
|
||||
const name = selectionOutputName(selection);
|
||||
if (!name) {
|
||||
throw new Error(
|
||||
"jsonArrayFrom and jsonObjectFrom can only handle explicit selections. selectAll() is not allowed in the subquery.",
|
||||
);
|
||||
}
|
||||
|
||||
const ref = sql.ref(`${table}.${name}`);
|
||||
|
||||
args.push(
|
||||
sql.lit(name),
|
||||
jsonValuedSelection(selection) ? sql`json(${ref})` : ref,
|
||||
);
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
44
apps/web-react/app/utils/logger.ts
Normal file
44
apps/web-react/app/utils/logger.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/** biome-ignore-all lint/suspicious/noConsole: stub file to enable different solution later */
|
||||
import { getSessionId as getClientSessionId } from "./session-id";
|
||||
|
||||
declare global {
|
||||
var __getServerSessionId: (() => string | undefined) | undefined;
|
||||
}
|
||||
|
||||
function getSessionIdForLog(): string {
|
||||
if (typeof window !== "undefined") {
|
||||
return getClientSessionId();
|
||||
}
|
||||
|
||||
return globalThis.__getServerSessionId?.() ?? "no-session";
|
||||
}
|
||||
|
||||
let infoLogsSilenced = false;
|
||||
|
||||
function formatLog(...args: unknown[]) {
|
||||
const sessionId = getSessionIdForLog();
|
||||
return [`[${sessionId}]`, ...args];
|
||||
}
|
||||
|
||||
export const logger = {
|
||||
info: (...args: unknown[]) => {
|
||||
if (infoLogsSilenced) return;
|
||||
console.log(...formatLog(...args));
|
||||
},
|
||||
error: (...args: unknown[]) => console.error(...formatLog(...args)),
|
||||
warn: (...args: unknown[]) => console.warn(...formatLog(...args)),
|
||||
debug: (...args: unknown[]) => {
|
||||
if (process.env.NODE_ENV === "production") return;
|
||||
console.debug(...formatLog(...args));
|
||||
},
|
||||
};
|
||||
|
||||
/** Runs `fn` without its `logger.info` output, warnings and errors still logging. */
|
||||
export async function withoutInfoLogs<T>(fn: () => Promise<T>): Promise<T> {
|
||||
infoLogsSilenced = true;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
infoLogsSilenced = false;
|
||||
}
|
||||
}
|
||||
80
apps/web-react/app/utils/number.ts
Normal file
80
apps/web-react/app/utils/number.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import * as R from "remeda";
|
||||
|
||||
/**
|
||||
* Rounds a number to a specified number of decimal places.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* roundToNDecimalPlaces(3.14159); // returns 3.14
|
||||
* roundToNDecimalPlaces(3.14159, 3); // returns 3.142
|
||||
* roundToNDecimalPlaces(2.5, 0); // returns 3
|
||||
* ```
|
||||
*/
|
||||
export function roundToNDecimalPlaces(num: number, n = 2) {
|
||||
return Number((Math.round(num * 10 ** n) / 10 ** n).toFixed(n));
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncates a number to a specified number of decimal places without rounding.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* cutToNDecimalPlaces(3.9999, 2); // returns 3.99
|
||||
* cutToNDecimalPlaces(3.12, 1); // returns 3.1
|
||||
* cutToNDecimalPlaces(100, 2); // returns 100
|
||||
* cutToNDecimalPlaces(3.0001, 2); // returns 3
|
||||
* ```
|
||||
*/
|
||||
export function cutToNDecimalPlaces(num: number, n = 2) {
|
||||
const multiplier = 10 ** n;
|
||||
// Round away floating point representation error (e.g. 0.29 * 100 = 28.999...) before truncating
|
||||
const scaled = Number((num * multiplier).toFixed(8));
|
||||
const truncatedNum = Math.trunc(scaled) / multiplier;
|
||||
const result = truncatedNum.toFixed(n);
|
||||
return Number(n > 0 ? result.replace(/\.?0+$/, "") : result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the average (arithmetic mean) of an array of numbers.
|
||||
* Returns 0 if the array is empty.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* averageArray([2, 4, 6, 8]); // returns 5
|
||||
* averageArray([-2, -4, -6, -8]); // returns -5
|
||||
* averageArray([10, -10, 20, -20]); // returns 0
|
||||
* averageArray([42]); // returns 42
|
||||
* averageArray([]); // returns 0
|
||||
* ```
|
||||
*/
|
||||
export function averageArray(arr: number[]) {
|
||||
if (arr.length === 0) return 0;
|
||||
|
||||
return R.sum(arr) / arr.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely parses a string into a number, returning `null` if the input is `null`,
|
||||
* empty, or not a valid number.
|
||||
*
|
||||
* Trims whitespace from the input before parsing. If the trimmed string is empty
|
||||
* or cannot be converted to a valid number, returns `null`.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* safeNumberParse("42"); // returns 42
|
||||
* safeNumberParse(" 3.14 "); // returns 3.14
|
||||
* safeNumberParse(""); // returns null
|
||||
* safeNumberParse("abc"); // returns null
|
||||
* safeNumberParse(null); // returns null
|
||||
* ```
|
||||
*/
|
||||
export function safeNumberParse(value: string | null) {
|
||||
if (value === null) return null;
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === "") return null;
|
||||
|
||||
const result = Number(trimmed);
|
||||
return Number.isNaN(result) ? null : result;
|
||||
}
|
||||
77
apps/web-react/app/utils/numbers.test.ts
Normal file
77
apps/web-react/app/utils/numbers.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
averageArray,
|
||||
cutToNDecimalPlaces,
|
||||
roundToNDecimalPlaces,
|
||||
safeNumberParse,
|
||||
} from "./number";
|
||||
|
||||
describe("roundToNDecimalPlaces()", () => {
|
||||
test.each([
|
||||
[1.234, 1.23],
|
||||
[1.235, 1.24],
|
||||
[1.2, 1.2],
|
||||
[1, 1],
|
||||
])("rounds %d to %d with the default 2 decimal places", (input, expected) => {
|
||||
expect(roundToNDecimalPlaces(input)).toBe(expected);
|
||||
});
|
||||
|
||||
test.each([
|
||||
[1.6, 0, 2],
|
||||
[1.4, 0, 1],
|
||||
[2.5, 0, 3],
|
||||
[1.23456, 3, 1.235],
|
||||
[1.23444, 3, 1.234],
|
||||
[-1.2345, 2, -1.23],
|
||||
[-1.2355, 2, -1.24],
|
||||
[0, 2, 0],
|
||||
[0, 0, 0],
|
||||
[123456.789, 1, 123456.8],
|
||||
[123456.789, 0, 123457],
|
||||
])("rounds %d to %d decimal places as %d", (input, decimals, expected) => {
|
||||
expect(roundToNDecimalPlaces(input, decimals)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cutToNDecimalPlaces()", () => {
|
||||
test.each([
|
||||
[3.9999, 2, 3.99],
|
||||
[3.12, 1, 3.1],
|
||||
[100, 2, 100],
|
||||
[3.0001, 2, 3],
|
||||
[0.29, 2, 0.29],
|
||||
// values whose binary representation is just below the decimal shown
|
||||
[2.32, 2, 2.32],
|
||||
[-0.29, 2, -0.29],
|
||||
])("cuts %d to %d decimal places as %d", (input, decimals, expected) => {
|
||||
expect(cutToNDecimalPlaces(input, decimals)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("averageArray()", () => {
|
||||
test.each([
|
||||
[[2, 4, 6, 8], 5],
|
||||
[[-2, -4, -6, -8], -5],
|
||||
[[10, -10, 20, -20], 0],
|
||||
[[42], 42],
|
||||
[[], 0],
|
||||
])("averages %j to %d", (input, expected) => {
|
||||
expect(averageArray(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("safeNumberParse()", () => {
|
||||
test.each([
|
||||
["42", 42],
|
||||
["3.14", 3.14],
|
||||
[" 7 ", 7],
|
||||
["-123", -123],
|
||||
["0", 0],
|
||||
["abc", null],
|
||||
["", null],
|
||||
[" ", null],
|
||||
[null, null],
|
||||
])("parses %j as %j", (input, expected) => {
|
||||
expect(safeNumberParse(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
339
apps/web-react/app/utils/oklch-gamut.ts
Normal file
339
apps/web-react/app/utils/oklch-gamut.ts
Normal file
@@ -0,0 +1,339 @@
|
||||
import type { z } from "zod";
|
||||
import type { CustomTheme } from "~/db/tables-json";
|
||||
import type { themeInputSchema } from "~/utils/zod";
|
||||
|
||||
export type ThemeInput = z.infer<typeof themeInputSchema>;
|
||||
|
||||
interface Lab {
|
||||
L: number;
|
||||
a: number;
|
||||
b: number;
|
||||
}
|
||||
|
||||
interface RGB {
|
||||
r: number;
|
||||
g: number;
|
||||
b: number;
|
||||
}
|
||||
|
||||
interface LC {
|
||||
L: number;
|
||||
C: number;
|
||||
}
|
||||
|
||||
function oklab_to_linear_srgb(c: Lab): RGB {
|
||||
const l_ = c.L + 0.3963377774 * c.a + 0.2158037573 * c.b;
|
||||
const m_ = c.L - 0.1055613458 * c.a - 0.0638541728 * c.b;
|
||||
const s_ = c.L - 0.0894841775 * c.a - 1.291485548 * c.b;
|
||||
|
||||
const l = l_ * l_ * l_;
|
||||
const m = m_ * m_ * m_;
|
||||
const s = s_ * s_ * s_;
|
||||
|
||||
return {
|
||||
r: +4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
|
||||
g: -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
|
||||
b: -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
|
||||
};
|
||||
}
|
||||
|
||||
function compute_max_saturation(a: number, b: number): number {
|
||||
let k0: number;
|
||||
let k1: number;
|
||||
let k2: number;
|
||||
let k3: number;
|
||||
let k4: number;
|
||||
let wl: number;
|
||||
let wm: number;
|
||||
let ws: number;
|
||||
|
||||
if (-1.88170328 * a - 0.80936493 * b > 1) {
|
||||
k0 = +1.19086277;
|
||||
k1 = +1.76576728;
|
||||
k2 = +0.59662641;
|
||||
k3 = +0.75515197;
|
||||
k4 = +0.56771245;
|
||||
wl = +4.0767416621;
|
||||
wm = -3.3077115913;
|
||||
ws = +0.2309699292;
|
||||
} else if (1.81444104 * a - 1.19445276 * b > 1) {
|
||||
k0 = +0.73956515;
|
||||
k1 = -0.45954404;
|
||||
k2 = +0.08285427;
|
||||
k3 = +0.1254107;
|
||||
k4 = +0.14503204;
|
||||
wl = -1.2684380046;
|
||||
wm = +2.6097574011;
|
||||
ws = -0.3413193965;
|
||||
} else {
|
||||
k0 = +1.35733652;
|
||||
k1 = -0.00915799;
|
||||
k2 = -1.1513021;
|
||||
k3 = -0.50559606;
|
||||
k4 = +0.00692167;
|
||||
wl = -0.0041960863;
|
||||
wm = -0.7034186147;
|
||||
ws = +1.707614701;
|
||||
}
|
||||
|
||||
let S = k0 + k1 * a + k2 * b + k3 * a * a + k4 * a * b;
|
||||
|
||||
const k_l = +0.3963377774 * a + 0.2158037573 * b;
|
||||
const k_m = -0.1055613458 * a - 0.0638541728 * b;
|
||||
const k_s = -0.0894841775 * a - 1.291485548 * b;
|
||||
|
||||
{
|
||||
const l_ = 1 + S * k_l;
|
||||
const m_ = 1 + S * k_m;
|
||||
const s_ = 1 + S * k_s;
|
||||
|
||||
const l = l_ * l_ * l_;
|
||||
const m = m_ * m_ * m_;
|
||||
const s = s_ * s_ * s_;
|
||||
|
||||
const l_dS = 3 * k_l * l_ * l_;
|
||||
const m_dS = 3 * k_m * m_ * m_;
|
||||
const s_dS = 3 * k_s * s_ * s_;
|
||||
|
||||
const l_dS2 = 6 * k_l * k_l * l_;
|
||||
const m_dS2 = 6 * k_m * k_m * m_;
|
||||
const s_dS2 = 6 * k_s * k_s * s_;
|
||||
|
||||
const f = wl * l + wm * m + ws * s;
|
||||
const f1 = wl * l_dS + wm * m_dS + ws * s_dS;
|
||||
const f2 = wl * l_dS2 + wm * m_dS2 + ws * s_dS2;
|
||||
|
||||
S = S - (f * f1) / (f1 * f1 - 0.5 * f * f2);
|
||||
}
|
||||
|
||||
return S;
|
||||
}
|
||||
|
||||
function find_cusp(a: number, b: number): LC {
|
||||
const S_cusp = compute_max_saturation(a, b);
|
||||
|
||||
const rgb_at_max = oklab_to_linear_srgb({
|
||||
L: 1,
|
||||
a: S_cusp * a,
|
||||
b: S_cusp * b,
|
||||
});
|
||||
const L_cusp = Math.cbrt(
|
||||
1 / Math.max(rgb_at_max.r, rgb_at_max.g, rgb_at_max.b),
|
||||
);
|
||||
const C_cusp = L_cusp * S_cusp;
|
||||
|
||||
return { L: L_cusp, C: C_cusp };
|
||||
}
|
||||
|
||||
function find_gamut_intersection(
|
||||
a: number,
|
||||
b: number,
|
||||
L1: number,
|
||||
C1: number,
|
||||
L0: number,
|
||||
): number {
|
||||
const cusp = find_cusp(a, b);
|
||||
|
||||
let t: number;
|
||||
if ((L1 - L0) * cusp.C - (cusp.L - L0) * C1 <= 0) {
|
||||
t = (cusp.C * L0) / (C1 * cusp.L + cusp.C * (L0 - L1));
|
||||
} else {
|
||||
t = (cusp.C * (L0 - 1)) / (C1 * (cusp.L - 1) + cusp.C * (L0 - L1));
|
||||
|
||||
{
|
||||
const dL = L1 - L0;
|
||||
const dC = C1;
|
||||
|
||||
const k_l = +0.3963377774 * a + 0.2158037573 * b;
|
||||
const k_m = -0.1055613458 * a - 0.0638541728 * b;
|
||||
const k_s = -0.0894841775 * a - 1.291485548 * b;
|
||||
|
||||
const l_dt = dL + dC * k_l;
|
||||
const m_dt = dL + dC * k_m;
|
||||
const s_dt = dL + dC * k_s;
|
||||
{
|
||||
const L = L0 * (1 - t) + t * L1;
|
||||
const C = t * C1;
|
||||
|
||||
const l_ = L + C * k_l;
|
||||
const m_ = L + C * k_m;
|
||||
const s_ = L + C * k_s;
|
||||
|
||||
const l = l_ * l_ * l_;
|
||||
const m = m_ * m_ * m_;
|
||||
const s = s_ * s_ * s_;
|
||||
|
||||
const ldt = 3 * l_dt * l_ * l_;
|
||||
const mdt = 3 * m_dt * m_ * m_;
|
||||
const sdt = 3 * s_dt * s_ * s_;
|
||||
|
||||
const ldt2 = 6 * l_dt * l_dt * l_;
|
||||
const mdt2 = 6 * m_dt * m_dt * m_;
|
||||
const sdt2 = 6 * s_dt * s_dt * s_;
|
||||
|
||||
const r = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s - 1;
|
||||
const r1 = 4.0767416621 * ldt - 3.3077115913 * mdt + 0.2309699292 * sdt;
|
||||
const r2 =
|
||||
4.0767416621 * ldt2 - 3.3077115913 * mdt2 + 0.2309699292 * sdt2;
|
||||
|
||||
const u_r = r1 / (r1 * r1 - 0.5 * r * r2);
|
||||
let t_r = -r * u_r;
|
||||
|
||||
const g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s - 1;
|
||||
const g1 =
|
||||
-1.2684380046 * ldt + 2.6097574011 * mdt - 0.3413193965 * sdt;
|
||||
const g2 =
|
||||
-1.2684380046 * ldt2 + 2.6097574011 * mdt2 - 0.3413193965 * sdt2;
|
||||
|
||||
const u_g = g1 / (g1 * g1 - 0.5 * g * g2);
|
||||
let t_g = -g * u_g;
|
||||
|
||||
const b = -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s - 1;
|
||||
const b1 = -0.0041960863 * ldt - 0.7034186147 * mdt + 1.707614701 * sdt;
|
||||
const b2 =
|
||||
-0.0041960863 * ldt2 - 0.7034186147 * mdt2 + 1.707614701 * sdt2;
|
||||
|
||||
const u_b = b1 / (b1 * b1 - 0.5 * b * b2);
|
||||
let t_b = -b * u_b;
|
||||
|
||||
t_r = u_r >= 0 ? t_r : Number.MAX_VALUE;
|
||||
t_g = u_g >= 0 ? t_g : Number.MAX_VALUE;
|
||||
t_b = u_b >= 0 ? t_b : Number.MAX_VALUE;
|
||||
|
||||
t += Math.min(t_r, Math.min(t_g, t_b));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return t;
|
||||
}
|
||||
|
||||
function clamp(x: number, min: number, max: number): number {
|
||||
return x < min ? min : x > max ? max : x;
|
||||
}
|
||||
|
||||
function maximum_chroma_for_lh(L: number, h: number): number {
|
||||
const a = Math.cos(h);
|
||||
const b = Math.sin(h);
|
||||
|
||||
const L0 = clamp(L, 0, 1);
|
||||
|
||||
const t = find_gamut_intersection(a, b, L, 1, L0);
|
||||
|
||||
return t;
|
||||
}
|
||||
|
||||
// These are the lightness values used in vars.css
|
||||
// Any changes here NEED to be reflected in vars.css as well.
|
||||
|
||||
const BASE_LIGHTNESS_VALUES = [
|
||||
1.0, // --base-c-0
|
||||
0.95, // --base-c-1
|
||||
0.9, // --base-c-2
|
||||
0.64, // --base-c-3
|
||||
0.46, // --base-c-4
|
||||
0.32, // --base-c-5
|
||||
0.25, // --base-c-6
|
||||
0.17, // --base-c-7
|
||||
] as const;
|
||||
|
||||
const ACCENT_LIGHTNESS_VALUES = [
|
||||
0.26, // --acc-c-0: dark mode low
|
||||
0.52, // --acc-c-1: dark mode mid
|
||||
0.83, // --acc-c-2: dark mode high
|
||||
0.88, // --acc-c-3: light mode low
|
||||
0.53, // --acc-c-4: light mode mid
|
||||
0.32, // --acc-c-5: light mode high
|
||||
] as const;
|
||||
|
||||
export const BASE_CHROMA_MULTIPLIERS = [
|
||||
0.01, // --base-c-0
|
||||
0.49, // --base-c-1
|
||||
0.62, // --base-c-2
|
||||
1.4, // --base-c-3
|
||||
1.29, // --base-c-4
|
||||
1.36, // --base-c-5
|
||||
1.29, // --base-c-6
|
||||
0.67, // --base-c-7
|
||||
] as const;
|
||||
|
||||
export const ACCENT_CHROMA_MULTIPLIERS = [
|
||||
0.38, // --acc-c-0
|
||||
1.11, // --acc-c-1
|
||||
0.34, // --acc-c-2
|
||||
0.25, // --acc-c-3
|
||||
1.09, // --acc-c-4
|
||||
0.56, // --acc-c-5
|
||||
] as const;
|
||||
|
||||
function clampChromaForColor(
|
||||
lightness: number,
|
||||
desiredChroma: number,
|
||||
hueRadians: number,
|
||||
): number {
|
||||
const maxChroma = maximum_chroma_for_lh(lightness, hueRadians);
|
||||
return Math.min(desiredChroma, maxChroma);
|
||||
}
|
||||
|
||||
export function clampThemeToGamut(input: ThemeInput): CustomTheme {
|
||||
const baseHueRadians = input.baseHue * (Math.PI / 180);
|
||||
const accentHueRadians = input.accentHue * (Math.PI / 180);
|
||||
|
||||
const clampedBaseChromas = BASE_LIGHTNESS_VALUES.map((lightness, index) => {
|
||||
const desiredChroma = input.baseChroma * BASE_CHROMA_MULTIPLIERS[index];
|
||||
return clampChromaForColor(lightness, desiredChroma, baseHueRadians);
|
||||
});
|
||||
|
||||
const clampedAccentChromas = ACCENT_LIGHTNESS_VALUES.map(
|
||||
(lightness, index) => {
|
||||
const desiredChroma =
|
||||
input.accentChroma * ACCENT_CHROMA_MULTIPLIERS[index];
|
||||
return clampChromaForColor(lightness, desiredChroma, accentHueRadians);
|
||||
},
|
||||
);
|
||||
|
||||
const secondaryHue = (input.accentHue + 180) % 360;
|
||||
const secondaryHueRadians = secondaryHue * (Math.PI / 180);
|
||||
|
||||
const clampedSecondaryChromas = ACCENT_LIGHTNESS_VALUES.map(
|
||||
(lightness, index) => {
|
||||
const desiredChroma =
|
||||
input.accentChroma * ACCENT_CHROMA_MULTIPLIERS[index];
|
||||
return clampChromaForColor(lightness, desiredChroma, secondaryHueRadians);
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
"--_base-h": input.baseHue,
|
||||
"--_base-c-0": clampedBaseChromas[0],
|
||||
"--_base-c-1": clampedBaseChromas[1],
|
||||
"--_base-c-2": clampedBaseChromas[2],
|
||||
"--_base-c-3": clampedBaseChromas[3],
|
||||
"--_base-c-4": clampedBaseChromas[4],
|
||||
"--_base-c-5": clampedBaseChromas[5],
|
||||
"--_base-c-6": clampedBaseChromas[6],
|
||||
"--_base-c-7": clampedBaseChromas[7],
|
||||
"--_acc-h": input.accentHue,
|
||||
"--_acc-c-0": clampedAccentChromas[0],
|
||||
"--_acc-c-1": clampedAccentChromas[1],
|
||||
"--_acc-c-2": clampedAccentChromas[2],
|
||||
"--_acc-c-3": clampedAccentChromas[3],
|
||||
"--_acc-c-4": clampedAccentChromas[4],
|
||||
"--_acc-c-5": clampedAccentChromas[5],
|
||||
"--_second-h": secondaryHue,
|
||||
"--_second-c-0": clampedSecondaryChromas[0],
|
||||
"--_second-c-1": clampedSecondaryChromas[1],
|
||||
"--_second-c-2": clampedSecondaryChromas[2],
|
||||
"--_second-c-3": clampedSecondaryChromas[3],
|
||||
"--_second-c-4": clampedSecondaryChromas[4],
|
||||
"--_second-c-5": clampedSecondaryChromas[5],
|
||||
"--_chat-h": input.chatHue,
|
||||
"--_radius-box": input.radiusBox,
|
||||
"--_radius-field": input.radiusField,
|
||||
"--_radius-selector": input.radiusSelector,
|
||||
"--_border-width": input.borderWidth,
|
||||
"--_size-field": input.sizeField,
|
||||
"--_size-selector": input.sizeSelector,
|
||||
"--_size-spacing": input.sizeSpacing,
|
||||
};
|
||||
}
|
||||
142
apps/web-react/app/utils/random.test.ts
Normal file
142
apps/web-react/app/utils/random.test.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { seededRandom } from "./random";
|
||||
|
||||
describe("seededRandom", () => {
|
||||
describe("random", () => {
|
||||
test("produces same values for same seed", () => {
|
||||
const rng1 = seededRandom("test-seed");
|
||||
const rng2 = seededRandom("test-seed");
|
||||
|
||||
expect(rng1.random()).toBe(rng2.random());
|
||||
expect(rng1.random()).toBe(rng2.random());
|
||||
expect(rng1.random()).toBe(rng2.random());
|
||||
});
|
||||
|
||||
test("produces different values for different seeds", () => {
|
||||
const rng1 = seededRandom("seed-1");
|
||||
const rng2 = seededRandom("seed-2");
|
||||
|
||||
expect(rng1.random()).not.toBe(rng2.random());
|
||||
});
|
||||
|
||||
test("returns values between 0 and 1 by default", () => {
|
||||
const rng = seededRandom("test");
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const value = rng.random();
|
||||
expect(value).toBeGreaterThanOrEqual(0);
|
||||
expect(value).toBeLessThan(1);
|
||||
}
|
||||
});
|
||||
|
||||
test("returns values between lo and hi when both provided", () => {
|
||||
const rng = seededRandom("test");
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const value = rng.random(5, 10);
|
||||
expect(value).toBeGreaterThanOrEqual(5);
|
||||
expect(value).toBeLessThan(10);
|
||||
}
|
||||
});
|
||||
|
||||
test("returns values between 0 and hi when only hi provided", () => {
|
||||
const rng = seededRandom("test");
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const value = rng.random(5);
|
||||
expect(value).toBeGreaterThanOrEqual(0);
|
||||
expect(value).toBeLessThan(5);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("randomInteger", () => {
|
||||
test("produces same values for same seed", () => {
|
||||
const rng1 = seededRandom("test-seed");
|
||||
const rng2 = seededRandom("test-seed");
|
||||
|
||||
expect(rng1.randomInteger(10)).toBe(rng2.randomInteger(10));
|
||||
expect(rng1.randomInteger(10)).toBe(rng2.randomInteger(10));
|
||||
expect(rng1.randomInteger(10)).toBe(rng2.randomInteger(10));
|
||||
});
|
||||
|
||||
test("produces different values for different seeds", () => {
|
||||
const rng1 = seededRandom("seed-1");
|
||||
const rng2 = seededRandom("seed-2");
|
||||
|
||||
expect(rng1.randomInteger(100)).not.toBe(rng2.randomInteger(100));
|
||||
});
|
||||
|
||||
test("returns integers between 0 and hi when only hi provided", () => {
|
||||
const rng = seededRandom("test");
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const value = rng.randomInteger(10);
|
||||
expect(Number.isInteger(value)).toBe(true);
|
||||
expect(value).toBeGreaterThanOrEqual(0);
|
||||
expect(value).toBeLessThan(10);
|
||||
}
|
||||
});
|
||||
|
||||
test("returns integers between lo and hi when both provided", () => {
|
||||
const rng = seededRandom("test");
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const value = rng.randomInteger(5, 10);
|
||||
expect(Number.isInteger(value)).toBe(true);
|
||||
expect(value).toBeGreaterThanOrEqual(5);
|
||||
expect(value).toBeLessThan(10);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("seededShuffle", () => {
|
||||
test("produces same shuffle for same seed", () => {
|
||||
const array = [1, 2, 3, 4, 5];
|
||||
const rng1 = seededRandom("test-seed");
|
||||
const rng2 = seededRandom("test-seed");
|
||||
|
||||
expect(rng1.seededShuffle(array)).toEqual(rng2.seededShuffle(array));
|
||||
});
|
||||
|
||||
test("produces different shuffles for different seeds", () => {
|
||||
const array = [1, 2, 3, 4, 5];
|
||||
const rng1 = seededRandom("seed-1");
|
||||
const rng2 = seededRandom("seed-2");
|
||||
|
||||
expect(rng1.seededShuffle(array)).not.toEqual(rng2.seededShuffle(array));
|
||||
});
|
||||
|
||||
test("does not mutate original array", () => {
|
||||
const array = [1, 2, 3, 4, 5];
|
||||
const original = [...array];
|
||||
const rng = seededRandom("test");
|
||||
|
||||
rng.seededShuffle(array);
|
||||
|
||||
expect(array).toEqual(original);
|
||||
});
|
||||
|
||||
test("returns array with same elements", () => {
|
||||
const array = [1, 2, 3, 4, 5];
|
||||
const rng = seededRandom("test");
|
||||
|
||||
const shuffled = rng.seededShuffle(array);
|
||||
|
||||
expect(shuffled.sort()).toEqual(array.sort());
|
||||
});
|
||||
|
||||
test("handles empty array", () => {
|
||||
const array: number[] = [];
|
||||
const rng = seededRandom("test");
|
||||
|
||||
const shuffled = rng.seededShuffle(array);
|
||||
|
||||
expect(shuffled).toEqual([]);
|
||||
});
|
||||
|
||||
test("handles single element array", () => {
|
||||
const array = [1];
|
||||
const rng = seededRandom("test");
|
||||
|
||||
const shuffled = rng.seededShuffle(array);
|
||||
|
||||
expect(shuffled).toEqual([1]);
|
||||
});
|
||||
});
|
||||
});
|
||||
78
apps/web-react/app/utils/random.ts
Normal file
78
apps/web-react/app/utils/random.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
function cyrb128(str: string) {
|
||||
let h1 = 1779033703;
|
||||
let h2 = 3144134277;
|
||||
let h3 = 1013904242;
|
||||
let h4 = 2773480762;
|
||||
// biome-ignore lint/suspicious/noImplicitAnyLet: biome migration
|
||||
for (let i = 0, k; i < str.length; i++) {
|
||||
k = str.charCodeAt(i);
|
||||
h1 = h2 ^ Math.imul(h1 ^ k, 597399067);
|
||||
h2 = h3 ^ Math.imul(h2 ^ k, 2869860233);
|
||||
h3 = h4 ^ Math.imul(h3 ^ k, 951274213);
|
||||
h4 = h1 ^ Math.imul(h4 ^ k, 2716044179);
|
||||
}
|
||||
h1 = Math.imul(h3 ^ (h1 >>> 18), 597399067);
|
||||
h2 = Math.imul(h4 ^ (h2 >>> 22), 2869860233);
|
||||
h3 = Math.imul(h1 ^ (h3 >>> 17), 951274213);
|
||||
h4 = Math.imul(h2 ^ (h4 >>> 19), 2716044179);
|
||||
return [
|
||||
(h1 ^ h2 ^ h3 ^ h4) >>> 0,
|
||||
(h2 ^ h1) >>> 0,
|
||||
(h3 ^ h1) >>> 0,
|
||||
(h4 ^ h1) >>> 0,
|
||||
];
|
||||
}
|
||||
|
||||
function mulberry32(a: number) {
|
||||
return () => {
|
||||
// biome-ignore lint/suspicious/noAssignInExpressions: biome migration
|
||||
// biome-ignore lint/style/noParameterAssign: biome migration
|
||||
let t = (a += 0x6d2b79f5);
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a seeded pseudo-random number generator that produces consistent results for the same seed.
|
||||
* Uses mulberry32 algorithm with cyrb128 hash function for string-to-number conversion.
|
||||
*
|
||||
* @param seed - String seed value (e.g., "2025-1-8" for daily rotation)
|
||||
* @returns Object with random number generation methods:
|
||||
* - `random(lo?, hi?)` - Returns random float between lo (inclusive) and hi (exclusive)
|
||||
* - `randomInteger(lo, hi?)` - Returns random integer between lo (inclusive) and hi (exclusive)
|
||||
* - `seededShuffle(array)` - Returns shuffled copy of array using seeded Fisher-Yates algorithm
|
||||
*
|
||||
* @example
|
||||
* const { seededShuffle } = seededRandom("2025-1-8");
|
||||
* const shuffled = seededShuffle([1, 2, 3, 4, 5]);
|
||||
*/
|
||||
export const seededRandom = (seed: string) => {
|
||||
const rng = mulberry32(cyrb128(seed)[0]);
|
||||
|
||||
const random = (lo?: number, hi?: number, defaultHi = 1) => {
|
||||
const actualLo = hi === undefined ? 0 : (lo ?? 0);
|
||||
const actualHi = hi === undefined ? (lo ?? defaultHi) : hi;
|
||||
|
||||
return rng() * (actualHi - actualLo) + actualLo;
|
||||
};
|
||||
|
||||
const randomInteger = (lo: number, hi?: number) =>
|
||||
Math.floor(random(lo, hi, 2));
|
||||
|
||||
const seededShuffle = <T>(o: T[]) => {
|
||||
const a = o.slice();
|
||||
|
||||
for (let i = a.length - 1; i > 0; i--) {
|
||||
const j = randomInteger(i + 1);
|
||||
const x = a[i];
|
||||
a[i] = a[j]!;
|
||||
a[j] = x!;
|
||||
}
|
||||
|
||||
return a;
|
||||
};
|
||||
|
||||
return { random, randomInteger, seededShuffle };
|
||||
};
|
||||
114
apps/web-react/app/utils/remix.server.test.ts
Normal file
114
apps/web-react/app/utils/remix.server.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { paginate } from "./remix.server";
|
||||
|
||||
const buildUrl = (url: string) => new URL(url);
|
||||
|
||||
const captureRedirect = (fn: () => void) => {
|
||||
try {
|
||||
fn();
|
||||
} catch (thrown) {
|
||||
if (thrown instanceof Response) return thrown;
|
||||
throw thrown;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
describe("paginate()", () => {
|
||||
test("returns the page count rounded up", () => {
|
||||
const result = paginate({
|
||||
url: buildUrl("https://sendou.ink/vods?page=1"),
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
totalCount: 41,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ currentPage: 1, pagesCount: 5 });
|
||||
});
|
||||
|
||||
test("does not redirect when page is within bounds", () => {
|
||||
const response = captureRedirect(() =>
|
||||
paginate({
|
||||
url: buildUrl("https://sendou.ink/vods?page=2"),
|
||||
page: 2,
|
||||
pageSize: 10,
|
||||
totalCount: 50,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response).toBeNull();
|
||||
});
|
||||
|
||||
test("does not redirect when page equals pagesCount", () => {
|
||||
const response = captureRedirect(() =>
|
||||
paginate({
|
||||
url: buildUrl("https://sendou.ink/vods?page=5"),
|
||||
page: 5,
|
||||
pageSize: 10,
|
||||
totalCount: 50,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response).toBeNull();
|
||||
});
|
||||
|
||||
test("redirects to last page when page exceeds pagesCount", () => {
|
||||
const response = captureRedirect(() =>
|
||||
paginate({
|
||||
url: buildUrl("https://sendou.ink/vods?page=99"),
|
||||
page: 99,
|
||||
pageSize: 10,
|
||||
totalCount: 50,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response).not.toBeNull();
|
||||
expect(response?.headers.get("Location")).toBe("/vods?page=5");
|
||||
});
|
||||
|
||||
test("preserves other search params when redirecting", () => {
|
||||
const response = captureRedirect(() =>
|
||||
paginate({
|
||||
url: buildUrl(
|
||||
"https://sendou.ink/vods?type=TOURNAMENT&page=99&mode=SZ",
|
||||
),
|
||||
page: 99,
|
||||
pageSize: 10,
|
||||
totalCount: 25,
|
||||
}),
|
||||
);
|
||||
|
||||
const location = response?.headers.get("Location");
|
||||
expect(location).not.toBeNull();
|
||||
const locationUrl = new URL(location!, "https://sendou.ink");
|
||||
expect(locationUrl.pathname).toBe("/vods");
|
||||
// biome-ignore-start lint/plugin: asserting on the raw redirect URL is the point of the test
|
||||
expect(locationUrl.searchParams.get("page")).toBe("3");
|
||||
expect(locationUrl.searchParams.get("type")).toBe("TOURNAMENT");
|
||||
expect(locationUrl.searchParams.get("mode")).toBe("SZ");
|
||||
// biome-ignore-end lint/plugin: asserting on the raw redirect URL is the point of the test
|
||||
});
|
||||
|
||||
test("stays on page 1 when there are no results", () => {
|
||||
const result = paginate({
|
||||
url: buildUrl("https://sendou.ink/vods?page=1"),
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
totalCount: 0,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ currentPage: 1, pagesCount: 1 });
|
||||
});
|
||||
|
||||
test("redirects to page 1 when there are no results and page exceeds 1", () => {
|
||||
const response = captureRedirect(() =>
|
||||
paginate({
|
||||
url: buildUrl("https://sendou.ink/vods?page=4"),
|
||||
page: 4,
|
||||
pageSize: 10,
|
||||
totalCount: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response?.headers.get("Location")).toBe("/vods?page=1");
|
||||
});
|
||||
});
|
||||
249
apps/web-react/app/utils/remix.server.ts
Normal file
249
apps/web-react/app/utils/remix.server.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
import type { Namespace, TFunction } from "i18next";
|
||||
import type { Params, UIMatch } from "react-router";
|
||||
import { data, redirect } from "react-router";
|
||||
import type { z } from "zod";
|
||||
import type { navItems } from "~/components/layout/nav-items";
|
||||
import { ServerConfig } from "~/config.server";
|
||||
import type { Ok, Result } from "~/utils/result";
|
||||
import { logger } from "./logger";
|
||||
import { currentRequestPathname } from "./request-context.server";
|
||||
|
||||
export function notFoundIfNullish<T>(value: T | null | undefined): T {
|
||||
if (value === null || value === undefined) {
|
||||
throw new Response(null, { status: 404 });
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
export function unauthorizedIfFalsy<T>(value: T | null | undefined): T {
|
||||
if (!value) throw new Response(null, { status: 401 });
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Throws a HTTP 403 (Forbidden) response, ending execution of the loader/action early */
|
||||
export function forbidden() {
|
||||
throw new Response(null, { status: 403 });
|
||||
}
|
||||
|
||||
export function badRequestIfFalsy<T>(value: T | null | undefined): T {
|
||||
if (!value) {
|
||||
throw new Response(null, { status: 400 });
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the pagination state of a loader whose current page comes from the
|
||||
* `page` search param. `pagesCount` is at minimum 1 so empty result sets stay
|
||||
* on page 1.
|
||||
*
|
||||
* If the requested `page` exceeds `pagesCount`, throws a redirect to the last
|
||||
* available page (preserving other search params).
|
||||
*/
|
||||
export function paginate({
|
||||
url,
|
||||
page,
|
||||
pageSize,
|
||||
totalCount,
|
||||
}: {
|
||||
url: URL;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalCount: number;
|
||||
}): { currentPage: number; pagesCount: number } {
|
||||
const pagesCount = Math.max(1, Math.ceil(totalCount / pageSize));
|
||||
|
||||
if (page > pagesCount) {
|
||||
const searchParams = new URLSearchParams(url.searchParams);
|
||||
searchParams.set("page", String(pagesCount));
|
||||
throw redirect(`${url.pathname}?${searchParams.toString()}`);
|
||||
}
|
||||
|
||||
return { currentPage: page, pagesCount };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse formData of a request with the given schema. Throws HTTP 400 response if fails.
|
||||
*
|
||||
* When using SendouForm, use parseFormData from /app/form/parse.server.ts instead.
|
||||
* */
|
||||
export async function parseRequestPayload<T extends z.ZodTypeAny>({
|
||||
request,
|
||||
schema,
|
||||
}: {
|
||||
request: Request;
|
||||
schema: T;
|
||||
}): Promise<z.infer<T>> {
|
||||
const formDataObj =
|
||||
request.headers.get("Content-Type") === "application/json"
|
||||
? await request.json()
|
||||
: formDataToObject(await request.formData());
|
||||
try {
|
||||
return await schema.parseAsync(formDataObj);
|
||||
} catch (e) {
|
||||
logger.error("Error parsing request payload", e);
|
||||
|
||||
throw errorToastRedirect("Validation failed");
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse params with the given schema. Throws HTTP 404 response if fails. */
|
||||
export function parseParams<T extends z.ZodTypeAny>({
|
||||
params,
|
||||
schema,
|
||||
}: {
|
||||
params: Params<string>;
|
||||
schema: T;
|
||||
}): z.infer<T> {
|
||||
const parsed = schema.safeParse(params);
|
||||
if (!parsed.success) {
|
||||
throw new Response(null, { status: 404 });
|
||||
}
|
||||
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
/** Parse JSON body with the given schema. Throws HTTP 400 response if fails. */
|
||||
export async function parseBody<T extends z.ZodTypeAny>({
|
||||
request,
|
||||
schema,
|
||||
}: {
|
||||
request: Request;
|
||||
schema: T;
|
||||
}): Promise<z.infer<T>> {
|
||||
const parsed = schema.safeParse(await request.json());
|
||||
if (!parsed.success) {
|
||||
throw new Response(null, { status: 400 });
|
||||
}
|
||||
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
export function formDataToObject(formData: FormData) {
|
||||
const result: Record<string, string | string[]> = {};
|
||||
|
||||
for (const [key, value] of formData.entries()) {
|
||||
const newValue = String(value);
|
||||
const existingValue = result[key];
|
||||
|
||||
if (Array.isArray(existingValue)) {
|
||||
existingValue.push(newValue);
|
||||
} else if (typeof existingValue === "string") {
|
||||
result[key] = [existingValue, newValue];
|
||||
} else {
|
||||
result[key] = newValue;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
const LOHI_TOKEN_HEADER_NAME = "Lohi-Token";
|
||||
|
||||
/** Some endpoints can only be accessed with an auth token. Used by Lohi bot and cron jobs. */
|
||||
export function canAccessLohiEndpoint(request: Request) {
|
||||
return request.headers.get(LOHI_TOKEN_HEADER_NAME) === ServerConfig.lohiToken;
|
||||
}
|
||||
|
||||
export function errorToastRedirect(message: string) {
|
||||
return redirect(`${currentRequestPathname() ?? ""}?__error=${message}`);
|
||||
}
|
||||
|
||||
/** Asserts condition is truthy. Throws a redirect triggering an error toast with given message otherwise. */
|
||||
export function errorToastIfFalsy(
|
||||
condition: any,
|
||||
message: string,
|
||||
): asserts condition {
|
||||
if (condition) return;
|
||||
|
||||
throw errorToastRedirect(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* To be used in loader or action function. Asserts that the provided `Result` value is an `Ok` variant.
|
||||
*
|
||||
* If the value is an `Err`, shows an error toast to the user with the error message. The function will stop execution by throwing a redirect meaning it is safe to operate on the value after this function call.
|
||||
*/
|
||||
export function errorToastIfErr<T, E extends string>(
|
||||
value: Result<T, E>,
|
||||
): asserts value is Ok<T> {
|
||||
if (!value.ok) {
|
||||
throw errorToastRedirect(value.error);
|
||||
}
|
||||
}
|
||||
|
||||
/** Throws a redirect triggering an error toast with given message. */
|
||||
export function errorToast(message: string) {
|
||||
throw errorToastRedirect(message);
|
||||
}
|
||||
|
||||
export function successToast(message: string) {
|
||||
return redirect(`${currentRequestPathname() ?? ""}?__success=${message}`);
|
||||
}
|
||||
|
||||
export function successToastWithRedirect({
|
||||
message,
|
||||
url,
|
||||
}: {
|
||||
message: string;
|
||||
url: string;
|
||||
}) {
|
||||
return redirect(`${url}?__success=${message}`);
|
||||
}
|
||||
|
||||
export type Breadcrumb =
|
||||
| {
|
||||
imgPath: string;
|
||||
type: "IMAGE";
|
||||
href: string;
|
||||
text?: string;
|
||||
/** Seed for the identicon shown if `imgPath` fails to load. */
|
||||
identiconInput?: string;
|
||||
}
|
||||
| { text: string; type: "TEXT"; href: string };
|
||||
|
||||
/**
|
||||
* Our custom type for route handles - the keys are defined by us or
|
||||
* libraries that parse them.
|
||||
*
|
||||
* Can be set per route using `export const handle: SendouRouteHandle = { };`
|
||||
* Can be accessed for all currently active routes via the `useMatches()` hook.
|
||||
*/
|
||||
export type SendouRouteHandle = {
|
||||
/** The i18n translation files used for this route, via remix-i18next */
|
||||
i18n?: Namespace;
|
||||
|
||||
/**
|
||||
* A function that returns the breadcrumb text that should be displayed in
|
||||
* the <Breadcrumb> component
|
||||
*/
|
||||
breadcrumb?: (args: {
|
||||
match: UIMatch;
|
||||
t: TFunction<"common", undefined>;
|
||||
}) => Breadcrumb | Array<Breadcrumb> | undefined;
|
||||
|
||||
/** The name of a navItem that is active on this route. See nav-items.ts */
|
||||
navItemName?: (typeof navItems)[number]["name"];
|
||||
|
||||
/**
|
||||
* When `true`, the shared `<Main>` rendered by a parent layout (e.g. the
|
||||
* tournament layout) fills the whole content area instead of the page
|
||||
* max-width, while the page content stays centered at the normal width.
|
||||
* Lets a descendant (e.g. the bracket) break out and grow wider than the
|
||||
* page when it needs to.
|
||||
*/
|
||||
mainBreakout?: boolean;
|
||||
};
|
||||
|
||||
/** Caches the loader response with "private" Cache-Control meaning that CDN won't cache the response.
|
||||
* To be used when the response is different for each user. This is especially useful when the response
|
||||
* is prefetched on link hover.
|
||||
*/
|
||||
export function privatelyCachedJson<T>(dataValue: T) {
|
||||
return data(dataValue, {
|
||||
headers: { "Cache-Control": "private, max-age=5" },
|
||||
});
|
||||
}
|
||||
41
apps/web-react/app/utils/remix.test.ts
Normal file
41
apps/web-react/app/utils/remix.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { Location } from "react-router";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { metaTags } from "./remix";
|
||||
import { COMMON_PREVIEW_IMAGE } from "./urls";
|
||||
|
||||
const location = { pathname: "/to/1/brackets" } as Location;
|
||||
|
||||
const contentOf = (tags: ReturnType<typeof metaTags>, property: string) =>
|
||||
tags.find((tag) => "property" in tag && tag.property === property)?.content;
|
||||
|
||||
describe("metaTags()", () => {
|
||||
test("uses the common preview image when no image given", () => {
|
||||
const tags = metaTags({ title: "sendou.ink", location });
|
||||
|
||||
expect(contentOf(tags, "og:image")).toBe(COMMON_PREVIEW_IMAGE);
|
||||
});
|
||||
|
||||
test("uses the given image url", () => {
|
||||
const tags = metaTags({
|
||||
title: "sendou.ink",
|
||||
location,
|
||||
image: { url: "https://cdn.example.com/img/preview.png" },
|
||||
});
|
||||
|
||||
expect(contentOf(tags, "og:image")).toBe(
|
||||
"https://cdn.example.com/img/preview.png",
|
||||
);
|
||||
});
|
||||
|
||||
test("resolves og:url from the location pathname", () => {
|
||||
const tags = metaTags({ title: "sendou.ink", location });
|
||||
|
||||
expect(contentOf(tags, "og:url")).toBe("https://sendou.ink/to/1/brackets");
|
||||
});
|
||||
|
||||
test("prefers the url override over the location pathname", () => {
|
||||
const tags = metaTags({ title: "sendou.ink", location, url: "/to/1" });
|
||||
|
||||
expect(contentOf(tags, "og:url")).toBe("https://sendou.ink/to/1");
|
||||
});
|
||||
});
|
||||
115
apps/web-react/app/utils/remix.ts
Normal file
115
apps/web-react/app/utils/remix.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import type {
|
||||
Location,
|
||||
ShouldRevalidateFunctionArgs,
|
||||
useLoaderData,
|
||||
} from "react-router";
|
||||
import { truncateBySentence } from "./strings";
|
||||
import { COMMON_PREVIEW_IMAGE } from "./urls";
|
||||
|
||||
export function isRevalidation(args: ShouldRevalidateFunctionArgs) {
|
||||
return (
|
||||
args.defaultShouldRevalidate &&
|
||||
args.nextUrl.href === args.currentUrl.href &&
|
||||
!args.formMethod
|
||||
);
|
||||
}
|
||||
|
||||
// https://remix.run/docs/en/main/start/future-flags#serializefrom
|
||||
export type SerializeFrom<T> = ReturnType<typeof useLoaderData<T>>;
|
||||
|
||||
interface OpenGraphArgs {
|
||||
/** Title as shown by the browser in the tab etc. Appended with "| sendou.ink"*/
|
||||
title: string;
|
||||
/** Title as shown when shared on Bluesky, Discord etc. Also used in search results. If omitted, "title" is used instead. */
|
||||
ogTitle?: string;
|
||||
/** Brief description of the page's contents used by search engines and social media sharing. If the description is over 300 characters long it is automatically truncated. */
|
||||
description?: string;
|
||||
location: Location;
|
||||
/** Optionally override location pathname. */
|
||||
url?: string;
|
||||
image?: {
|
||||
/** Absolute URL of the image. */
|
||||
url: string;
|
||||
dimensions?: {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const ROOT_URL = "https://sendou.ink";
|
||||
|
||||
export function metaTitle(args: Pick<OpenGraphArgs, "title" | "ogTitle">) {
|
||||
return [
|
||||
{
|
||||
title:
|
||||
args.title === "sendou.ink" ? args.title : `${args.title} | sendou.ink`,
|
||||
},
|
||||
{
|
||||
property: "og:title",
|
||||
content: args.ogTitle ?? args.title,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function metaTags(args: OpenGraphArgs) {
|
||||
const truncatedDescription = args.description
|
||||
? truncateBySentence(args.description, 300)
|
||||
: null;
|
||||
|
||||
const result = [
|
||||
...metaTitle(args),
|
||||
args.description
|
||||
? {
|
||||
name: "description",
|
||||
content: truncatedDescription,
|
||||
}
|
||||
: null,
|
||||
args.description
|
||||
? {
|
||||
property: "og:description",
|
||||
content: truncatedDescription,
|
||||
}
|
||||
: null,
|
||||
{
|
||||
property: "og:site_name",
|
||||
content: "sendou.ink",
|
||||
},
|
||||
{
|
||||
property: "og:type",
|
||||
content: "website",
|
||||
},
|
||||
{
|
||||
property: "og:url",
|
||||
content: `${ROOT_URL}${args.url ?? args.location.pathname}`,
|
||||
},
|
||||
{
|
||||
property: "og:image",
|
||||
content: args.image?.url ?? COMMON_PREVIEW_IMAGE,
|
||||
},
|
||||
].filter((val) => val !== null);
|
||||
|
||||
if (!args.image) {
|
||||
result.push({
|
||||
property: "og:image:width",
|
||||
content: "1920",
|
||||
});
|
||||
|
||||
result.push({
|
||||
property: "og:image:height",
|
||||
content: "1080",
|
||||
});
|
||||
} else if (args.image.dimensions) {
|
||||
result.push({
|
||||
property: "og:image:width",
|
||||
content: String(args.image.dimensions.width),
|
||||
});
|
||||
|
||||
result.push({
|
||||
property: "og:image:height",
|
||||
content: String(args.image.dimensions.height),
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { runWithRequestContext } from "./request-context.server";
|
||||
|
||||
type MiddlewareArgs = {
|
||||
request: Request;
|
||||
url: URL;
|
||||
context: unknown;
|
||||
};
|
||||
|
||||
type MiddlewareFn = (
|
||||
args: MiddlewareArgs,
|
||||
next: () => Promise<Response>,
|
||||
) => Promise<Response>;
|
||||
|
||||
// TODO: this is only needed for our current hacky toast setup, once a proper one in place this middleware can be deleted
|
||||
export const requestContextMiddleware: MiddlewareFn = ({ url }, next) =>
|
||||
runWithRequestContext({ url }, () => next());
|
||||
26
apps/web-react/app/utils/request-context.server.ts
Normal file
26
apps/web-react/app/utils/request-context.server.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
|
||||
// TODO: this is only needed for our current hacky toast setup, once a proper one in place this middleware can be deleted
|
||||
|
||||
interface RequestContext {
|
||||
/** Normalized request URL, as provided to middleware in framework mode
|
||||
* (single-fetch `.data` suffix and internal search params removed). */
|
||||
url: URL;
|
||||
}
|
||||
|
||||
const requestContextAsyncLocalStorage = new AsyncLocalStorage<RequestContext>();
|
||||
|
||||
/** Runs `fn` with the given request context available to server-side helpers
|
||||
* (e.g. toast redirects) that don't otherwise receive the request. */
|
||||
export function runWithRequestContext<T>(
|
||||
context: RequestContext,
|
||||
fn: () => T,
|
||||
): T {
|
||||
return requestContextAsyncLocalStorage.run(context, fn);
|
||||
}
|
||||
|
||||
/** Normalized pathname of the current request, or `undefined` outside a request
|
||||
* context. Used to build absolute redirects from helpers lacking the request. */
|
||||
export function currentRequestPathname(): string | undefined {
|
||||
return requestContextAsyncLocalStorage.getStore()?.url.pathname;
|
||||
}
|
||||
41
apps/web-react/app/utils/result.ts
Normal file
41
apps/web-react/app/utils/result.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
export type Ok<T> = { readonly ok: true; readonly value: T };
|
||||
export type Err<E> = { readonly ok: false; readonly error: E };
|
||||
|
||||
/** Discriminated union representing either a success (`Ok`) or a failure (`Err`). Narrow with the `ok` property. */
|
||||
export type Result<T, E> = Ok<T> | Err<E>;
|
||||
|
||||
/** Creates an `Ok` variant of `Result` holding the success value. */
|
||||
export function ok<T>(value: T): Ok<T> {
|
||||
return { ok: true, value };
|
||||
}
|
||||
|
||||
/** Creates an `Err` variant of `Result` holding the error value. */
|
||||
export function err<E>(error: E): Err<E> {
|
||||
return { ok: false, error };
|
||||
}
|
||||
|
||||
/** Returns the success value, or the given default value if the result is an `Err`. */
|
||||
export function unwrapOr<T, D>(
|
||||
result: Result<T, unknown>,
|
||||
defaultValue: D,
|
||||
): T | D {
|
||||
return result.ok ? result.value : defaultValue;
|
||||
}
|
||||
|
||||
/** Returns the success value. Throws if the result is an `Err`. */
|
||||
export function unwrap<T>(result: Result<T, unknown>): T {
|
||||
if (!result.ok) {
|
||||
throw new Error(`Expected Ok, got Err: ${JSON.stringify(result.error)}`);
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
|
||||
/** Returns the error value. Throws if the result is an `Ok`. */
|
||||
export function unwrapErr<E>(result: Result<unknown, E>): E {
|
||||
if (result.ok) {
|
||||
throw new Error("Expected Err, got Ok");
|
||||
}
|
||||
|
||||
return result.error;
|
||||
}
|
||||
15
apps/web-react/app/utils/session-id.ts
Normal file
15
apps/web-react/app/utils/session-id.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { customAlphabet } from "nanoid";
|
||||
|
||||
const nanoid = customAlphabet(
|
||||
// avoid 1/I and 0/O
|
||||
"23456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz",
|
||||
);
|
||||
|
||||
let sessionId: string | undefined;
|
||||
|
||||
export function getSessionId(): string {
|
||||
if (!sessionId) {
|
||||
sessionId = nanoid(10);
|
||||
}
|
||||
return sessionId;
|
||||
}
|
||||
24
apps/web-react/app/utils/sql.ts
Normal file
24
apps/web-react/app/utils/sql.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { DBBoolean } from "~/db/tables";
|
||||
|
||||
/** Converts a JS boolean to the 0/1 representation SQLite stores booleans as. */
|
||||
export function toDBBoolean(value: boolean): DBBoolean {
|
||||
return value ? 1 : 0;
|
||||
}
|
||||
|
||||
export function errorIsSqliteForeignKeyConstraintFailure(
|
||||
error: unknown,
|
||||
): error is Error {
|
||||
return (
|
||||
error instanceof Error &&
|
||||
error?.message?.includes("FOREIGN KEY constraint failed")
|
||||
);
|
||||
}
|
||||
|
||||
export function errorIsSqliteUniqueConstraintFailure(
|
||||
error: unknown,
|
||||
): error is Error {
|
||||
return (
|
||||
error instanceof Error &&
|
||||
error?.message?.includes("UNIQUE constraint failed")
|
||||
);
|
||||
}
|
||||
124
apps/web-react/app/utils/string.test.ts
Normal file
124
apps/web-react/app/utils/string.test.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
pathnameFromPotentialURL,
|
||||
removeMarkdown,
|
||||
truncateBySentence,
|
||||
} from "./strings";
|
||||
|
||||
describe("pathnameFromPotentialURL()", () => {
|
||||
test("Resolves path name from valid URL", () => {
|
||||
expect(pathnameFromPotentialURL("https://bsky.app/sendouc")).toBe(
|
||||
"sendouc",
|
||||
);
|
||||
});
|
||||
|
||||
test("Returns string as is if not URL", () => {
|
||||
expect(pathnameFromPotentialURL("sendouc")).toBe("sendouc");
|
||||
});
|
||||
|
||||
test("Strips trailing slash from URL path", () => {
|
||||
expect(pathnameFromPotentialURL("https://discord.gg/FW4dKrY/")).toBe(
|
||||
"FW4dKrY",
|
||||
);
|
||||
});
|
||||
|
||||
test("Resolves a scheme-less URL paste to the path", () => {
|
||||
// otherwise the generated discordUrl becomes https://discord.gg/discord.gg/FW4dKrY
|
||||
expect(pathnameFromPotentialURL("discord.gg/FW4dKrY")).toBe("FW4dKrY");
|
||||
});
|
||||
});
|
||||
|
||||
describe("truncateBySentence()", () => {
|
||||
test("Truncates text by sentence within max length", () => {
|
||||
const text = "This is the first sentence. This is the second sentence.";
|
||||
expect(truncateBySentence(text, 30)).toBe("This is the first sentence.");
|
||||
});
|
||||
|
||||
test("Returns original text if no sentences fit within max length", () => {
|
||||
const text = "This is a very long sentence that exceeds the max length.";
|
||||
expect(truncateBySentence(text, 10)).toBe("This is a");
|
||||
});
|
||||
|
||||
test("Returns original text if it is shorter than max length", () => {
|
||||
const text = "Short text.";
|
||||
expect(truncateBySentence(text, 50)).toBe("Short text.");
|
||||
});
|
||||
|
||||
test("Handles no senteces", () => {
|
||||
const text = "One two three four five six seven eight nine ten";
|
||||
expect(truncateBySentence(text, 10)).toBe("One two th");
|
||||
});
|
||||
|
||||
test("Truncates text by sentence with newline characters", () => {
|
||||
const text = "This is the first sentence\nThis is the second sentence";
|
||||
expect(truncateBySentence(text, 30)).toBe("This is the first sentence");
|
||||
});
|
||||
|
||||
test("Handles text with multiple newline characters", () => {
|
||||
const text = "First line\nSecond line\nThird line";
|
||||
expect(truncateBySentence(text, 20)).toBe("First line");
|
||||
});
|
||||
|
||||
test("Does not treat a period inside a time like 18.00 as a sentence end", () => {
|
||||
const text =
|
||||
"Doors at 18.00, we will be playing five rounds of swiss followed by a top cut.";
|
||||
expect(truncateBySentence(text, 40)).not.toBe("Doors at 18.");
|
||||
});
|
||||
|
||||
test("Does not return a tiny fraction of the budget when a long sentence follows a short one", () => {
|
||||
const text = `Hi. ${"x".repeat(400)}. Everyone is welcome.`;
|
||||
expect(truncateBySentence(text, 300)).not.toBe("Hi.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeMarkdown()", () => {
|
||||
test("Decodes entities and collapses runs", () => {
|
||||
const text = " Global Gauntlet is an event";
|
||||
expect(removeMarkdown(text)).toBe("Global Gauntlet is an event");
|
||||
});
|
||||
|
||||
test("Decodes common named HTML entities", () => {
|
||||
expect(removeMarkdown("Tom & Jerry <3 "hi"")).toBe(
|
||||
'Tom & Jerry <3 "hi"',
|
||||
);
|
||||
});
|
||||
|
||||
test("Decodes numeric HTML entities", () => {
|
||||
expect(removeMarkdown("café & tea")).toBe("café & tea");
|
||||
});
|
||||
|
||||
test("Does not throw on an out of range numeric entity", () => {
|
||||
// e.g. an organizer writing a hex color code in the description
|
||||
expect(() => removeMarkdown("background: � here")).not.toThrow();
|
||||
});
|
||||
|
||||
test("Leaves unknown named entities untouched", () => {
|
||||
expect(removeMarkdown("AT&T &fakeentity; rules")).toBe(
|
||||
"AT&T &fakeentity; rules",
|
||||
);
|
||||
});
|
||||
|
||||
test("Strips HTML tags and markdown emphasis", () => {
|
||||
expect(removeMarkdown("<p>Hello **world**!</p>")).toBe("Hello world!");
|
||||
});
|
||||
|
||||
test("Keeps the link text of inline links", () => {
|
||||
expect(
|
||||
removeMarkdown("Check out [the site](https://example.com) today"),
|
||||
).toBe("Check out the site today");
|
||||
});
|
||||
|
||||
test("Keeps non-header # characters", () => {
|
||||
expect(removeMarkdown("Showdown #1 starts now")).toBe(
|
||||
"Showdown #1 starts now",
|
||||
);
|
||||
});
|
||||
|
||||
test("Leaves space-flanked asterisks intact instead of mangling them", () => {
|
||||
expect(removeMarkdown("** bold text **")).toBe("** bold text **");
|
||||
});
|
||||
|
||||
test("Strips emphasis with inner spaces", () => {
|
||||
expect(removeMarkdown("*a b c*")).toBe("a b c");
|
||||
});
|
||||
});
|
||||
168
apps/web-react/app/utils/strings.ts
Normal file
168
apps/web-react/app/utils/strings.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import type { GearType } from "~/modules/in-game-lists/types";
|
||||
import { assertUnreachable } from "./types";
|
||||
|
||||
export function inGameNameWithoutDiscriminator(inGameName: string) {
|
||||
return inGameName.split("#")[0];
|
||||
}
|
||||
|
||||
export const rawSensToString = (sens: number) =>
|
||||
`${sens > 0 ? "+" : ""}${sens / 10}`;
|
||||
|
||||
type WithStart<
|
||||
S extends string,
|
||||
Start extends string,
|
||||
> = S extends `${Start}${infer Rest}` ? `${Start}${Rest}` : never;
|
||||
|
||||
export function startsWith<S extends string, Start extends string>(
|
||||
str: S,
|
||||
start: Start,
|
||||
// @ts-expect-error TS 4.9 upgrade
|
||||
): str is WithStart<S, Start> {
|
||||
return str.startsWith(start);
|
||||
}
|
||||
|
||||
type Split<S extends string, Sep extends string> = string extends S
|
||||
? string[]
|
||||
: S extends ""
|
||||
? []
|
||||
: S extends `${infer T}${Sep}${infer U}`
|
||||
? [T, ...Split<U, Sep>]
|
||||
: [S];
|
||||
|
||||
export function split<S extends string, Sep extends string>(
|
||||
str: S,
|
||||
seperator: Sep,
|
||||
) {
|
||||
return str.split(seperator) as Split<S, Sep>;
|
||||
}
|
||||
|
||||
export function gearTypeToInitial(gearType: GearType) {
|
||||
switch (gearType) {
|
||||
case "HEAD":
|
||||
return "H";
|
||||
case "CLOTHES":
|
||||
return "C";
|
||||
case "SHOES":
|
||||
return "S";
|
||||
default:
|
||||
assertUnreachable(gearType);
|
||||
}
|
||||
}
|
||||
|
||||
export function pathnameFromPotentialURL(maybeUrl: string) {
|
||||
const parsed = safeParseUrl(maybeUrl);
|
||||
if (parsed) return stripEdgeSlashes(parsed.pathname);
|
||||
|
||||
// handle a URL pasted without a protocol, e.g. "discord.gg/FW4dKrY"
|
||||
const parsedWithProtocol = safeParseUrl(`https://${maybeUrl}`);
|
||||
const pathname = parsedWithProtocol
|
||||
? stripEdgeSlashes(parsedWithProtocol.pathname)
|
||||
: "";
|
||||
|
||||
return pathname || maybeUrl;
|
||||
}
|
||||
|
||||
function safeParseUrl(value: string) {
|
||||
try {
|
||||
return new URL(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function stripEdgeSlashes(pathname: string) {
|
||||
return pathname.replace(/^\/+|\/+$/g, "");
|
||||
}
|
||||
|
||||
export function truncateBySentence(value: string, max: number) {
|
||||
if (value.length <= max) {
|
||||
return value;
|
||||
}
|
||||
|
||||
// a sentence only ends at a terminator followed by whitespace, so that
|
||||
// e.g. "18.00" does not split in the middle
|
||||
const sentences = value.match(/[\s\S]+?(?:[.!?](?=\s|$)|\n|$)/g) || [];
|
||||
let result = "";
|
||||
|
||||
for (const sentence of sentences) {
|
||||
if ((result + sentence).length > max) {
|
||||
break;
|
||||
}
|
||||
result += sentence;
|
||||
}
|
||||
|
||||
// when cutting at a sentence boundary would leave most of the budget
|
||||
// unused, a mid-sentence cut that fills it is more informative
|
||||
if (result.length < max / 2) {
|
||||
return value.slice(0, max).trim();
|
||||
}
|
||||
|
||||
return result.trim();
|
||||
}
|
||||
|
||||
// based on https://github.com/zuchka/remove-markdown
|
||||
const NAMED_HTML_ENTITIES: Record<string, string> = {
|
||||
nbsp: " ",
|
||||
amp: "&",
|
||||
lt: "<",
|
||||
gt: ">",
|
||||
quot: '"',
|
||||
apos: "'",
|
||||
};
|
||||
|
||||
export function removeMarkdown(value: string) {
|
||||
const htmlReplaceRegex = /<[^>]*>/g;
|
||||
return (
|
||||
value
|
||||
// Remove HTML tags
|
||||
.replace(htmlReplaceRegex, "")
|
||||
// Decode named HTML entities (e.g. , &)
|
||||
.replace(/&([a-zA-Z]+);/g, (match, name: string) => {
|
||||
const replacement = NAMED_HTML_ENTITIES[name.toLowerCase()];
|
||||
return replacement ?? match;
|
||||
})
|
||||
// Decode numeric HTML entities (e.g.   or  )
|
||||
.replace(/&#(x?[0-9a-fA-F]+);/g, (_, code: string) => {
|
||||
const codePoint = code.startsWith("x")
|
||||
? Number.parseInt(code.slice(1), 16)
|
||||
: Number.parseInt(code, 10);
|
||||
const isValidCodePoint =
|
||||
Number.isFinite(codePoint) && codePoint >= 0 && codePoint <= 0x10ffff;
|
||||
return isValidCodePoint ? String.fromCodePoint(codePoint) : "";
|
||||
})
|
||||
// Remove setext-style headers
|
||||
.replace(/^[=-]{2,}\s*$/g, "")
|
||||
// Remove footnotes?
|
||||
.replace(/\[\^.+?\](: .*?$)?/g, "")
|
||||
.replace(/\s{0,2}\[.*?\]: .*?$/g, "")
|
||||
// Remove images
|
||||
.replace(/!\[(.*?)\][[(].*?[\])]/g, "")
|
||||
// Remove inline links
|
||||
.replace(/\[([^\]]*?)\][[(].*?[\])]/g, "$1")
|
||||
// Remove blockquotes
|
||||
.replace(/^(\n)?\s{0,3}>\s?/gm, "$1")
|
||||
// Remove reference-style links?
|
||||
.replace(/^\s{1,2}\[(.*?)\]: (\S+)( ".*?")?\s*$/g, "")
|
||||
// Remove headers
|
||||
.replace(/^\s{0,3}#{1,6}\s*/gm, "")
|
||||
// Remove * emphasis
|
||||
.replace(/(\*+)([^\s*])(.*?[^\s*])??\1/g, "$2$3")
|
||||
// Remove _ emphasis. Unlike *, _ emphasis gets rendered only if
|
||||
// 1. Either there is a whitespace character before opening _ and after closing _.
|
||||
// 2. Or _ is at the start/end of the string.
|
||||
.replace(/(^|\W)([_]+)(\S)(.*?\S)??\2($|\W)/g, "$1$3$4$5")
|
||||
// Remove code blocks
|
||||
.replace(/(`{3,})(.*?)\1/gm, "$2")
|
||||
// Remove inline code
|
||||
.replace(/`(.+?)`/g, "$1")
|
||||
// // Replace two or more newlines with exactly two? Not entirely sure this belongs here...
|
||||
// .replace(/\n{2,}/g, '\n\n')
|
||||
// // Remove newlines in a paragraph
|
||||
// .replace(/(\S+)\n\s*(\S+)/g, '$1 $2')
|
||||
// Replace strike through
|
||||
.replace(/~(.*?)~/g, "$1")
|
||||
// Collapse runs of whitespace (e.g. from decoded or stripped tags)
|
||||
.replace(/[ \t ]{2,}/g, " ")
|
||||
.trim()
|
||||
);
|
||||
}
|
||||
2055
apps/web-react/app/utils/team-name-data.ts
Normal file
2055
apps/web-react/app/utils/team-name-data.ts
Normal file
File diff suppressed because it is too large
Load Diff
12
apps/web-react/app/utils/team-name.ts
Normal file
12
apps/web-react/app/utils/team-name.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { ADJECTIVES, SUBTITLES_PLURALIZED } from "./team-name-data";
|
||||
|
||||
/** Generates a random team name by combining a Splatoon 3 title adjective with a pluralized subtitle e.g. "Prestigious Heat Haters" */
|
||||
export function randomTeamName() {
|
||||
const adjective = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)];
|
||||
const subtitle =
|
||||
SUBTITLES_PLURALIZED[
|
||||
Math.floor(Math.random() * SUBTITLES_PLURALIZED.length)
|
||||
];
|
||||
|
||||
return `${adjective} ${subtitle}`;
|
||||
}
|
||||
30
apps/web-react/app/utils/types.ts
Normal file
30
apps/web-react/app/utils/types.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Asserts that a code path is unreachable by accepting a value of type `never`.
|
||||
* This function is useful for exhaustive checks in switch statements or discriminated unions.
|
||||
* If called, it throws an error with a message containing the unexpected value.
|
||||
*
|
||||
* @param x - The value that should never occur (of type `never`).
|
||||
* @throws {Error} Throws an error indicating an unexpected value was encountered.
|
||||
*/
|
||||
export function assertUnreachable(x: never): never {
|
||||
throw new Error(
|
||||
`Didn't expect to get here. Unexpected value: ${JSON.stringify(x)}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** @link https://stackoverflow.com/a/69413184 */
|
||||
export const assertType = <A, _B extends A>() => {};
|
||||
|
||||
export type Unpacked<T> = T extends (infer U)[]
|
||||
? U
|
||||
: T extends (...args: unknown[]) => infer U
|
||||
? U
|
||||
: T extends Promise<infer U>
|
||||
? U
|
||||
: T;
|
||||
|
||||
export type Nullish<T> = T | null | undefined;
|
||||
|
||||
export type Unwrapped<T extends (...args: any) => any> = Unpacked<
|
||||
Awaited<ReturnType<T>>
|
||||
>;
|
||||
40
apps/web-react/app/utils/unslugify.server.ts
Normal file
40
apps/web-react/app/utils/unslugify.server.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type {
|
||||
MainWeaponId,
|
||||
SpecialWeaponId,
|
||||
SubWeaponId,
|
||||
} from "~/modules/in-game-lists/types";
|
||||
import weaponTranslations from "../../locales/en/weapons.json";
|
||||
import { mySlugify } from "./urls";
|
||||
|
||||
function buildSlugToIdMap<T extends number>(prefix: string): Record<string, T> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(weaponTranslations)
|
||||
.filter(([id]) => id.startsWith(`${prefix}_`))
|
||||
.map(([id, name]) => [
|
||||
mySlugify(name),
|
||||
Number(id.replace(`${prefix}_`, "")) as T,
|
||||
]),
|
||||
) as Record<string, T>;
|
||||
}
|
||||
|
||||
const SLUG_TO_WEAPON_ID = buildSlugToIdMap<MainWeaponId>("MAIN");
|
||||
const SLUG_TO_SUB_WEAPON_ID = buildSlugToIdMap<SubWeaponId>("SUB");
|
||||
const SLUG_TO_SPECIAL_WEAPON_ID = buildSlugToIdMap<SpecialWeaponId>("SPECIAL");
|
||||
|
||||
export function weaponNameSlugToId(slug?: string) {
|
||||
if (!slug) return null;
|
||||
|
||||
return SLUG_TO_WEAPON_ID[slug.toLowerCase()] ?? null;
|
||||
}
|
||||
|
||||
export function subWeaponNameSlugToId(slug?: string) {
|
||||
if (!slug) return null;
|
||||
|
||||
return SLUG_TO_SUB_WEAPON_ID[slug.toLowerCase()] ?? null;
|
||||
}
|
||||
|
||||
export function specialWeaponNameSlugToId(slug?: string) {
|
||||
if (!slug) return null;
|
||||
|
||||
return SLUG_TO_SPECIAL_WEAPON_ID[slug.toLowerCase()] ?? null;
|
||||
}
|
||||
419
apps/web-react/app/utils/urls.ts
Normal file
419
apps/web-react/app/utils/urls.ts
Normal file
@@ -0,0 +1,419 @@
|
||||
import slugify from "slugify";
|
||||
import { Config } from "~/config";
|
||||
import type { Tables } from "~/db/tables";
|
||||
import type { Preference } from "~/db/tables-json";
|
||||
import type { AuthErrorCode } from "~/features/auth/core/errors";
|
||||
import type {
|
||||
StageBackgroundStyle,
|
||||
StageWaterLevel,
|
||||
} from "~/features/map-planner/plans-types";
|
||||
import type { TierName } from "~/features/mmr/mmr-constants";
|
||||
import type {
|
||||
AbilityWithUnknown,
|
||||
BrandId,
|
||||
GearType,
|
||||
MainWeaponId,
|
||||
ModeShort,
|
||||
ModeShortWithSpecial,
|
||||
SpecialWeaponId,
|
||||
StageId,
|
||||
SubWeaponId,
|
||||
} from "~/modules/in-game-lists/types";
|
||||
import type { weaponCategories } from "~/modules/in-game-lists/weapon-ids";
|
||||
|
||||
export const discordAvatarUrl = ({
|
||||
discordId,
|
||||
discordAvatar,
|
||||
size,
|
||||
}: {
|
||||
discordId: string;
|
||||
discordAvatar: string;
|
||||
size: "lg" | "sm";
|
||||
}) =>
|
||||
`https://cdn.discordapp.com/avatars/${discordId}/${
|
||||
discordAvatar
|
||||
}.webp${size === "lg" ? "?size=240" : "?size=80"}`;
|
||||
|
||||
/**
|
||||
* Resolves the avatar image url of an user, preferring their custom avatar over
|
||||
* the Discord one. Returns undefined if the user has neither.
|
||||
*/
|
||||
export const resolveAvatarUrl = ({
|
||||
customAvatarUrl,
|
||||
discordId,
|
||||
discordAvatar,
|
||||
size,
|
||||
}: {
|
||||
customAvatarUrl?: string | null;
|
||||
discordId: string;
|
||||
discordAvatar?: string | null;
|
||||
size: "lg" | "sm";
|
||||
}) => {
|
||||
if (customAvatarUrl) return customAvatarUrl;
|
||||
if (discordAvatar) {
|
||||
return discordAvatarUrl({ discordId, discordAvatar, size });
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const SENDOU_INK_BASE_URL = "https://sendou.ink";
|
||||
|
||||
export const BADGES_DOC_LINK =
|
||||
"https://github.com/sendou-ink/sendou.ink/blob/rewrite/docs/badges.md";
|
||||
export const API_DOC_LINK =
|
||||
"https://github.com/sendou-ink/sendou.ink/blob/rewrite/docs/dev/api.md";
|
||||
|
||||
export const CREATING_TOURNAMENT_DOC_LINK =
|
||||
"https://github.com/sendou-ink/sendou.ink/blob/rewrite/docs/tournament-creation.md";
|
||||
|
||||
export const PLUS_SERVER_DISCORD_URL = "https://discord.gg/FW4dKrY";
|
||||
export const SENDOU_INK_DISCORD_URL = "https://discord.gg/sendou";
|
||||
export const SENDOU_INK_PATREON_URL = "https://patreon.com/sendou";
|
||||
export const NINTENDO_COMMUNITY_TOURNAMENTS_GUIDELINES_URL =
|
||||
"https://en-americas-support.nintendo.com/app/answers/detail/a_id/63454";
|
||||
export const PATREON_HOW_TO_CONNECT_DISCORD_URL =
|
||||
"https://support.patreon.com/hc/en-us/articles/212052266-How-do-I-connect-Discord-to-Patreon-Patron-";
|
||||
export const SENDOU_INK_GITHUB_URL = "https://github.com/sendou-ink/sendou.ink";
|
||||
export const GITHUB_CONTRIBUTORS_URL =
|
||||
"https://github.com/sendou-ink/sendou.ink/graphs/contributors";
|
||||
export const ipLabsMaps = (pool: string) =>
|
||||
`https://maps.iplabs.ink/?3&pool=${pool}`;
|
||||
export const SPLATOON_3_INK = "https://splatoon3.ink/";
|
||||
export const RHODESMAS_FREESOUND_PROFILE_URL =
|
||||
"https://freesound.org/people/rhodesmas/";
|
||||
export const SPR_INFO_URL =
|
||||
"https://web.archive.org/web/20250513034545/https://www.pgstats.com/articles/introducing-spr-and-uf";
|
||||
export const SPLATOON3_INK_SCHEDULES_URL =
|
||||
"https://splatoon3.ink/data/schedules.json";
|
||||
export const PICOCAD2_WEB_VIEWER_URL =
|
||||
"https://picocad2-web-viewer.hfcred.workers.dev/";
|
||||
|
||||
export const bskyUrl = (accountName: string) =>
|
||||
`https://bsky.app/profile/${accountName}`;
|
||||
export const twitchUrl = (accountName: string) =>
|
||||
`https://twitch.tv/${accountName}`;
|
||||
export const youtubeUrl = (channelId: string) =>
|
||||
`https://youtube.com/channel/${channelId}`;
|
||||
|
||||
export const LOG_IN_URL = "/auth";
|
||||
export const LOG_OUT_URL = "/auth/logout";
|
||||
export const ADMIN_PAGE = "/admin";
|
||||
export const API_PAGE = "/api";
|
||||
export const ARTICLES_MAIN_PAGE = "/a";
|
||||
export const FAQ_PAGE = "/faq";
|
||||
export const WELCOME_PAGE = "/welcome";
|
||||
export const SUPPORT_PAGE = "/support";
|
||||
export const CONTRIBUTIONS_PAGE = "/contributions";
|
||||
export const BADGES_PAGE = "/badges";
|
||||
export const TROPHIES_PAGE = "/trophies";
|
||||
export const NEW_TROPHY_PAGE = "/trophies/new";
|
||||
export const BUILDS_PAGE = "/builds";
|
||||
export const TEAM_SEARCH_PAGE = "/t";
|
||||
export const NEW_TEAM_PAGE = "/t/new";
|
||||
export const CALENDAR_PAGE = "/calendar";
|
||||
export const CALENDAR_NEW_PAGE = "/calendar/new";
|
||||
export const TOURNAMENT_NEW_PAGE = "/calendar/new?tournament=true";
|
||||
export const ORGANIZATION_NEW_PAGE = "/org/new";
|
||||
export const STOP_IMPERSONATING_URL = "/auth/impersonate/stop";
|
||||
export const SEED_URL = "/seed";
|
||||
export const PLANNER_URL = "/plans";
|
||||
export const MAPS_URL = "/maps";
|
||||
export const TIER_LIST_MAKER_URL = "/tier-list-maker";
|
||||
export const ANALYZER_URL = "/analyzer";
|
||||
export const COMP_ANALYZER_URL = "/comp-analyzer";
|
||||
export const OBJECT_DAMAGE_CALCULATOR_URL = "/object-damage-calculator";
|
||||
export const SCANNER_PAGE = "/scanner";
|
||||
export const VODS_PAGE = "/vods";
|
||||
export const LEADERBOARDS_PAGE = "/leaderboards";
|
||||
export const LINKS_PAGE = "/links";
|
||||
export const SENDOUQ_PAGE = "/q";
|
||||
export const SENDOUQ_RULES_PAGE = "/q/rules";
|
||||
export const SENDOUQ_INFO_PAGE = "/q/info";
|
||||
export const MATCH_PROFILE_PAGE = "/settings?tab=match-profile";
|
||||
export const SENDOUQ_PREPARING_PAGE = "/q/preparing";
|
||||
export const SENDOUQ_LOOKING_PAGE = "/q/looking";
|
||||
export const SENDOUQ_READY_PAGE = "/q/ready";
|
||||
export const SENDOUQ_LOOKING_PREVIEW_PAGE = "/q/looking?preview=true";
|
||||
export const SENDOUQ_STREAMS_PAGE = "/q/streams";
|
||||
export const TIERS_PAGE = "/tiers";
|
||||
export const SUSPENDED_PAGE = "/suspended";
|
||||
export const LFG_PAGE = "/lfg";
|
||||
export const EVENTS_PAGE = "/events";
|
||||
export const FRIENDS_PAGE = "/friends";
|
||||
export const SETTINGS_PAGE = "/settings";
|
||||
export const LUTI_PAGE = "/luti";
|
||||
export const PLUS_VOTING_PAGE = "/plus/voting";
|
||||
|
||||
const STATIC_ASSETS_URL = Config.staticAssetsUrl;
|
||||
|
||||
export const BLANK_IMAGE_URL = `${STATIC_ASSETS_URL}/img/blank.gif`;
|
||||
export const COMMON_PREVIEW_IMAGE = `${STATIC_ASSETS_URL}/img/layout/common-preview.png`;
|
||||
export const ERROR_GIRL_IMAGE_PATH = `${STATIC_ASSETS_URL}/img/layout/error-girl`;
|
||||
export const SENDOU_LOVE_EMOJI_PATH = `${STATIC_ASSETS_URL}/img/layout/sendou_love`;
|
||||
export const FIRST_PLACEMENT_ICON_PATH = `${STATIC_ASSETS_URL}/svg/placements/first.svg`;
|
||||
export const SECOND_PLACEMENT_ICON_PATH = `${STATIC_ASSETS_URL}/svg/placements/second.svg`;
|
||||
export const THIRD_PLACEMENT_ICON_PATH = `${STATIC_ASSETS_URL}/svg/placements/third.svg`;
|
||||
export const WELCOME_HERO_IMAGE_PATH = `${STATIC_ASSETS_URL}/img/welcome-hero.webp`;
|
||||
|
||||
export const APP_ICON_URL = `${STATIC_ASSETS_URL}/img/app-icon.png`;
|
||||
export const pwaSplashScreenImageUrl = (fileName: string) =>
|
||||
`${STATIC_ASSETS_URL}/img/splash-screens/${fileName}`;
|
||||
|
||||
export const soundPath = (fileName: string) =>
|
||||
`${STATIC_ASSETS_URL}/sounds/${fileName}.wav`;
|
||||
|
||||
export const GET_FRIENDS_FOR_ADDING_ROUTE = "/friends-for-adding";
|
||||
export const PATRONS_LIST_ROUTE = "/patrons-list";
|
||||
|
||||
export const LAYOUT_DATA_ROUTE = "/api/layout";
|
||||
export const NOTIFICATIONS_URL = "/notifications";
|
||||
export const NOTIFICATIONS_MARK_AS_SEEN_ROUTE = "/notifications/seen";
|
||||
export const NOTIFICATIONS_SUBSCRIBE_ROUTE = "/notifications/subscribe";
|
||||
export const NOTIFICATIONS_DATA_ROUTE = "/api/notifications";
|
||||
|
||||
export const userCardFriendshipPage = (userId: number) =>
|
||||
`/user-card/${userId}/friendship`;
|
||||
|
||||
export const userCardNotePage = (userId: number) => `/user-card/${userId}/note`;
|
||||
|
||||
export const userReportPage = (userId: number) => `/user-report/${userId}`;
|
||||
|
||||
export const trophyPage = (trophyId: number) => `${TROPHIES_PAGE}/${trophyId}`;
|
||||
|
||||
export const trophyWinsPage = (args: { trophyId: number; userId: number }) =>
|
||||
`${TROPHIES_PAGE}/${args.trophyId}/wins/${args.userId}`;
|
||||
|
||||
export const trophyTournamentsPage = (trophyId: number) =>
|
||||
`${TROPHIES_PAGE}/${trophyId}/tournaments`;
|
||||
|
||||
export interface UserLinkArgs {
|
||||
discordId: Tables["User"]["discordId"];
|
||||
customUrl?: Tables["User"]["customUrl"];
|
||||
}
|
||||
|
||||
export const userPage = (user: UserLinkArgs) =>
|
||||
`/u/${user.customUrl ?? user.discordId}`;
|
||||
export const userEditProfilePage = (user: UserLinkArgs) =>
|
||||
`${userPage(user)}/edit`;
|
||||
export const userBuildsPage = (user: UserLinkArgs) =>
|
||||
`${userPage(user)}/builds`;
|
||||
export const userResultsPage = (user: UserLinkArgs) =>
|
||||
`${userPage(user)}/results`;
|
||||
export const userVodsPage = (user: UserLinkArgs) => `${userPage(user)}/vods`;
|
||||
export const userResultsEditHighlightsPage = (user: UserLinkArgs) =>
|
||||
`${userResultsPage(user)}/highlights`;
|
||||
export const userAdminPage = (user: UserLinkArgs) => `${userPage(user)}/admin`;
|
||||
|
||||
export const teamPage = (customUrl: string) => `/t/${customUrl}`;
|
||||
export const editTeamPage = (customUrl: string) =>
|
||||
`${teamPage(customUrl)}/edit`;
|
||||
export const manageTeamRosterPage = (customUrl: string) =>
|
||||
`${teamPage(customUrl)}/roster`;
|
||||
|
||||
export const authErrorUrl = (errorCode: AuthErrorCode) =>
|
||||
`/?authError=${errorCode}`;
|
||||
export const impersonateUrl = (idToLogInAs: number) =>
|
||||
`/auth/impersonate?id=${idToLogInAs}`;
|
||||
export const badgePage = (badgeId: number) => `${BADGES_PAGE}/${badgeId}`;
|
||||
|
||||
export const weaponBuildPage = (weaponSlug: string) =>
|
||||
`${BUILDS_PAGE}/${weaponSlug}`;
|
||||
export const weaponBuildStatsPage = (weaponSlug: string) =>
|
||||
`${weaponBuildPage(weaponSlug)}/stats`;
|
||||
export const weaponBuildPopularPage = (weaponSlug: string) =>
|
||||
`${weaponBuildPage(weaponSlug)}/popular`;
|
||||
export const weaponParamsPage = (weaponSlug: string) => `/params/${weaponSlug}`;
|
||||
|
||||
export const calendarEventPage = (eventId: number) => `/calendar/${eventId}`;
|
||||
export const calendarReportWinnersPage = (eventId: number) =>
|
||||
`/calendar/${eventId}/report-winners`;
|
||||
export const tournamentPage = (tournamentId: number) => `/to/${tournamentId}`;
|
||||
export const tournamentTeamsPage = (tournamentId: number) =>
|
||||
`/to/${tournamentId}/teams`;
|
||||
export const tournamentTeamPage = ({
|
||||
tournamentId,
|
||||
tournamentTeamId,
|
||||
}: {
|
||||
tournamentId: number;
|
||||
tournamentTeamId: number;
|
||||
}) => `/to/${tournamentId}/teams/${tournamentTeamId}`;
|
||||
export const tournamentTeamCompsPage = ({
|
||||
tournamentId,
|
||||
tournamentTeamId,
|
||||
}: {
|
||||
tournamentId: number;
|
||||
tournamentTeamId: number;
|
||||
}) => `${tournamentTeamPage({ tournamentId, tournamentTeamId })}/comps`;
|
||||
export const tournamentInfoPage = (tournamentId: number) =>
|
||||
`/to/${tournamentId}/info`;
|
||||
export const tournamentRegisterPage = (tournamentId: number) =>
|
||||
`/to/${tournamentId}/register`;
|
||||
export const tournamentRulesPage = (tournamentId: number) =>
|
||||
`/to/${tournamentId}/rules`;
|
||||
export const tournamentAdminPage = (tournamentId: number) =>
|
||||
`/to/${tournamentId}/admin`;
|
||||
export const tournamentAdminRegistrationPage = (tournamentId: number) =>
|
||||
`${tournamentAdminPage(tournamentId)}/registration`;
|
||||
export const tournamentAdminRegistrationEditPage = (
|
||||
tournamentId: number,
|
||||
tournamentTeamId: number,
|
||||
) => `${tournamentAdminRegistrationPage(tournamentId)}/${tournamentTeamId}`;
|
||||
export const tournamentDivisionsPage = (tournamentId: number) =>
|
||||
`/to/${tournamentId}/divisions`;
|
||||
export const tournamentResultsPage = (tournamentId: number) =>
|
||||
`/to/${tournamentId}/results`;
|
||||
export const tournamentMatchPage = ({
|
||||
tournamentId,
|
||||
matchId,
|
||||
}: {
|
||||
tournamentId: number;
|
||||
matchId: number;
|
||||
}) => `/to/${tournamentId}/matches/${matchId}`;
|
||||
export const tournamentSubsPage = (tournamentId: number) => {
|
||||
return `/to/${tournamentId}/looking`;
|
||||
};
|
||||
export const tournamentStreamsPage = (tournamentId: number) => {
|
||||
return `/to/${tournamentId}/streams`;
|
||||
};
|
||||
|
||||
export const sendouQMatchPage = (id: Tables["GroupMatch"]["id"]) => {
|
||||
return `${SENDOUQ_PAGE}/match/${id}`;
|
||||
};
|
||||
|
||||
export const scrimsPage = () => {
|
||||
return "/scrims";
|
||||
};
|
||||
|
||||
export const scrimPage = (id: number) => {
|
||||
return `${scrimsPage()}/${id}`;
|
||||
};
|
||||
|
||||
export const newScrimPostPage = () => {
|
||||
return "/scrims/new";
|
||||
};
|
||||
|
||||
export const newAssociationsPage = () => {
|
||||
return "/associations/new";
|
||||
};
|
||||
|
||||
export const articlePage = (slug: string) => `${ARTICLES_MAIN_PAGE}/${slug}`;
|
||||
|
||||
export const vodVideoPage = (videoId: number) => `${VODS_PAGE}/${videoId}`;
|
||||
|
||||
export const badgeUrl = ({
|
||||
code,
|
||||
extension,
|
||||
}: {
|
||||
code: Tables["Badge"]["code"];
|
||||
extension?: "gif";
|
||||
}) => `${STATIC_ASSETS_URL}/badges/${code}${extension ? `.${extension}` : ""}`;
|
||||
export const gameBadgeUrl = (id: string) =>
|
||||
`${STATIC_ASSETS_URL}/img/badges/${id}.avif`;
|
||||
export const articlePreviewUrl = (slug: string) =>
|
||||
`${STATIC_ASSETS_URL}/img/article-previews/${slug}.png`;
|
||||
|
||||
export const navIconUrl = (navItem: string) =>
|
||||
`${STATIC_ASSETS_URL}/img/layout/${navItem}`;
|
||||
export const gearImageUrl = (gearType: GearType, gearSplId: number) =>
|
||||
`${STATIC_ASSETS_URL}/img/gear/${gearType.toLowerCase()}/${gearSplId}`;
|
||||
export const weaponCategoryUrl = (
|
||||
category: (typeof weaponCategories)[number]["name"],
|
||||
) => `${STATIC_ASSETS_URL}/img/weapon-categories/${category}`;
|
||||
export const mainWeaponImageUrl = (mainWeaponSplId: MainWeaponId) =>
|
||||
`${STATIC_ASSETS_URL}/img/main-weapons/${mainWeaponSplId}`;
|
||||
export const mainWeaponVariantImageUrl = (
|
||||
mainWeaponSplId: MainWeaponId,
|
||||
variant: "launched",
|
||||
) =>
|
||||
`${STATIC_ASSETS_URL}/img/main-weapons/variants/${mainWeaponSplId}-${variant}`;
|
||||
export const outlinedMainWeaponImageUrl = (mainWeaponSplId: MainWeaponId) =>
|
||||
`${STATIC_ASSETS_URL}/img/main-weapons-outlined/${mainWeaponSplId}`;
|
||||
export const outlinedFiveStarMainWeaponImageUrl = (
|
||||
mainWeaponSplId: MainWeaponId,
|
||||
) => `${STATIC_ASSETS_URL}/img/main-weapons-outlined-2/${mainWeaponSplId}`;
|
||||
export const outlinedTenStarMainWeaponImageUrl = (
|
||||
mainWeaponSplId: MainWeaponId,
|
||||
) => `${STATIC_ASSETS_URL}/img/main-weapons-outlined-3/${mainWeaponSplId}`;
|
||||
export const subWeaponImageUrl = (subWeaponSplId: SubWeaponId) =>
|
||||
`${STATIC_ASSETS_URL}/img/sub-weapons/${subWeaponSplId}`;
|
||||
export const specialWeaponImageUrl = (specialWeaponSplId: SpecialWeaponId) =>
|
||||
`${STATIC_ASSETS_URL}/img/special-weapons/${specialWeaponSplId}`;
|
||||
export const specialWeaponVariantImageUrl = (
|
||||
specialWeaponSplId: SpecialWeaponId,
|
||||
variant: "weakpoints",
|
||||
) =>
|
||||
`${STATIC_ASSETS_URL}/img/special-weapons/variants/${specialWeaponSplId}-${variant}`;
|
||||
export const abilityImageUrl = (ability: AbilityWithUnknown) =>
|
||||
`${STATIC_ASSETS_URL}/img/abilities/${ability}`;
|
||||
export const brandImageUrl = (brand: BrandId) =>
|
||||
`${STATIC_ASSETS_URL}/img/brands/${brand}`;
|
||||
export const modeImageUrl = (mode: ModeShortWithSpecial) =>
|
||||
`${STATIC_ASSETS_URL}/img/modes/${mode}`;
|
||||
export const stageImageUrl = (stageId: StageId) =>
|
||||
`${STATIC_ASSETS_URL}/img/stages/${stageId}`;
|
||||
export const stageBannerImageUrl = (stageId: StageId) =>
|
||||
`${STATIC_ASSETS_URL}/img/stage-banners/${stageId}.avif`;
|
||||
export const tierImageUrl = (tier: TierName | "CALCULATING") =>
|
||||
`${STATIC_ASSETS_URL}/img/tiers/${tier === "CALCULATING" ? "unranked" : tier.toLowerCase()}`;
|
||||
export const controllerImageUrl = (controller: string) =>
|
||||
`${STATIC_ASSETS_URL}/img/controllers/${controller}.avif`;
|
||||
export const preferenceEmojiUrl = (preference?: Preference) => {
|
||||
const emoji =
|
||||
preference === "PREFER"
|
||||
? "grin"
|
||||
: preference === "AVOID"
|
||||
? "unamused"
|
||||
: "no-mouth";
|
||||
|
||||
return `${STATIC_ASSETS_URL}/img/emoji/${emoji}.svg`;
|
||||
};
|
||||
export const TIER_PLUS_URL = `${STATIC_ASSETS_URL}/img/tiers/plus`;
|
||||
|
||||
export const winnersImageUrl = ({
|
||||
season,
|
||||
placement,
|
||||
}: {
|
||||
season: number;
|
||||
placement: number;
|
||||
}) => `${STATIC_ASSETS_URL}/img/winners/${season}/${placement}`;
|
||||
|
||||
export const sqHeaderGuyImageUrl = (season: number) =>
|
||||
`${STATIC_ASSETS_URL}/img/sq-header/${season}`;
|
||||
|
||||
export const stageMinimapImageUrlWithEnding = ({
|
||||
stageId,
|
||||
mode,
|
||||
style,
|
||||
waterLevel,
|
||||
}: {
|
||||
stageId: StageId;
|
||||
mode: ModeShort;
|
||||
style: StageBackgroundStyle;
|
||||
waterLevel?: StageWaterLevel;
|
||||
}) =>
|
||||
`${STATIC_ASSETS_URL}/planner-maps/${stageId}-${mode}-${style}${
|
||||
waterLevel === "down" ? "-DOWN" : ""
|
||||
}.png`;
|
||||
|
||||
export function resolveBaseUrl(url: string) {
|
||||
return new URL(url).host;
|
||||
}
|
||||
|
||||
export const mySlugify = (name: string) => {
|
||||
return slugify(name, {
|
||||
lower: true,
|
||||
strict: true,
|
||||
});
|
||||
};
|
||||
|
||||
export const isCustomUrl = (value: string) => {
|
||||
return Number.isNaN(Number(value));
|
||||
};
|
||||
|
||||
export function vodUrl(vod: {
|
||||
platformVideoId: string;
|
||||
timestampSeconds: number;
|
||||
}) {
|
||||
return `https://www.twitch.tv/videos/${vod.platformVideoId}?t=${vod.timestampSeconds}s`;
|
||||
}
|
||||
99
apps/web-react/app/utils/users.test.ts
Normal file
99
apps/web-react/app/utils/users.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { queryToUserIdentifier, userDiscordIdIsAged } from "./users";
|
||||
|
||||
describe("queryToUserIdentifier()", () => {
|
||||
test("returns null if no match", () => {
|
||||
expect(queryToUserIdentifier("foo")).toBe(null);
|
||||
});
|
||||
|
||||
test("gets custom url from url", () => {
|
||||
expect(queryToUserIdentifier("https://sendou.ink/u/sendou")).toEqual({
|
||||
customUrl: "sendou",
|
||||
});
|
||||
});
|
||||
|
||||
test("gets discord id from url", () => {
|
||||
expect(
|
||||
queryToUserIdentifier("https://sendou.ink/u/79237403620945920"),
|
||||
).toEqual({
|
||||
discordId: "79237403620945920",
|
||||
});
|
||||
});
|
||||
|
||||
test("gets custom url from url (without https://)", () => {
|
||||
expect(queryToUserIdentifier("sendou.ink/u/sendou")).toEqual({
|
||||
customUrl: "sendou",
|
||||
});
|
||||
});
|
||||
|
||||
test("gets discord id", () => {
|
||||
expect(queryToUserIdentifier("79237403620945920")).toEqual({
|
||||
discordId: "79237403620945920",
|
||||
});
|
||||
});
|
||||
|
||||
test("gets id", () => {
|
||||
expect(queryToUserIdentifier("1")).toEqual({
|
||||
id: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test("gets id from url", () => {
|
||||
expect(queryToUserIdentifier("https://sendou.ink/u/42")).toEqual({
|
||||
id: 42,
|
||||
});
|
||||
});
|
||||
|
||||
test("gets custom url from url with trailing slash", () => {
|
||||
expect(queryToUserIdentifier("https://sendou.ink/u/sendou/")).toEqual({
|
||||
customUrl: "sendou",
|
||||
});
|
||||
});
|
||||
|
||||
test("gets custom url from profile sub-page url", () => {
|
||||
expect(queryToUserIdentifier("https://sendou.ink/u/sendou/builds")).toEqual(
|
||||
{
|
||||
customUrl: "sendou",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("gets discord id from url with query string", () => {
|
||||
expect(
|
||||
queryToUserIdentifier(
|
||||
"https://sendou.ink/u/79237403620945920?utm_source=discord",
|
||||
),
|
||||
).toEqual({
|
||||
discordId: "79237403620945920",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("userDiscordIdIsAged()", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2023-11-25T00:00:00.000Z"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test("returns false if discord id is not aged", () => {
|
||||
expect(userDiscordIdIsAged({ discordId: "1177730652641181871" })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("returns true if discord id is aged", () => {
|
||||
expect(userDiscordIdIsAged({ discordId: "79237403620945920" })).toBe(true);
|
||||
});
|
||||
|
||||
test("return false if discord id missing", () => {
|
||||
expect(userDiscordIdIsAged({ discordId: "" })).toBe(false);
|
||||
});
|
||||
|
||||
test("return false if discord id too short", () => {
|
||||
expect(userDiscordIdIsAged({ discordId: "1234" })).toBe(false);
|
||||
});
|
||||
});
|
||||
69
apps/web-react/app/utils/users.ts
Normal file
69
apps/web-react/app/utils/users.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { logger } from "./logger";
|
||||
import { isCustomUrl } from "./urls";
|
||||
|
||||
const longUrlRegExp = /(https:\/\/)?sendou\.ink\/u\/([^/?#]+)/;
|
||||
const shortUrlRegExp = /(https:\/\/)?snd\.ink\/([^/?#]+)/;
|
||||
const DISCORD_ID_MIN_LENGTH = 17;
|
||||
export function queryToUserIdentifier(
|
||||
query: string,
|
||||
): { id: number } | { discordId: string } | { customUrl: string } | null {
|
||||
const longUrlMatch = query.match(longUrlRegExp);
|
||||
const shortUrlMatch = query.match(shortUrlRegExp);
|
||||
|
||||
if (longUrlMatch || shortUrlMatch) {
|
||||
const [, , identifier] = (longUrlMatch ?? shortUrlMatch)!;
|
||||
|
||||
if (isCustomUrl(identifier)) {
|
||||
return { customUrl: identifier };
|
||||
}
|
||||
|
||||
if (identifier.length >= DISCORD_ID_MIN_LENGTH) {
|
||||
return { discordId: identifier };
|
||||
}
|
||||
|
||||
return { id: Number(identifier) };
|
||||
}
|
||||
|
||||
// = it's numeric
|
||||
if (!isCustomUrl(query)) {
|
||||
if (query.length >= DISCORD_ID_MIN_LENGTH) {
|
||||
return { discordId: query };
|
||||
}
|
||||
|
||||
return { id: Number(query) };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// snowflake logic from https://github.dev/vegeta897/snow-stamp/blob/main/src/util.js
|
||||
const DISCORD_EPOCH = 1420070400000;
|
||||
|
||||
// Converts a snowflake ID string into a JS Date object using the provided epoch (in ms), or Discord's epoch if not provided
|
||||
export function convertSnowflakeToDate(snowflake: string) {
|
||||
// Convert snowflake to BigInt to extract timestamp bits
|
||||
// https://discord.com/developers/docs/reference#snowflakes
|
||||
const milliseconds = BigInt(snowflake) >> 22n;
|
||||
return new Date(Number(milliseconds) + DISCORD_EPOCH);
|
||||
}
|
||||
|
||||
const AGED_CRITERIA = 1000 * 60 * 60 * 24 * 30 * 3; // 3 months
|
||||
export function userDiscordIdIsAged(user: { discordId: string }) {
|
||||
if (!user.discordId || user.discordId.length < DISCORD_ID_MIN_LENGTH) {
|
||||
logger.error("Invalid or missing discord id", {
|
||||
discordId: user.discordId,
|
||||
});
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
const timestamp = convertSnowflakeToDate(user.discordId).getTime();
|
||||
|
||||
return Date.now() - timestamp > AGED_CRITERIA;
|
||||
}
|
||||
|
||||
export function accountCreatedInTheLastSixMonths(discordId: string) {
|
||||
const timestamp = convertSnowflakeToDate(discordId).getTime();
|
||||
|
||||
return Date.now() - timestamp < 1000 * 60 * 60 * 24 * 30 * 6;
|
||||
}
|
||||
179
apps/web-react/app/utils/zod.test.ts
Normal file
179
apps/web-react/app/utils/zod.test.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
actuallyNonEmptyStringOrNull,
|
||||
hasZalgo,
|
||||
hexCodeWithoutAlpha,
|
||||
normalizeFriendCode,
|
||||
timeString,
|
||||
} from "./zod";
|
||||
|
||||
describe("normalizeFriendCode", () => {
|
||||
test("returns well formatted friend code as is", () => {
|
||||
expect(normalizeFriendCode("1234-5678-9012")).toBe("1234-5678-9012");
|
||||
});
|
||||
|
||||
test("handles no dashes", () => {
|
||||
expect(normalizeFriendCode("123456789012")).toBe("1234-5678-9012");
|
||||
});
|
||||
|
||||
test("handles SW-suffix", () => {
|
||||
expect(normalizeFriendCode("SW-1234-5678-9012")).toBe("1234-5678-9012");
|
||||
});
|
||||
|
||||
test("handles a mix", () => {
|
||||
expect(normalizeFriendCode("SW-1234-56789012")).toBe("1234-5678-9012");
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasZalgo", () => {
|
||||
test("returns true for text containing Zalgo characters", () => {
|
||||
expect(hasZalgo("z͎͗ͣḁ̵̑l̉̃ͦg̐̓̒o͓̔ͥ")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for text without Zalgo characters", () => {
|
||||
expect(hasZalgo("normal text")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for an empty string", () => {
|
||||
expect(hasZalgo("")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for text with special but non-Zalgo characters", () => {
|
||||
expect(hasZalgo("!@#$%^&*()")).toBe(false);
|
||||
});
|
||||
|
||||
test("accepts japanese characters", () => {
|
||||
expect(hasZalgo("こんにちは")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns a stable result when called repeatedly with the same input", () => {
|
||||
const withCombiningMark = "á"; // "á" as base letter + single combining accent
|
||||
|
||||
expect(hasZalgo(withCombiningMark)).toBe(true);
|
||||
expect(hasZalgo(withCombiningMark)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("actuallyNonEmptyStringOrNull", () => {
|
||||
test("returns null for an empty string", () => {
|
||||
expect(actuallyNonEmptyStringOrNull("")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for a string with only spaces", () => {
|
||||
expect(actuallyNonEmptyStringOrNull(" ")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns trimmed string for a string with visible characters and spaces", () => {
|
||||
expect(actuallyNonEmptyStringOrNull(" hello world ")).toBe("hello world");
|
||||
});
|
||||
|
||||
test("removes invisible characters and trims", () => {
|
||||
expect(actuallyNonEmptyStringOrNull("test")).toBe("test");
|
||||
});
|
||||
|
||||
test("returns original value if not a string", () => {
|
||||
expect(actuallyNonEmptyStringOrNull(123)).toBe(123);
|
||||
expect(actuallyNonEmptyStringOrNull(null)).toBe(null);
|
||||
expect(actuallyNonEmptyStringOrNull(undefined)).toBe(undefined);
|
||||
expect(actuallyNonEmptyStringOrNull({})).toEqual({});
|
||||
});
|
||||
|
||||
test("returns null for a string with only zero width spaces", () => {
|
||||
expect(actuallyNonEmptyStringOrNull("")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for a string with only tag space emoji", () => {
|
||||
expect(actuallyNonEmptyStringOrNull("")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for a string with only Hangul Filler", () => {
|
||||
expect(actuallyNonEmptyStringOrNull("\u3164")).toBeNull();
|
||||
expect(actuallyNonEmptyStringOrNull("ㅤㅤㅤ")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for other invisible characters", () => {
|
||||
expect(actuallyNonEmptyStringOrNull("\u115F")).toBeNull();
|
||||
expect(actuallyNonEmptyStringOrNull("\u1160")).toBeNull();
|
||||
expect(actuallyNonEmptyStringOrNull("\uFEFF")).toBeNull();
|
||||
expect(actuallyNonEmptyStringOrNull("\u2060")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for a string with only soft hyphens", () => {
|
||||
expect(actuallyNonEmptyStringOrNull("\u00AD")).toBeNull();
|
||||
expect(actuallyNonEmptyStringOrNull("\u00AD\u00AD\u00AD")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for a string with only braille blanks", () => {
|
||||
expect(actuallyNonEmptyStringOrNull("\u2800")).toBeNull();
|
||||
expect(actuallyNonEmptyStringOrNull("\u2800\u2800\u2800\u2800")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for a string with only variation selectors", () => {
|
||||
expect(actuallyNonEmptyStringOrNull("\ufe0e")).toBeNull();
|
||||
expect(actuallyNonEmptyStringOrNull("\ufe0e\ufe0e")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("hexCodeWithoutAlpha", () => {
|
||||
test("accepts valid 3 and 6 digit hex colors", () => {
|
||||
expect(hexCodeWithoutAlpha.safeParse("#fff").success).toBe(true);
|
||||
expect(hexCodeWithoutAlpha.safeParse("#FFF").success).toBe(true);
|
||||
expect(hexCodeWithoutAlpha.safeParse("#abc").success).toBe(true);
|
||||
expect(hexCodeWithoutAlpha.safeParse("#ffffff").success).toBe(true);
|
||||
expect(hexCodeWithoutAlpha.safeParse("#a1b2c3").success).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects strings that are not valid hex colors", () => {
|
||||
expect(hexCodeWithoutAlpha.safeParse("#fff99").success).toBe(false);
|
||||
expect(hexCodeWithoutAlpha.safeParse("#abc12").success).toBe(false);
|
||||
expect(hexCodeWithoutAlpha.safeParse("#12345").success).toBe(false);
|
||||
expect(hexCodeWithoutAlpha.safeParse("#ffffff99").success).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects alpha (4 and 8 digit) hex colors", () => {
|
||||
expect(hexCodeWithoutAlpha.safeParse("#ffff").success).toBe(false);
|
||||
expect(hexCodeWithoutAlpha.safeParse("#ffffffff").success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("timeString", () => {
|
||||
test("accepts valid time in HH:MM format", () => {
|
||||
expect(timeString.safeParse("00:00").success).toBe(true);
|
||||
expect(timeString.safeParse("12:30").success).toBe(true);
|
||||
expect(timeString.safeParse("23:59").success).toBe(true);
|
||||
});
|
||||
|
||||
test("accepts times with leading zeros", () => {
|
||||
expect(timeString.safeParse("01:05").success).toBe(true);
|
||||
expect(timeString.safeParse("09:00").success).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects invalid hour values", () => {
|
||||
expect(timeString.safeParse("24:00").success).toBe(false);
|
||||
expect(timeString.safeParse("25:30").success).toBe(false);
|
||||
expect(timeString.safeParse("99:00").success).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects invalid minute values", () => {
|
||||
expect(timeString.safeParse("12:60").success).toBe(false);
|
||||
expect(timeString.safeParse("12:99").success).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects malformed time strings", () => {
|
||||
expect(timeString.safeParse("1:30").success).toBe(false);
|
||||
expect(timeString.safeParse("12:3").success).toBe(false);
|
||||
expect(timeString.safeParse("12-30").success).toBe(false);
|
||||
expect(timeString.safeParse("1230").success).toBe(false);
|
||||
expect(timeString.safeParse("12:30:00").success).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects non-string values", () => {
|
||||
expect(timeString.safeParse(1230).success).toBe(false);
|
||||
expect(timeString.safeParse(null).success).toBe(false);
|
||||
expect(timeString.safeParse(undefined).success).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects empty string", () => {
|
||||
expect(timeString.safeParse("").success).toBe(false);
|
||||
});
|
||||
});
|
||||
450
apps/web-react/app/utils/zod.ts
Normal file
450
apps/web-react/app/utils/zod.ts
Normal file
@@ -0,0 +1,450 @@
|
||||
import type { ZodType } from "zod";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
abilities,
|
||||
type abilitiesShort,
|
||||
} from "~/modules/in-game-lists/abilities";
|
||||
import { stageIds } from "~/modules/in-game-lists/stage-ids";
|
||||
import {
|
||||
mainWeaponIds,
|
||||
specialWeaponIds,
|
||||
subWeaponIds,
|
||||
} from "~/modules/in-game-lists/weapon-ids";
|
||||
import { SHORT_NANOID_LENGTH } from "./id";
|
||||
import type { Unpacked } from "./types";
|
||||
import { assertType } from "./types";
|
||||
|
||||
export const id = z.coerce.number({ message: "Required" }).int().positive();
|
||||
export const idObject = z.object({
|
||||
id,
|
||||
});
|
||||
|
||||
export const inviteCode = z.string().length(SHORT_NANOID_LENGTH);
|
||||
|
||||
export const nonEmptyString = z.string().trim().min(1, {
|
||||
message: "Required",
|
||||
});
|
||||
|
||||
// matches #RGB and #RRGGBB only (no alpha) https://stackoverflow.com/a/1636354
|
||||
const hexCodeWithoutAlphaRegex = /^#(?:[0-9a-fA-F]{3}){1,2}$/;
|
||||
export const hexCodeWithoutAlpha = z.string().regex(hexCodeWithoutAlphaRegex);
|
||||
|
||||
export const THEME_INPUT_LIMITS = {
|
||||
BASE_HUE_MIN: 0,
|
||||
BASE_HUE_MAX: 360,
|
||||
BASE_CHROMA_MIN: 0,
|
||||
BASE_CHROMA_MAX: 0.1,
|
||||
ACCENT_HUE_MIN: 0,
|
||||
ACCENT_HUE_MAX: 360,
|
||||
ACCENT_CHROMA_MIN: 0,
|
||||
ACCENT_CHROMA_MAX: 0.3,
|
||||
RADIUS_MIN: 0,
|
||||
RADIUS_MAX: 5,
|
||||
RADIUS_STEP: 1,
|
||||
BORDER_WIDTH_MIN: 0.5,
|
||||
BORDER_WIDTH_MAX: 2,
|
||||
BORDER_WIDTH_STEP: 0.5,
|
||||
SIZE_MIN: 0.9,
|
||||
SIZE_MAX: 1.1,
|
||||
SIZE_STEP: 0.05,
|
||||
} as const;
|
||||
|
||||
function isValidStep(value: number, min: number, step: number) {
|
||||
const diff = value - min;
|
||||
const steps = Math.round(diff / step);
|
||||
return Math.abs(diff - steps * step) < 0.0001;
|
||||
}
|
||||
|
||||
export const themeInputSchema = z.object({
|
||||
baseHue: z
|
||||
.number()
|
||||
.min(THEME_INPUT_LIMITS.BASE_HUE_MIN)
|
||||
.max(THEME_INPUT_LIMITS.BASE_HUE_MAX),
|
||||
baseChroma: z
|
||||
.number()
|
||||
.min(THEME_INPUT_LIMITS.BASE_CHROMA_MIN)
|
||||
.max(THEME_INPUT_LIMITS.BASE_CHROMA_MAX),
|
||||
accentHue: z
|
||||
.number()
|
||||
.min(THEME_INPUT_LIMITS.ACCENT_HUE_MIN)
|
||||
.max(THEME_INPUT_LIMITS.ACCENT_HUE_MAX),
|
||||
accentChroma: z
|
||||
.number()
|
||||
.min(THEME_INPUT_LIMITS.ACCENT_CHROMA_MIN)
|
||||
.max(THEME_INPUT_LIMITS.ACCENT_CHROMA_MAX),
|
||||
chatHue: z
|
||||
.number()
|
||||
.min(THEME_INPUT_LIMITS.BASE_HUE_MIN)
|
||||
.max(THEME_INPUT_LIMITS.BASE_HUE_MAX)
|
||||
.nullable(),
|
||||
radiusBox: z
|
||||
.number()
|
||||
.int()
|
||||
.min(THEME_INPUT_LIMITS.RADIUS_MIN)
|
||||
.max(THEME_INPUT_LIMITS.RADIUS_MAX),
|
||||
radiusField: z
|
||||
.number()
|
||||
.int()
|
||||
.min(THEME_INPUT_LIMITS.RADIUS_MIN)
|
||||
.max(THEME_INPUT_LIMITS.RADIUS_MAX),
|
||||
radiusSelector: z
|
||||
.number()
|
||||
.int()
|
||||
.min(THEME_INPUT_LIMITS.RADIUS_MIN)
|
||||
.max(THEME_INPUT_LIMITS.RADIUS_MAX),
|
||||
borderWidth: z
|
||||
.number()
|
||||
.min(THEME_INPUT_LIMITS.BORDER_WIDTH_MIN)
|
||||
.max(THEME_INPUT_LIMITS.BORDER_WIDTH_MAX)
|
||||
.refine(
|
||||
(val) =>
|
||||
isValidStep(
|
||||
val,
|
||||
THEME_INPUT_LIMITS.BORDER_WIDTH_MIN,
|
||||
THEME_INPUT_LIMITS.BORDER_WIDTH_STEP,
|
||||
),
|
||||
{ message: "Must be a valid step increment" },
|
||||
),
|
||||
sizeField: z
|
||||
.number()
|
||||
.min(THEME_INPUT_LIMITS.SIZE_MIN)
|
||||
.max(THEME_INPUT_LIMITS.SIZE_MAX)
|
||||
.refine(
|
||||
(val) =>
|
||||
isValidStep(
|
||||
val,
|
||||
THEME_INPUT_LIMITS.SIZE_MIN,
|
||||
THEME_INPUT_LIMITS.SIZE_STEP,
|
||||
),
|
||||
{ message: "Must be a valid step increment" },
|
||||
),
|
||||
sizeSelector: z
|
||||
.number()
|
||||
.min(THEME_INPUT_LIMITS.SIZE_MIN)
|
||||
.max(THEME_INPUT_LIMITS.SIZE_MAX)
|
||||
.refine(
|
||||
(val) =>
|
||||
isValidStep(
|
||||
val,
|
||||
THEME_INPUT_LIMITS.SIZE_MIN,
|
||||
THEME_INPUT_LIMITS.SIZE_STEP,
|
||||
),
|
||||
{ message: "Must be a valid step increment" },
|
||||
),
|
||||
sizeSpacing: z
|
||||
.number()
|
||||
.min(THEME_INPUT_LIMITS.SIZE_MIN)
|
||||
.max(THEME_INPUT_LIMITS.SIZE_MAX)
|
||||
.refine(
|
||||
(val) =>
|
||||
isValidStep(
|
||||
val,
|
||||
THEME_INPUT_LIMITS.SIZE_MIN,
|
||||
THEME_INPUT_LIMITS.SIZE_STEP,
|
||||
),
|
||||
{ message: "Must be a valid step increment" },
|
||||
),
|
||||
});
|
||||
|
||||
const timeStringRegex = /^([01]\d|2[0-3]):([0-5]\d)$/;
|
||||
export const timeString = z.string().regex(timeStringRegex);
|
||||
|
||||
const abilityNameToType = (val: string) =>
|
||||
abilities.find((ability) => ability.name === val)?.type;
|
||||
export const headMainSlotAbility = z
|
||||
.string()
|
||||
.refine(
|
||||
(val) =>
|
||||
["STACKABLE", "HEAD_MAIN_ONLY"].includes(abilityNameToType(val) as any),
|
||||
{ message: "forms:errors.required" },
|
||||
);
|
||||
export const clothesMainSlotAbility = z
|
||||
.string()
|
||||
.refine(
|
||||
(val) =>
|
||||
["STACKABLE", "CLOTHES_MAIN_ONLY"].includes(
|
||||
abilityNameToType(val) as any,
|
||||
),
|
||||
{ message: "forms:errors.required" },
|
||||
);
|
||||
export const shoesMainSlotAbility = z
|
||||
.string()
|
||||
.refine(
|
||||
(val) =>
|
||||
["STACKABLE", "SHOES_MAIN_ONLY"].includes(abilityNameToType(val) as any),
|
||||
{ message: "forms:errors.required" },
|
||||
);
|
||||
export const stackableAbility = z
|
||||
.string()
|
||||
.refine((val) => abilityNameToType(val) === "STACKABLE", {
|
||||
message: "forms:errors.required",
|
||||
});
|
||||
|
||||
export const normalizeFriendCode = (value: string) => {
|
||||
const onlyNumbers = value.replace(/\D/g, "");
|
||||
|
||||
const withDashes = onlyNumbers
|
||||
.split(/(\d{4})/)
|
||||
.filter(Boolean)
|
||||
.join("-");
|
||||
|
||||
return withDashes;
|
||||
};
|
||||
|
||||
export const ability = z.enum([
|
||||
"ISM",
|
||||
"ISS",
|
||||
"IRU",
|
||||
"RSU",
|
||||
"SSU",
|
||||
"SCU",
|
||||
"SS",
|
||||
"SPU",
|
||||
"QR",
|
||||
"QSJ",
|
||||
"BRU",
|
||||
"RES",
|
||||
"SRU",
|
||||
"IA",
|
||||
"OG",
|
||||
"LDE",
|
||||
"T",
|
||||
"CB",
|
||||
"NS",
|
||||
"H",
|
||||
"TI",
|
||||
"RP",
|
||||
"AD",
|
||||
"SJ",
|
||||
"OS",
|
||||
"DR",
|
||||
]);
|
||||
// keep in-game-lists and the zod enum in sync
|
||||
assertType<z.infer<typeof ability>, Unpacked<typeof abilitiesShort>>();
|
||||
|
||||
export const weaponSplId = z.preprocess(
|
||||
actualNumber,
|
||||
numericEnum(mainWeaponIds),
|
||||
);
|
||||
|
||||
export const subWeaponId = numericEnum(subWeaponIds);
|
||||
|
||||
export const specialWeaponId = numericEnum(specialWeaponIds);
|
||||
|
||||
export const modeShort = z.enum(["TW", "SZ", "TC", "RM", "CB"]);
|
||||
export const modeShortWithSpecial = z.enum([
|
||||
"TW",
|
||||
"SZ",
|
||||
"TC",
|
||||
"RM",
|
||||
"CB",
|
||||
"SR",
|
||||
"TB",
|
||||
]);
|
||||
|
||||
export const gamesShortSchema = z.enum(["S1", "S2", "S3"]);
|
||||
|
||||
export const stageId = z.preprocess(actualNumber, numericEnum(stageIds));
|
||||
|
||||
export function processMany(
|
||||
...processFuncs: Array<(value: unknown) => unknown>
|
||||
) {
|
||||
return (value: unknown) => {
|
||||
let result = value;
|
||||
|
||||
for (const processFunc of processFuncs) {
|
||||
result = processFunc(result);
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
export function safeJSONParse(value: unknown): unknown {
|
||||
try {
|
||||
if (typeof value !== "string") return value;
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const EMPTY_CHARACTERS = [
|
||||
"\u00AD",
|
||||
"\u200B",
|
||||
"\u200C",
|
||||
"\u200D",
|
||||
"\u200E",
|
||||
"\u200F",
|
||||
"",
|
||||
"\u2800",
|
||||
"\u3164",
|
||||
"\u115F",
|
||||
"\u1160",
|
||||
"\uFEFF",
|
||||
"\u2060",
|
||||
"[\\uFE00-\\uFE0F]",
|
||||
];
|
||||
const EMPTY_CHARACTERS_REGEX = new RegExp(EMPTY_CHARACTERS.join("|"), "g");
|
||||
|
||||
const zalgoRe = /%CC%/;
|
||||
export const hasZalgo = (txt: string) => zalgoRe.test(encodeURIComponent(txt));
|
||||
|
||||
/** Non-empty string that has the given length (max and optionally min). Prevents z͎͗ͣḁ̵̑l̉̃ͦg̐̓̒o͓̔ͥ text as well as filters out characters that have no width. */
|
||||
export const safeStringSchema = ({ min, max }: { min?: number; max: number }) =>
|
||||
z.preprocess(
|
||||
actuallyNonEmptyStringOrNull, // if this returns null, none of the checks below will run because it's not a string
|
||||
z
|
||||
.string()
|
||||
.min(min ?? 0)
|
||||
.max(max)
|
||||
.refine((text) => !hasZalgo(text), {
|
||||
message: "Includes not allowed characters.",
|
||||
}),
|
||||
);
|
||||
|
||||
/** Nullable string that has the given length (max and optionally min). Prevents z͎͗ͣḁ̵̑l̉̃ͦg̐̓̒o͓̔ͥ text as well as filters out characters that have no width. */
|
||||
export const safeNullableStringSchema = ({
|
||||
min,
|
||||
max,
|
||||
}: {
|
||||
min?: number;
|
||||
max: number;
|
||||
}) =>
|
||||
z.preprocess(
|
||||
processMany(undefinedToNull, actuallyNonEmptyStringOrNull),
|
||||
z
|
||||
.string()
|
||||
.min(min ?? 0)
|
||||
.max(max)
|
||||
.nullable()
|
||||
.refine(
|
||||
(text) => {
|
||||
if (typeof text !== "string") return true;
|
||||
|
||||
return !hasZalgo(text);
|
||||
},
|
||||
{
|
||||
message: "Includes not allowed characters.",
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* Processes the input value and returns a non-empty string with invisible characters cleaned out or null.
|
||||
*/
|
||||
export function actuallyNonEmptyStringOrNull(value: unknown) {
|
||||
if (typeof value !== "string") return value;
|
||||
|
||||
const trimmed = value.replace(EMPTY_CHARACTERS_REGEX, "").trim();
|
||||
|
||||
return trimmed === "" ? null : trimmed;
|
||||
}
|
||||
|
||||
export function falsyToNull(value: unknown): unknown {
|
||||
if (value) return value;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function nullLiteraltoNull(value: unknown): unknown {
|
||||
if (value === "null") return null;
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function undefinedToNull(value: unknown): unknown {
|
||||
if (value === undefined) return null;
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
export function actualNumber(value: unknown) {
|
||||
if (value === "") return undefined;
|
||||
|
||||
const parsed = Number(value);
|
||||
|
||||
return Number.isNaN(parsed) ? undefined : parsed;
|
||||
}
|
||||
|
||||
export function date(value: unknown) {
|
||||
if (typeof value === "string" || typeof value === "number") {
|
||||
const valueAsNumber = Number(value);
|
||||
|
||||
return new Date(Number.isNaN(valueAsNumber) ? value : valueAsNumber);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
export function noDuplicates(arr: (number | string)[]) {
|
||||
return new Set(arr).size === arr.length;
|
||||
}
|
||||
|
||||
export function filterOutNullishMembers(value: unknown) {
|
||||
if (!Array.isArray(value)) return value;
|
||||
|
||||
return value.filter((member) => member !== null && member !== undefined);
|
||||
}
|
||||
|
||||
export function removeDuplicates(value: unknown) {
|
||||
if (!Array.isArray(value)) return value;
|
||||
|
||||
return Array.from(new Set(value));
|
||||
}
|
||||
|
||||
export function emptyArrayToNull(value: unknown) {
|
||||
if (Array.isArray(value) && value.length === 0) return null;
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
export function checkboxValueToBoolean(value: unknown) {
|
||||
if (!value) return false;
|
||||
|
||||
if (typeof value !== "string") {
|
||||
throw new Error("Expected string checkbox value");
|
||||
}
|
||||
|
||||
return value === "on";
|
||||
}
|
||||
|
||||
export const _action = <T extends string>(value: T) =>
|
||||
z.preprocess(deduplicate, z.literal(value));
|
||||
|
||||
// Fix bug at least in Safari 15 where SubmitButton value might get sent twice
|
||||
export function deduplicate(value: unknown) {
|
||||
if (Array.isArray(value)) {
|
||||
const [one, two, ...rest] = value;
|
||||
if (rest.length > 0) return value;
|
||||
if (one !== two) return value;
|
||||
|
||||
return one;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
// https://github.com/colinhacks/zod/issues/1118#issuecomment-1235065111
|
||||
export function numericEnum<TValues extends readonly number[]>(
|
||||
values: TValues,
|
||||
) {
|
||||
return z.number().superRefine((val, ctx) => {
|
||||
if (!values.includes(val)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.invalid_value,
|
||||
input: val,
|
||||
values: [...values],
|
||||
message: `Expected one of: ${values.join(", ")}, received ${val}`,
|
||||
});
|
||||
}
|
||||
}) as ZodType<TValues[number]>;
|
||||
}
|
||||
|
||||
export const dayMonthYear = z.object({
|
||||
day: z.coerce.number().int().min(1).max(31),
|
||||
month: z.coerce.number().int().min(0).max(11),
|
||||
year: z.coerce.number().int().min(2015).max(2100),
|
||||
});
|
||||
|
||||
export type DayMonthYear = z.infer<typeof dayMonthYear>;
|
||||
Reference in New Issue
Block a user