mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-02 08:07:40 -05:00
Scaffold apps/web SvelteKit 3 app with leaderboards server slice
- SvelteKit 3 (RC, pinned) + Svelte 5 + Vite 8 + adapter-node + TS 6 - paraglide pipeline: combine-locales from shared web-react locales, English-only dev settings, build-with-all-locales - db layer port: node:sqlite dialect, json column handling, trimmed DB schema (leaderboards slice tables) - cookie-compatible auth session decode (React Router HMAC format) resolved into event.locals via hooks - leaderboards repository/core ported verbatim; loader converted to a remote query(), action split into skipTeam/unskipTeam commands with server-driven refresh - valibot schemas for remote function validation
This commit is contained in:
6
apps/web/.gitignore
vendored
Normal file
6
apps/web/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
.svelte-kit/
|
||||
build/
|
||||
messages/
|
||||
src/lib/paraglide/
|
||||
project.inlang/cache/
|
||||
project.inlang/settings.json.backup
|
||||
44
apps/web/package.json
Normal file
44
apps/web/package.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "web",
|
||||
"version": "3.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
"#lib/*": "./src/lib/*"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "pnpm run combine-locales && vite dev",
|
||||
"build": "pnpm run combine-locales && vite build",
|
||||
"build:all-locales": "tsx scripts/build-with-all-locales.ts",
|
||||
"preview": "vite preview",
|
||||
"start": "node build/index.js",
|
||||
"combine-locales": "tsx scripts/combine-locales.ts",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"typecheck": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"test:unit": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@epic-web/cachified": "5.6.3",
|
||||
"@sendou/in-game-lists": "workspace:*",
|
||||
"@sendou/utils": "workspace:*",
|
||||
"date-fns": "4.4.0",
|
||||
"kysely": "0.29.0",
|
||||
"lru-cache": "^11.2.1",
|
||||
"remeda": "2.39.0",
|
||||
"sql-formatter": "^15.8.2",
|
||||
"valibot": "^1.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@inlang/paraglide-js": "^2.24.1",
|
||||
"@sveltejs/adapter-node": "6.0.0-next.10",
|
||||
"@sveltejs/kit": "3.0.0-next.23",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.3.0",
|
||||
"@types/node": "26.1.2",
|
||||
"svelte": "^5.56.9",
|
||||
"svelte-check": "^4.7.6",
|
||||
"tsx": "^4.20.5",
|
||||
"typescript": "~6.0.3",
|
||||
"vite": "^8.2.1",
|
||||
"vitest": "4.1.10"
|
||||
}
|
||||
}
|
||||
12
apps/web/project.inlang/settings.json
Normal file
12
apps/web/project.inlang/settings.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://inlang.com/schema/project-settings",
|
||||
"baseLocale": "en",
|
||||
"locales": ["en"],
|
||||
"modules": [
|
||||
"https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4/dist/index.js",
|
||||
"https://cdn.jsdelivr.net/npm/@inlang/plugin-m-function-matcher@2/dist/index.js"
|
||||
],
|
||||
"plugin.inlang.messageFormat": {
|
||||
"pathPattern": "./messages/{locale}.json"
|
||||
}
|
||||
}
|
||||
35
apps/web/scripts/build-with-all-locales.ts
Normal file
35
apps/web/scripts/build-with-all-locales.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const WEB_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const SETTINGS_PATH = path.join(WEB_DIR, "project.inlang/settings.json");
|
||||
const LOCALES_DIR = path.resolve(WEB_DIR, "../web-react/locales");
|
||||
|
||||
const allLocales = fs
|
||||
.readdirSync(LOCALES_DIR)
|
||||
.filter((item) => fs.statSync(path.join(LOCALES_DIR, item)).isDirectory())
|
||||
.sort();
|
||||
|
||||
console.log(`Building with locales: ${allLocales.join(", ")}`);
|
||||
|
||||
const backupPath = `${SETTINGS_PATH}.backup`;
|
||||
fs.writeFileSync(backupPath, fs.readFileSync(SETTINGS_PATH, "utf8"));
|
||||
|
||||
try {
|
||||
const buildSettings = {
|
||||
...JSON.parse(fs.readFileSync(SETTINGS_PATH, "utf8")),
|
||||
locales: allLocales,
|
||||
};
|
||||
fs.writeFileSync(
|
||||
SETTINGS_PATH,
|
||||
`${JSON.stringify(buildSettings, null, "\t")}\n`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
execSync("pnpm run build", { stdio: "inherit", cwd: WEB_DIR });
|
||||
} finally {
|
||||
fs.writeFileSync(SETTINGS_PATH, fs.readFileSync(backupPath, "utf8"));
|
||||
fs.unlinkSync(backupPath);
|
||||
}
|
||||
80
apps/web/scripts/combine-locales.ts
Normal file
80
apps/web/scripts/combine-locales.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const WEB_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const LOCALES_DIR = path.resolve(WEB_DIR, "../web-react/locales");
|
||||
const OUTPUT_DIR = path.join(WEB_DIR, "messages");
|
||||
|
||||
const settings = JSON.parse(
|
||||
fs.readFileSync(path.join(WEB_DIR, "project.inlang/settings.json"), "utf8"),
|
||||
) as { locales: string[] };
|
||||
|
||||
function convertVariableSyntax(text: unknown) {
|
||||
if (typeof text !== "string") return text;
|
||||
return text.replace(/\{\{([^}]+)\}\}/g, "{$1}");
|
||||
}
|
||||
|
||||
function flattenObject(obj: Record<string, unknown>, prefix = "") {
|
||||
const flattened: Record<string, unknown> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const formatKey = key.replaceAll(".", "_").replaceAll("-", "_");
|
||||
const formatPrefix = prefix
|
||||
? prefix.replaceAll(".", "_").replaceAll("-", "_")
|
||||
: prefix;
|
||||
const newKey = formatPrefix ? `${formatPrefix}_${formatKey}` : formatKey;
|
||||
|
||||
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
||||
Object.assign(
|
||||
flattened,
|
||||
flattenObject(value as Record<string, unknown>, newKey),
|
||||
);
|
||||
} else {
|
||||
flattened[newKey] = convertVariableSyntax(value);
|
||||
}
|
||||
}
|
||||
|
||||
return flattened;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(OUTPUT_DIR)) {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
const locales = fs.readdirSync(LOCALES_DIR).filter((item) => {
|
||||
if (!fs.statSync(path.join(LOCALES_DIR, item)).isDirectory()) return false;
|
||||
return settings.locales.includes(item);
|
||||
});
|
||||
|
||||
console.log(
|
||||
`Creating ${locales.length} language file(s) from ${LOCALES_DIR} to /messages...`,
|
||||
);
|
||||
|
||||
for (const locale of locales) {
|
||||
const localeDir = path.join(LOCALES_DIR, locale);
|
||||
const jsonFiles = fs
|
||||
.readdirSync(localeDir)
|
||||
.filter((file) => file.endsWith(".json"));
|
||||
|
||||
const combinedMessages: Record<string, unknown> = {
|
||||
$schema: "https://inlang.com/schema/inlang-message-format",
|
||||
};
|
||||
|
||||
for (const file of jsonFiles) {
|
||||
const filePath = path.join(localeDir, file);
|
||||
const fileName = path.basename(file, ".json");
|
||||
|
||||
const jsonContent = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||
|
||||
Object.assign(combinedMessages, flattenObject(jsonContent, fileName));
|
||||
}
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(OUTPUT_DIR, `${locale}.json`),
|
||||
`${JSON.stringify(combinedMessages, null, "\t")}\n`,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
console.log("Done.");
|
||||
15
apps/web/src/app.d.ts
vendored
Normal file
15
apps/web/src/app.d.ts
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { AuthenticatedUser } from "#lib/features/auth/user-types.ts";
|
||||
|
||||
declare global {
|
||||
namespace App {
|
||||
interface Locals {
|
||||
user: AuthenticatedUser | undefined;
|
||||
}
|
||||
// interface Error {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
21
apps/web/src/app.html
Normal file
21
apps/web/src/app.html
Normal file
@@ -0,0 +1,21 @@
|
||||
<!doctype html>
|
||||
<html lang="%lang%" dir="ltr" class="%theme%scrollbar">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="initial-scale=1, viewport-fit=cover, user-scalable=no"
|
||||
/>
|
||||
<meta
|
||||
name="apple-mobile-web-app-status-bar-style"
|
||||
content="black-translucent"
|
||||
/>
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="theme-color" content="#010115" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body>
|
||||
%sveltekit.body%
|
||||
</body>
|
||||
</html>
|
||||
34
apps/web/src/hooks.server.ts
Normal file
34
apps/web/src/hooks.server.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { Handle } from "@sveltejs/kit/hooks";
|
||||
import { userIsBanned } from "#lib/features/ban/banned.server.ts";
|
||||
import {
|
||||
AUTH_COOKIE_NAME,
|
||||
readSessionCookie,
|
||||
sessionUserId,
|
||||
} from "#lib/features/auth/session.server.ts";
|
||||
import { getTheme } from "#lib/features/theme/theme.server.ts";
|
||||
import * as UserRepository from "#lib/features/user-page/UserRepository.server.ts";
|
||||
import { paraglideMiddleware } from "#lib/paraglide/server.js";
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
const session = readSessionCookie(event.cookies.get(AUTH_COOKIE_NAME));
|
||||
const userId = sessionUserId(session);
|
||||
|
||||
if (userId && !(await userIsBanned(userId))) {
|
||||
event.locals.user = await UserRepository.findLeanById(userId);
|
||||
}
|
||||
|
||||
return paraglideMiddleware(
|
||||
event.request,
|
||||
// no URL-based locale strategy is in use, so the request needs no rewriting
|
||||
({ locale }) => {
|
||||
return resolve(event, {
|
||||
transformPageChunk: ({ html }) => {
|
||||
const theme = getTheme(event.cookies);
|
||||
return html
|
||||
.replace("%lang%", locale)
|
||||
.replace("%theme%", theme ? `${theme} ` : "");
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
};
|
||||
104
apps/web/src/lib/config.ts
Normal file
104
apps/web/src/lib/config.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { IS_E2E_TEST_RUN } from "#lib/utils/e2e.ts";
|
||||
|
||||
/**
|
||||
* Client (`VITE_*`) configuration. Import with `import { Config } from "#lib/config.ts"`
|
||||
* and read values like `Config.siteDomain` or `Config.staticAssetsUrl`.
|
||||
*
|
||||
* Values are validated once when this module is first imported. Variables
|
||||
* required in production fall back to development defaults outside of
|
||||
* production.
|
||||
*/
|
||||
|
||||
const env =
|
||||
typeof import.meta.env !== "undefined"
|
||||
? (import.meta.env as Record<string, string | undefined>)
|
||||
: {};
|
||||
|
||||
const isProd =
|
||||
typeof import.meta.env !== "undefined" &&
|
||||
import.meta.env.PROD === true &&
|
||||
!IS_E2E_TEST_RUN;
|
||||
|
||||
const TRUTHY_STRINGS = ["true", "1", "yes", "on", "y", "enabled"];
|
||||
const FALSY_STRINGS = ["false", "0", "no", "off", "n", "disabled"];
|
||||
|
||||
const issues: Array<{ name: string; message: string }> = [];
|
||||
|
||||
const values = {
|
||||
VITE_SITE_DOMAIN: requiredInProd("VITE_SITE_DOMAIN", "http://localhost:5173"),
|
||||
VITE_STATIC_ASSETS_URL: withDefault(
|
||||
"VITE_STATIC_ASSETS_URL",
|
||||
"https://sendou-assets.nyc3.cdn.digitaloceanspaces.com",
|
||||
),
|
||||
VITE_TOURNAMENT_DEFAULT_LOGO: requiredInProd(
|
||||
"VITE_TOURNAMENT_DEFAULT_LOGO",
|
||||
"tournament-logo-default.avif",
|
||||
),
|
||||
VITE_PROD_MODE: stringBool("VITE_PROD_MODE"),
|
||||
VITE_SHOW_LUTI_NAV_ITEM: stringBool("VITE_SHOW_LUTI_NAV_ITEM"),
|
||||
};
|
||||
|
||||
if (issues.length > 0) {
|
||||
throw envError(issues);
|
||||
}
|
||||
|
||||
export const Config = {
|
||||
/** Base URL of the site, e.g. `https://sendou.ink`. */
|
||||
siteDomain: values.VITE_SITE_DOMAIN,
|
||||
/** Base URL for static assets (images, sounds, svg). */
|
||||
staticAssetsUrl: values.VITE_STATIC_ASSETS_URL,
|
||||
/** Filename of the default tournament logo asset. */
|
||||
tournamentDefaultLogo: values.VITE_TOURNAMENT_DEFAULT_LOGO,
|
||||
/** Whether to use real seasons & league data (used when developing against the production database). */
|
||||
prodMode: values.VITE_PROD_MODE,
|
||||
/** Whether to show the LUTI navigation item. */
|
||||
showLutiNavItem: values.VITE_SHOW_LUTI_NAV_ITEM,
|
||||
};
|
||||
|
||||
function requiredInProd(name: string, devFallback: string): string {
|
||||
const value = env[name];
|
||||
|
||||
if (!isProd) {
|
||||
return value ?? devFallback;
|
||||
}
|
||||
|
||||
if (value === undefined) {
|
||||
issues.push({ name, message: "required in production" });
|
||||
return "";
|
||||
}
|
||||
if (value.length === 0) {
|
||||
issues.push({ name, message: "required in production (cannot be empty)" });
|
||||
return "";
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function withDefault(name: string, defaultValue: string): string {
|
||||
return env[name] ?? defaultValue;
|
||||
}
|
||||
|
||||
function stringBool(name: string): boolean {
|
||||
const value = env[name];
|
||||
if (value === undefined) return false;
|
||||
|
||||
const normalized = value.toLowerCase();
|
||||
if (TRUTHY_STRINGS.includes(normalized)) return true;
|
||||
if (FALSY_STRINGS.includes(normalized)) return false;
|
||||
|
||||
issues.push({
|
||||
name,
|
||||
message: `must be a boolean-like string (e.g. "true" or "false"), got "${value}"`,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
function envError(issues: Array<{ name: string; message: string }>): Error {
|
||||
const lines = issues.map((issue) => ` - ${issue.name}: ${issue.message}`);
|
||||
|
||||
return new Error(
|
||||
`Invalid client environment configuration:\n${lines.join(
|
||||
"\n",
|
||||
)}\n\nSee .env.example for the full list of variables and how to set them.`,
|
||||
);
|
||||
}
|
||||
34
apps/web/src/lib/db/tables-json.ts
Normal file
34
apps/web/src/lib/db/tables-json.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Shapes of JSON column payloads (and small column unions) shared by the
|
||||
* server-only `DB` schema and client-facing types. Kept outside `src/lib/server`
|
||||
* so client code can import the types without touching server modules.
|
||||
*/
|
||||
|
||||
export type XRankPlacementRegion = "WEST" | "JPN";
|
||||
|
||||
export interface UserPreferences {
|
||||
disableBuildAbilitySorting?: boolean;
|
||||
disallowScrimPickupsFromUntrusted?: boolean;
|
||||
/**
|
||||
* What time format the user prefers?
|
||||
*
|
||||
* "auto" = use browser default (default value)
|
||||
* "24h" = 24 hour format (e.g. 14:00)
|
||||
* "12h" = 12 hour format (e.g. 2:00 PM)
|
||||
* */
|
||||
clockFormat?: "24h" | "12h" | "auto";
|
||||
/** Is the new widget based user page enabled? (Supporter early preview) */
|
||||
newProfileEnabled?: boolean;
|
||||
/** Is spoiler-free mode enabled? Hides recent tournament results and scores until the user chooses to reveal them. */
|
||||
spoilerFreeMode?: boolean;
|
||||
weaponReportDefaultOpen?: boolean;
|
||||
}
|
||||
|
||||
export interface PeakXP {
|
||||
/** Peak XP across all divisions */
|
||||
overall: number;
|
||||
/** Peak XP (Takoroka division) */
|
||||
takoroka: number | null;
|
||||
/** Peak XP (Tentatek division) */
|
||||
tentatek: number | null;
|
||||
}
|
||||
12
apps/web/src/lib/features/admin/admin-constants.ts
Normal file
12
apps/web/src/lib/features/admin/admin-constants.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export const ADMIN_DISCORD_ID = "79237403620945920";
|
||||
export const ADMIN_ID =
|
||||
typeof process !== "undefined" && process.env.NODE_ENV === "test" ? 1 : 274;
|
||||
|
||||
// Panda Scep Acing Baja Michi
|
||||
export const STAFF_IDS = [11329, 9719, 9342, 20774, 23094];
|
||||
// hfcRed
|
||||
export const DEV_IDS = [27883];
|
||||
// hfcRed Dreamy Cafy Acing
|
||||
export const QA_IDS: number[] = [27883, 38176, 10654, 9342];
|
||||
/** Users who can access the scanner while it is not yet enabled for everyone */
|
||||
export const SCANNER_TESTER_IDS: number[] = [30228];
|
||||
147
apps/web/src/lib/features/auth/session.server.ts
Normal file
147
apps/web/src/lib/features/auth/session.server.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
import { ServerConfig } from "#lib/server/config.ts";
|
||||
import { IS_E2E_TEST_RUN } from "#lib/utils/e2e.ts";
|
||||
|
||||
/**
|
||||
* The auth session cookie, byte-compatible with the React app's
|
||||
* `createCookieSessionStorage` cookie (name, JSON→base64 encoding and HMAC
|
||||
* signature all identical) so that nobody gets logged out at cutover.
|
||||
*/
|
||||
|
||||
export const AUTH_COOKIE_NAME = "__session";
|
||||
export const SESSION_KEY = "user";
|
||||
export const IMPERSONATED_SESSION_KEY = "impersonated_user";
|
||||
|
||||
const ONE_YEAR_IN_SECONDS = 31_536_000;
|
||||
|
||||
export type SessionData = Record<string, unknown>;
|
||||
|
||||
/** Decodes and verifies the auth session cookie value. Returns `null` for missing, tampered or malformed cookies. */
|
||||
export function readSessionCookie(
|
||||
cookieValue: string | undefined,
|
||||
): SessionData | null {
|
||||
if (!cookieValue) return null;
|
||||
|
||||
const unsigned = unsign(cookieValue, ServerConfig.sessionSecret);
|
||||
if (unsigned === false) return null;
|
||||
|
||||
return decodeData(unsigned);
|
||||
}
|
||||
|
||||
/** Encodes and signs session data into the auth cookie value. */
|
||||
export function writeSessionCookie(data: SessionData): string {
|
||||
return sign(encodeData(data), ServerConfig.sessionSecret);
|
||||
}
|
||||
|
||||
export const authCookieOptions = {
|
||||
sameSite: "lax",
|
||||
domain:
|
||||
ServerConfig.isProduction && !IS_E2E_TEST_RUN ? "sendou.ink" : undefined,
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
secure: ServerConfig.isProduction && !IS_E2E_TEST_RUN,
|
||||
maxAge: ONE_YEAR_IN_SECONDS,
|
||||
} as const;
|
||||
|
||||
/** Resolves the acting user id of a session: the impersonated user when impersonating, otherwise the logged in user. */
|
||||
export function sessionUserId(session: SessionData | null): number | null {
|
||||
if (!session) return null;
|
||||
|
||||
const userId = session[IMPERSONATED_SESSION_KEY] ?? session[SESSION_KEY];
|
||||
return typeof userId === "number" ? userId : null;
|
||||
}
|
||||
|
||||
function sign(value: string, secret: string) {
|
||||
const signature = createHmac("sha256", secret)
|
||||
.update(value)
|
||||
.digest("base64")
|
||||
.replace(/=+$/, "");
|
||||
|
||||
return `${value}.${signature}`;
|
||||
}
|
||||
|
||||
function unsign(cookie: string, secret: string): string | false {
|
||||
const index = cookie.lastIndexOf(".");
|
||||
if (index < 0) return false;
|
||||
|
||||
const value = cookie.slice(0, index);
|
||||
const hash = cookie.slice(index + 1);
|
||||
|
||||
const expected = createHmac("sha256", secret)
|
||||
.update(value)
|
||||
.digest("base64")
|
||||
.replace(/=+$/, "");
|
||||
|
||||
const hashBuffer = Buffer.from(hash);
|
||||
const expectedBuffer = Buffer.from(expected);
|
||||
if (hashBuffer.length !== expectedBuffer.length) return false;
|
||||
|
||||
return timingSafeEqual(hashBuffer, expectedBuffer) ? value : false;
|
||||
}
|
||||
|
||||
function encodeData(value: SessionData): string {
|
||||
return btoa(myUnescape(encodeURIComponent(JSON.stringify(value))));
|
||||
}
|
||||
|
||||
function decodeData(value: string): SessionData {
|
||||
try {
|
||||
const parsed = JSON.parse(decodeURIComponent(myEscape(atob(value))));
|
||||
return typeof parsed === "object" && parsed !== null ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
// escape/unescape ported from React Router's cookie encoding (which follows
|
||||
// core-js's escape/unescape) so multibyte session values round-trip identically
|
||||
function myEscape(value: string): string {
|
||||
let result = "";
|
||||
let index = 0;
|
||||
while (index < value.length) {
|
||||
const chr = value.charAt(index++);
|
||||
if (/[\w*+\-./@]/.exec(chr)) {
|
||||
result += chr;
|
||||
} else {
|
||||
const code = chr.charCodeAt(0);
|
||||
if (code < 256) {
|
||||
result += `%${hex(code, 2)}`;
|
||||
} else {
|
||||
result += `%u${hex(code, 4).toUpperCase()}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function hex(code: number, length: number): string {
|
||||
let result = code.toString(16);
|
||||
while (result.length < length) result = `0${result}`;
|
||||
return result;
|
||||
}
|
||||
|
||||
function myUnescape(value: string): string {
|
||||
let result = "";
|
||||
let index = 0;
|
||||
while (index < value.length) {
|
||||
let chr = value.charAt(index++);
|
||||
if (chr === "%") {
|
||||
if (value.charAt(index) === "u") {
|
||||
const part = value.slice(index + 1, index + 5);
|
||||
if (/^[\da-f]{4}$/i.exec(part)) {
|
||||
result += String.fromCharCode(Number.parseInt(part, 16));
|
||||
index += 5;
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
const part = value.slice(index, index + 2);
|
||||
if (/^[\da-f]{2}$/i.exec(part)) {
|
||||
result += String.fromCharCode(Number.parseInt(part, 16));
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
result += chr;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
23
apps/web/src/lib/features/auth/user-types.ts
Normal file
23
apps/web/src/lib/features/auth/user-types.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { UserPreferences } from "#lib/db/tables-json.ts";
|
||||
import type { CustomTheme } from "#lib/features/theme/theme-types.ts";
|
||||
import type { UnifiedLanguageCode } from "#lib/modules/i18n/languages.ts";
|
||||
import type { Role } from "#lib/modules/permissions/types.ts";
|
||||
|
||||
/** The logged in user as resolved once per request, shape matching `UserRepository.findLeanById`. */
|
||||
export interface AuthenticatedUser {
|
||||
id: number;
|
||||
username: string;
|
||||
discordId: string;
|
||||
discordAvatar: string | null;
|
||||
customUrl: string | null;
|
||||
customAvatarUrl: string | null;
|
||||
createdAt: number | null;
|
||||
customTheme: CustomTheme | null;
|
||||
patronTier: number | null;
|
||||
languages: UnifiedLanguageCode[] | null;
|
||||
inGameName: string | null;
|
||||
preferences: UserPreferences | null;
|
||||
plusTier: number | null;
|
||||
friendCode: string | null;
|
||||
roles: Role[];
|
||||
}
|
||||
35
apps/web/src/lib/features/auth/user.server.ts
Normal file
35
apps/web/src/lib/features/auth/user.server.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { error } from "@sveltejs/kit";
|
||||
import { getRequestEvent } from "$app/server";
|
||||
import type { AuthenticatedUser } from "./user-types.ts";
|
||||
|
||||
export type { AuthenticatedUser };
|
||||
|
||||
/** The logged in user of the current request, or `undefined` when logged out. */
|
||||
export function getUser(): AuthenticatedUser | undefined {
|
||||
return getRequestEvent().locals.user;
|
||||
}
|
||||
|
||||
/** The logged in user of the current request; responds with 401 when logged out. */
|
||||
export function requireUser(): AuthenticatedUser {
|
||||
const user = getUser();
|
||||
if (!user) {
|
||||
error(401);
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
/** Id of the acting user; throws when logged out (use only where auth was already established). */
|
||||
export function actorId(): number {
|
||||
const user = getUser();
|
||||
if (!user) {
|
||||
throw new Error("No acting user in context");
|
||||
}
|
||||
|
||||
return user.id;
|
||||
}
|
||||
|
||||
/** Id of the acting user, or `null` when logged out. */
|
||||
export function actorIdOrNull(): number | null {
|
||||
return getUser()?.id ?? null;
|
||||
}
|
||||
32
apps/web/src/lib/features/ban/banned.server.ts
Normal file
32
apps/web/src/lib/features/ban/banned.server.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import * as UserRepository from "#lib/features/user-page/UserRepository.server.ts";
|
||||
import { databaseTimestampToDate } from "#lib/utils/dates.ts";
|
||||
|
||||
let bannedUsers: Awaited<
|
||||
ReturnType<typeof UserRepository.findAllBannedUsers>
|
||||
> | null = null;
|
||||
|
||||
export function checkBanStatus(
|
||||
banned: number | null | undefined,
|
||||
now: Date = new Date(),
|
||||
): boolean {
|
||||
if (!banned) return false;
|
||||
if (banned === 1) return true;
|
||||
|
||||
const banExpiresAt = databaseTimestampToDate(banned);
|
||||
|
||||
return banExpiresAt > now;
|
||||
}
|
||||
|
||||
export async function userIsBanned(userId: number) {
|
||||
if (!bannedUsers) {
|
||||
bannedUsers = await UserRepository.findAllBannedUsers();
|
||||
}
|
||||
|
||||
const banStatus = bannedUsers.get(userId);
|
||||
|
||||
return checkBanStatus(banStatus?.banned);
|
||||
}
|
||||
|
||||
export async function refreshBannedCache() {
|
||||
bannedUsers = await UserRepository.findAllBannedUsers();
|
||||
}
|
||||
@@ -0,0 +1,507 @@
|
||||
import type {
|
||||
MainWeaponId,
|
||||
RankedModeShort,
|
||||
} from "@sendou/in-game-lists/types";
|
||||
import type { InferResult } from "kysely";
|
||||
import { sql } from "kysely";
|
||||
import * as R from "remeda";
|
||||
import { actorId } from "#lib/features/auth/user.server.ts";
|
||||
import { db } from "#lib/server/db/sql.ts";
|
||||
import type { Tables } from "#lib/server/db/tables.ts";
|
||||
import {
|
||||
commonUserSelect,
|
||||
concatUserSubmittedImagePrefix,
|
||||
jsonArrayFrom,
|
||||
latestSkillPerSeason,
|
||||
skillCountsAsSeasonSet,
|
||||
} from "#lib/server/kysely.ts";
|
||||
import { dateToDatabaseTimestamp } from "#lib/utils/dates.ts";
|
||||
import * as Seasons from "../mmr/Seasons.ts";
|
||||
import { ordinalToSp, type SkillTeamIdentifier } from "../mmr/mmr-utils.ts";
|
||||
import {
|
||||
DEFAULT_LEADERBOARD_MAX_SIZE,
|
||||
MATCHES_COUNT_NEEDED_FOR_LEADERBOARD,
|
||||
} from "./leaderboards-constants.ts";
|
||||
|
||||
// must stay above the largest weaponSplId
|
||||
const USER_WEAPON_PACK_FACTOR = 100_000;
|
||||
|
||||
function addPowers<T extends { ordinal: number }>(entries: T[]) {
|
||||
return entries.map((entry) => ({
|
||||
...entry,
|
||||
power: ordinalToSp(entry.ordinal),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Numbers the entries by placement. A skipped team keeps its spot in the order but takes no number, the one below it getting the number it would have had. */
|
||||
function addPlacementRank<T extends { isSkipped: boolean }>(entries: T[]) {
|
||||
let placementRank = 0;
|
||||
|
||||
return entries.map((entry): T & { placementRank: number | null } => {
|
||||
if (entry.isSkipped) return { ...entry, placementRank: null };
|
||||
|
||||
placementRank++;
|
||||
return { ...entry, placementRank };
|
||||
});
|
||||
}
|
||||
|
||||
const teamLeaderboardBySeasonQuery = (season: number) =>
|
||||
db
|
||||
.selectFrom((eb) =>
|
||||
eb
|
||||
.selectFrom(
|
||||
latestSkillPerSeason({ season, by: "identifier" }).as("LatestOfTeam"),
|
||||
)
|
||||
.select([
|
||||
"LatestOfTeam.latestId as entryId",
|
||||
"LatestOfTeam.ordinal",
|
||||
"LatestOfTeam.identifier",
|
||||
])
|
||||
.where(
|
||||
"LatestOfTeam.matchesCount",
|
||||
">=",
|
||||
MATCHES_COUNT_NEEDED_FOR_LEADERBOARD,
|
||||
)
|
||||
.orderBy("LatestOfTeam.ordinal", "desc")
|
||||
.limit(DEFAULT_LEADERBOARD_MAX_SIZE)
|
||||
.as("Entry"),
|
||||
)
|
||||
.select((eb) => [
|
||||
"Entry.entryId",
|
||||
"Entry.ordinal",
|
||||
"Entry.identifier",
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("SkillTeamUser")
|
||||
.innerJoin("User", "SkillTeamUser.userId", "User.id")
|
||||
.select((eb) => commonUserSelect(eb))
|
||||
.whereRef("SkillTeamUser.skillId", "=", "Entry.entryId"),
|
||||
).as("members"),
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("SkillTeamUser")
|
||||
.innerJoin("User", "SkillTeamUser.userId", "User.id")
|
||||
.innerJoin(
|
||||
"TeamMemberWithSecondary",
|
||||
"TeamMemberWithSecondary.userId",
|
||||
"User.id",
|
||||
)
|
||||
.innerJoin("Team", "Team.id", "TeamMemberWithSecondary.teamId")
|
||||
.leftJoin(
|
||||
"UserSubmittedImage",
|
||||
"UserSubmittedImage.id",
|
||||
"Team.avatarImgId",
|
||||
)
|
||||
.select((eb) => [
|
||||
"Team.id",
|
||||
"Team.name",
|
||||
concatUserSubmittedImagePrefix(eb.ref("UserSubmittedImage.url")).as(
|
||||
"avatarUrl",
|
||||
),
|
||||
"Team.customUrl",
|
||||
"TeamMemberWithSecondary.isMainTeam",
|
||||
"TeamMemberWithSecondary.userId",
|
||||
])
|
||||
.whereRef("SkillTeamUser.skillId", "=", "Entry.entryId"),
|
||||
).as("teams"),
|
||||
])
|
||||
.orderBy("Entry.ordinal", "desc")
|
||||
.$narrowType<{ identifier: SkillTeamIdentifier }>();
|
||||
type TeamLeaderboardBySeasonQueryReturnType = InferResult<
|
||||
ReturnType<typeof teamLeaderboardBySeasonQuery>
|
||||
>;
|
||||
type TeamLeaderboardEntry = TeamLeaderboardBySeasonQueryReturnType[number] & {
|
||||
isSkipped: boolean;
|
||||
};
|
||||
|
||||
export async function findTeamLeaderboardBySeason({
|
||||
season,
|
||||
onlyOneEntryPerUser,
|
||||
}: {
|
||||
season: number;
|
||||
onlyOneEntryPerUser: boolean;
|
||||
}) {
|
||||
const entries = addSkipped({
|
||||
entries: await teamLeaderboardBySeasonQuery(season).execute(),
|
||||
skippedIdentifiers: await findAllTeamSkipIdentifiersBySeason(season),
|
||||
});
|
||||
const withNonSqPlayersHandled = onlyOneEntryPerUser
|
||||
? await filterOutNonSqPlayers({ season, entries })
|
||||
: entries;
|
||||
|
||||
const oneEntryPerUser = onlyOneEntryPerUser
|
||||
? filterOneEntryPerUser(withNonSqPlayersHandled)
|
||||
: withNonSqPlayersHandled;
|
||||
const withSharedTeam = resolveSharedTeam(oneEntryPerUser);
|
||||
const withPower = addPowers(withSharedTeam);
|
||||
|
||||
return addPlacementRank(withPower);
|
||||
}
|
||||
|
||||
function addSkipped(args: {
|
||||
entries: TeamLeaderboardBySeasonQueryReturnType;
|
||||
skippedIdentifiers: Set<SkillTeamIdentifier>;
|
||||
}): TeamLeaderboardEntry[] {
|
||||
return args.entries.map((entry) => ({
|
||||
...entry,
|
||||
isSkipped: args.skippedIdentifiers.has(entry.identifier),
|
||||
}));
|
||||
}
|
||||
|
||||
async function filterOutNonSqPlayers(args: {
|
||||
entries: TeamLeaderboardEntry[];
|
||||
season: number;
|
||||
}) {
|
||||
const validUserIds = new Set(
|
||||
await userIdsWithEnoughSqMatchesForTeamLeaderboard(args.season),
|
||||
);
|
||||
|
||||
return args.entries.filter((entry) =>
|
||||
entry.members.every((member) => validUserIds.has(member.id)),
|
||||
);
|
||||
}
|
||||
|
||||
async function userIdsWithEnoughSqMatchesForTeamLeaderboard(seasonNth: number) {
|
||||
// a Skill row with groupMatchId set exists exactly once per user per
|
||||
// completed (not canceled) SendouQ match of the season
|
||||
const rows = await db
|
||||
.selectFrom("Skill")
|
||||
.select("userId")
|
||||
.where("season", "=", seasonNth)
|
||||
.where("groupMatchId", "is not", null)
|
||||
.where("userId", "is not", null)
|
||||
.groupBy("userId")
|
||||
.having(
|
||||
(eb) => eb.fn.countAll(),
|
||||
">=",
|
||||
MATCHES_COUNT_NEEDED_FOR_LEADERBOARD,
|
||||
)
|
||||
.$narrowType<{ userId: number }>()
|
||||
.execute();
|
||||
|
||||
return rows.map((row) => row.userId);
|
||||
}
|
||||
|
||||
export async function hasEnoughSqMatchesByUserId(userId: number) {
|
||||
const season = Seasons.currentOrPrevious();
|
||||
if (!season) return false;
|
||||
|
||||
const dateRange = Seasons.nthToReportingDateRange(season.nth);
|
||||
if (!dateRange) return false;
|
||||
|
||||
const rows = await db
|
||||
.selectFrom("GroupMatch")
|
||||
.innerJoin("GroupMember", (join) =>
|
||||
join.on((eb) =>
|
||||
eb.or([
|
||||
eb("GroupMatch.alphaGroupId", "=", eb.ref("GroupMember.groupId")),
|
||||
eb("GroupMatch.bravoGroupId", "=", eb.ref("GroupMember.groupId")),
|
||||
]),
|
||||
),
|
||||
)
|
||||
.innerJoin("Skill", (join) =>
|
||||
join
|
||||
.onRef("Skill.groupMatchId", "=", "GroupMatch.id")
|
||||
.onRef("Skill.userId", "=", "GroupMember.userId"),
|
||||
)
|
||||
.where("GroupMember.userId", "=", userId)
|
||||
.where(
|
||||
"GroupMatch.createdAt",
|
||||
">",
|
||||
dateToDatabaseTimestamp(dateRange.starts),
|
||||
)
|
||||
.where("GroupMatch.createdAt", "<", dateToDatabaseTimestamp(dateRange.ends))
|
||||
.select(db.fn.countAll<number>().as("count"))
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
return rows.count >= MATCHES_COUNT_NEEDED_FOR_LEADERBOARD;
|
||||
}
|
||||
|
||||
/** The highest placing entry of each user. A skipped team is always kept and doesn't spend
|
||||
* its players' entry, so a roster sharing players with it can still place below it. */
|
||||
function filterOneEntryPerUser(entries: TeamLeaderboardEntry[]) {
|
||||
const encounteredUserIds = new Set<number>();
|
||||
return entries.filter((entry) => {
|
||||
if (entry.isSkipped) return true;
|
||||
|
||||
if (entry.members.some((m) => encounteredUserIds.has(m.id))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const member of entry.members) {
|
||||
encounteredUserIds.add(member.id);
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function resolveSharedTeam(entries: ReturnType<typeof filterOneEntryPerUser>) {
|
||||
return entries.map(({ teams, ...entry }) => {
|
||||
const uniqueTeamIds = R.unique(teams.map((team) => team.id));
|
||||
|
||||
for (const teamId of uniqueTeamIds) {
|
||||
const count = teams.filter((team) => team.id === teamId).length;
|
||||
|
||||
if (count === 4) {
|
||||
return {
|
||||
...entry,
|
||||
team: teams.find((team) => team.id === teamId),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...entry,
|
||||
team: undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function findAllTeamSkipIdentifiersBySeason(season: number) {
|
||||
const rows = await db
|
||||
.selectFrom("LeaderboardTeamSkip")
|
||||
.select("LeaderboardTeamSkip.identifier")
|
||||
.where("LeaderboardTeamSkip.season", "=", season)
|
||||
.execute();
|
||||
|
||||
return new Set(rows.map((row) => row.identifier));
|
||||
}
|
||||
|
||||
/** Marks a team as not counting for the season's team leaderboard placements. Records the acting user as `skippedByUserId`. */
|
||||
export async function insertTeamSkip(args: {
|
||||
season: number;
|
||||
identifier: SkillTeamIdentifier;
|
||||
}) {
|
||||
await db
|
||||
.insertInto("LeaderboardTeamSkip")
|
||||
.values({ ...args, skippedByUserId: actorId() })
|
||||
.onConflict((oc) => oc.columns(["season", "identifier"]).doNothing())
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function deleteTeamSkip(args: {
|
||||
season: number;
|
||||
identifier: SkillTeamIdentifier;
|
||||
}) {
|
||||
await db
|
||||
.deleteFrom("LeaderboardTeamSkip")
|
||||
.where("LeaderboardTeamSkip.season", "=", args.season)
|
||||
.where("LeaderboardTeamSkip.identifier", "=", args.identifier)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function findSeasonsParticipatedInByUserId(userId: number) {
|
||||
const rows = await db
|
||||
.selectFrom("Skill")
|
||||
.select("season")
|
||||
.where("userId", "=", userId)
|
||||
.where((eb) => skillCountsAsSeasonSet(eb, userId))
|
||||
.groupBy("season")
|
||||
.orderBy("season", "desc")
|
||||
.execute();
|
||||
|
||||
return rows.map((row) => row.season);
|
||||
}
|
||||
|
||||
export type XPLeaderboardItem = Awaited<
|
||||
ReturnType<typeof findAllXPLeaderboard>
|
||||
>[number];
|
||||
|
||||
function xpLeaderboardQuery(where?: {
|
||||
mode?: RankedModeShort;
|
||||
weaponSplId?: MainWeaponId;
|
||||
}) {
|
||||
// walks placements from the highest power down (power-descending indexes
|
||||
// keeping only each player's best placement, so it can
|
||||
// stop at the 500th distinct player instead of aggregating every player's
|
||||
// max power first
|
||||
return db
|
||||
.selectFrom((eb) => {
|
||||
let placements = eb
|
||||
.selectFrom("XRankPlacement")
|
||||
.select([
|
||||
"XRankPlacement.id as entryId",
|
||||
"XRankPlacement.playerId",
|
||||
"XRankPlacement.weaponSplId",
|
||||
"XRankPlacement.name",
|
||||
"XRankPlacement.power",
|
||||
])
|
||||
.where(({ not, exists, selectFrom }) =>
|
||||
not(
|
||||
exists(
|
||||
selectFrom("XRankPlacement as Better")
|
||||
.select("Better.id")
|
||||
.whereRef("Better.playerId", "=", "XRankPlacement.playerId")
|
||||
.$if(Boolean(where?.mode), (qb) =>
|
||||
qb.where("Better.mode", "=", where!.mode!),
|
||||
)
|
||||
.$if(typeof where?.weaponSplId === "number", (qb) =>
|
||||
qb.where("Better.weaponSplId", "=", where!.weaponSplId!),
|
||||
)
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
eb("Better.power", ">", eb.ref("XRankPlacement.power")),
|
||||
eb.and([
|
||||
eb("Better.power", "=", eb.ref("XRankPlacement.power")),
|
||||
eb("Better.id", "<", eb.ref("XRankPlacement.id")),
|
||||
]),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.orderBy("XRankPlacement.power", "desc")
|
||||
.limit(DEFAULT_LEADERBOARD_MAX_SIZE);
|
||||
|
||||
if (where?.mode) {
|
||||
placements = placements.where("XRankPlacement.mode", "=", where.mode);
|
||||
}
|
||||
|
||||
if (typeof where?.weaponSplId === "number") {
|
||||
placements = placements.where(
|
||||
"XRankPlacement.weaponSplId",
|
||||
"=",
|
||||
where.weaponSplId,
|
||||
);
|
||||
}
|
||||
|
||||
return placements.as("Placement");
|
||||
})
|
||||
.innerJoin("SplatoonPlayer", "SplatoonPlayer.id", "Placement.playerId")
|
||||
.leftJoin("User", "User.id", "SplatoonPlayer.userId")
|
||||
.select((eb) => [
|
||||
...commonUserSelect(eb),
|
||||
"Placement.entryId",
|
||||
"Placement.playerId",
|
||||
"Placement.weaponSplId",
|
||||
"Placement.name",
|
||||
"Placement.power",
|
||||
sql<number>`rank() over (order by "Placement"."power" desc)`.as(
|
||||
"placementRank",
|
||||
),
|
||||
])
|
||||
.orderBy("Placement.power", "desc")
|
||||
.limit(DEFAULT_LEADERBOARD_MAX_SIZE);
|
||||
}
|
||||
|
||||
export async function findAllXPLeaderboard() {
|
||||
return xpLeaderboardQuery().execute();
|
||||
}
|
||||
|
||||
export async function findModeXPLeaderboard(mode: RankedModeShort) {
|
||||
return xpLeaderboardQuery({ mode }).execute();
|
||||
}
|
||||
|
||||
export async function findWeaponXPLeaderboard(weaponSplId: MainWeaponId) {
|
||||
return xpLeaderboardQuery({ weaponSplId }).execute();
|
||||
}
|
||||
|
||||
export type UserSPLeaderboardItem = Awaited<
|
||||
ReturnType<typeof findUserSPLeaderboard>
|
||||
>[number];
|
||||
|
||||
export async function findUserSPLeaderboard(season: number) {
|
||||
const rows = await db
|
||||
.selectFrom(latestSkillPerSeason({ season, by: "userId" }).as("Latest"))
|
||||
.innerJoin("User", "User.id", "Latest.userId")
|
||||
.select((eb) => [
|
||||
...commonUserSelect(eb),
|
||||
"Latest.latestId as entryId",
|
||||
"Latest.ordinal",
|
||||
"User.plusSkippedForSeasonNth",
|
||||
])
|
||||
.where("Latest.matchesCount", ">=", MATCHES_COUNT_NEEDED_FOR_LEADERBOARD)
|
||||
.orderBy("Latest.ordinal", "desc")
|
||||
.execute();
|
||||
|
||||
let placementRank = 0;
|
||||
return rows.map(({ ordinal, ...rest }, index) => {
|
||||
if (index === 0 || ordinal !== rows[index - 1].ordinal) {
|
||||
placementRank = index + 1;
|
||||
}
|
||||
|
||||
return {
|
||||
...rest,
|
||||
placementRank,
|
||||
pendingPlusTier: null as number | null,
|
||||
power: ordinalToSp(ordinal),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export type SeasonPopularUsersWeapon = Record<
|
||||
Tables["User"]["id"],
|
||||
MainWeaponId
|
||||
>;
|
||||
|
||||
export async function findSeasonPopularUsersWeapon(
|
||||
season: number,
|
||||
): Promise<SeasonPopularUsersWeapon> {
|
||||
const { starts, ends } = Seasons.nthToReportingDateRange(season);
|
||||
const startsTs = dateToDatabaseTimestamp(starts);
|
||||
const endsTs = dateToDatabaseTimestamp(ends);
|
||||
|
||||
// grouping the ~quarter million rows a season has by one packed integer is
|
||||
// measurably faster than by the (userId, weaponSplId) pair; the packed key
|
||||
// also sorts identically to the pair so max() tie-breaking is unchanged
|
||||
const packedUserWeapon = sql<number>`"ReportedWeapon"."userId" * ${sql.lit(
|
||||
USER_WEAPON_PACK_FACTOR,
|
||||
)} + "ReportedWeapon"."weaponSplId"`;
|
||||
|
||||
const sendouqWeapons = db
|
||||
.selectFrom("ReportedWeapon")
|
||||
.innerJoin("GroupMatch", "ReportedWeapon.groupMatchId", "GroupMatch.id")
|
||||
.select(packedUserWeapon.as("packedUserWeapon"))
|
||||
.where("GroupMatch.createdAt", ">=", startsTs)
|
||||
.where("GroupMatch.createdAt", "<=", endsTs);
|
||||
|
||||
const tournamentWeapons = db
|
||||
.selectFrom("ReportedWeapon")
|
||||
.innerJoin(
|
||||
"TournamentMatch",
|
||||
"TournamentMatch.id",
|
||||
"ReportedWeapon.tournamentMatchId",
|
||||
)
|
||||
.innerJoin(
|
||||
"TournamentStage",
|
||||
"TournamentStage.id",
|
||||
"TournamentMatch.stageId",
|
||||
)
|
||||
.innerJoin("Tournament", "Tournament.id", "TournamentStage.tournamentId")
|
||||
.select(packedUserWeapon.as("packedUserWeapon"))
|
||||
.where("Tournament.isFinalized", "=", 1)
|
||||
.where("ReportedWeapon.createdAt", ">=", startsTs)
|
||||
.where("ReportedWeapon.createdAt", "<=", endsTs);
|
||||
|
||||
const rows = await db
|
||||
.with("q1", (db) =>
|
||||
db
|
||||
.selectFrom(sendouqWeapons.unionAll(tournamentWeapons).as("merged"))
|
||||
.select(({ fn, ref }) => [
|
||||
sql<number>`${ref("merged.packedUserWeapon")} / ${sql.lit(
|
||||
USER_WEAPON_PACK_FACTOR,
|
||||
)}`.as("userId"),
|
||||
sql<MainWeaponId>`${ref("merged.packedUserWeapon")} % ${sql.lit(
|
||||
USER_WEAPON_PACK_FACTOR,
|
||||
)}`.as("weaponSplId"),
|
||||
fn.countAll<number>().as("count"),
|
||||
])
|
||||
.groupBy("merged.packedUserWeapon"),
|
||||
)
|
||||
.selectFrom("q1")
|
||||
.select(({ fn }) => [
|
||||
"q1.userId",
|
||||
"q1.weaponSplId",
|
||||
fn.max("q1.count").as("count"),
|
||||
])
|
||||
.groupBy("q1.userId")
|
||||
.having(
|
||||
({ fn }) => fn.max("q1.count"),
|
||||
">",
|
||||
MATCHES_COUNT_NEEDED_FOR_LEADERBOARD,
|
||||
)
|
||||
.execute();
|
||||
|
||||
return Object.fromEntries(rows.map((r) => [r.userId, r.weaponSplId]));
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { rankedModesShort } from "@sendou/in-game-lists/modes";
|
||||
import {
|
||||
mainWeaponIds,
|
||||
weaponCategories,
|
||||
} from "@sendou/in-game-lists/weapon-ids";
|
||||
|
||||
export const MATCHES_COUNT_NEEDED_FOR_LEADERBOARD = 7;
|
||||
export const DEFAULT_LEADERBOARD_MAX_SIZE = 500;
|
||||
export const WEAPON_LEADERBOARD_MAX_SIZE = 100;
|
||||
/** How many teams of the team leaderboard qualify, the divider being shown below the last of them. */
|
||||
export const TEAM_LEADERBOARD_QUALIFYING_COUNT = 12;
|
||||
|
||||
export const LEADERBOARD_TYPES = [
|
||||
"USER",
|
||||
"TEAM",
|
||||
"TEAM-ALL",
|
||||
...(weaponCategories.map(
|
||||
(category) => `USER-${category.name}`,
|
||||
) as `USER-${(typeof weaponCategories)[number]["name"]}`[]),
|
||||
"XP-ALL",
|
||||
...(rankedModesShort.map(
|
||||
(mode) => `XP-MODE-${mode}`,
|
||||
) as `XP-MODE-${(typeof rankedModesShort)[number]}`[]),
|
||||
...(mainWeaponIds.map(
|
||||
(id) => `XP-WEAPON-${id}`,
|
||||
) as `XP-WEAPON-${(typeof mainWeaponIds)[number]}`[]),
|
||||
] as const;
|
||||
|
||||
export type LeaderboardType = (typeof LEADERBOARD_TYPES)[number];
|
||||
@@ -0,0 +1,235 @@
|
||||
import { cachified } from "@epic-web/cachified";
|
||||
import type { MainWeaponId } from "@sendou/in-game-lists/types";
|
||||
import { weaponCategories } from "@sendou/in-game-lists/weapon-ids";
|
||||
import type { Unwrapped } from "@sendou/utils/types";
|
||||
import { cache, IN_MILLISECONDS, ttl } from "#lib/server/cache.ts";
|
||||
import * as UserRepository from "../user-page/UserRepository.server.ts";
|
||||
import { USER_LEADERBOARD_MIN_ENTRIES_FOR_LEVIATHAN } from "../mmr/mmr-constants.ts";
|
||||
import * as Seasons from "../mmr/Seasons.ts";
|
||||
import { freshUserSkills } from "../mmr/tiered.server.ts";
|
||||
import type {
|
||||
SeasonPopularUsersWeapon,
|
||||
UserSPLeaderboardItem,
|
||||
} from "./LeaderboardRepository.server.ts";
|
||||
import * as LeaderboardRepository from "./LeaderboardRepository.server.ts";
|
||||
import { DEFAULT_LEADERBOARD_MAX_SIZE } from "./leaderboards-constants.ts";
|
||||
import { seasonHasTopTen } from "./leaderboards-utils.ts";
|
||||
|
||||
export type UserLeaderboardWithAdditionsItem = Unwrapped<
|
||||
typeof cachedFullUserLeaderboard
|
||||
>;
|
||||
export async function cachedFullUserLeaderboard(season: number) {
|
||||
return cachified({
|
||||
key: `user-leaderboard-season-${season}`,
|
||||
cache,
|
||||
ttl: ttl(IN_MILLISECONDS.HALF_HOUR),
|
||||
staleWhileRevalidate: ttl(IN_MILLISECONDS.TWO_HOURS),
|
||||
async getFreshValue() {
|
||||
const leaderboard =
|
||||
await LeaderboardRepository.findUserSPLeaderboard(season);
|
||||
const withTiers = await addTiers(leaderboard, season);
|
||||
|
||||
const shouldAddPendingPlusTier =
|
||||
season === Seasons.current()?.nth &&
|
||||
leaderboard.length >= USER_LEADERBOARD_MIN_ENTRIES_FOR_LEVIATHAN;
|
||||
const withPendingPlusTiers = shouldAddPendingPlusTier
|
||||
? addPendingPlusTiers(
|
||||
withTiers,
|
||||
await UserRepository.findAllPlusServerMembers(),
|
||||
season,
|
||||
)
|
||||
: withTiers;
|
||||
|
||||
return addWeapons(
|
||||
withPendingPlusTiers,
|
||||
await LeaderboardRepository.findSeasonPopularUsersWeapon(season),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function cachedTeamLeaderboard({
|
||||
season,
|
||||
onlyOneEntryPerUser,
|
||||
}: {
|
||||
season: number;
|
||||
onlyOneEntryPerUser: boolean;
|
||||
}) {
|
||||
return cachified({
|
||||
key: teamLeaderboardCacheKey({ season, onlyOneEntryPerUser }),
|
||||
cache,
|
||||
ttl: ttl(IN_MILLISECONDS.HALF_HOUR),
|
||||
staleWhileRevalidate: ttl(IN_MILLISECONDS.TWO_HOURS),
|
||||
async getFreshValue() {
|
||||
return LeaderboardRepository.findTeamLeaderboardBySeason({
|
||||
season,
|
||||
onlyOneEntryPerUser,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Clears both variants of a season's cached team leaderboard, so that a change to which teams are skipped shows without waiting for the cache to expire. */
|
||||
export function clearCachedTeamLeaderboards(season: number) {
|
||||
for (const onlyOneEntryPerUser of [true, false]) {
|
||||
cache.delete(teamLeaderboardCacheKey({ season, onlyOneEntryPerUser }));
|
||||
}
|
||||
}
|
||||
|
||||
function teamLeaderboardCacheKey({
|
||||
season,
|
||||
onlyOneEntryPerUser,
|
||||
}: {
|
||||
season: number;
|
||||
onlyOneEntryPerUser: boolean;
|
||||
}) {
|
||||
return `team-leaderboard-season-${season}-${onlyOneEntryPerUser ? "TEAM" : "TEAM-ALL"}`;
|
||||
}
|
||||
|
||||
async function addTiers<T extends UserSPLeaderboardItem>(
|
||||
entries: T[],
|
||||
season: number,
|
||||
) {
|
||||
const tiers = await freshUserSkills(season);
|
||||
|
||||
const encounteredTiers = new Set<string>();
|
||||
return entries.map((entry, i) => {
|
||||
const tier = tiers.userSkills[entry.id].tier;
|
||||
if (i < 10 && seasonHasTopTen(season)) {
|
||||
return { ...entry, tier, firstOfTier: undefined };
|
||||
}
|
||||
|
||||
const tierKey = `${tier.name}${tier.isPlus ? "+" : ""}`;
|
||||
const tierAlreadyEncountered = encounteredTiers.has(tierKey);
|
||||
if (!tierAlreadyEncountered) {
|
||||
encounteredTiers.add(tierKey);
|
||||
}
|
||||
|
||||
return {
|
||||
...entry,
|
||||
tier,
|
||||
firstOfTier: !tierAlreadyEncountered ? tier : undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const PLUS_TIER_QUOTA = {
|
||||
"+1": 5,
|
||||
"+2": 10,
|
||||
"+3": 15,
|
||||
} as const;
|
||||
export function addPendingPlusTiers<T extends UserSPLeaderboardItem>(
|
||||
entries: T[],
|
||||
plusTiers: Array<{
|
||||
userId: number;
|
||||
plusTier: number;
|
||||
}>,
|
||||
seasonNth: number,
|
||||
) {
|
||||
const quota: { "+1": number; "+2": number; "+3": number } = {
|
||||
...PLUS_TIER_QUOTA,
|
||||
};
|
||||
|
||||
const resolveHighestPlusTierWithSpace = () => {
|
||||
if (quota["+1"] > 0) return 1;
|
||||
if (quota["+2"] > 0) return 2;
|
||||
if (quota["+3"] > 0) return 3;
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
for (const entry of entries) {
|
||||
const highestPlusTierWithSpace = resolveHighestPlusTierWithSpace();
|
||||
if (!highestPlusTierWithSpace) break;
|
||||
|
||||
const plusTier = plusTiers.find((t) => t.userId === entry.id)?.plusTier;
|
||||
|
||||
if (plusTier && plusTier <= highestPlusTierWithSpace) continue;
|
||||
if (entry.plusSkippedForSeasonNth === seasonNth) {
|
||||
entry.plusSkippedForSeasonNth = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
entry.pendingPlusTier = highestPlusTierWithSpace;
|
||||
const key = `+${highestPlusTierWithSpace}` as const;
|
||||
quota[key] -= 1;
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
function addWeapons<T extends { id: number }>(
|
||||
entries: T[],
|
||||
weapons: SeasonPopularUsersWeapon,
|
||||
) {
|
||||
return entries.map((entry) => {
|
||||
const weaponSplId = weapons[entry.id] as MainWeaponId | undefined;
|
||||
return {
|
||||
...entry,
|
||||
weaponSplId,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function filterByWeaponCategory<
|
||||
T extends { weaponSplId?: MainWeaponId },
|
||||
>(entries: Array<T>, category: (typeof weaponCategories)[number]["name"]) {
|
||||
const weaponIdsOfCategory = new Set(
|
||||
weaponCategories.find((c) => c.name === category)!.weaponIds,
|
||||
);
|
||||
|
||||
return entries.filter(
|
||||
(entry) =>
|
||||
typeof entry.weaponSplId === "number" &&
|
||||
weaponIdsOfCategory.has(entry.weaponSplId),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The entries of the full user leaderboard that are visible on the leaderboard
|
||||
* page. Cut by placement rank instead of entry count so that players tied
|
||||
* across the cutoff are all shown; {@link ownEntryPeek} covers exactly the
|
||||
* entries this leaves out.
|
||||
*/
|
||||
export function shownUserLeaderboard(
|
||||
leaderboard: UserLeaderboardWithAdditionsItem[],
|
||||
) {
|
||||
return leaderboard.filter(
|
||||
(entry) => entry.placementRank <= DEFAULT_LEADERBOARD_MAX_SIZE,
|
||||
);
|
||||
}
|
||||
|
||||
export async function ownEntryPeek({
|
||||
leaderboard,
|
||||
userId,
|
||||
season,
|
||||
}: {
|
||||
leaderboard: UserLeaderboardWithAdditionsItem[];
|
||||
userId: number;
|
||||
season: number;
|
||||
}) {
|
||||
const found = leaderboard.find(
|
||||
(entry) =>
|
||||
entry.id === userId && entry.placementRank > DEFAULT_LEADERBOARD_MAX_SIZE,
|
||||
);
|
||||
|
||||
if (!found) return null;
|
||||
|
||||
const withTier = (await addTiers([found], season))[0];
|
||||
|
||||
const { intervals } = await freshUserSkills(season);
|
||||
|
||||
const currentTierIndex = intervals.findIndex(
|
||||
(interval) =>
|
||||
interval.name === withTier.tier.name &&
|
||||
interval.isPlus === withTier.tier.isPlus,
|
||||
);
|
||||
|
||||
const nextTier =
|
||||
currentTierIndex > 0 ? intervals[currentTierIndex - 1] : undefined;
|
||||
|
||||
return {
|
||||
entry: withTier,
|
||||
nextTier,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import * as v from "valibot";
|
||||
import type { SkillTeamIdentifier } from "#lib/features/mmr/mmr-utils.ts";
|
||||
import { LEADERBOARD_TYPES } from "./leaderboards-constants.ts";
|
||||
|
||||
export const leaderboardsQuerySchema = v.object({
|
||||
type: v.picklist(LEADERBOARD_TYPES),
|
||||
season: v.nullable(v.pipe(v.number(), v.integer())),
|
||||
});
|
||||
|
||||
export type LeaderboardsQueryArgs = v.InferOutput<
|
||||
typeof leaderboardsQuerySchema
|
||||
>;
|
||||
|
||||
export const teamLeaderboardEntrySchema = v.object({
|
||||
season: v.pipe(v.number(), v.integer(), v.minValue(0)),
|
||||
identifier: v.pipe(
|
||||
v.string(),
|
||||
v.regex(/^\d+-\d+-\d+-\d+$/),
|
||||
v.transform((value) => value as SkillTeamIdentifier),
|
||||
),
|
||||
});
|
||||
40
apps/web/src/lib/features/leaderboards/leaderboards-utils.ts
Normal file
40
apps/web/src/lib/features/leaderboards/leaderboards-utils.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import playerData from "./top-ten.json" with { type: "json" };
|
||||
|
||||
interface TopTenPlayerData {
|
||||
id: number;
|
||||
name: string;
|
||||
countryCode: string;
|
||||
transforms?: { top?: number; left?: number };
|
||||
}
|
||||
|
||||
const seasons = playerData as Array<TopTenPlayerData[] | undefined>;
|
||||
|
||||
export function seasonHasTopTen(season: number) {
|
||||
return !!seasons[season];
|
||||
}
|
||||
|
||||
export function playerTopTenPlacement({
|
||||
season,
|
||||
userId,
|
||||
}: {
|
||||
season: number;
|
||||
userId: number;
|
||||
}) {
|
||||
for (const [i, player] of (seasons[season] ?? []).entries()) {
|
||||
if (player.id === userId) {
|
||||
return i + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function topTenPlayerData({
|
||||
season,
|
||||
placement,
|
||||
}: {
|
||||
season: number;
|
||||
placement: number;
|
||||
}): TopTenPlayerData | undefined {
|
||||
return seasons[season]?.[placement - 1];
|
||||
}
|
||||
121
apps/web/src/lib/features/leaderboards/leaderboards.remote.ts
Normal file
121
apps/web/src/lib/features/leaderboards/leaderboards.remote.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import type {
|
||||
MainWeaponId,
|
||||
RankedModeShort,
|
||||
} from "@sendou/in-game-lists/types";
|
||||
import type { weaponCategories } from "@sendou/in-game-lists/weapon-ids";
|
||||
import { logger } from "@sendou/utils/logger";
|
||||
import { command, query } from "$app/server";
|
||||
import { getUser, requireUser } from "#lib/features/auth/user.server.ts";
|
||||
import { requireRole } from "#lib/modules/permissions/guards.server.ts";
|
||||
import * as Seasons from "../mmr/Seasons.ts";
|
||||
import * as LeaderboardRepository from "./LeaderboardRepository.server.ts";
|
||||
import {
|
||||
cachedFullUserLeaderboard,
|
||||
cachedTeamLeaderboard,
|
||||
clearCachedTeamLeaderboards,
|
||||
filterByWeaponCategory,
|
||||
ownEntryPeek,
|
||||
shownUserLeaderboard,
|
||||
} from "./leaderboards-core.server.ts";
|
||||
import { WEAPON_LEADERBOARD_MAX_SIZE } from "./leaderboards-constants.ts";
|
||||
import {
|
||||
leaderboardsQuerySchema,
|
||||
teamLeaderboardEntrySchema,
|
||||
} from "./leaderboards-schemas.ts";
|
||||
|
||||
export const getLeaderboards = query(
|
||||
leaderboardsQuerySchema,
|
||||
async ({ type, season: seasonParam }) => {
|
||||
const user = getUser();
|
||||
|
||||
const season =
|
||||
Seasons.allStarted().find((s) => s === seasonParam) ??
|
||||
Seasons.currentOrPrevious()!.nth;
|
||||
|
||||
const fullUserLeaderboard = type.includes("USER")
|
||||
? await cachedFullUserLeaderboard(season)
|
||||
: null;
|
||||
|
||||
const userLeaderboard = fullUserLeaderboard
|
||||
? shownUserLeaderboard(fullUserLeaderboard)
|
||||
: undefined;
|
||||
|
||||
const teamLeaderboard =
|
||||
type === "TEAM" || type === "TEAM-ALL"
|
||||
? await cachedTeamLeaderboard({
|
||||
season,
|
||||
onlyOneEntryPerUser: type !== "TEAM-ALL",
|
||||
})
|
||||
: null;
|
||||
|
||||
const isWeaponLeaderboard = userLeaderboard && type !== "USER";
|
||||
|
||||
const filteredLeaderboard = isWeaponLeaderboard
|
||||
? filterByWeaponCategory(
|
||||
fullUserLeaderboard!,
|
||||
type.split("-")[1] as (typeof weaponCategories)[number]["name"],
|
||||
).slice(0, WEAPON_LEADERBOARD_MAX_SIZE)
|
||||
: userLeaderboard;
|
||||
|
||||
const showOwnEntryPeek = fullUserLeaderboard && !isWeaponLeaderboard && user;
|
||||
|
||||
const xpLeaderboard =
|
||||
type === "XP-ALL"
|
||||
? await LeaderboardRepository.findAllXPLeaderboard()
|
||||
: type.startsWith("XP-MODE")
|
||||
? await LeaderboardRepository.findModeXPLeaderboard(
|
||||
type.split("-")[2] as RankedModeShort,
|
||||
)
|
||||
: type.startsWith("XP-WEAPON")
|
||||
? await LeaderboardRepository.findWeaponXPLeaderboard(
|
||||
Number(type.split("-")[2]) as MainWeaponId,
|
||||
)
|
||||
: null;
|
||||
|
||||
return {
|
||||
userLeaderboard: filteredLeaderboard ?? userLeaderboard,
|
||||
ownEntryPeek: showOwnEntryPeek
|
||||
? await ownEntryPeek({
|
||||
leaderboard: fullUserLeaderboard,
|
||||
season,
|
||||
userId: user.id,
|
||||
})
|
||||
: null,
|
||||
teamLeaderboard,
|
||||
xpLeaderboard,
|
||||
season,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
export const skipTeam = command(
|
||||
teamLeaderboardEntrySchema,
|
||||
async ({ season, identifier }) => {
|
||||
requireRole("STAFF");
|
||||
const user = requireUser();
|
||||
|
||||
await LeaderboardRepository.insertTeamSkip({ season, identifier });
|
||||
logger.info(
|
||||
`Team leaderboard: user ${user.id} skipped team ${identifier} of season ${season}`,
|
||||
);
|
||||
|
||||
clearCachedTeamLeaderboards(season);
|
||||
void getLeaderboards({ type: "TEAM", season }).refresh();
|
||||
},
|
||||
);
|
||||
|
||||
export const unskipTeam = command(
|
||||
teamLeaderboardEntrySchema,
|
||||
async ({ season, identifier }) => {
|
||||
requireRole("STAFF");
|
||||
const user = requireUser();
|
||||
|
||||
await LeaderboardRepository.deleteTeamSkip({ season, identifier });
|
||||
logger.info(
|
||||
`Team leaderboard: user ${user.id} unskipped team ${identifier} of season ${season}`,
|
||||
);
|
||||
|
||||
clearCachedTeamLeaderboards(season);
|
||||
void getLeaderboards({ type: "TEAM", season }).refresh();
|
||||
},
|
||||
);
|
||||
278
apps/web/src/lib/features/leaderboards/top-ten.json
Normal file
278
apps/web/src/lib/features/leaderboards/top-ten.json
Normal file
@@ -0,0 +1,278 @@
|
||||
[
|
||||
[
|
||||
{
|
||||
"name": "Grey",
|
||||
"countryCode": "FR",
|
||||
"id": 123,
|
||||
"transforms": {
|
||||
"left": 25,
|
||||
"top": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Kyo",
|
||||
"id": 101,
|
||||
"countryCode": "US",
|
||||
"transforms": {
|
||||
"left": 11,
|
||||
"top": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "biscuit!",
|
||||
"id": 25,
|
||||
"countryCode": "US",
|
||||
"transforms": {
|
||||
"left": 29,
|
||||
"top": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "stalk",
|
||||
"id": 40,
|
||||
"countryCode": "US",
|
||||
"transforms": {
|
||||
"left": 32,
|
||||
"top": 6
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Xenith",
|
||||
"id": 11517,
|
||||
"countryCode": "US",
|
||||
"transforms": {
|
||||
"left": 19,
|
||||
"top": 6
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Kiver",
|
||||
"id": 35,
|
||||
"countryCode": "FR",
|
||||
"transforms": {
|
||||
"left": 34,
|
||||
"top": 6
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Oscar",
|
||||
"id": 1343,
|
||||
"countryCode": "MX",
|
||||
"transforms": {
|
||||
"left": 18,
|
||||
"top": 6
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Home",
|
||||
"id": 201,
|
||||
"countryCode": "ID",
|
||||
"transforms": {
|
||||
"left": 34,
|
||||
"top": 8
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Gos",
|
||||
"id": 279,
|
||||
"countryCode": "US",
|
||||
"transforms": {
|
||||
"left": 30,
|
||||
"top": 6
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Elis",
|
||||
"id": 141,
|
||||
"countryCode": "GB-WLS",
|
||||
"transforms": {
|
||||
"left": 27,
|
||||
"top": 8
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"name": "Jared",
|
||||
"countryCode": "US",
|
||||
"id": 305,
|
||||
"transforms": {
|
||||
"left": 25,
|
||||
"top": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "bran",
|
||||
"id": 147,
|
||||
"countryCode": "US",
|
||||
"transforms": {
|
||||
"left": 32,
|
||||
"top": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Gos",
|
||||
"id": 279,
|
||||
"countryCode": "US",
|
||||
"transforms": {
|
||||
"left": 26,
|
||||
"top": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "stalk",
|
||||
"id": 40,
|
||||
"countryCode": "US",
|
||||
"transforms": {
|
||||
"left": 32,
|
||||
"top": 6
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Volty",
|
||||
"id": 32,
|
||||
"countryCode": "FR",
|
||||
"transforms": {
|
||||
"left": 34,
|
||||
"top": 4
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Grey",
|
||||
"id": 123,
|
||||
"countryCode": "FR",
|
||||
"transforms": {
|
||||
"left": 31,
|
||||
"top": 6
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "bagel",
|
||||
"id": 1343,
|
||||
"countryCode": "US",
|
||||
"transforms": {
|
||||
"left": 11,
|
||||
"top": 6
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Burstie",
|
||||
"id": 3128,
|
||||
"countryCode": "CA",
|
||||
"transforms": {
|
||||
"left": 31,
|
||||
"top": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Zero",
|
||||
"id": 257,
|
||||
"countryCode": "US",
|
||||
"transforms": {
|
||||
"left": 28,
|
||||
"top": 7
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": ".q",
|
||||
"id": 164,
|
||||
"countryCode": "US",
|
||||
"transforms": {
|
||||
"left": 17,
|
||||
"top": 4
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"name": "Jared",
|
||||
"countryCode": "US",
|
||||
"id": 305,
|
||||
"transforms": {
|
||||
"left": 25,
|
||||
"top": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Kyo",
|
||||
"id": 101,
|
||||
"countryCode": "US",
|
||||
"transforms": {
|
||||
"left": 16,
|
||||
"top": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "y0shell",
|
||||
"id": 439,
|
||||
"countryCode": "GB",
|
||||
"transforms": {
|
||||
"left": 1,
|
||||
"top": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "PeLu",
|
||||
"id": 62,
|
||||
"countryCode": "PA",
|
||||
"transforms": {
|
||||
"left": -3,
|
||||
"top": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "bran",
|
||||
"id": 147,
|
||||
"countryCode": "US",
|
||||
"transforms": {
|
||||
"left": 18,
|
||||
"top": 4
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Half",
|
||||
"id": 451,
|
||||
"countryCode": "US",
|
||||
"transforms": {
|
||||
"left": 13,
|
||||
"top": 4
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Scar",
|
||||
"id": 1043,
|
||||
"countryCode": "FR",
|
||||
"transforms": {
|
||||
"left": 28,
|
||||
"top": 4
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Xenith",
|
||||
"id": 11517,
|
||||
"countryCode": "US",
|
||||
"transforms": {
|
||||
"left": 17,
|
||||
"top": 4
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": ".q",
|
||||
"id": 164,
|
||||
"countryCode": "US",
|
||||
"transforms": {
|
||||
"left": 17,
|
||||
"top": 4
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Volty",
|
||||
"id": 32,
|
||||
"countryCode": "FR",
|
||||
"transforms": {
|
||||
"left": 34,
|
||||
"top": 4
|
||||
}
|
||||
}
|
||||
]
|
||||
]
|
||||
238
apps/web/src/lib/features/mmr/Seasons.ts
Normal file
238
apps/web/src/lib/features/mmr/Seasons.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
import { addHours } from "date-fns";
|
||||
import { Config } from "#lib/config.ts";
|
||||
import { IS_E2E_TEST_RUN } from "#lib/utils/e2e.ts";
|
||||
|
||||
/**
|
||||
* How long past a season's end its matches can still be resolved. A match can be
|
||||
* created up to the very end of the season and the stale match routine resolves
|
||||
* it 24h after creation, with up to an hour of scheduling lag on top.
|
||||
*/
|
||||
const REPORTING_GRACE_HOURS = 25;
|
||||
|
||||
/**
|
||||
* List of seasons with their respective start and end dates.
|
||||
*
|
||||
* Each season is represented as an object with the following properties:
|
||||
* - `nth`: The sequential number of the season (starting from 0).
|
||||
* - `starts`: The start date of the season as a `Date` object.
|
||||
* - `ends`: The end date of the season as a `Date` object.
|
||||
*
|
||||
* Note: The value is conditionally set based on the environment. In development mode,
|
||||
* the end date of the first season is set to a later date for testing purposes (ensures a season is always open).
|
||||
*/
|
||||
export const list =
|
||||
// when we do pnpm run setup NODE_ENV is not set -> use test seasons
|
||||
!process.env.NODE_ENV ||
|
||||
IS_E2E_TEST_RUN ||
|
||||
(process.env.NODE_ENV === "development" && !Config.prodMode)
|
||||
? ([
|
||||
{
|
||||
nth: 0,
|
||||
starts: new Date("2023-08-14T17:00:00.000Z"),
|
||||
ends: new Date("2023-08-27T20:59:59.999Z"),
|
||||
},
|
||||
{
|
||||
nth: 1,
|
||||
starts: new Date("2023-09-11T17:00:00.000Z"),
|
||||
ends: new Date("2030-11-17T20:59:59.999Z"),
|
||||
},
|
||||
] as const)
|
||||
: ([
|
||||
{
|
||||
nth: 0,
|
||||
starts: new Date("2023-08-14T17:00:00.000Z"),
|
||||
ends: new Date("2023-08-27T20:59:59.999Z"),
|
||||
},
|
||||
{
|
||||
nth: 1,
|
||||
starts: new Date("2023-09-11T17:00:00.000Z"),
|
||||
ends: new Date("2023-11-19T20:59:59.999Z"),
|
||||
},
|
||||
{
|
||||
nth: 2,
|
||||
starts: new Date("2023-12-04T17:00:00.000Z"),
|
||||
ends: new Date("2024-02-18T20:59:59.999Z"),
|
||||
},
|
||||
{
|
||||
nth: 3,
|
||||
starts: new Date("2024-03-04T17:00:00.000Z"),
|
||||
ends: new Date("2024-05-19T20:59:59.999Z"),
|
||||
},
|
||||
{
|
||||
nth: 4,
|
||||
starts: new Date("2024-06-03T17:00:00.000Z"),
|
||||
ends: new Date("2024-08-18T20:59:59.999Z"),
|
||||
},
|
||||
{
|
||||
nth: 5,
|
||||
starts: new Date("2024-09-02T17:00:00.000Z"),
|
||||
ends: new Date("2024-11-17T22:59:59.999Z"),
|
||||
},
|
||||
{
|
||||
nth: 6,
|
||||
starts: new Date("2024-12-02T18:00:00.000Z"),
|
||||
ends: new Date("2025-02-16T21:59:59.999Z"),
|
||||
},
|
||||
{
|
||||
nth: 7,
|
||||
starts: new Date("2025-03-07T18:00:00.000Z"),
|
||||
ends: new Date("2025-05-25T21:59:59.999Z"),
|
||||
},
|
||||
{
|
||||
nth: 8,
|
||||
starts: new Date("2025-06-16T18:00:00.000Z"),
|
||||
ends: new Date("2025-08-24T22:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
nth: 9,
|
||||
starts: new Date("2025-09-08T17:00:00.000Z"),
|
||||
ends: new Date("2025-11-23T22:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
nth: 10,
|
||||
starts: new Date("2025-12-08T17:00:00.000Z"),
|
||||
ends: new Date("2026-02-22T22:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
nth: 11,
|
||||
starts: new Date("2026-03-09T17:00:00.000Z"),
|
||||
ends: new Date("2026-05-17T22:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
nth: 12,
|
||||
starts: new Date("2026-06-01T17:00:00.000Z"),
|
||||
ends: new Date("2026-08-23T22:00:00.000Z"),
|
||||
},
|
||||
] as const);
|
||||
|
||||
/**
|
||||
* Represents an individual item from the `Seasons.list` array.
|
||||
*/
|
||||
export type ListItem = (typeof list)[number];
|
||||
|
||||
/**
|
||||
* Determines the current season relative to the provided date (defaults to now), or falls back to the previous season if no current season is found.
|
||||
*
|
||||
* @returns The current season if it exists; otherwise, the previous season.
|
||||
*/
|
||||
export function currentOrPrevious(date?: Date): ListItem | null {
|
||||
const _currentSeason = current(date);
|
||||
if (_currentSeason) return _currentSeason;
|
||||
|
||||
return previous(date);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the previous season relative to the provided date (defaults to now).
|
||||
*
|
||||
* @returns The previous season if one exists.
|
||||
*/
|
||||
export function previous(date = new Date()): ListItem | null {
|
||||
let latestPreviousSeason: ListItem | null = null;
|
||||
for (const season of list) {
|
||||
if (date > season.ends) latestPreviousSeason = season;
|
||||
}
|
||||
|
||||
return latestPreviousSeason;
|
||||
}
|
||||
|
||||
let seasonEndedOverride = false;
|
||||
|
||||
/**
|
||||
* Tests only: makes `current()` resolve to `null` as if every season had ended, so
|
||||
* tests can cover the season boundary without one having to actually pass. Only
|
||||
* "now" is affected, an explicitly given date still resolves to its real season.
|
||||
*/
|
||||
export function DANGEROUS_setSeasonEndedOverride(seasonEnded: boolean) {
|
||||
seasonEndedOverride = seasonEnded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the current ongoing season relative to the provided date (defaults to now).
|
||||
*
|
||||
* @returns The current season if one exists.
|
||||
*/
|
||||
export function current(date?: Date): ListItem | null {
|
||||
if (seasonEndedOverride && !date) return null;
|
||||
|
||||
const resolvedDate = date ?? new Date();
|
||||
|
||||
for (const season of list) {
|
||||
if (resolvedDate >= season.starts && resolvedDate <= season.ends) {
|
||||
return season;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the next upcoming season relative to the provided date (defaults to now).
|
||||
*
|
||||
* @returns The next season if one exists.
|
||||
*/
|
||||
export function next(date = new Date()): ListItem | null {
|
||||
for (const season of list) {
|
||||
if (date < season.starts) return season;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the date range for a specific season based on its number.
|
||||
*
|
||||
* @returns An object containing the start and end dates of the specified season.
|
||||
* @throws {Error} If the season does not exist.
|
||||
*/
|
||||
export function nthToDateRange(nth: number) {
|
||||
const seasonObject = list[nth];
|
||||
if (!seasonObject) {
|
||||
throw new Error(`Season ${nth} not found`);
|
||||
}
|
||||
|
||||
return {
|
||||
starts: seasonObject.starts,
|
||||
ends: seasonObject.ends,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the date range within which a season's results can land, i.e. the season
|
||||
* itself plus the grace period matches started at the buzzer are resolved within.
|
||||
*
|
||||
* @returns An object containing the start date of the specified season and the end of its reporting grace period.
|
||||
* @throws {Error} If the season does not exist.
|
||||
*/
|
||||
export function nthToReportingDateRange(nth: number) {
|
||||
const { starts, ends } = nthToDateRange(nth);
|
||||
|
||||
return {
|
||||
starts,
|
||||
ends: addHours(ends, REPORTING_GRACE_HOURS),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a list of season numbers that have started based on the provided date (defaults to now).
|
||||
*
|
||||
* @returns An array of season numbers in descending order (newest first). If no seasons have started, returns an array containing only `[0]`.
|
||||
*/
|
||||
export function allStarted(date = new Date()) {
|
||||
const startedSeasons = list.filter((s) => date >= s.starts);
|
||||
if (startedSeasons.length > 0) {
|
||||
return startedSeasons.map((s) => s.nth).reverse();
|
||||
}
|
||||
|
||||
return [0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a list of season numbers that have finished based on the provided date (defaults to now).
|
||||
*
|
||||
* @returns An array of season numbers in descending order. If no seasons have finished, returns an empty array.
|
||||
*/
|
||||
export function allFinished(date = new Date()) {
|
||||
const finishedSeasons = list.filter((s) => date > s.ends);
|
||||
return finishedSeasons.map((s) => s.nth).reverse();
|
||||
}
|
||||
11
apps/web/src/lib/features/mmr/SkillRepository.server.ts
Normal file
11
apps/web/src/lib/features/mmr/SkillRepository.server.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { db } from "#lib/server/db/sql.ts";
|
||||
import { latestSkillPerSeason } from "#lib/server/kysely.ts";
|
||||
|
||||
/** Every user's latest skill row of the season, ordered by ordinal descending. */
|
||||
export async function findOrderedUserOrdinalsBySeason(season: number) {
|
||||
return db
|
||||
.selectFrom(latestSkillPerSeason({ season, by: "userId" }).as("latest"))
|
||||
.select(["ordinal", "matchesCount", "userId"])
|
||||
.orderBy("ordinal", "desc")
|
||||
.execute();
|
||||
}
|
||||
26
apps/web/src/lib/features/mmr/mmr-constants.ts
Normal file
26
apps/web/src/lib/features/mmr/mmr-constants.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export const TIERS = [
|
||||
{ name: "LEVIATHAN", percentile: 5 },
|
||||
{ name: "DIAMOND", percentile: 10 },
|
||||
{ name: "PLATINUM", percentile: 15 },
|
||||
{ name: "GOLD", percentile: 17.5 },
|
||||
{ name: "SILVER", percentile: 20 },
|
||||
{ name: "BRONZE", percentile: 17.5 },
|
||||
{ name: "IRON", percentile: 15 },
|
||||
] as const;
|
||||
|
||||
export const TIERS_BEFORE_LEVIATHAN = [
|
||||
{ name: "DIAMOND", percentile: 15 },
|
||||
{ name: "PLATINUM", percentile: 15 },
|
||||
{ name: "GOLD", percentile: 17.5 },
|
||||
{ name: "SILVER", percentile: 20 },
|
||||
{ name: "BRONZE", percentile: 17.5 },
|
||||
{ name: "IRON", percentile: 15 },
|
||||
] as const;
|
||||
|
||||
export type TierName = (typeof TIERS)[number]["name"];
|
||||
|
||||
/** `Skill.season` of the dummy skill inserted to close a canceled match without a skill change. */
|
||||
export const CANCELED_MATCH_SEASON = -1;
|
||||
|
||||
export const USER_LEADERBOARD_MIN_ENTRIES_FOR_LEVIATHAN = 200;
|
||||
export const TEAM_LEADERBOARD_MIN_ENTRIES_FOR_LEVIATHAN = 100;
|
||||
38
apps/web/src/lib/features/mmr/mmr-utils.ts
Normal file
38
apps/web/src/lib/features/mmr/mmr-utils.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import invariant from "@sendou/utils/invariant";
|
||||
import { TIERS, type TierName } from "./mmr-constants.ts";
|
||||
|
||||
/** Converts an openskill ordinal to the user-facing SP value (two decimals). */
|
||||
export function ordinalToSp(ordinal: number) {
|
||||
return toTwoDecimals(ordinal * 15 + 1000);
|
||||
}
|
||||
|
||||
/** Converts an openskill ordinal to the user-facing SP value rounded to an integer. */
|
||||
export function ordinalToRoundedSp(ordinal: number) {
|
||||
return Math.round(ordinalToSp(ordinal));
|
||||
}
|
||||
|
||||
function toTwoDecimals(value: number) {
|
||||
return Number(value.toFixed(2));
|
||||
}
|
||||
|
||||
/** The four user ids of a full team, ascending and joined by `-`. Identifies a team across matches. */
|
||||
export type SkillTeamIdentifier = `${number}-${number}-${number}-${number}`;
|
||||
|
||||
/** Builds the {@link SkillTeamIdentifier} of the given four user ids. */
|
||||
export function userIdsToIdentifier(userIds: number[]): SkillTeamIdentifier {
|
||||
invariant(userIds.length === 4, "userIds for identifier must be length 4");
|
||||
return [...userIds].sort((a, b) => a - b).join("-") as SkillTeamIdentifier;
|
||||
}
|
||||
|
||||
/** Splits a {@link SkillTeamIdentifier} back into its user ids. */
|
||||
export function identifierToUserIds(identifier: SkillTeamIdentifier) {
|
||||
return identifier.split("-").map(Number);
|
||||
}
|
||||
|
||||
/** Compares two tiers by rank; negative when `tier1` is higher. */
|
||||
export function compareTwoTiers(tier1: TierName, tier2: TierName) {
|
||||
return (
|
||||
TIERS.findIndex(({ name }) => name === tier1) -
|
||||
TIERS.findIndex(({ name }) => name === tier2)
|
||||
);
|
||||
}
|
||||
148
apps/web/src/lib/features/mmr/tiered.server.ts
Normal file
148
apps/web/src/lib/features/mmr/tiered.server.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { cachified } from "@epic-web/cachified";
|
||||
import { cache } from "#lib/server/cache.ts";
|
||||
import type { Tables } from "#lib/server/db/tables.ts";
|
||||
import { MATCHES_COUNT_NEEDED_FOR_LEADERBOARD } from "../leaderboards/leaderboards-constants.ts";
|
||||
import {
|
||||
TEAM_LEADERBOARD_MIN_ENTRIES_FOR_LEVIATHAN,
|
||||
TIERS,
|
||||
TIERS_BEFORE_LEVIATHAN,
|
||||
type TierName,
|
||||
USER_LEADERBOARD_MIN_ENTRIES_FOR_LEVIATHAN,
|
||||
} from "./mmr-constants.ts";
|
||||
import * as SkillRepository from "./SkillRepository.server.ts";
|
||||
|
||||
const USER_SKILLS_CACHE_KEY = "user-skills";
|
||||
|
||||
export interface TieredSkill {
|
||||
ordinal: number;
|
||||
tier: {
|
||||
name: TierName;
|
||||
isPlus: boolean;
|
||||
};
|
||||
approximate: boolean;
|
||||
}
|
||||
|
||||
export async function freshUserSkills(season: number): Promise<{
|
||||
userSkills: Record<string, TieredSkill>;
|
||||
intervals: SkillTierInterval[];
|
||||
isAccurateTiers: boolean;
|
||||
}> {
|
||||
const points = await SkillRepository.findOrderedUserOrdinalsBySeason(season);
|
||||
|
||||
const { intervals, isAccurateTiers } = skillTierIntervals(points, "user");
|
||||
|
||||
return {
|
||||
intervals,
|
||||
isAccurateTiers,
|
||||
userSkills: Object.fromEntries(
|
||||
points.map((p) => {
|
||||
const { name, isPlus } = intervals.find(
|
||||
(t) => t.neededOrdinal! <= p.ordinal,
|
||||
) ?? { name: "IRON", isPlus: false };
|
||||
return [
|
||||
p.userId as number,
|
||||
{
|
||||
ordinal: p.ordinal,
|
||||
tier: { name, isPlus },
|
||||
approximate: p.matchesCount < MATCHES_COUNT_NEEDED_FOR_LEADERBOARD,
|
||||
},
|
||||
];
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const userSkillsCacheKey = (season: number) =>
|
||||
`${USER_SKILLS_CACHE_KEY}-${season}`;
|
||||
|
||||
export function userSkills(season: number, { forceFresh = false } = {}) {
|
||||
return cachified({
|
||||
key: userSkillsCacheKey(season),
|
||||
cache,
|
||||
forceFresh,
|
||||
// no ttl: a season's tiers only go stale when a match of it is played,
|
||||
// and those code paths refresh this themselves
|
||||
getFreshValue: () => freshUserSkills(season),
|
||||
});
|
||||
}
|
||||
|
||||
export async function refreshUserSkills(season: number) {
|
||||
await userSkills(season, { forceFresh: true });
|
||||
}
|
||||
|
||||
export type SkillTierInterval = ReturnType<
|
||||
typeof skillTierIntervals
|
||||
>["intervals"][number];
|
||||
|
||||
function skillTierIntervals(
|
||||
orderedPoints: Array<Pick<Tables["Skill"], "ordinal" | "matchesCount">>,
|
||||
type: "user" | "team",
|
||||
) {
|
||||
const LEADERBOARD_MIN_ENTRIES_FOR_LEVIATHAN =
|
||||
type === "user"
|
||||
? USER_LEADERBOARD_MIN_ENTRIES_FOR_LEVIATHAN
|
||||
: TEAM_LEADERBOARD_MIN_ENTRIES_FOR_LEVIATHAN;
|
||||
let points = orderedPoints.filter(
|
||||
(p) => p.matchesCount >= MATCHES_COUNT_NEEDED_FOR_LEADERBOARD,
|
||||
);
|
||||
const hasLeviathan = points.length >= LEADERBOARD_MIN_ENTRIES_FOR_LEVIATHAN;
|
||||
if (!hasLeviathan) {
|
||||
// using all entries, no matter if they have enough to be on the leaderboard
|
||||
// to create the tiers
|
||||
points = orderedPoints;
|
||||
}
|
||||
|
||||
const totalPlayers = points.length;
|
||||
|
||||
const tiersToUse = hasLeviathan ? TIERS : TIERS_BEFORE_LEVIATHAN;
|
||||
|
||||
const allTiers = tiersToUse.flatMap((tier) =>
|
||||
[true, false].map((isPlus) => ({
|
||||
...tier,
|
||||
isPlus,
|
||||
percentile: tier.percentile / 2,
|
||||
})),
|
||||
);
|
||||
const result: Array<{
|
||||
name: TierName;
|
||||
isPlus: boolean;
|
||||
/** inclusive */
|
||||
neededOrdinal?: number;
|
||||
}> = [
|
||||
{
|
||||
name: tiersToUse[0].name,
|
||||
isPlus: true,
|
||||
},
|
||||
];
|
||||
|
||||
if (points.length === 1) {
|
||||
result[0].neededOrdinal = points[0].ordinal;
|
||||
return { intervals: result, isAccurateTiers: hasLeviathan };
|
||||
}
|
||||
|
||||
let previousPercentiles = 0;
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
const currentTier = allTiers[result.length - 1];
|
||||
const currentPercentile = ((i + 1) / totalPlayers) * 100;
|
||||
|
||||
// "isPlus" is top 50% of that tier
|
||||
const accPercentile = previousPercentiles + currentTier.percentile;
|
||||
|
||||
if (currentPercentile > accPercentile) {
|
||||
// with few enough players the very first one already exceeds the top
|
||||
// tier's share, and there is nobody below them to close the tier at
|
||||
const previousPoints = points[i - 1] ?? points[i];
|
||||
const thisTier = result[result.length - 1];
|
||||
thisTier.neededOrdinal = previousPoints.ordinal;
|
||||
|
||||
const newTier = allTiers[result.length];
|
||||
result.push({
|
||||
name: newTier.name,
|
||||
isPlus: newTier.isPlus,
|
||||
});
|
||||
previousPercentiles = accPercentile;
|
||||
}
|
||||
}
|
||||
|
||||
return { intervals: result, isAccurateTiers: hasLeviathan };
|
||||
}
|
||||
36
apps/web/src/lib/features/theme/theme-constants.ts
Normal file
36
apps/web/src/lib/features/theme/theme-constants.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/** CSS variables a supporter can override with their own custom theme. */
|
||||
export const CUSTOM_THEME_VARS = [
|
||||
"--_base-h",
|
||||
"--_base-c-0",
|
||||
"--_base-c-1",
|
||||
"--_base-c-2",
|
||||
"--_base-c-3",
|
||||
"--_base-c-4",
|
||||
"--_base-c-5",
|
||||
"--_base-c-6",
|
||||
"--_base-c-7",
|
||||
"--_acc-h",
|
||||
"--_acc-c-0",
|
||||
"--_acc-c-1",
|
||||
"--_acc-c-2",
|
||||
"--_acc-c-3",
|
||||
"--_acc-c-4",
|
||||
"--_acc-c-5",
|
||||
"--_second-h",
|
||||
"--_second-c-0",
|
||||
"--_second-c-1",
|
||||
"--_second-c-2",
|
||||
"--_second-c-3",
|
||||
"--_second-c-4",
|
||||
"--_second-c-5",
|
||||
"--_chat-h",
|
||||
"--_radius-box",
|
||||
"--_radius-field",
|
||||
"--_radius-selector",
|
||||
"--_border-width",
|
||||
"--_size-field",
|
||||
"--_size-selector",
|
||||
"--_size-spacing",
|
||||
] as const;
|
||||
|
||||
export type CustomThemeVar = (typeof CUSTOM_THEME_VARS)[number];
|
||||
5
apps/web/src/lib/features/theme/theme-types.ts
Normal file
5
apps/web/src/lib/features/theme/theme-types.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { CustomThemeVar } from "./theme-constants.ts";
|
||||
|
||||
export type CustomTheme = Omit<Record<CustomThemeVar, number>, "--_chat-h"> & {
|
||||
"--_chat-h": number | null;
|
||||
};
|
||||
50
apps/web/src/lib/features/theme/theme.server.ts
Normal file
50
apps/web/src/lib/features/theme/theme.server.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { Cookies } from "@sveltejs/kit";
|
||||
import {
|
||||
readSessionCookie,
|
||||
writeSessionCookie,
|
||||
} from "#lib/features/auth/session.server.ts";
|
||||
import { ServerConfig } from "#lib/server/config.ts";
|
||||
import { IS_E2E_TEST_RUN } from "#lib/utils/e2e.ts";
|
||||
|
||||
/**
|
||||
* The theme cookie, byte-compatible with the React app's signed `theme`
|
||||
* session cookie so user theme choices survive the cutover.
|
||||
*/
|
||||
|
||||
export const THEME_COOKIE_NAME = "theme";
|
||||
|
||||
const TEN_YEARS_IN_SECONDS = 315_360_000;
|
||||
|
||||
export type Theme = "dark" | "light";
|
||||
|
||||
export function isTheme(value: unknown): value is Theme {
|
||||
return value === "dark" || value === "light";
|
||||
}
|
||||
|
||||
const themeCookieOptions = {
|
||||
secure: ServerConfig.isProduction && !IS_E2E_TEST_RUN,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
maxAge: TEN_YEARS_IN_SECONDS,
|
||||
} as const;
|
||||
|
||||
/** The user's explicitly chosen theme, or `null` when following the system theme. */
|
||||
export function getTheme(cookies: Cookies): Theme | null {
|
||||
const session = readSessionCookie(cookies.get(THEME_COOKIE_NAME));
|
||||
const themeValue = session?.theme;
|
||||
|
||||
return isTheme(themeValue) ? themeValue : null;
|
||||
}
|
||||
|
||||
export function setTheme(cookies: Cookies, theme: Theme) {
|
||||
cookies.set(
|
||||
THEME_COOKIE_NAME,
|
||||
writeSessionCookie({ theme }),
|
||||
themeCookieOptions,
|
||||
);
|
||||
}
|
||||
|
||||
export function clearTheme(cookies: Cookies) {
|
||||
cookies.delete(THEME_COOKIE_NAME, { path: "/" });
|
||||
}
|
||||
79
apps/web/src/lib/features/user-page/UserRepository.server.ts
Normal file
79
apps/web/src/lib/features/user-page/UserRepository.server.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import * as R from "remeda";
|
||||
import { userRoles } from "#lib/modules/permissions/mapper.server.ts";
|
||||
import { db } from "#lib/server/db/sql.ts";
|
||||
import { commonUserSelect } from "#lib/server/kysely.ts";
|
||||
|
||||
/**
|
||||
* The lean user representation used for the authenticated user: common fields
|
||||
* plus the columns the app shell and permission checks need.
|
||||
*/
|
||||
export async function findLeanById(id: number) {
|
||||
const user = await db
|
||||
.selectFrom("User")
|
||||
.leftJoin("PlusTier", "PlusTier.userId", "User.id")
|
||||
.where("User.id", "=", id)
|
||||
.select(({ eb }) => [
|
||||
...commonUserSelect(eb),
|
||||
"User.createdAt",
|
||||
"User.customTheme",
|
||||
"User.isArtist",
|
||||
"User.isVideoAdder",
|
||||
"User.isTournamentOrganizer",
|
||||
"User.isApiAccesser",
|
||||
"User.patronTier",
|
||||
"User.languages",
|
||||
"User.inGameName",
|
||||
"User.preferences",
|
||||
"PlusTier.tier as plusTier",
|
||||
eb
|
||||
.selectFrom("UserFriendCode")
|
||||
.select("UserFriendCode.friendCode")
|
||||
.where("UserFriendCode.userId", "=", id)
|
||||
.orderBy("UserFriendCode.createdAt", "desc")
|
||||
.limit(1)
|
||||
.as("friendCode"),
|
||||
])
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!user) return;
|
||||
|
||||
return {
|
||||
...R.omit(user, [
|
||||
"isArtist",
|
||||
"isVideoAdder",
|
||||
"isTournamentOrganizer",
|
||||
"isApiAccesser",
|
||||
]),
|
||||
roles: userRoles(user),
|
||||
};
|
||||
}
|
||||
|
||||
/** Every user in the plus server with their tier. */
|
||||
export function findAllPlusServerMembers() {
|
||||
return db
|
||||
.selectFrom("User")
|
||||
.innerJoin("PlusTier", "PlusTier.userId", "User.id")
|
||||
.select([
|
||||
"User.id as userId",
|
||||
"User.discordId",
|
||||
"PlusTier.tier as plusTier",
|
||||
])
|
||||
.execute();
|
||||
}
|
||||
|
||||
/** Users that currently have an active ban, keyed by user id. */
|
||||
export async function findAllBannedUsers() {
|
||||
const rows = await db
|
||||
.selectFrom("User")
|
||||
.select(["User.id as userId", "User.banned", "User.bannedReason"])
|
||||
.where("User.banned", "!=", 0)
|
||||
.execute();
|
||||
|
||||
const result: Map<number, (typeof rows)[number]> = new Map();
|
||||
|
||||
for (const row of rows) {
|
||||
result.set(row.userId, row);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
41
apps/web/src/lib/modules/i18n/languages.ts
Normal file
41
apps/web/src/lib/modules/i18n/languages.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
export const DEFAULT_LANGUAGE = "en";
|
||||
|
||||
export const languages = [
|
||||
{ code: "da", name: "Dansk" },
|
||||
{ code: "de", name: "Deutsch" },
|
||||
{ code: "en", name: "English" },
|
||||
{ code: "es-ES", name: "Español (España)" },
|
||||
{ code: "es-US", name: "Español (Latino)" },
|
||||
{ code: "fr-CA", name: "Français (NoA)" },
|
||||
{ code: "fr-EU", name: "Français (NoE)" },
|
||||
{ code: "he", name: "עברית" },
|
||||
{ code: "it", name: "Italiano" },
|
||||
{ code: "ja", name: "日本語" },
|
||||
{ code: "ko", name: "한국어" },
|
||||
{ code: "pl", name: "Polski" },
|
||||
{ code: "pt-BR", name: "Português do Brasil" },
|
||||
{ code: "nl", name: "Nederlands" },
|
||||
{ code: "ru", name: "Русский" },
|
||||
{ code: "zh", name: "中文" },
|
||||
] as const;
|
||||
|
||||
export const languagesUnified = [
|
||||
{ code: "da", name: "Dansk" },
|
||||
{ code: "de", name: "Deutsch" },
|
||||
{ code: "en", name: "English" },
|
||||
{ code: "es", name: "Español" },
|
||||
{ code: "fr", name: "Français" },
|
||||
{ code: "he", name: "עברית" },
|
||||
{ code: "it", name: "Italiano" },
|
||||
{ code: "ja", name: "日本語" },
|
||||
{ code: "ko", name: "한국어" },
|
||||
{ code: "pl", name: "Polski" },
|
||||
{ code: "pt", name: "Português" },
|
||||
{ code: "nl", name: "Nederlands" },
|
||||
{ code: "ru", name: "Русский" },
|
||||
{ code: "zh", name: "中文(普通话)" },
|
||||
{ code: "yue", name: "中文(廣東話)" },
|
||||
] as const;
|
||||
|
||||
export type LanguageCode = (typeof languages)[number]["code"];
|
||||
export type UnifiedLanguageCode = (typeof languagesUnified)[number]["code"];
|
||||
12
apps/web/src/lib/modules/permissions/guards.server.ts
Normal file
12
apps/web/src/lib/modules/permissions/guards.server.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { error } from "@sveltejs/kit";
|
||||
import { getUser } from "#lib/features/auth/user.server.ts";
|
||||
import type { Role } from "./types.ts";
|
||||
|
||||
/** Responds with 403 unless the logged in user has the given role. */
|
||||
export function requireRole(role: Role) {
|
||||
const user = getUser();
|
||||
|
||||
if (!user?.roles.includes(role)) {
|
||||
error(403, "Forbidden");
|
||||
}
|
||||
}
|
||||
78
apps/web/src/lib/modules/permissions/mapper.server.ts
Normal file
78
apps/web/src/lib/modules/permissions/mapper.server.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { userDiscordIdIsAged } from "#lib/utils/users.ts";
|
||||
import type { Role } from "./types.ts";
|
||||
import {
|
||||
isAdmin,
|
||||
isDev,
|
||||
isQa,
|
||||
isScannerTester,
|
||||
isStaff,
|
||||
isSupporter,
|
||||
} from "./utils.ts";
|
||||
|
||||
/** Derives the user's global roles from their user row (joined with `PlusTier`). */
|
||||
export function userRoles(user: {
|
||||
id: number;
|
||||
discordId: string;
|
||||
plusTier: number | null;
|
||||
isArtist: number;
|
||||
isTournamentOrganizer: number;
|
||||
isVideoAdder: number;
|
||||
isApiAccesser: number;
|
||||
patronTier: number | null;
|
||||
}) {
|
||||
const result: Array<Role> = [];
|
||||
|
||||
if (isAdmin(user)) {
|
||||
result.push("ADMIN");
|
||||
}
|
||||
|
||||
if (isStaff(user) || isAdmin(user)) {
|
||||
result.push("STAFF");
|
||||
}
|
||||
|
||||
if (isDev(user)) {
|
||||
result.push("DEV");
|
||||
}
|
||||
|
||||
if (isQa(user)) {
|
||||
result.push("QA");
|
||||
}
|
||||
|
||||
if (isScannerTester(user)) {
|
||||
result.push("SCANNER_TESTER");
|
||||
}
|
||||
|
||||
if (typeof user.patronTier === "number") {
|
||||
result.push("MINOR_SUPPORT");
|
||||
}
|
||||
|
||||
if (isSupporter(user)) {
|
||||
result.push("SUPPORTER");
|
||||
}
|
||||
|
||||
if (typeof user.plusTier === "number") {
|
||||
result.push("PLUS_SERVER_MEMBER");
|
||||
}
|
||||
|
||||
if (user.isArtist) {
|
||||
result.push("ARTIST");
|
||||
}
|
||||
|
||||
if (user.isVideoAdder) {
|
||||
result.push("VIDEO_ADDER");
|
||||
}
|
||||
|
||||
if (user.isTournamentOrganizer || isSupporter(user)) {
|
||||
result.push("TOURNAMENT_ADDER");
|
||||
}
|
||||
|
||||
if (userDiscordIdIsAged(user) || isSupporter(user)) {
|
||||
result.push("CALENDAR_EVENT_ADDER");
|
||||
}
|
||||
|
||||
if (user.isTournamentOrganizer || user.isApiAccesser || isSupporter(user)) {
|
||||
result.push("API_ACCESSER");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
21
apps/web/src/lib/modules/permissions/types.ts
Normal file
21
apps/web/src/lib/modules/permissions/types.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export type Permissions = Record<string, number[]>;
|
||||
|
||||
export type EntityWithPermissions = {
|
||||
permissions: Permissions;
|
||||
};
|
||||
|
||||
/** Represents a "global" role with permissions associated to it */
|
||||
export type Role =
|
||||
| "ADMIN"
|
||||
| "STAFF"
|
||||
| "PLUS_SERVER_MEMBER"
|
||||
| "VIDEO_ADDER"
|
||||
| "ARTIST"
|
||||
| "CALENDAR_EVENT_ADDER"
|
||||
| "TOURNAMENT_ADDER"
|
||||
| "API_ACCESSER"
|
||||
| "QA"
|
||||
| "DEV"
|
||||
| "SCANNER_TESTER"
|
||||
| "SUPPORTER" // patrons of "Supporter" tier or higher
|
||||
| "MINOR_SUPPORT"; // patrons of "Support" tier or higher
|
||||
66
apps/web/src/lib/modules/permissions/utils.ts
Normal file
66
apps/web/src/lib/modules/permissions/utils.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
ADMIN_ID,
|
||||
DEV_IDS,
|
||||
QA_IDS,
|
||||
SCANNER_TESTER_IDS,
|
||||
STAFF_IDS,
|
||||
} from "#lib/features/admin/admin-constants.ts";
|
||||
import { IS_E2E_TEST_RUN } from "#lib/utils/e2e.ts";
|
||||
import type { EntityWithPermissions } from "./types.ts";
|
||||
|
||||
/**
|
||||
* Determines whether a user has a specific permission for a given entity.
|
||||
* Single source of truth shared by `requirePermission` and `useHasPermission`.
|
||||
*
|
||||
* @returns A boolean indicating whether the user has the specified permission. Always false if user is not logged in.
|
||||
*/
|
||||
export function hasPermission<
|
||||
T extends EntityWithPermissions,
|
||||
K extends keyof T["permissions"],
|
||||
>(obj: T, permission: K, user?: { id: number } | null) {
|
||||
if (!user) return false;
|
||||
|
||||
// admin can do anything in production but not in development or e2e tests for better testing
|
||||
if (
|
||||
typeof process !== "undefined" &&
|
||||
process.env.NODE_ENV === "production" &&
|
||||
!IS_E2E_TEST_RUN &&
|
||||
isAdmin(user)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (obj.permissions as Record<K, number[]>)[permission].includes(user.id);
|
||||
}
|
||||
|
||||
export function isAdmin(user?: { id: number }) {
|
||||
return user?.id === ADMIN_ID;
|
||||
}
|
||||
|
||||
export function isStaff(user?: { id: number }) {
|
||||
if (!user) return false;
|
||||
|
||||
return STAFF_IDS.includes(user.id);
|
||||
}
|
||||
|
||||
export function isDev(user?: { id: number }) {
|
||||
if (!user) return false;
|
||||
|
||||
return DEV_IDS.includes(user.id);
|
||||
}
|
||||
|
||||
export function isQa(user?: { id: number }) {
|
||||
if (!user) return false;
|
||||
|
||||
return QA_IDS.includes(user.id);
|
||||
}
|
||||
|
||||
export function isScannerTester(user?: { id: number }) {
|
||||
if (!user) return false;
|
||||
|
||||
return SCANNER_TESTER_IDS.includes(user.id);
|
||||
}
|
||||
|
||||
export function isSupporter(user?: { patronTier: number | null }) {
|
||||
return typeof user?.patronTier === "number" && user.patronTier >= 2;
|
||||
}
|
||||
80
apps/web/src/lib/server/cache.ts
Normal file
80
apps/web/src/lib/server/cache.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import type { CacheEntry } from "@epic-web/cachified";
|
||||
import { ServerConfig } from "#lib/server/config.ts";
|
||||
|
||||
/**
|
||||
* A lightweight least-recently-used cache backed by a `Map`. Once `max` entries
|
||||
* are stored, inserting a new key evicts the least recently used one. Reading a
|
||||
* key via `get` marks it as most recently used.
|
||||
*/
|
||||
export class LRUCache<K, V> {
|
||||
private readonly max: number;
|
||||
private readonly map = new Map<K, V>();
|
||||
|
||||
constructor({ max }: { max: number }) {
|
||||
this.max = max;
|
||||
}
|
||||
|
||||
get(key: K): V | undefined {
|
||||
if (!this.map.has(key)) return undefined;
|
||||
|
||||
const value = this.map.get(key) as V;
|
||||
this.map.delete(key);
|
||||
this.map.set(key, value);
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
has(key: K): boolean {
|
||||
return this.map.has(key);
|
||||
}
|
||||
|
||||
set(key: K, value: V): void {
|
||||
if (this.map.has(key)) {
|
||||
this.map.delete(key);
|
||||
}
|
||||
|
||||
this.map.set(key, value);
|
||||
|
||||
if (this.map.size > this.max) {
|
||||
const oldest = this.map.keys().next().value as K;
|
||||
this.map.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
delete(key: K): void {
|
||||
this.map.delete(key);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.map.clear();
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
// This preserves the LRU cache during development
|
||||
var __lruCache: LRUCache<string, CacheEntry<unknown>> | undefined;
|
||||
}
|
||||
|
||||
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 CacheEntry<unknown>);
|
||||
|
||||
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,
|
||||
};
|
||||
111
apps/web/src/lib/server/config.ts
Normal file
111
apps/web/src/lib/server/config.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { IS_E2E_TEST_RUN } from "#lib/utils/e2e.ts";
|
||||
|
||||
/**
|
||||
* Server (`process.env`) configuration. Import with
|
||||
* `import { ServerConfig } from "#lib/server/config.ts"` and read values like
|
||||
* `ServerConfig.dbPath`.
|
||||
*
|
||||
* Values are validated once when this module is first imported. Variables
|
||||
* required in production fall back to development defaults outside of
|
||||
* production.
|
||||
*/
|
||||
|
||||
const isProd = process.env.NODE_ENV === "production" && !IS_E2E_TEST_RUN;
|
||||
|
||||
const TRUTHY_STRINGS = ["true", "1", "yes", "on", "y", "enabled"];
|
||||
const FALSY_STRINGS = ["false", "0", "no", "off", "n", "disabled"];
|
||||
|
||||
const issues: Array<{ name: string; message: string }> = [];
|
||||
|
||||
const values = {
|
||||
NODE_ENV: process.env.NODE_ENV ?? "development",
|
||||
DB_PATH: requiredInProd("DB_PATH", "db.sqlite3"),
|
||||
SESSION_SECRET: requiredInProd("SESSION_SECRET", "secret"),
|
||||
SQL_LOG: oneOf("SQL_LOG", ["none", "trunc", "full"] as const, "none"),
|
||||
DISABLE_CACHE: stringBool("DISABLE_CACHE"),
|
||||
};
|
||||
|
||||
if (issues.length > 0) {
|
||||
throw envError(issues);
|
||||
}
|
||||
|
||||
export const ServerConfig = {
|
||||
/**
|
||||
* Whether `NODE_ENV` is `"production"`. Note: this is `true` during e2e tests
|
||||
* (which run a production build), so combine it with `IS_E2E_TEST_RUN` when
|
||||
* you specifically need to exclude the e2e environment (as the session
|
||||
* cookies do).
|
||||
*/
|
||||
isProduction: values.NODE_ENV === "production",
|
||||
/** Whether the app is running under the test runner. */
|
||||
isTest: values.NODE_ENV === "test",
|
||||
|
||||
/** Path to the SQLite database file. */
|
||||
dbPath: values.DB_PATH,
|
||||
/** Secret used to sign session cookies. */
|
||||
sessionSecret: values.SESSION_SECRET,
|
||||
/** SQL query logging level. */
|
||||
sqlLog: values.SQL_LOG,
|
||||
/** Whether response caching is disabled. */
|
||||
disableCache: values.DISABLE_CACHE,
|
||||
};
|
||||
|
||||
function requiredInProd(name: string, devFallback: string): string {
|
||||
const value = process.env[name];
|
||||
|
||||
if (!isProd) {
|
||||
return value ?? devFallback;
|
||||
}
|
||||
|
||||
if (value === undefined) {
|
||||
issues.push({ name, message: "required in production" });
|
||||
return "";
|
||||
}
|
||||
if (value.length === 0) {
|
||||
issues.push({ name, message: "required in production (cannot be empty)" });
|
||||
return "";
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function oneOf<T extends string>(
|
||||
name: string,
|
||||
options: readonly T[],
|
||||
defaultValue: T,
|
||||
): T {
|
||||
const value = process.env[name];
|
||||
if (value === undefined) return defaultValue;
|
||||
if ((options as readonly string[]).includes(value)) return value as T;
|
||||
|
||||
issues.push({
|
||||
name,
|
||||
message: `must be one of ${options.join(", ")}, got "${value}"`,
|
||||
});
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
function stringBool(name: string): boolean {
|
||||
const value = process.env[name];
|
||||
if (value === undefined) return false;
|
||||
|
||||
const normalized = value.toLowerCase();
|
||||
if (TRUTHY_STRINGS.includes(normalized)) return true;
|
||||
if (FALSY_STRINGS.includes(normalized)) return false;
|
||||
|
||||
issues.push({
|
||||
name,
|
||||
message: `must be a boolean-like string (e.g. "true" or "false"), got "${value}"`,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
function envError(issues: Array<{ name: string; message: string }>): Error {
|
||||
const lines = issues.map((issue) => ` - ${issue.name}: ${issue.message}`);
|
||||
|
||||
return new Error(
|
||||
`Invalid server environment configuration:\n${lines.join(
|
||||
"\n",
|
||||
)}\n\nSee .env.example for the full list of variables and how to set them.`,
|
||||
);
|
||||
}
|
||||
58
apps/web/src/lib/server/db/empty-values-noop-plugin.ts
Normal file
58
apps/web/src/lib/server/db/empty-values-noop-plugin.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
type InsertQueryNode,
|
||||
type KyselyPlugin,
|
||||
type PluginTransformQueryArgs,
|
||||
type PluginTransformResultArgs,
|
||||
type QueryResult,
|
||||
RawNode,
|
||||
type RootOperationNode,
|
||||
SelectionNode,
|
||||
SelectQueryNode,
|
||||
type UnknownRow,
|
||||
ValuesNode,
|
||||
WhereNode,
|
||||
} from "kysely";
|
||||
|
||||
/**
|
||||
* Makes inserting an empty array of values a no-op instead of a syntax error.
|
||||
* Kysely compiles `.values([])` into invalid SQL, so without this plugin every
|
||||
* dynamic multi-row insert would need a length check before it. The empty
|
||||
* insert is rewritten into `INSERT INTO "T" SELECT * FROM "T" WHERE 0` which
|
||||
* inserts zero rows and returns zero rows for any `returning` clause.
|
||||
*/
|
||||
export class EmptyValuesNoopPlugin implements KyselyPlugin {
|
||||
transformQuery(args: PluginTransformQueryArgs): RootOperationNode {
|
||||
if (args.node.kind !== "InsertQueryNode" || !isEmptyInsert(args.node)) {
|
||||
return args.node;
|
||||
}
|
||||
|
||||
const { columns: _columns, ...node } = args.node;
|
||||
|
||||
return Object.freeze({
|
||||
...node,
|
||||
values: selectNothingFrom(args.node),
|
||||
});
|
||||
}
|
||||
|
||||
async transformResult(
|
||||
args: PluginTransformResultArgs,
|
||||
): Promise<QueryResult<UnknownRow>> {
|
||||
return args.result;
|
||||
}
|
||||
}
|
||||
|
||||
function isEmptyInsert(node: InsertQueryNode) {
|
||||
return (
|
||||
node.values !== undefined &&
|
||||
ValuesNode.is(node.values) &&
|
||||
node.values.values.length === 0
|
||||
);
|
||||
}
|
||||
|
||||
function selectNothingFrom(node: InsertQueryNode): SelectQueryNode {
|
||||
return Object.freeze({
|
||||
...SelectQueryNode.createFrom([node.into!]),
|
||||
selections: Object.freeze([SelectionNode.createSelectAll()]),
|
||||
where: WhereNode.create(RawNode.createWithSql("0")),
|
||||
});
|
||||
}
|
||||
51
apps/web/src/lib/server/db/json-columns.ts
Normal file
51
apps/web/src/lib/server/db/json-columns.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Every "Table.column" whose text content is a JSON document. The node-sqlite
|
||||
* dialect parses only these columns (plus computed expression columns) when
|
||||
* reading rows; all other text columns stay plain strings even if a user typed
|
||||
* something JSON-shaped into them. Kept in sync with the JSONColumnType
|
||||
* declarations of tables.ts by json-columns.test.ts.
|
||||
*/
|
||||
export const JSON_COLUMNS: ReadonlySet<string> = new Set([
|
||||
"AllTeam.customTheme",
|
||||
"AllTeam.mapModePreferences",
|
||||
"Build.abilities",
|
||||
"Build.modes",
|
||||
"CalendarEvent.tags",
|
||||
"GroupMatch.memento",
|
||||
"IngestedMatch.data",
|
||||
"LFGPost.languages",
|
||||
"Notification.meta",
|
||||
"NotificationUserSubscription.subscription",
|
||||
"ScrimPost.visibility",
|
||||
"SplatoonPlayer.peakXp",
|
||||
"Team.customTheme",
|
||||
"Team.mapModePreferences",
|
||||
"Tournament.castTwitchAccounts",
|
||||
"Tournament.castedMatchesInfo",
|
||||
"Tournament.preparedMaps",
|
||||
"Tournament.seedingSnapshot",
|
||||
"Tournament.settings",
|
||||
"TournamentAuditLog.metadata",
|
||||
"TournamentMatch.opponentOne",
|
||||
"TournamentMatch.opponentTwo",
|
||||
"TournamentOrganization.socials",
|
||||
"TournamentOrganizationSeries.substringMatches",
|
||||
"TournamentOrganizationSeries.tierHistory",
|
||||
"TournamentResult.setResults",
|
||||
"TournamentRound.maps",
|
||||
"TournamentStage.settings",
|
||||
"TournamentTeam.activeRosterUserIds",
|
||||
"User.buildSorting",
|
||||
"User.customTheme",
|
||||
"User.favoriteBadgeIds",
|
||||
"User.favoriteTrophyIds",
|
||||
"User.hiddenCardStats",
|
||||
"User.hiddenTrophyIds",
|
||||
"User.languages",
|
||||
"User.mapModePreferences",
|
||||
"User.preferences",
|
||||
"User.pronouns",
|
||||
"User.unverifiedPeakXP",
|
||||
"User.weaponPool",
|
||||
"UserWidget.widget",
|
||||
]);
|
||||
153
apps/web/src/lib/server/db/json-selections.ts
Normal file
153
apps/web/src/lib/server/db/json-selections.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import {
|
||||
AggregateFunctionNode,
|
||||
AliasNode,
|
||||
ColumnNode,
|
||||
FunctionNode,
|
||||
IdentifierNode,
|
||||
type OperationNode,
|
||||
RawNode,
|
||||
ReferenceNode,
|
||||
type RootOperationNode,
|
||||
SelectQueryNode,
|
||||
} from "kysely";
|
||||
import { JSON_COLUMNS } from "./json-columns";
|
||||
|
||||
/** SQL shapes {@link jsonValuedNode} recognizes: the subqueries the json helpers emit and direct `json*(` function calls (e.g. `jsonBuildObject`, `json_set`). */
|
||||
const JSON_EXPRESSION_PREFIX =
|
||||
/^\s*(\(select\s+(coalesce\()?)?json(_\w+)?\s*\(/i;
|
||||
|
||||
const NO_NAMES: ReadonlySet<string> = new Set();
|
||||
|
||||
/** Output names of the query's computed result columns whose value is a JSON document, e.g. the subquery a `jsonArrayFrom` selection compiles to. */
|
||||
export function computedJsonColumns(
|
||||
query: RootOperationNode,
|
||||
): ReadonlySet<string> {
|
||||
// only select queries have computed result columns: `returning` selections keep
|
||||
// the origin metadata of the column they write to
|
||||
return SelectQueryNode.is(query) ? outputNames(query) : NO_NAMES;
|
||||
}
|
||||
|
||||
/** Output name a selection comes back under, or `undefined` for selections that have none (`selectAll()`). */
|
||||
export function selectionOutputName(
|
||||
selection: OperationNode,
|
||||
): string | undefined {
|
||||
if (ReferenceNode.is(selection) && ColumnNode.is(selection.column)) {
|
||||
return selection.column.column.name;
|
||||
}
|
||||
if (ColumnNode.is(selection)) {
|
||||
return selection.column.name;
|
||||
}
|
||||
if (AliasNode.is(selection) && IdentifierNode.is(selection.alias)) {
|
||||
return selection.alias.name;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Whether a select list entry resolves to a JSON document. */
|
||||
export function jsonValuedSelection(
|
||||
selection: OperationNode,
|
||||
sources?: SourceOutputNames,
|
||||
): boolean {
|
||||
if (AliasNode.is(selection)) return jsonValuedNode(selection.node, sources);
|
||||
|
||||
return jsonValuedReference(selection, sources);
|
||||
}
|
||||
|
||||
/** Whether an expression resolves to a JSON document. */
|
||||
export function jsonValuedNode(
|
||||
node: OperationNode,
|
||||
sources?: SourceOutputNames,
|
||||
): boolean {
|
||||
if (RawNode.is(node)) {
|
||||
return JSON_EXPRESSION_PREFIX.test(node.sqlFragments[0] ?? "");
|
||||
}
|
||||
if (AggregateFunctionNode.is(node) || FunctionNode.is(node)) {
|
||||
return node.func.startsWith("json");
|
||||
}
|
||||
if (SelectQueryNode.is(node)) {
|
||||
const selections = node.selections ?? [];
|
||||
return (
|
||||
selections.length === 1 &&
|
||||
jsonValuedSelection(selections[0].selection, sourceOutputNames(node))
|
||||
);
|
||||
}
|
||||
|
||||
return jsonValuedReference(node, sources);
|
||||
}
|
||||
|
||||
/** JSON-valued output names of the derived tables and CTEs a query selects from, by the name they are visible under. */
|
||||
type SourceOutputNames = ReadonlyMap<string, ReadonlySet<string>>;
|
||||
|
||||
function outputNames(select: SelectQueryNode): ReadonlySet<string> {
|
||||
const sources = sourceOutputNames(select);
|
||||
const names = new Set<string>();
|
||||
|
||||
for (const { selection } of select.selections ?? []) {
|
||||
const name = selectionOutputName(selection);
|
||||
if (name && jsonValuedSelection(selection, sources)) {
|
||||
names.add(name);
|
||||
}
|
||||
}
|
||||
|
||||
// a compound select is named after its first branch, but any branch can be the
|
||||
// one contributing the JSON document
|
||||
for (const { expression } of select.setOperations ?? []) {
|
||||
if (!SelectQueryNode.is(expression)) continue;
|
||||
|
||||
for (const name of outputNames(expression)) {
|
||||
names.add(name);
|
||||
}
|
||||
}
|
||||
|
||||
return names;
|
||||
}
|
||||
|
||||
function sourceOutputNames(select: SelectQueryNode): SourceOutputNames {
|
||||
const sources = new Map<string, ReadonlySet<string>>();
|
||||
|
||||
for (const cte of select.with?.expressions ?? []) {
|
||||
if (!SelectQueryNode.is(cte.expression)) continue;
|
||||
|
||||
sources.set(
|
||||
cte.name.table.table.identifier.name,
|
||||
outputNames(cte.expression),
|
||||
);
|
||||
}
|
||||
|
||||
const tables = [
|
||||
...(select.from?.froms ?? []),
|
||||
...(select.joins ?? []).map((join) => join.table),
|
||||
];
|
||||
for (const table of tables) {
|
||||
if (
|
||||
!AliasNode.is(table) ||
|
||||
!SelectQueryNode.is(table.node) ||
|
||||
!IdentifierNode.is(table.alias)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
sources.set(table.alias.name, outputNames(table.node));
|
||||
}
|
||||
|
||||
return sources;
|
||||
}
|
||||
|
||||
function jsonValuedReference(node: OperationNode, sources?: SourceOutputNames) {
|
||||
if (
|
||||
!ReferenceNode.is(node) ||
|
||||
!ColumnNode.is(node.column) ||
|
||||
node.table === undefined
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const table = node.table.table.identifier.name;
|
||||
const column = node.column.column.name;
|
||||
|
||||
return (
|
||||
JSON_COLUMNS.has(`${table}.${column}`) ||
|
||||
Boolean(sources?.get(table)?.has(column))
|
||||
);
|
||||
}
|
||||
456
apps/web/src/lib/server/db/node-sqlite-dialect.ts
Normal file
456
apps/web/src/lib/server/db/node-sqlite-dialect.ts
Normal file
@@ -0,0 +1,456 @@
|
||||
import type {
|
||||
DatabaseSync,
|
||||
SQLInputValue,
|
||||
SQLOutputValue,
|
||||
StatementSync,
|
||||
} from "node:sqlite";
|
||||
import {
|
||||
CompiledQuery,
|
||||
createQueryId,
|
||||
type DatabaseConnection,
|
||||
type Dialect,
|
||||
type Driver,
|
||||
IdentifierNode,
|
||||
type Kysely,
|
||||
type QueryCompiler,
|
||||
type QueryResult,
|
||||
RawNode,
|
||||
type RootOperationNode,
|
||||
SelectQueryNode,
|
||||
SqliteAdapter,
|
||||
SqliteIntrospector,
|
||||
SqliteQueryCompiler,
|
||||
} from "kysely";
|
||||
|
||||
/**
|
||||
* Query kinds whose compiled SQL is stable enough to keep a prepared statement
|
||||
* around for. Everything else (DDL, raw SQL, `begin`/`commit`) is prepared fresh.
|
||||
*/
|
||||
const CACHEABLE_QUERY_KINDS = new Set([
|
||||
"SelectQueryNode",
|
||||
"InsertQueryNode",
|
||||
"UpdateQueryNode",
|
||||
"DeleteQueryNode",
|
||||
"MergeQueryNode",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Leading keywords of raw statements that can not change the schema, so the
|
||||
* column lists the statement cache is holding stay valid across them. Raw DDL
|
||||
* (`create`, `alter`, `drop`, ...) is not here and clears the cache.
|
||||
*/
|
||||
const SCHEMA_PRESERVING_RAW_COMMANDS = new Set([
|
||||
"begin",
|
||||
"commit",
|
||||
"rollback",
|
||||
"savepoint",
|
||||
"release",
|
||||
"select",
|
||||
"with",
|
||||
"insert",
|
||||
"update",
|
||||
"delete",
|
||||
"replace",
|
||||
"pragma",
|
||||
"analyze",
|
||||
"explain",
|
||||
]);
|
||||
|
||||
const STATEMENT_CACHE_SIZE = 5000;
|
||||
|
||||
const NO_JSON_OUTPUT_NAMES: ReadonlySet<string> = new Set();
|
||||
|
||||
export interface NodeSqliteDialectConfig {
|
||||
database: DatabaseSync;
|
||||
/**
|
||||
* Keeps prepared statements around between queries, keyed by their SQL. Saves
|
||||
* a re-compile per query at the cost of holding onto the compiled programs.
|
||||
* Off by default because it assumes the schema does not change under the
|
||||
* connection, which is not true while migrations run.
|
||||
*/
|
||||
cacheStatements?: boolean;
|
||||
/**
|
||||
* "Table.column" names whose text content is a JSON document. When given,
|
||||
* result values of these columns are parsed into objects. Other text columns
|
||||
* are always returned verbatim, so JSON-shaped user input stays a string.
|
||||
* Origin metadata from `statement.columns()` sees through aliases, views,
|
||||
* subqueries and CTEs, so the names here are the underlying table names
|
||||
* (e.g. `AllTeam`, not the `Team` view).
|
||||
*/
|
||||
jsonColumns?: ReadonlySet<string>;
|
||||
/**
|
||||
* Output names of a query's computed result columns whose value is a JSON
|
||||
* document, which is what `jsonArrayFrom`/`jsonObjectFrom` subqueries compile
|
||||
* to. SQLite reports no origin for a computed expression, so only the query's
|
||||
* own AST tells those apart from an ordinary `coalesce(...)` over user text.
|
||||
* Called once per prepared statement. Requires {@link jsonColumns}.
|
||||
*/
|
||||
computedJsonColumns?: (query: RootOperationNode) => ReadonlySet<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kysely dialect backed by Node's built-in `node:sqlite` module, replacing the
|
||||
* `better-sqlite3` native addon that Kysely's own `SqliteDialect` expects.
|
||||
*
|
||||
* Rows come back from `node:sqlite` as arrays rather than objects: the objects it
|
||||
* builds itself are both slower to produce and have a `null` prototype, which is
|
||||
* not what the rest of the codebase (or Kysely's own dialects) hand out.
|
||||
*/
|
||||
export class NodeSqliteDialect implements Dialect {
|
||||
readonly #config: NodeSqliteDialectConfig;
|
||||
|
||||
constructor(config: NodeSqliteDialectConfig) {
|
||||
this.#config = config;
|
||||
}
|
||||
|
||||
createDriver(): Driver {
|
||||
return new NodeSqliteDriver(this.#config);
|
||||
}
|
||||
|
||||
createQueryCompiler(): QueryCompiler {
|
||||
return new SqliteQueryCompiler();
|
||||
}
|
||||
|
||||
createAdapter(): SqliteAdapter {
|
||||
return new SqliteAdapter();
|
||||
}
|
||||
|
||||
createIntrospector(db: Kysely<any>): SqliteIntrospector {
|
||||
return new SqliteIntrospector(db);
|
||||
}
|
||||
}
|
||||
|
||||
class NodeSqliteDriver implements Driver {
|
||||
readonly #config: NodeSqliteDialectConfig;
|
||||
#connection?: NodeSqliteConnection;
|
||||
|
||||
constructor(config: NodeSqliteDialectConfig) {
|
||||
this.#config = config;
|
||||
}
|
||||
|
||||
async init(): Promise<void> {
|
||||
this.#connection = new NodeSqliteConnection(this.#config);
|
||||
}
|
||||
|
||||
async acquireConnection(): Promise<DatabaseConnection> {
|
||||
return this.#connection!;
|
||||
}
|
||||
|
||||
async beginTransaction(connection: DatabaseConnection): Promise<void> {
|
||||
await connection.executeQuery(CompiledQuery.raw("begin"));
|
||||
}
|
||||
|
||||
async commitTransaction(connection: DatabaseConnection): Promise<void> {
|
||||
await connection.executeQuery(CompiledQuery.raw("commit"));
|
||||
}
|
||||
|
||||
async rollbackTransaction(connection: DatabaseConnection): Promise<void> {
|
||||
await connection.executeQuery(CompiledQuery.raw("rollback"));
|
||||
}
|
||||
|
||||
async savepoint(
|
||||
connection: DatabaseConnection,
|
||||
savepointName: string,
|
||||
compileQuery: QueryCompiler["compileQuery"],
|
||||
): Promise<void> {
|
||||
await connection.executeQuery(
|
||||
compileQuery(
|
||||
savepointCommand("savepoint", savepointName),
|
||||
createQueryId(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async rollbackToSavepoint(
|
||||
connection: DatabaseConnection,
|
||||
savepointName: string,
|
||||
compileQuery: QueryCompiler["compileQuery"],
|
||||
): Promise<void> {
|
||||
await connection.executeQuery(
|
||||
compileQuery(
|
||||
savepointCommand("rollback to", savepointName),
|
||||
createQueryId(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async releaseSavepoint(
|
||||
connection: DatabaseConnection,
|
||||
savepointName: string,
|
||||
compileQuery: QueryCompiler["compileQuery"],
|
||||
): Promise<void> {
|
||||
await connection.executeQuery(
|
||||
compileQuery(savepointCommand("release", savepointName), createQueryId()),
|
||||
);
|
||||
}
|
||||
|
||||
async releaseConnection(): Promise<void> {
|
||||
// the single connection is never handed back to a pool
|
||||
}
|
||||
|
||||
async destroy(): Promise<void> {
|
||||
this.#connection?.dispose();
|
||||
this.#config.database.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** Which result columns of one query hold a JSON document: by column origin, and by output name for the columns that have no origin. */
|
||||
interface JsonColumns {
|
||||
byOrigin: ReadonlySet<string>;
|
||||
byOutputName: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
interface PreparedStatement {
|
||||
statement: StatementSync;
|
||||
/** Empty for statements that return no rows, which is how writes are detected. */
|
||||
columnNames: string[];
|
||||
/** Per result column: parse text values as JSON when building rows. */
|
||||
jsonColumnFlags: boolean[];
|
||||
/** Kept for re-deriving the flags when the column list turns out to be stale. */
|
||||
jsonColumns?: JsonColumns;
|
||||
}
|
||||
|
||||
class NodeSqliteConnection implements DatabaseConnection {
|
||||
readonly #database: DatabaseSync;
|
||||
readonly #cacheStatements: boolean;
|
||||
readonly #jsonColumns?: ReadonlySet<string>;
|
||||
readonly #computedJsonColumns?: (
|
||||
query: RootOperationNode,
|
||||
) => ReadonlySet<string>;
|
||||
readonly #cache = new Map<string, PreparedStatement>();
|
||||
|
||||
constructor(config: NodeSqliteDialectConfig) {
|
||||
this.#database = config.database;
|
||||
this.#cacheStatements = config.cacheStatements ?? false;
|
||||
this.#jsonColumns = config.jsonColumns;
|
||||
this.#computedJsonColumns = config.computedJsonColumns;
|
||||
}
|
||||
|
||||
async executeQuery<R>(compiledQuery: CompiledQuery): Promise<QueryResult<R>> {
|
||||
const prepared = this.#preparedStatementFor(compiledQuery);
|
||||
const parameters = compiledQuery.parameters as SQLInputValue[];
|
||||
|
||||
if (prepared.columnNames.length > 0) {
|
||||
return { rows: readRows<R>(prepared, parameters) };
|
||||
}
|
||||
|
||||
const { changes, lastInsertRowid } = prepared.statement.run(...parameters);
|
||||
|
||||
return {
|
||||
insertId: BigInt(lastInsertRowid),
|
||||
numAffectedRows: BigInt(changes),
|
||||
rows: [],
|
||||
};
|
||||
}
|
||||
|
||||
async *streamQuery<R>(
|
||||
compiledQuery: CompiledQuery,
|
||||
): AsyncIterableIterator<QueryResult<R>> {
|
||||
if (!SelectQueryNode.is(compiledQuery.query)) {
|
||||
throw new Error(
|
||||
"Sqlite driver only supports streaming of select queries",
|
||||
);
|
||||
}
|
||||
|
||||
// deliberately uncached: the cursor stays open across yields, so sharing the
|
||||
// statement with another query would reset it mid-iteration
|
||||
const prepared = prepare(
|
||||
this.#database,
|
||||
compiledQuery.sql,
|
||||
this.#jsonColumnsFor(compiledQuery.query),
|
||||
);
|
||||
const parameters = compiledQuery.parameters as SQLInputValue[];
|
||||
|
||||
for (const row of prepared.statement.iterate(...parameters)) {
|
||||
yield {
|
||||
rows: [toRow<R>(prepared, row as unknown as SQLOutputValue[])],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.#cache.clear();
|
||||
}
|
||||
|
||||
#preparedStatementFor(compiledQuery: CompiledQuery): PreparedStatement {
|
||||
const { sql, query } = compiledQuery;
|
||||
|
||||
if (!this.#cacheStatements || !CACHEABLE_QUERY_KINDS.has(query.kind)) {
|
||||
// a schema change invalidates every column list the cache is holding
|
||||
if (query.kind !== "RawNode" || canChangeSchema(sql)) {
|
||||
this.#cache.clear();
|
||||
}
|
||||
|
||||
return prepare(this.#database, sql, this.#jsonColumnsFor(query));
|
||||
}
|
||||
|
||||
const cached = this.#cache.get(sql);
|
||||
if (cached) {
|
||||
// re-insert so the least recently used entry stays at the front
|
||||
this.#cache.delete(sql);
|
||||
this.#cache.set(sql, cached);
|
||||
return cached;
|
||||
}
|
||||
|
||||
const prepared = prepare(this.#database, sql, this.#jsonColumnsFor(query));
|
||||
|
||||
if (this.#cache.size >= STATEMENT_CACHE_SIZE) {
|
||||
this.#cache.delete(this.#cache.keys().next().value!);
|
||||
}
|
||||
this.#cache.set(sql, prepared);
|
||||
|
||||
return prepared;
|
||||
}
|
||||
|
||||
#jsonColumnsFor(query: RootOperationNode): JsonColumns | undefined {
|
||||
if (!this.#jsonColumns) return undefined;
|
||||
|
||||
return {
|
||||
byOrigin: this.#jsonColumns,
|
||||
byOutputName: this.#computedJsonColumns?.(query) ?? NO_JSON_OUTPUT_NAMES,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function canChangeSchema(sql: string) {
|
||||
const firstKeyword = sql
|
||||
.trimStart()
|
||||
.split(/[\s;(]/, 1)[0]
|
||||
.toLowerCase();
|
||||
|
||||
return !SCHEMA_PRESERVING_RAW_COMMANDS.has(firstKeyword);
|
||||
}
|
||||
|
||||
function prepare(
|
||||
database: DatabaseSync,
|
||||
sql: string,
|
||||
jsonColumns: JsonColumns | undefined,
|
||||
): PreparedStatement {
|
||||
const statement = database.prepare(sql);
|
||||
statement.setReturnArrays(true);
|
||||
|
||||
return { statement, jsonColumns, ...columnMetadata(statement, jsonColumns) };
|
||||
}
|
||||
|
||||
function columnMetadata(
|
||||
statement: StatementSync,
|
||||
jsonColumns: JsonColumns | undefined,
|
||||
) {
|
||||
const columns = statement.columns();
|
||||
|
||||
return {
|
||||
columnNames: columns.map((it) => it.name),
|
||||
jsonColumnFlags: columns.map((it) => {
|
||||
if (!jsonColumns) return false;
|
||||
// a null origin is a computed expression (a jsonArrayFrom subquery, but also
|
||||
// e.g. a coalesce over user text), which only the query itself can classify
|
||||
if (it.column === null) return jsonColumns.byOutputName.has(it.name);
|
||||
return jsonColumns.byOrigin.has(`${it.table}.${it.column}`);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function readRows<R>(
|
||||
prepared: PreparedStatement,
|
||||
parameters: SQLInputValue[],
|
||||
): R[] {
|
||||
const rawRows = prepared.statement.all(
|
||||
...parameters,
|
||||
) as unknown as SQLOutputValue[][];
|
||||
|
||||
if (rawRows.length === 0) return [];
|
||||
|
||||
// `select *` widens when a migration adds a column, leaving a cached statement
|
||||
// with a stale column list until the next read notices the mismatch
|
||||
if (rawRows[0].length !== prepared.columnNames.length) {
|
||||
Object.assign(
|
||||
prepared,
|
||||
columnMetadata(prepared.statement, prepared.jsonColumns),
|
||||
);
|
||||
}
|
||||
|
||||
const rows = new Array<R>(rawRows.length);
|
||||
for (let i = 0; i < rawRows.length; i++) {
|
||||
rows[i] = toRow<R>(prepared, rawRows[i]);
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
function toRow<R>(prepared: PreparedStatement, rawRow: SQLOutputValue[]): R {
|
||||
const { columnNames, jsonColumnFlags } = prepared;
|
||||
|
||||
const row: Record<string, unknown> = {};
|
||||
for (let i = 0; i < columnNames.length; i++) {
|
||||
const value = rawRow[i];
|
||||
row[columnNames[i]] =
|
||||
jsonColumnFlags[i] && typeof value === "string" && maybeJson(value)
|
||||
? parseJsonValue(value)
|
||||
: value;
|
||||
}
|
||||
|
||||
return row as R;
|
||||
}
|
||||
|
||||
function maybeJson(value: string) {
|
||||
return (
|
||||
(value.startsWith("{") && value.endsWith("}")) ||
|
||||
(value.startsWith("[") && value.endsWith("]"))
|
||||
);
|
||||
}
|
||||
|
||||
function parseJsonValue(value: string): unknown {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
|
||||
sanitizeParsedJson(parsed);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/** Strips `__proto__` and `constructor.prototype` so the parsed document can not prototype-pollute downstream merges. */
|
||||
function sanitizeParsedJson(value: unknown) {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
sanitizeParsedJson(item);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isPlainObject(value)) return;
|
||||
|
||||
for (const key of Object.keys(value)) {
|
||||
if (key === "__proto__") {
|
||||
delete value[key];
|
||||
continue;
|
||||
}
|
||||
|
||||
const child = value[key];
|
||||
if (
|
||||
key === "constructor" &&
|
||||
isPlainObject(child) &&
|
||||
Object.hasOwn(child, "prototype")
|
||||
) {
|
||||
delete child.prototype;
|
||||
}
|
||||
|
||||
sanitizeParsedJson(child);
|
||||
}
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null) return false;
|
||||
|
||||
const proto = Object.getPrototypeOf(value);
|
||||
return proto === null || proto === Object.prototype;
|
||||
}
|
||||
|
||||
function savepointCommand(command: string, savepointName: string) {
|
||||
return RawNode.createWithChildren([
|
||||
RawNode.createWithSql(`${command} `),
|
||||
IdentifierNode.create(savepointName),
|
||||
]);
|
||||
}
|
||||
178
apps/web/src/lib/server/db/sql.ts
Normal file
178
apps/web/src/lib/server/db/sql.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { styleText } from "node:util";
|
||||
import { logger } from "@sendou/utils/logger";
|
||||
import { roundToNDecimalPlaces } from "@sendou/utils/number";
|
||||
import { Kysely, type LogEvent } from "kysely";
|
||||
import { format } from "sql-formatter";
|
||||
import { ServerConfig } from "#lib/server/config.ts";
|
||||
import { EmptyValuesNoopPlugin } from "./empty-values-noop-plugin";
|
||||
import { JSON_COLUMNS } from "./json-columns";
|
||||
import { computedJsonColumns } from "./json-selections";
|
||||
import { NodeSqliteDialect } from "./node-sqlite-dialect";
|
||||
import type { DB } from "./tables";
|
||||
import { WriteTrackerPlugin } from "./write-tracker";
|
||||
|
||||
const sql = new DatabaseSync(
|
||||
ServerConfig.isTest ? ":memory:" : ServerConfig.dbPath,
|
||||
);
|
||||
|
||||
if (ServerConfig.isTest) {
|
||||
applyMigratedSchema(sql);
|
||||
}
|
||||
|
||||
sql.exec("PRAGMA journal_mode = WAL");
|
||||
// The synchronous=NORMAL setting provides the best balance between performance and safety for most applications running in WAL mode.
|
||||
// You lose durability across power lose with synchronous NORMAL in WAL mode, but that is not important for most applications.
|
||||
// Transactions are still atomic, consistent, and isolated, which are the most important characteristics in most use cases.
|
||||
// Source: https://sqlite.org/pragma.html
|
||||
sql.exec("PRAGMA synchronous = NORMAL");
|
||||
sql.exec("PRAGMA foreign_keys = ON");
|
||||
sql.exec("PRAGMA busy_timeout = 5000");
|
||||
// 64MB page cache (default is 2MB)
|
||||
sql.exec("PRAGMA cache_size = -65536");
|
||||
// lets reads come straight from the OS page cache without read() syscalls
|
||||
// Source: https://sqlite.org/mmap.html
|
||||
sql.exec("PRAGMA mmap_size = 3221225472");
|
||||
// see https://sqlite.org/pragma.html#pragma_optimize — recommended for long-lived
|
||||
// connections; pair with a periodic `PRAGMA optimize;` (see OptimizeDatabase routine)
|
||||
sql.exec("PRAGMA optimize = 0x10002");
|
||||
|
||||
// Strips diacritics so accent-insensitive name searches are possible
|
||||
// (e.g. "cafe" matches "Café"). Combined with LIKE's built-in ASCII
|
||||
// case-insensitivity this also folds case for the resulting latin letters.
|
||||
sql.function("unaccent", { deterministic: true }, (value) =>
|
||||
typeof value === "string"
|
||||
? value.normalize("NFD").replace(/\p{M}/gu, "")
|
||||
: value,
|
||||
);
|
||||
|
||||
export const db = new Kysely<DB>({
|
||||
dialect: new NodeSqliteDialect({
|
||||
database: sql,
|
||||
cacheStatements: true,
|
||||
jsonColumns: JSON_COLUMNS,
|
||||
computedJsonColumns,
|
||||
}),
|
||||
log,
|
||||
plugins: [new EmptyValuesNoopPlugin(), new WriteTrackerPlugin()],
|
||||
});
|
||||
|
||||
// Every test worker gets its own in-memory database, built by replaying the
|
||||
// schema of the migrated (and otherwise empty) file that scripts/ensure-test-db.ts
|
||||
// creates in vitest's globalSetup. Replaying the DDL rather than copying the file
|
||||
// is what `node:sqlite` allows: unlike better-sqlite3 it cannot deserialize a
|
||||
// database into memory.
|
||||
function applyMigratedSchema(target: DatabaseSync) {
|
||||
const source = new DatabaseSync("db-test.sqlite3", {
|
||||
readOnly: true,
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
try {
|
||||
// virtual tables create their own shadow tables (e.g. UserSearch_data), so
|
||||
// replaying those would fail on a table that already exists
|
||||
const statements = source
|
||||
.prepare(`
|
||||
SELECT sql FROM sqlite_master m
|
||||
WHERE sql IS NOT NULL
|
||||
AND name NOT LIKE 'sqlite_%'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM sqlite_master AS vt
|
||||
WHERE vt.sql LIKE 'CREATE VIRTUAL TABLE%'
|
||||
AND m.name LIKE vt.name || '_%'
|
||||
)
|
||||
ORDER BY m.rowid
|
||||
`)
|
||||
.all() as Array<{ sql: string }>;
|
||||
|
||||
for (const statement of statements) {
|
||||
target.exec(statement.sql);
|
||||
}
|
||||
} finally {
|
||||
source.close();
|
||||
}
|
||||
}
|
||||
|
||||
function log(event: LogEvent) {
|
||||
if (ServerConfig.sqlLog === "trunc" || ServerConfig.sqlLog === "full") {
|
||||
logQuery(event);
|
||||
} else {
|
||||
logError(event);
|
||||
}
|
||||
}
|
||||
|
||||
function logQuery(event: LogEvent) {
|
||||
const isSelectQuery = Boolean((event.query.query as any).from?.froms);
|
||||
|
||||
if (event.level === "query" && isSelectQuery) {
|
||||
const from = () =>
|
||||
(event.query.query as any).from.froms.map(
|
||||
// plain tables have the name under table, aliased tables and
|
||||
// subqueries under alias
|
||||
(f: any) => f.table?.identifier?.name ?? f.alias?.name ?? "unknown",
|
||||
);
|
||||
// eslint-disable-next-line no-console -- dev only
|
||||
console.log(styleText("blue", `-- SQLITE QUERY to "${from()}" --`));
|
||||
// eslint-disable-next-line no-console -- dev only
|
||||
console.log(
|
||||
styleText(
|
||||
millisToColor(event.queryDurationMillis),
|
||||
`${roundToNDecimalPlaces(event.queryDurationMillis, 1)}ms`,
|
||||
),
|
||||
);
|
||||
// eslint-disable-next-line no-console -- dev only
|
||||
console.log(formatSql(event.query.sql, event.query.parameters));
|
||||
} else {
|
||||
logError(event);
|
||||
}
|
||||
}
|
||||
|
||||
function logError(event: LogEvent) {
|
||||
if (
|
||||
event.level === "error" &&
|
||||
// it seems that this error happens everytime something goes wrong inside transaction
|
||||
// my guess is that the transaction is already implicitly rolled back in the case of error
|
||||
// but kysely also does it explicitly -> fails because there is no transaction to rollback.
|
||||
// this `logError` function at least makes it so that due to that the error doesn't get logged
|
||||
// but of course the best solution would also avoid useless rollbacks, something for the future
|
||||
// btw this particular check is here just to avoid the double "no transaction is active" log
|
||||
!(event.error as Error).message.includes("no transaction is active")
|
||||
) {
|
||||
logger.error(event.error);
|
||||
}
|
||||
}
|
||||
|
||||
function millisToColor(millis: number) {
|
||||
if (millis < 1) {
|
||||
return "bgGreen";
|
||||
}
|
||||
if (millis < 5) {
|
||||
return "green";
|
||||
}
|
||||
if (millis < 50) {
|
||||
return "yellow";
|
||||
}
|
||||
return "red";
|
||||
}
|
||||
|
||||
function formatSql(sql: string, params: readonly unknown[]) {
|
||||
const formatted = format(sql);
|
||||
|
||||
const lines = formatted.split("\n");
|
||||
|
||||
if (ServerConfig.sqlLog === "full" || lines.length <= 11) {
|
||||
return addParams(formatted, params);
|
||||
}
|
||||
|
||||
const linesNotShown = lines.length - 10;
|
||||
|
||||
return `${lines.slice(0, 10).join("\n")}\n... (${linesNotShown} more lines) ...\n`;
|
||||
}
|
||||
|
||||
function addParams(sql: string, params: readonly unknown[]) {
|
||||
const coloredParams = params.map((param) =>
|
||||
styleText("yellow", JSON.stringify(param)),
|
||||
);
|
||||
|
||||
return sql.replace(/\?/g, () => coloredParams.shift() || "");
|
||||
}
|
||||
284
apps/web/src/lib/server/db/tables.ts
Normal file
284
apps/web/src/lib/server/db/tables.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
import type {
|
||||
MainWeaponId,
|
||||
ModeShort,
|
||||
} from "@sendou/in-game-lists/types";
|
||||
import type {
|
||||
ColumnType,
|
||||
GeneratedAlways,
|
||||
Insertable,
|
||||
Selectable,
|
||||
} from "kysely";
|
||||
import type {
|
||||
PeakXP,
|
||||
UserPreferences,
|
||||
XRankPlacementRegion,
|
||||
} from "#lib/db/tables-json.ts";
|
||||
import type { CustomTheme } from "#lib/features/theme/theme-types.ts";
|
||||
import type { SkillTeamIdentifier } from "#lib/features/mmr/mmr-utils.ts";
|
||||
import type { UnifiedLanguageCode } from "#lib/modules/i18n/languages.ts";
|
||||
|
||||
/**
|
||||
* Trimmed port of `apps/web-react/app/db/tables.ts`: contains exactly the
|
||||
* tables (and columns) the migrated features query. Grows with each migrated
|
||||
* feature; the React app's file stays the source of truth until cutover.
|
||||
*/
|
||||
|
||||
type Generated<T> =
|
||||
T extends ColumnType<infer S, infer I, infer U>
|
||||
? ColumnType<S, I | undefined, U>
|
||||
: ColumnType<T, T | undefined, T>;
|
||||
|
||||
/** In SQLite booleans are presented as 0 (false) and 1 (true) */
|
||||
export type DBBoolean = 0 | 1;
|
||||
|
||||
export type JSONColumnTypeNullable<
|
||||
SelectType extends object | string | number | null,
|
||||
> = ColumnType<SelectType | null, string | null, string | null>;
|
||||
|
||||
export type { PeakXP, UserPreferences, XRankPlacementRegion };
|
||||
|
||||
export interface User {
|
||||
/** 1 = permabanned, timestamp = ban active till then */
|
||||
banned: Generated<number | null>;
|
||||
bannedReason: string | null;
|
||||
country: string | null;
|
||||
customTheme: JSONColumnTypeNullable<CustomTheme>;
|
||||
customUrl: string | null;
|
||||
discordAvatar: string | null;
|
||||
customAvatarImgId: number | null;
|
||||
discordId: string;
|
||||
discordName: string;
|
||||
customName: string | null;
|
||||
/** coalesce(customName, discordName) */
|
||||
username: ColumnType<string, never, never>;
|
||||
/** Name the user is shown under in tournaments, set by organizers of established organizations. `null` = their `username` is used. */
|
||||
tournamentName: string | null;
|
||||
id: GeneratedAlways<number>;
|
||||
inGameName: string | null;
|
||||
isArtist: Generated<DBBoolean>;
|
||||
isVideoAdder: Generated<DBBoolean>;
|
||||
isTournamentOrganizer: Generated<DBBoolean>;
|
||||
isApiAccesser: Generated<DBBoolean>;
|
||||
languages: JSONColumnTypeNullable<UnifiedLanguageCode[]>;
|
||||
patronTier: number | null;
|
||||
preferences: JSONColumnTypeNullable<UserPreferences>;
|
||||
plusSkippedForSeasonNth: number | null;
|
||||
/** User creation date. Can be null because we did not always save this. */
|
||||
createdAt: number | null;
|
||||
}
|
||||
|
||||
export interface PlusTier {
|
||||
tier: number;
|
||||
userId: number;
|
||||
}
|
||||
|
||||
export interface UserFriendCode {
|
||||
friendCode: string;
|
||||
userId: number;
|
||||
submitterUserId: number;
|
||||
createdAt: Generated<number>;
|
||||
}
|
||||
|
||||
/** Shape shared by the `AllTeam` table and the `Team` view. See {@link DB} for which to select from. */
|
||||
export interface Team {
|
||||
avatarImgId: number | null;
|
||||
bannerImgId: number | null;
|
||||
createdAt: Generated<number>;
|
||||
customUrl: string;
|
||||
/** Soft delete marker. Always `null` when selected via the `Team` view, which filters these rows out. */
|
||||
deletedAt: number | null;
|
||||
id: GeneratedAlways<number>;
|
||||
inviteCode: string;
|
||||
name: string;
|
||||
tag: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shape shared by the `AllTeamMember` table and the `TeamMember` &
|
||||
* `TeamMemberWithSecondary` views. See {@link DB} for which to select from.
|
||||
*/
|
||||
export interface TeamMember {
|
||||
createdAt: Generated<number>;
|
||||
isOwner: Generated<DBBoolean>;
|
||||
isManager: Generated<DBBoolean>;
|
||||
/** Always `null` when selected via the `TeamMember` or `TeamMemberWithSecondary` views, which filter these rows out. */
|
||||
leftAt: number | null;
|
||||
/** User-defined ordering of members within a team (ascending) */
|
||||
order: Generated<number>;
|
||||
teamId: number;
|
||||
userId: number;
|
||||
isMainTeam: Generated<DBBoolean>;
|
||||
}
|
||||
|
||||
/** Read-only. See {@link DB} for how this view relates to `UnvalidatedUserSubmittedImage`. */
|
||||
export interface UserSubmittedImage {
|
||||
id: GeneratedAlways<number>;
|
||||
submitterUserId: number | null;
|
||||
url: string;
|
||||
/** Never `null` in practice, the view filters unvalidated rows out. */
|
||||
validatedAt: number | null;
|
||||
}
|
||||
|
||||
export interface UnvalidatedUserSubmittedImage {
|
||||
id: GeneratedAlways<number>;
|
||||
submitterUserId: number;
|
||||
url: string;
|
||||
/** When was the image validated? If `null` should be hidden from other users. */
|
||||
validatedAt: number | null;
|
||||
}
|
||||
|
||||
export interface Skill {
|
||||
groupMatchId: number | null;
|
||||
id: GeneratedAlways<number>;
|
||||
/** Set for team ratings, `null` for the solo ratings identified by `userId` instead. */
|
||||
identifier: SkillTeamIdentifier | null;
|
||||
matchesCount: number;
|
||||
mu: number;
|
||||
ordinal: number;
|
||||
sigma: number;
|
||||
season: number;
|
||||
tournamentId: number | null;
|
||||
userId: number | null;
|
||||
/** Can be null because we did not always save this. */
|
||||
createdAt: number | null;
|
||||
}
|
||||
|
||||
export interface SkillTeamUser {
|
||||
skillId: number;
|
||||
userId: number;
|
||||
}
|
||||
|
||||
/** A team that is shown on the team leaderboard but doesn't count for its placements, e.g. because its players want to qualify with another roster. */
|
||||
export interface LeaderboardTeamSkip {
|
||||
id: GeneratedAlways<number>;
|
||||
season: number;
|
||||
/** The team's roster, same as `Skill.identifier`. */
|
||||
identifier: SkillTeamIdentifier;
|
||||
skippedByUserId: number;
|
||||
createdAt: Generated<number>;
|
||||
}
|
||||
|
||||
export interface SplatoonPlayer {
|
||||
id: GeneratedAlways<number>;
|
||||
splId: string;
|
||||
userId: number | null;
|
||||
/** Players best XP across both divisions. Denormalized for performance. */
|
||||
peakXp: JSONColumnTypeNullable<PeakXP>;
|
||||
}
|
||||
|
||||
export interface XRankPlacement {
|
||||
badges: string;
|
||||
bannerSplId: number;
|
||||
id: GeneratedAlways<number>;
|
||||
mode: ModeShort;
|
||||
month: number;
|
||||
name: string;
|
||||
nameDiscriminator: string;
|
||||
playerId: number;
|
||||
power: number;
|
||||
rank: number;
|
||||
region: XRankPlacementRegion;
|
||||
title: string;
|
||||
weaponSplId: MainWeaponId;
|
||||
year: number;
|
||||
}
|
||||
|
||||
export interface ReportedWeapon {
|
||||
groupMatchId: number | null;
|
||||
tournamentMatchId: number | null;
|
||||
mapIndex: number;
|
||||
userId: number;
|
||||
weaponSplId: MainWeaponId;
|
||||
createdAt: Generated<number>;
|
||||
}
|
||||
|
||||
export interface GroupMatch {
|
||||
alphaGroupId: number;
|
||||
bravoGroupId: number;
|
||||
chatCode: string | null;
|
||||
confirmedAt: number | null;
|
||||
confirmedByUserId: number | null;
|
||||
createdAt: Generated<number>;
|
||||
id: GeneratedAlways<number>;
|
||||
cancelRequestedByUserId: number | null;
|
||||
cancelAcceptedByUserId: number | null;
|
||||
noScreen: Generated<DBBoolean>;
|
||||
}
|
||||
|
||||
export interface GroupMember {
|
||||
createdAt: Generated<number>;
|
||||
groupId: number;
|
||||
missedReadyCheckAt: number | null;
|
||||
note: string | null;
|
||||
userId: number;
|
||||
}
|
||||
|
||||
export interface Tournament {
|
||||
id: GeneratedAlways<number>;
|
||||
/** Is the tournament finalized meaning all the matches are played and TO has locked it making it read-only */
|
||||
isFinalized: Generated<DBBoolean>;
|
||||
}
|
||||
|
||||
export interface TournamentStage {
|
||||
id: GeneratedAlways<number>;
|
||||
name: string;
|
||||
number: number;
|
||||
tournamentId: number;
|
||||
createdAt: Generated<number>;
|
||||
}
|
||||
|
||||
export interface TournamentMatch {
|
||||
chatCode: string | null;
|
||||
groupId: number;
|
||||
id: GeneratedAlways<number>;
|
||||
number: number;
|
||||
roundId: number;
|
||||
stageId: number;
|
||||
startedAt: number | null;
|
||||
}
|
||||
|
||||
export interface TournamentResult {
|
||||
isHighlight: Generated<DBBoolean>;
|
||||
participantCount: number;
|
||||
placement: number;
|
||||
tournamentId: number;
|
||||
tournamentTeamId: number;
|
||||
/** The SP change in total after the finalization of a ranked tournament. */
|
||||
spDiff: number | null;
|
||||
userId: number;
|
||||
/** Division label for tournaments with multiple starting brackets (e.g., "D1", "D2") */
|
||||
div: string | null;
|
||||
}
|
||||
|
||||
export type Tables = { [P in keyof DB]: Selectable<DB[P]> };
|
||||
export type TablesInsertable = { [P in keyof DB]: Insertable<DB[P]> };
|
||||
|
||||
export interface DB {
|
||||
/** Table backing the `Team` view. Includes soft-deleted teams. */
|
||||
AllTeam: Team;
|
||||
/** Table backing the `TeamMember` & `TeamMemberWithSecondary` views. Includes members who have left and members of deleted teams. */
|
||||
AllTeamMember: TeamMember;
|
||||
GroupMatch: GroupMatch;
|
||||
GroupMember: GroupMember;
|
||||
LeaderboardTeamSkip: LeaderboardTeamSkip;
|
||||
PlusTier: PlusTier;
|
||||
ReportedWeapon: ReportedWeapon;
|
||||
Skill: Skill;
|
||||
SkillTeamUser: SkillTeamUser;
|
||||
SplatoonPlayer: SplatoonPlayer;
|
||||
/** View of `AllTeam` without soft-deleted teams. */
|
||||
Team: Team;
|
||||
/** View of `AllTeamMember` with only current members of the user's main team. */
|
||||
TeamMember: TeamMember;
|
||||
/** View of `AllTeamMember` with current members of both main and secondary teams. */
|
||||
TeamMemberWithSecondary: TeamMember;
|
||||
Tournament: Tournament;
|
||||
TournamentMatch: TournamentMatch;
|
||||
TournamentResult: TournamentResult;
|
||||
TournamentStage: TournamentStage;
|
||||
UnvalidatedUserSubmittedImage: UnvalidatedUserSubmittedImage;
|
||||
User: User;
|
||||
UserFriendCode: UserFriendCode;
|
||||
UserSubmittedImage: UserSubmittedImage;
|
||||
XRankPlacement: XRankPlacement;
|
||||
}
|
||||
41
apps/web/src/lib/server/db/write-tracker.ts
Normal file
41
apps/web/src/lib/server/db/write-tracker.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type {
|
||||
KyselyPlugin,
|
||||
PluginTransformQueryArgs,
|
||||
PluginTransformResultArgs,
|
||||
QueryResult,
|
||||
RootOperationNode,
|
||||
UnknownRow,
|
||||
} from "kysely";
|
||||
|
||||
let dirty = false;
|
||||
|
||||
/**
|
||||
* Records whether anything has been written to the database, so that a caller can
|
||||
* react to writes it did not make itself: the vitest teardown wipes only when a
|
||||
* test wrote something, and an e2e worker flushes the app server's caches only
|
||||
* when its factories wrote something.
|
||||
*/
|
||||
export class WriteTrackerPlugin implements KyselyPlugin {
|
||||
transformQuery(args: PluginTransformQueryArgs): RootOperationNode {
|
||||
if (args.node.kind !== "SelectQueryNode") {
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
return args.node;
|
||||
}
|
||||
|
||||
async transformResult(
|
||||
args: PluginTransformResultArgs,
|
||||
): Promise<QueryResult<UnknownRow>> {
|
||||
return args.result;
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the database has been written to since the last {@link markDatabaseClean}. */
|
||||
export function isDatabaseDirty() {
|
||||
return dirty;
|
||||
}
|
||||
|
||||
export function markDatabaseClean() {
|
||||
dirty = false;
|
||||
}
|
||||
273
apps/web/src/lib/server/kysely.ts
Normal file
273
apps/web/src/lib/server/kysely.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
import {
|
||||
type AliasedRawBuilder,
|
||||
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 "#lib/config.ts";
|
||||
import {
|
||||
jsonValuedNode,
|
||||
jsonValuedSelection,
|
||||
selectionOutputName,
|
||||
} from "#lib/server/db/json-selections.ts";
|
||||
import { db } from "#lib/server/db/sql.ts";
|
||||
import type { DB, Tables } from "#lib/server/db/tables.ts";
|
||||
import { IS_E2E_TEST_RUN } from "#lib/utils/e2e.ts";
|
||||
|
||||
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}; reach for this only when it
|
||||
* doesn'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;
|
||||
};
|
||||
|
||||
/** Resolves the name a user is shown under in tournaments: `tournamentName` when set, otherwise `username`. */
|
||||
export function tournamentUsername(alias = "User") {
|
||||
return sql<string>`coalesce(${sql.ref(`${alias}.tournamentName`)}, ${sql.ref(
|
||||
`${alias}.username`,
|
||||
)})`;
|
||||
}
|
||||
|
||||
const USER_SUBMITTED_IMAGE_ROOT =
|
||||
(typeof process !== "undefined" &&
|
||||
process.env.NODE_ENV === "development" &&
|
||||
!Config.prodMode) ||
|
||||
IS_E2E_TEST_RUN ||
|
||||
(typeof process !== "undefined" && process.env.NODE_ENV === "test")
|
||||
? "http://127.0.0.1:9000/sendou"
|
||||
: "https://sendou.nyc3.cdn.digitaloceanspaces.com";
|
||||
|
||||
/**
|
||||
* Groups a season's `Skill` rows to the latest one per user or per team
|
||||
* identifier via SQLite's bare column rule: with a `max()` aggregate the other
|
||||
* selected columns come from the row that produced the max. This form is
|
||||
* plan-stable regardless of stats: a single grouped scan of the covering index,
|
||||
* no temp b-tree per partition.
|
||||
*/
|
||||
export function latestSkillPerSeason<By extends "userId" | "identifier">({
|
||||
season,
|
||||
by,
|
||||
}: {
|
||||
season: number;
|
||||
by: By;
|
||||
}) {
|
||||
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})`;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
659
apps/web/src/lib/styles/common.css
Normal file
659
apps/web/src/lib/styles/common.css
Normal file
@@ -0,0 +1,659 @@
|
||||
/** biome-ignore-all lint/style/noDescendingSpecificity: Biome v2 migration */
|
||||
@layer base {
|
||||
* {
|
||||
margin: 0;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
html {
|
||||
color-scheme: light dark;
|
||||
accent-color: var(--color-text-accent);
|
||||
scroll-padding-top: var(--layout-sticky-top);
|
||||
}
|
||||
|
||||
body {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
line-height: var(--line-height);
|
||||
overflow-x: hidden;
|
||||
color: var(--color-text);
|
||||
background-color: var(--color-bg);
|
||||
font-family: lexend, sans-serif;
|
||||
|
||||
-moz-osx-font-smoothing: antialiased;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-webkit-tab-highlight-color: transparent;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6,
|
||||
p {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
text-wrap: balance;
|
||||
}
|
||||
p {
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
img,
|
||||
svg,
|
||||
canvas,
|
||||
video,
|
||||
audio,
|
||||
iframe,
|
||||
embed,
|
||||
object {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
input,
|
||||
button,
|
||||
textarea,
|
||||
select {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
details {
|
||||
& summary {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-text-accent);
|
||||
text-decoration: none;
|
||||
border-radius: var(--radius-field);
|
||||
|
||||
&:focus-visible {
|
||||
outline: var(--focus-ring);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
/* :not([name="text"]) workaround not to select textarea on map planner */
|
||||
textarea:not([name="text"]) {
|
||||
width: 100%;
|
||||
min-height: 8rem;
|
||||
padding: var(--field-padding);
|
||||
border: var(--border-style);
|
||||
border-radius: var(--radius-field);
|
||||
background-color: var(--color-bg);
|
||||
font-size: var(--font-sm);
|
||||
outline: none;
|
||||
resize: both;
|
||||
|
||||
&:focus-within {
|
||||
outline: var(--focus-ring);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.input-container {
|
||||
display: flex;
|
||||
border: var(--border-style);
|
||||
border-radius: var(--radius-field);
|
||||
background-color: var(--color-bg);
|
||||
height: var(--field-size);
|
||||
font-size: var(--font-sm);
|
||||
outline: none;
|
||||
|
||||
&:focus-within {
|
||||
outline: var(--focus-ring);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.in-container {
|
||||
width: 100%;
|
||||
padding: 0 var(--field-padding);
|
||||
border: none;
|
||||
background: transparent;
|
||||
margin: auto 0;
|
||||
outline: none;
|
||||
|
||||
&:-webkit-autofill,
|
||||
&:-webkit-autofill:hover,
|
||||
&:-webkit-autofill:focus {
|
||||
-webkit-text-fill-color: var(--color-text);
|
||||
-webkit-background-clip: text;
|
||||
caret-color: var(--color-text);
|
||||
}
|
||||
}
|
||||
|
||||
.input-addon {
|
||||
--addon-radius: calc(var(--radius-field) - 2px);
|
||||
display: grid;
|
||||
border-radius: var(--addon-radius) 0 0 var(--addon-radius);
|
||||
background-color: var(--color-bg-high);
|
||||
color: var(--color-text-high);
|
||||
font-size: var(--font-sm);
|
||||
padding-inline: var(--field-padding);
|
||||
place-items: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
input:not(.in-container) {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
border: var(--border-style);
|
||||
border-radius: var(--radius-field);
|
||||
background: var(--color-bg);
|
||||
padding: 0 var(--field-padding);
|
||||
height: var(--field-size);
|
||||
font-size: var(--font-sm);
|
||||
outline: none;
|
||||
|
||||
&:-webkit-autofill,
|
||||
&:-webkit-autofill:hover,
|
||||
&:-webkit-autofill:focus {
|
||||
-webkit-text-fill-color: var(--color-text);
|
||||
-webkit-background-clip: text;
|
||||
caret-color: var(--color-text);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--color-text-high);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
pointer-events: none;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:focus-within:not(:read-only) {
|
||||
outline: var(--focus-ring);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: var(--selector-size-sm);
|
||||
height: var(--selector-size-sm);
|
||||
border-radius: calc(var(--radius-selector) / 2);
|
||||
background-color: var(--color-bg);
|
||||
border: var(--border-style);
|
||||
|
||||
&:focus-within {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: var(--focus-ring);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
&:checked,
|
||||
&:indeterminate {
|
||||
border-color: var(--color-text-accent);
|
||||
background-color: var(--color-text-accent);
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
background-size: var(--field-size-icon) auto;
|
||||
}
|
||||
|
||||
&:checked {
|
||||
background-image: var(--field-icon-checkbox);
|
||||
}
|
||||
|
||||
&:indeterminate {
|
||||
background-image: var(--field-icon-minus);
|
||||
}
|
||||
|
||||
/* Hacky selector to remove the margin bottom from the container from Label.tsx */
|
||||
&:has(+ div > label) + div {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
input[type="radio"] {
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
vertical-align: middle;
|
||||
width: var(--selector-size-sm);
|
||||
height: var(--selector-size-sm);
|
||||
background-color: var(--color-bg);
|
||||
border: var(--border-style);
|
||||
border-radius: var(--radius-full);
|
||||
padding: 0;
|
||||
|
||||
&:focus-within {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: var(--focus-ring);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 50%;
|
||||
height: 50%;
|
||||
border-radius: var(--radius-full);
|
||||
background-color: var(--color-bg);
|
||||
margin: auto;
|
||||
margin-top: 25%;
|
||||
}
|
||||
|
||||
&:checked {
|
||||
border-color: var(--color-text-accent);
|
||||
|
||||
&::after {
|
||||
background-color: var(--color-text-accent);
|
||||
}
|
||||
}
|
||||
|
||||
/* Hacky selector to remove the margin bottom from the container from Label.tsx */
|
||||
&:has(+ div > label) + div {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-size: var(--font-xs);
|
||||
font-weight: var(--weight-bold);
|
||||
margin-block-end: var(--label-margin);
|
||||
}
|
||||
|
||||
fieldset {
|
||||
border: none;
|
||||
border-radius: var(--radius-box);
|
||||
background-color: var(--color-bg-high);
|
||||
font-size: var(--font-sm);
|
||||
padding-block-end: var(--s-3);
|
||||
padding-inline: var(--s-3);
|
||||
}
|
||||
|
||||
legend {
|
||||
border-radius: var(--radius-field);
|
||||
background-color: transparent;
|
||||
font-size: var(--font-xs);
|
||||
font-weight: var(--weight-bold);
|
||||
}
|
||||
|
||||
article {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
select {
|
||||
all: unset;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
border: var(--border-style);
|
||||
border-radius: var(--radius-field);
|
||||
background-color: var(--color-bg);
|
||||
cursor: pointer;
|
||||
padding: 0 var(--field-padding);
|
||||
padding-right: calc(
|
||||
var(--field-size-icon) +
|
||||
var(--field-padding) +
|
||||
var(--s-2)
|
||||
);
|
||||
height: var(--field-size);
|
||||
font-size: var(--font-sm);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
appearance: none;
|
||||
overflow: hidden;
|
||||
|
||||
@supports (appearance: base-select) {
|
||||
appearance: base-select;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
pointer-events: none;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:focus-within {
|
||||
outline: var(--focus-ring);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
&::picker(select) {
|
||||
@supports (appearance: base-select) {
|
||||
appearance: base-select;
|
||||
}
|
||||
|
||||
background-color: var(--color-bg);
|
||||
border: var(--border-style);
|
||||
border-radius: var(--radius-field);
|
||||
padding: var(--s-1);
|
||||
margin: 0.5rem;
|
||||
translate: -0.5rem;
|
||||
max-height: 50dvh;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--color-bg-higher) transparent;
|
||||
}
|
||||
|
||||
&::picker-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
optgroup {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
option {
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
border-radius: var(--radius-field);
|
||||
font-size: var(--font-sm);
|
||||
font-weight: var(--weight-semi);
|
||||
padding: var(--s-1-5);
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
|
||||
&[disabled] {
|
||||
color: var(--color-text-high);
|
||||
}
|
||||
|
||||
&:hover:not([disabled]) {
|
||||
background-color: var(--color-bg-high);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
input[type="date"],
|
||||
input[type="datetime-local"],
|
||||
input[type="month"],
|
||||
input[type="time"],
|
||||
input[type="week"],
|
||||
select {
|
||||
background-position: center right var(--field-padding);
|
||||
background-size: var(--field-size-icon) auto;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
input[type="date"],
|
||||
input[type="datetime-local"],
|
||||
input[type="month"],
|
||||
input[type="week"] {
|
||||
background-image: var(--field-icon-date);
|
||||
}
|
||||
|
||||
input[type="time"] {
|
||||
background-image: var(--field-icon-time);
|
||||
}
|
||||
|
||||
select {
|
||||
background-image: var(--field-icon-chevron);
|
||||
}
|
||||
|
||||
[type="date"]::-webkit-calendar-picker-indicator,
|
||||
[type="datetime-local"]::-webkit-calendar-picker-indicator,
|
||||
[type="month"]::-webkit-calendar-picker-indicator,
|
||||
[type="time"]::-webkit-calendar-picker-indicator,
|
||||
[type="week"]::-webkit-calendar-picker-indicator {
|
||||
width: var(--field-size-icon);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@-moz-document url-prefix() {
|
||||
[type="date"],
|
||||
[type="datetime-local"],
|
||||
[type="month"],
|
||||
[type="time"],
|
||||
[type="week"] {
|
||||
background-image: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@-moz-document url-prefix() {
|
||||
input[type="date"],
|
||||
input[type="datetime-local"],
|
||||
input[type="month"],
|
||||
input[type="time"],
|
||||
input[type="week"] {
|
||||
background-image: none;
|
||||
}
|
||||
}
|
||||
|
||||
input[type="color"] {
|
||||
padding: calc(var(--field-size) / 8) var(--field-padding);
|
||||
|
||||
&::-webkit-color-swatch {
|
||||
border: 0;
|
||||
border-radius: var(--radius-field);
|
||||
}
|
||||
|
||||
&::-moz-color-swatch {
|
||||
border: 0;
|
||||
border-radius: var(--radius-field);
|
||||
}
|
||||
}
|
||||
|
||||
input[type="file"] {
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
font-size: var(--font-sm);
|
||||
color: var(--color-text-high);
|
||||
overflow: hidden;
|
||||
|
||||
&:focus-visible {
|
||||
outline: var(--focus-ring);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
&::file-selector-button {
|
||||
height: 100%;
|
||||
border: none;
|
||||
background-color: var(--color-bg-higher);
|
||||
color: var(--color-text-high);
|
||||
white-space: nowrap;
|
||||
margin-right: var(--s-2);
|
||||
}
|
||||
}
|
||||
|
||||
input[type="range"] {
|
||||
--track-color: var(--color-bg-higher);
|
||||
--thumb-color: var(--color-text-high);
|
||||
border: none;
|
||||
padding: 0;
|
||||
appearance: none;
|
||||
background: 0 0;
|
||||
width: 100%;
|
||||
height: var(--selector-size);
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:focus-within {
|
||||
--track-color: var(--color-bg-higher);
|
||||
--thumb-color: var(--color-text-accent);
|
||||
}
|
||||
|
||||
&::-webkit-slider-runnable-track {
|
||||
width: 100%;
|
||||
height: calc(var(--selector-size) / 4);
|
||||
background-color: var(--track-color);
|
||||
border-radius: var(--radius-full);
|
||||
transition: 100ms background-color;
|
||||
}
|
||||
|
||||
&::-moz-range-track {
|
||||
width: 100%;
|
||||
height: calc(var(--selector-size) / 4);
|
||||
background-color: var(--track-color);
|
||||
border-radius: var(--radius-full);
|
||||
transition: 100ms background-color;
|
||||
}
|
||||
|
||||
&::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: var(--selector-size);
|
||||
height: var(--selector-size);
|
||||
margin-top: calc(
|
||||
(calc(var(--selector-size) / 4) - var(--selector-size)) /
|
||||
2
|
||||
);
|
||||
background-color: var(--thumb-color);
|
||||
border: 2px solid var(--color-bg);
|
||||
border-radius: var(--radius-full);
|
||||
cursor: pointer;
|
||||
transition: 100ms background-color;
|
||||
}
|
||||
|
||||
&::-moz-range-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: var(--selector-size);
|
||||
height: var(--selector-size);
|
||||
margin-top: calc(
|
||||
(calc(var(--selector-size) / 4) - var(--selector-size)) /
|
||||
2
|
||||
);
|
||||
background-color: var(--thumb-color);
|
||||
border: 2px solid var(--color-bg);
|
||||
border-radius: var(--radius-full);
|
||||
cursor: pointer;
|
||||
transition: 100ms background-color;
|
||||
}
|
||||
}
|
||||
|
||||
progress {
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
appearance: none;
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
height: var(--selector-size);
|
||||
overflow: hidden;
|
||||
border: none;
|
||||
border-radius: var(--radius-full);
|
||||
background-color: var(--color-bg-high);
|
||||
color: var(--color-text-accent);
|
||||
vertical-align: baseline;
|
||||
|
||||
&::-webkit-progress-bar {
|
||||
border-radius: var(--radius-full);
|
||||
background: 0 0;
|
||||
}
|
||||
|
||||
&::-moz-progress-bar {
|
||||
background-color: var(--color-text-accent);
|
||||
}
|
||||
|
||||
&[value]::-webkit-progress-value {
|
||||
background-color: var(--color-text-accent);
|
||||
transition: inline-size 0.2s ease;
|
||||
}
|
||||
}
|
||||
|
||||
td > input[type="checkbox"] {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
hr {
|
||||
border: 1px solid var(--color-bg-high);
|
||||
}
|
||||
|
||||
abbr {
|
||||
&:not([title]) {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
&[title] {
|
||||
cursor: help;
|
||||
}
|
||||
}
|
||||
|
||||
.article {
|
||||
& > p {
|
||||
padding-block: var(--s-3);
|
||||
}
|
||||
|
||||
& img {
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* Hack to deal with Safari bug when the image is loading. See: https://stackoverflow.com/a/73466877 */
|
||||
@media not all and (min-resolution: 0.001dpcm) {
|
||||
img[loading="lazy"] {
|
||||
clip-path: inset(0.5px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#nprogress {
|
||||
& .bar {
|
||||
background: var(--color-text-accent) !important;
|
||||
}
|
||||
|
||||
& .spinner {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
& .peg {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Track that NProgress renders its bar into (see NPROGRESS_ANCHOR_ID). It is a
|
||||
* child of the header positioned just below the header's 1.5px bottom border, so
|
||||
* it follows the header on scroll and in standalone (PWA) mode. It spans only the
|
||||
* header width (the area between the sidebars), and its overflow: hidden clips the
|
||||
* bar — which NProgress slides in from the left — so the bar can never extend over
|
||||
* a sidebar. Unlayered + id selector so it beats NProgress's own unlayered
|
||||
* .nprogress-custom-parent rules (and the @layer-wrapped CSS modules). */
|
||||
#nprogress-anchor {
|
||||
position: absolute;
|
||||
top: calc(100% + 1.5px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2.5px;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.nprogress-custom-parent #nprogress .bar {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.top-leaderboard {
|
||||
min-height: 100px;
|
||||
max-height: 100px;
|
||||
overflow-y: clip;
|
||||
margin-top: 10px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 599px) {
|
||||
body:not(:has(.planner)) .publift-widget-sticky_footer-container {
|
||||
bottom: calc(
|
||||
var(--layout-nav-height) +
|
||||
env(safe-area-inset-bottom)
|
||||
) !important;
|
||||
}
|
||||
}
|
||||
1018
apps/web/src/lib/styles/flags.css
Normal file
1018
apps/web/src/lib/styles/flags.css
Normal file
File diff suppressed because it is too large
Load Diff
32
apps/web/src/lib/styles/fonts.css
Normal file
32
apps/web/src/lib/styles/fonts.css
Normal file
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
Lexend variable font, self-hosted. The weight range is capped at 700 to match
|
||||
`--weight-extra`, so `font-weight: bolder` keeps clamping there as it did when
|
||||
the four static weights were requested from Google Fonts.
|
||||
|
||||
The vietnamese subset is intentionally omitted: no locale uses it.
|
||||
*/
|
||||
|
||||
@font-face {
|
||||
font-family: "Lexend";
|
||||
font-style: normal;
|
||||
font-weight: 400 700;
|
||||
font-display: swap;
|
||||
src: url("./fonts/lexend-latin.woff2") format("woff2");
|
||||
unicode-range:
|
||||
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6,
|
||||
U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122,
|
||||
U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Lexend";
|
||||
font-style: normal;
|
||||
font-weight: 400 700;
|
||||
font-display: swap;
|
||||
src: url("./fonts/lexend-latin-ext.woff2") format("woff2");
|
||||
unicode-range:
|
||||
U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7,
|
||||
U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F,
|
||||
U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F,
|
||||
U+A720-A7FF;
|
||||
}
|
||||
BIN
apps/web/src/lib/styles/fonts/lexend-latin-ext.woff2
Normal file
BIN
apps/web/src/lib/styles/fonts/lexend-latin-ext.woff2
Normal file
Binary file not shown.
BIN
apps/web/src/lib/styles/fonts/lexend-latin.woff2
Normal file
BIN
apps/web/src/lib/styles/fonts/lexend-latin.woff2
Normal file
Binary file not shown.
217
apps/web/src/lib/styles/normalize.css
vendored
Normal file
217
apps/web/src/lib/styles/normalize.css
vendored
Normal file
@@ -0,0 +1,217 @@
|
||||
@layer reset {
|
||||
/*! modern-normalize v3.0.1 | MIT License | https://github.com/sindresorhus/modern-normalize */
|
||||
|
||||
/*
|
||||
Document
|
||||
========
|
||||
*/
|
||||
|
||||
/**
|
||||
Use a better box model (opinionated).
|
||||
*/
|
||||
|
||||
*,
|
||||
::before,
|
||||
::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/**
|
||||
1. Improve consistency of default fonts in all browsers. (https://github.com/sindresorhus/modern-normalize/issues/3)
|
||||
2. Correct the line height in all browsers.
|
||||
3. Prevent adjustments of font size after orientation changes in iOS.
|
||||
4. Use a more readable tab size (opinionated).
|
||||
*/
|
||||
|
||||
html {
|
||||
font-family:
|
||||
system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif,
|
||||
"Apple Color Emoji", "Segoe UI Emoji"; /* 1 */
|
||||
line-height: 1.15; /* 2 */
|
||||
-webkit-text-size-adjust: 100%; /* 3 */
|
||||
tab-size: 4; /* 4 */
|
||||
}
|
||||
|
||||
/*
|
||||
Sections
|
||||
========
|
||||
*/
|
||||
|
||||
/**
|
||||
Remove the margin in all browsers.
|
||||
*/
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
Text-level semantics
|
||||
====================
|
||||
*/
|
||||
|
||||
/**
|
||||
Add the correct font weight in Chrome and Safari.
|
||||
*/
|
||||
|
||||
b,
|
||||
strong {
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
/**
|
||||
1. Improve consistency of default fonts in all browsers. (https://github.com/sindresorhus/modern-normalize/issues/3)
|
||||
2. Correct the odd 'em' font sizing in all browsers.
|
||||
*/
|
||||
|
||||
code,
|
||||
kbd,
|
||||
samp,
|
||||
pre {
|
||||
font-family:
|
||||
ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", Menlo,
|
||||
monospace; /* 1 */
|
||||
font-size: 1em; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
Add the correct font size in all browsers.
|
||||
*/
|
||||
|
||||
small {
|
||||
font-size: 80%;
|
||||
}
|
||||
|
||||
/**
|
||||
Prevent 'sub' and 'sup' elements from affecting the line height in all browsers.
|
||||
*/
|
||||
|
||||
sub,
|
||||
sup {
|
||||
font-size: 75%;
|
||||
line-height: 0;
|
||||
position: relative;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
sub {
|
||||
bottom: -0.25em;
|
||||
}
|
||||
|
||||
sup {
|
||||
top: -0.5em;
|
||||
}
|
||||
|
||||
/*
|
||||
Tabular data
|
||||
============
|
||||
*/
|
||||
|
||||
/**
|
||||
Correct table border color inheritance in Chrome and Safari. (https://issues.chromium.org/issues/40615503, https://bugs.webkit.org/show_bug.cgi?id=195016)
|
||||
*/
|
||||
|
||||
table {
|
||||
border-color: currentcolor;
|
||||
}
|
||||
|
||||
/*
|
||||
Forms
|
||||
=====
|
||||
*/
|
||||
|
||||
/**
|
||||
1. Change the font styles in all browsers.
|
||||
2. Remove the margin in Firefox and Safari.
|
||||
*/
|
||||
|
||||
button,
|
||||
input,
|
||||
optgroup,
|
||||
select,
|
||||
textarea {
|
||||
font-family: inherit; /* 1 */
|
||||
font-size: 100%; /* 1 */
|
||||
line-height: 1.15; /* 1 */
|
||||
margin: 0; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
Correct the inability to style clickable types in iOS and Safari.
|
||||
*/
|
||||
|
||||
button,
|
||||
[type="button"],
|
||||
[type="reset"],
|
||||
[type="submit"] {
|
||||
appearance: button;
|
||||
-webkit-appearance: button;
|
||||
}
|
||||
|
||||
/**
|
||||
Remove the padding so developers are not caught out when they zero out 'fieldset' elements in all browsers.
|
||||
*/
|
||||
|
||||
legend {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
Add the correct vertical alignment in Chrome and Firefox.
|
||||
*/
|
||||
|
||||
progress {
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
/**
|
||||
Correct the cursor style of increment and decrement buttons in Safari.
|
||||
*/
|
||||
|
||||
::-webkit-inner-spin-button,
|
||||
::-webkit-outer-spin-button {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/**
|
||||
1. Correct the odd appearance in Chrome and Safari.
|
||||
2. Correct the outline style in Safari.
|
||||
*/
|
||||
|
||||
[type="search"] {
|
||||
appearance: textfield; /* 1 */
|
||||
-webkit-appearance: textfield; /* 1 */
|
||||
outline-offset: -2px; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
Remove the inner padding in Chrome and Safari on macOS.
|
||||
*/
|
||||
|
||||
::-webkit-search-decoration {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
/**
|
||||
1. Correct the inability to style clickable types in iOS and Safari.
|
||||
2. Change font properties to 'inherit' in Safari.
|
||||
*/
|
||||
|
||||
::-webkit-file-upload-button {
|
||||
-webkit-appearance: button; /* 1 */
|
||||
font: inherit; /* 2 */
|
||||
}
|
||||
|
||||
/*
|
||||
Interactive
|
||||
===========
|
||||
*/
|
||||
|
||||
/*
|
||||
Add the correct display in Chrome and Safari.
|
||||
*/
|
||||
|
||||
summary {
|
||||
display: list-item;
|
||||
}
|
||||
}
|
||||
537
apps/web/src/lib/styles/utils.css
Normal file
537
apps/web/src/lib/styles/utils.css
Normal file
@@ -0,0 +1,537 @@
|
||||
@layer utilities {
|
||||
.text-xl {
|
||||
font-size: var(--font-xl);
|
||||
}
|
||||
|
||||
.text-lg {
|
||||
font-size: var(--font-lg);
|
||||
}
|
||||
|
||||
.text-sm {
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
|
||||
.text-md {
|
||||
font-size: var(--font-md);
|
||||
}
|
||||
|
||||
.text-xs {
|
||||
font-size: var(--font-xs);
|
||||
}
|
||||
|
||||
.text-xxs {
|
||||
font-size: var(--font-2xs);
|
||||
}
|
||||
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.text-main-forced {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.text-lighter {
|
||||
color: var(--color-text-high);
|
||||
}
|
||||
|
||||
.text-error {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.text-success {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.text-accent-high {
|
||||
color: var(--color-accent-high);
|
||||
}
|
||||
|
||||
.text-warning {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.text-theme {
|
||||
color: var(--color-text-accent);
|
||||
}
|
||||
|
||||
.text-theme-secondary {
|
||||
color: var(--color-text-second);
|
||||
}
|
||||
|
||||
.text-uppercase {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.text-capitalize {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.underline {
|
||||
text-decoration: underline;
|
||||
text-decoration-thickness: 2px;
|
||||
}
|
||||
|
||||
.line-through {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.color-success {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.color-warning {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.color-error {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.color-info {
|
||||
color: var(--color-info);
|
||||
}
|
||||
|
||||
.bg {
|
||||
background-color: var(--color-bg);
|
||||
}
|
||||
|
||||
.bg-lighter {
|
||||
background-color: var(--color-bg-high);
|
||||
}
|
||||
|
||||
.rounded {
|
||||
border-radius: var(--radius-box);
|
||||
}
|
||||
|
||||
.rounded-sm {
|
||||
border-radius: var(--radius-field);
|
||||
}
|
||||
|
||||
.rounded-full {
|
||||
border-radius: 100%;
|
||||
}
|
||||
|
||||
.font-body {
|
||||
font-weight: var(--weight-body);
|
||||
}
|
||||
|
||||
.font-semi-bold {
|
||||
font-weight: var(--weight-semi);
|
||||
}
|
||||
|
||||
.font-bold {
|
||||
font-weight: var(--weight-bold);
|
||||
}
|
||||
|
||||
.h-max {
|
||||
height: max-content;
|
||||
}
|
||||
|
||||
.w-full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.w-4 {
|
||||
width: var(--s-4);
|
||||
}
|
||||
|
||||
.w-6 {
|
||||
width: var(--s-6);
|
||||
}
|
||||
|
||||
.w-24 {
|
||||
width: var(--s-24);
|
||||
}
|
||||
|
||||
.w-max {
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.p-1 {
|
||||
padding: var(--s-1);
|
||||
}
|
||||
|
||||
.py-4 {
|
||||
padding-block: var(--s-4);
|
||||
}
|
||||
|
||||
.pl-0 {
|
||||
padding-inline-start: 0;
|
||||
}
|
||||
|
||||
.pl-4 {
|
||||
padding-inline-start: var(--s-4);
|
||||
}
|
||||
|
||||
.pt-0 {
|
||||
padding-block-start: 0;
|
||||
}
|
||||
|
||||
.pt-0-5 {
|
||||
padding-block-start: var(--s-0-5);
|
||||
}
|
||||
|
||||
.m-auto-0 {
|
||||
margin: auto 0;
|
||||
}
|
||||
|
||||
.mt-1 {
|
||||
margin-block-start: var(--s-1);
|
||||
}
|
||||
|
||||
.mt-2 {
|
||||
margin-block-start: var(--s-2);
|
||||
}
|
||||
|
||||
.mt-3 {
|
||||
margin-block-start: var(--s-3);
|
||||
}
|
||||
|
||||
.mt-4 {
|
||||
margin-block-start: var(--s-4);
|
||||
}
|
||||
|
||||
.mt-6 {
|
||||
margin-block-start: var(--s-6);
|
||||
}
|
||||
|
||||
.mt-12 {
|
||||
margin-block-start: var(--s-12);
|
||||
}
|
||||
|
||||
.mb-0 {
|
||||
margin-block-end: 0;
|
||||
}
|
||||
|
||||
.mb-1 {
|
||||
margin-block-end: var(--s-1);
|
||||
}
|
||||
|
||||
.mb-2 {
|
||||
margin-block-end: var(--s-2);
|
||||
}
|
||||
|
||||
.mb-4 {
|
||||
margin-block-end: var(--s-4);
|
||||
}
|
||||
|
||||
.ml-auto {
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
.ml-1 {
|
||||
margin-inline-start: var(--s-1);
|
||||
}
|
||||
|
||||
.ml-2 {
|
||||
margin-inline-start: var(--s-2);
|
||||
}
|
||||
|
||||
.ml-6 {
|
||||
margin-inline-start: var(--s-6);
|
||||
}
|
||||
|
||||
.mr-auto {
|
||||
margin-inline-end: auto;
|
||||
}
|
||||
|
||||
.mr-1 {
|
||||
margin-inline-end: var(--s-1);
|
||||
}
|
||||
|
||||
.mr-2 {
|
||||
margin-inline-end: var(--s-2);
|
||||
}
|
||||
|
||||
.mx-auto {
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.mx-1 {
|
||||
margin-inline: var(--s-1);
|
||||
}
|
||||
|
||||
.mx-2 {
|
||||
margin-inline: var(--s-2);
|
||||
}
|
||||
|
||||
.my-1 {
|
||||
margin-block: var(--s-1);
|
||||
}
|
||||
|
||||
.my-2 {
|
||||
margin-block: var(--s-2);
|
||||
}
|
||||
|
||||
.my-3 {
|
||||
margin-block: var(--s-3);
|
||||
}
|
||||
|
||||
.my-4 {
|
||||
margin-block: var(--s-4);
|
||||
}
|
||||
|
||||
.my-24 {
|
||||
margin-block: var(--s-24);
|
||||
}
|
||||
|
||||
.m-0-auto {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.line-height-tight {
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.block {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.invisible {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.whitespace-pre-wrap {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.whitespace-nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.overflow-x-auto {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.relative {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.italic {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.contents {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.items-center {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.items-start {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.items-end {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.justify-center {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.justify-end {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.justify-between {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.justify-self-end {
|
||||
justify-self: flex-end;
|
||||
}
|
||||
|
||||
.justify-self-center {
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
.self-center {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.self-start {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.justify-self-start {
|
||||
justify-self: flex-start;
|
||||
}
|
||||
|
||||
.flex-wrap {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.all-unset {
|
||||
all: unset;
|
||||
}
|
||||
|
||||
.focus-text-decoration {
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.label-no-spacing {
|
||||
--label-margin: 0;
|
||||
}
|
||||
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.opaque {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.scrollbar,
|
||||
textarea {
|
||||
/* Firefox, Chrome 121+, Safari 18.2+ */
|
||||
@supports (scrollbar-color: auto) {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--color-bg-higher) transparent;
|
||||
}
|
||||
|
||||
/* Legacy */
|
||||
@supports not (scrollbar-color: auto) {
|
||||
&::-webkit-scrollbar,
|
||||
&::-webkit-scrollbar-track {
|
||||
background-color: transparent;
|
||||
height: 14px;
|
||||
width: 14px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: var(--color-base-4);
|
||||
border-radius: var(--radius-full);
|
||||
border: 4px solid transparent;
|
||||
background-clip: content-box;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-corner {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&.xxxs {
|
||||
gap: var(--s-0-5);
|
||||
}
|
||||
|
||||
&.xxs {
|
||||
gap: var(--s-1);
|
||||
}
|
||||
|
||||
&.xs {
|
||||
gap: var(--s-1-5);
|
||||
}
|
||||
|
||||
&.sm {
|
||||
gap: var(--s-2);
|
||||
}
|
||||
|
||||
&.sm-column {
|
||||
column-gap: var(--s-2);
|
||||
}
|
||||
|
||||
&.sm-plus {
|
||||
gap: var(--s-3);
|
||||
}
|
||||
|
||||
&.md {
|
||||
gap: var(--s-4);
|
||||
}
|
||||
|
||||
&.md-plus {
|
||||
gap: var(--s-6);
|
||||
}
|
||||
|
||||
&.lg {
|
||||
gap: var(--s-8);
|
||||
}
|
||||
|
||||
&.xs-row {
|
||||
row-gap: var(--s-1-5);
|
||||
}
|
||||
|
||||
&.lg-row {
|
||||
row-gap: var(--s-8);
|
||||
}
|
||||
|
||||
&.xl {
|
||||
gap: var(--s-12);
|
||||
}
|
||||
|
||||
&.xxl {
|
||||
gap: var(--s-16);
|
||||
}
|
||||
|
||||
&.horizontal {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
|
||||
.flex-same-size {
|
||||
flex: 1 1 0px;
|
||||
}
|
||||
|
||||
.small-icon {
|
||||
width: 1.2rem;
|
||||
height: 1.2rem;
|
||||
}
|
||||
|
||||
.small-text {
|
||||
font-size: var(--font-2xs);
|
||||
}
|
||||
|
||||
.dotted {
|
||||
text-decoration-style: dotted;
|
||||
text-decoration-line: underline;
|
||||
text-decoration-thickness: 2px;
|
||||
}
|
||||
|
||||
.summary {
|
||||
border-radius: var(--radius-box);
|
||||
background-color: var(--color-bg);
|
||||
font-size: var(--font-xs);
|
||||
font-weight: var(--weight-semi);
|
||||
padding-block: var(--s-1);
|
||||
padding-inline: var(--s-2);
|
||||
}
|
||||
|
||||
html[dir="rtl"] .fix-rtl {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.no-results {
|
||||
text-align: center;
|
||||
color: var(--color-text-high);
|
||||
font-style: italic;
|
||||
font-size: var(--font-xs);
|
||||
}
|
||||
|
||||
.half-width {
|
||||
width: 100%;
|
||||
max-width: 24rem;
|
||||
margin-inline: auto;
|
||||
}
|
||||
}
|
||||
313
apps/web/src/lib/styles/vars.css
Normal file
313
apps/web/src/lib/styles/vars.css
Normal file
@@ -0,0 +1,313 @@
|
||||
@layer reset, base, elements, components, utilities;
|
||||
|
||||
/*
|
||||
⚠️⚠️⚠️
|
||||
Make sure to read styles.md before editing colors in this file,
|
||||
it contains important information about how the styles work and how to edit them properly
|
||||
⚠️⚠️⚠️
|
||||
*/
|
||||
|
||||
/*
|
||||
All the tokens that can be set by custom themes
|
||||
and their default values are defined here
|
||||
|
||||
These should not be consumed directly, only used as a base for other vars!
|
||||
|
||||
The hue and chroma values should not be edited manually, instead
|
||||
use oklch-gamut.ts to generate valid color palettes!
|
||||
|
||||
A subtree can force a color scheme regardless of the html class with
|
||||
[data-theme="dark"|"light"] and opt out of an active custom theme with
|
||||
[data-default-theme] (e.g. image export previews)
|
||||
*/
|
||||
html,
|
||||
[data-default-theme] {
|
||||
--_base-h: 268;
|
||||
--_base-c-0: 0;
|
||||
--_base-c-1: 0.02357547860966049;
|
||||
--_base-c-2: 0.031;
|
||||
--_base-c-3: 0.06999999999999999;
|
||||
--_base-c-4: 0.0645;
|
||||
--_base-c-5: 0.068;
|
||||
--_base-c-6: 0.0645;
|
||||
--_base-c-7: 0.0335;
|
||||
|
||||
--_acc-h: 253;
|
||||
--_acc-c-0: 0.0801740871856143;
|
||||
--_acc-c-1: 0.1603481743712286;
|
||||
--_acc-c-2: 0.0816;
|
||||
--_acc-c-3: 0.06;
|
||||
--_acc-c-4: 0.16343179310913686;
|
||||
--_acc-c-5: 0.09867579961306376;
|
||||
|
||||
--_second-h: 73;
|
||||
--_second-c-0: 0.055379201356259726;
|
||||
--_second-c-1: 0.11075840271251945;
|
||||
--_second-c-2: 0.0816;
|
||||
--_second-c-3: 0.06;
|
||||
--_second-c-4: 0.11288837199545253;
|
||||
--_second-c-5: 0.06815901705385813;
|
||||
|
||||
--_radius-box: 3;
|
||||
--_radius-field: 2;
|
||||
--_radius-selector: 2;
|
||||
|
||||
--_border-width: 2;
|
||||
|
||||
/*
|
||||
Ommited from being overridden when viewing a page that loads a custom theme
|
||||
to prevent accessibility issues and layout shifts
|
||||
*/
|
||||
--_size-field: 1;
|
||||
--_size-selector: 1;
|
||||
--_size-spacing: 1;
|
||||
}
|
||||
|
||||
/*
|
||||
'low' and 'high' do not indicate lightness!
|
||||
This is important because dark goes from light to dark, and light goes from dark to light
|
||||
|
||||
--color-base-x should rarely be consumed directly
|
||||
They mostly exist as a base for other vars like --color-text and --color-bg
|
||||
|
||||
--color-text-accent and --color-text-second can be used as a background
|
||||
or highlight color that maintains a high contrast ratio in both light and dark modes
|
||||
If used as a background color, --color-text-inverse should be used as the text color
|
||||
|
||||
Info, success, warning, and error colors:
|
||||
Preffer using --color-x-high for text
|
||||
It is recommended to use --color-x-low as the background to ensure a high contrast ratio
|
||||
You can use --color-x for things like borders and icons
|
||||
|
||||
Field icons are defined for both dark and light mode because SVGs
|
||||
cannot use currentColor inside data URLs
|
||||
|
||||
Any changes here NEED to be reflected in oklch-gamut.ts as well
|
||||
*/
|
||||
html.dark,
|
||||
html.dark [data-custom-theme],
|
||||
[data-theme="dark"] {
|
||||
--color-base-0: oklch(100% var(--_base-c-0) var(--_base-h));
|
||||
--color-base-1: oklch(95% var(--_base-c-1) var(--_base-h));
|
||||
--color-base-2: oklch(90% var(--_base-c-2) var(--_base-h));
|
||||
--color-base-3: oklch(64% var(--_base-c-3) var(--_base-h));
|
||||
--color-base-4: oklch(46% var(--_base-c-4) var(--_base-h));
|
||||
--color-base-5: oklch(32% var(--_base-c-5) var(--_base-h));
|
||||
--color-base-6: oklch(25% var(--_base-c-6) var(--_base-h));
|
||||
--color-base-7: oklch(17% var(--_base-c-7) var(--_base-h));
|
||||
|
||||
--color-accent-low: oklch(26% var(--_acc-c-0) var(--_acc-h));
|
||||
--color-accent: oklch(52% var(--_acc-c-1) var(--_acc-h));
|
||||
--color-accent-high: oklch(83% var(--_acc-c-2) var(--_acc-h));
|
||||
|
||||
--color-second-low: oklch(
|
||||
from var(--color-accent-low) l var(--_second-c-0) var(--_second-h)
|
||||
);
|
||||
--color-second: oklch(
|
||||
from var(--color-accent) l var(--_second-c-1) var(--_second-h)
|
||||
);
|
||||
--color-second-high: oklch(
|
||||
from var(--color-accent-high) l var(--_second-c-2) var(--_second-h)
|
||||
);
|
||||
|
||||
--color-text-accent: var(--color-accent-high);
|
||||
--color-text-second: var(--color-second-high);
|
||||
|
||||
--color-info-low: oklch(26% 0.09 275);
|
||||
--color-info: oklch(52% 0.27 275);
|
||||
--color-info-high: oklch(83% 0.08 275);
|
||||
|
||||
--color-success-low: oklch(35% 0.08 135);
|
||||
--color-success: oklch(85% 0.22 135);
|
||||
--color-success-high: oklch(91% 0.12 135);
|
||||
|
||||
--color-warning-low: oklch(38% 0.05 85);
|
||||
--color-warning: oklch(82% 0.14 85);
|
||||
--color-warning-high: oklch(94% 0.05 85);
|
||||
|
||||
--color-error-low: oklch(31% 0.07 10);
|
||||
--color-error: oklch(65% 0.25 10);
|
||||
--color-error-high: oklch(87% 0.07 10);
|
||||
|
||||
--color-chart-alpha: #da5b76;
|
||||
--color-chart-bravo: #6572e4;
|
||||
|
||||
--field-icon-checkbox: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(20, 20, 20)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");
|
||||
--field-icon-minus: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(20, 20, 20)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='5' y1='12' x2='19' y2='12'%3E%3C/line%3E%3C/svg%3E");
|
||||
--field-icon-chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(145, 145, 145)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
|
||||
--field-icon-date: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(145, 145, 145)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='4' width='18' height='18' rx='2' ry='2'%3E%3C/rect%3E%3Cline x1='16' y1='2' x2='16' y2='6'%3E%3C/line%3E%3Cline x1='8' y1='2' x2='8' y2='6'%3E%3C/line%3E%3Cline x1='3' y1='10' x2='21' y2='10'%3E%3C/line%3E%3C/svg%3E");
|
||||
--field-icon-time: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(145, 145, 145)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cpolyline points='12 6 12 12 16 14'%3E%3C/polyline%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
html.light,
|
||||
html.light [data-custom-theme],
|
||||
[data-theme="light"] {
|
||||
--color-base-0: oklch(17% var(--_base-c-7) var(--_base-h));
|
||||
--color-base-1: oklch(25% var(--_base-c-6) var(--_base-h));
|
||||
--color-base-2: oklch(32% var(--_base-c-5) var(--_base-h));
|
||||
--color-base-3: oklch(46% var(--_base-c-4) var(--_base-h));
|
||||
--color-base-4: oklch(64% var(--_base-c-3) var(--_base-h));
|
||||
--color-base-5: oklch(90% var(--_base-c-2) var(--_base-h));
|
||||
--color-base-6: oklch(95% var(--_base-c-1) var(--_base-h));
|
||||
--color-base-7: oklch(100% var(--_base-c-0) var(--_base-h));
|
||||
|
||||
--color-accent-low: oklch(88% var(--_acc-c-3) var(--_acc-h));
|
||||
--color-accent: oklch(53% var(--_acc-c-4) var(--_acc-h));
|
||||
--color-accent-high: oklch(32% var(--_acc-c-5) var(--_acc-h));
|
||||
|
||||
--color-second-low: oklch(
|
||||
from var(--color-accent-low) l var(--_second-c-0) var(--_second-h)
|
||||
);
|
||||
--color-second: oklch(
|
||||
from var(--color-accent) l var(--_second-c-1) var(--_second-h)
|
||||
);
|
||||
--color-second-high: oklch(
|
||||
from var(--color-accent-high) l var(--_second-c-2) var(--_second-h)
|
||||
);
|
||||
|
||||
--color-text-accent: var(--color-accent);
|
||||
--color-text-second: var(--color-second);
|
||||
|
||||
--color-info-low: oklch(87.897% 0.0555 279.573);
|
||||
--color-info: oklch(53.03% 0.24331 270.147);
|
||||
--color-info-high: oklch(31.865% 0.1766 267.138);
|
||||
|
||||
--color-success-low: oklch(95.604% 0.06419 135.506);
|
||||
--color-success: oklch(79.407% 0.25035 139.5);
|
||||
--color-success-high: oklch(44.893% 0.13293 138.416);
|
||||
|
||||
--color-warning-low: oklch(94.216% 0.05193 85.101);
|
||||
--color-warning: oklch(82.426% 0.15029 82.564);
|
||||
--color-warning-high: oklch(46.236% 0.09033 79.825);
|
||||
|
||||
--color-error-low: oklch(89.949% 0.04928 355.498);
|
||||
--color-error: oklch(65.223% 0.22164 5.216);
|
||||
--color-error-high: oklch(41.268% 0.15511 8.446);
|
||||
|
||||
--color-chart-alpha: #b82851;
|
||||
--color-chart-bravo: #4246c2;
|
||||
|
||||
--field-icon-checkbox: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");
|
||||
--field-icon-minus: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='5' y1='12' x2='19' y2='12'%3E%3C/line%3E%3C/svg%3E");
|
||||
--field-icon-chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(90, 90, 90)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
|
||||
--field-icon-date: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(90, 90, 90)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='4' width='18' height='18' rx='2' ry='2'%3E%3C/rect%3E%3Cline x1='16' y1='2' x2='16' y2='6'%3E%3C/line%3E%3Cline x1='8' y1='2' x2='8' y2='6'%3E%3C/line%3E%3Cline x1='3' y1='10' x2='21' y2='10'%3E%3C/line%3E%3C/svg%3E");
|
||||
--field-icon-time: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(90, 90, 90)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cpolyline points='12 6 12 12 16 14'%3E%3C/polyline%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
/*
|
||||
These are the vars you mainly want to use
|
||||
*/
|
||||
html,
|
||||
/* biome-ignore lint/style/noDescendingSpecificity: [data-custom-theme] and [data-theme] re-declare theme vars for subtrees; different properties than the html.dark/light blocks so no real override */
|
||||
[data-custom-theme],
|
||||
[data-theme] {
|
||||
--color-text: var(--color-base-0);
|
||||
--color-text-high: var(--color-base-3);
|
||||
--color-text-inverse: var(--color-base-7);
|
||||
|
||||
--color-bg: var(--color-base-7);
|
||||
--color-bg-high: var(--color-base-6);
|
||||
--color-bg-higher: var(--color-base-5);
|
||||
--color-bg-nav: color-mix(
|
||||
in oklch,
|
||||
var(--color-bg) 75%,
|
||||
var(--color-bg-high) 25%
|
||||
);
|
||||
--color-bg-ability: oklch(11.736% 0.00867 215.976);
|
||||
--color-bg-badge: oklch(0% 0 0);
|
||||
|
||||
--color-border: var(--color-base-5);
|
||||
--color-border-high: var(--color-base-4);
|
||||
--border-width: calc(var(--_border-width) * 1px);
|
||||
--border-style: var(--border-width) solid var(--color-border);
|
||||
--border-style-high: var(--border-width) solid var(--color-border-high);
|
||||
--border-style-accent: var(--border-width) solid var(--color-text-accent);
|
||||
|
||||
--focus-ring: 2px solid var(--color-text-accent);
|
||||
--focus-ring-error: 2px solid var(--color-error);
|
||||
|
||||
--font-xl: 1.5rem;
|
||||
--font-lg: 1.2rem;
|
||||
--font-md: 1rem;
|
||||
--font-sm: 0.9rem;
|
||||
--font-xs: 0.8rem;
|
||||
--font-2xs: 0.7rem;
|
||||
--font-3xs: 0.6rem;
|
||||
|
||||
--weight-extra: 700;
|
||||
--weight-bold: 600;
|
||||
--weight-semi: 500;
|
||||
--weight-body: 400;
|
||||
|
||||
--line-height: 1.5;
|
||||
|
||||
/*
|
||||
Tokens used for spacings
|
||||
*/
|
||||
--s-0-5: calc(var(--_size-spacing) * 0.5 * 0.25rem);
|
||||
--s-1: calc(var(--_size-spacing) * 1 * 0.25rem);
|
||||
--s-1-5: calc(var(--_size-spacing) * 1.5 * 0.25rem);
|
||||
--s-2: calc(var(--_size-spacing) * 2 * 0.25rem);
|
||||
--s-2-5: calc(var(--_size-spacing) * 2.5 * 0.25rem);
|
||||
--s-3: calc(var(--_size-spacing) * 3 * 0.25rem);
|
||||
--s-4: calc(var(--_size-spacing) * 4 * 0.25rem);
|
||||
--s-5: calc(var(--_size-spacing) * 5 * 0.25rem);
|
||||
--s-6: calc(var(--_size-spacing) * 6 * 0.25rem);
|
||||
--s-8: calc(var(--_size-spacing) * 8 * 0.25rem);
|
||||
--s-10: calc(var(--_size-spacing) * 10 * 0.25rem);
|
||||
--s-12: calc(var(--_size-spacing) * 12 * 0.25rem);
|
||||
--s-16: calc(var(--_size-spacing) * 16 * 0.25rem);
|
||||
--s-24: calc(var(--_size-spacing) * 24 * 0.25rem);
|
||||
--s-32: calc(var(--_size-spacing) * 32 * 0.25rem);
|
||||
|
||||
/*
|
||||
Tokens used to define layout dimensions
|
||||
|
||||
--layout-sticky-top can be used by any element with position: sticky
|
||||
that sticks relative to the document scroll to ensure
|
||||
a smooth transition between sticking and un-sticking
|
||||
|
||||
Elements with position: sticky that do not stick relative
|
||||
to the document scroll do not need to use this var
|
||||
*/
|
||||
--layout-nav-height: 55px;
|
||||
--layout-sidenav-width: 250px;
|
||||
--layout-fuse-bottom-height: 90px;
|
||||
--layout-main-padding: var(--s-3);
|
||||
--layout-sticky-top: calc(
|
||||
var(--layout-nav-height) +
|
||||
var(--layout-main-padding) +
|
||||
env(safe-area-inset-top)
|
||||
);
|
||||
|
||||
/*
|
||||
Boxes are elements that contain other elements, like cards, containers, and modals
|
||||
but can also be top level elements in a visual hierarchy
|
||||
|
||||
Fields are interactive elements like buttons, inputs, and selects
|
||||
but also any child of a box that is interactable or contains content
|
||||
|
||||
Selectors are elements like checkboxes, radio buttons, and toggles
|
||||
but can also be any small element like bages, tags, and pills
|
||||
that are complementary to fields
|
||||
*/
|
||||
--field-size-xs: calc(var(--_size-field) * 6 * 0.25rem);
|
||||
--field-size-sm: calc(var(--_size-field) * 8 * 0.25rem);
|
||||
--field-size: calc(var(--_size-field) * 10 * 0.25rem);
|
||||
--field-size-lg: calc(var(--_size-field) * 12 * 0.25rem);
|
||||
--field-size-icon: 18px;
|
||||
--field-padding: 0.6rem;
|
||||
|
||||
--selector-size-xs: calc(var(--_size-selector) * 4 * 0.25rem);
|
||||
--selector-size-sm: calc(var(--_size-selector) * 5 * 0.25rem);
|
||||
--selector-size: calc(var(--_size-selector) * 6 * 0.25rem);
|
||||
--selector-size-lg: calc(var(--_size-selector) * 7 * 0.25rem);
|
||||
|
||||
--radius-box: calc(var(--_radius-box) * 4px);
|
||||
--radius-field: calc(var(--_radius-field) * 4px);
|
||||
--radius-selector: calc(var(--_radius-selector) * 4px);
|
||||
--radius-full: 99999px;
|
||||
--radius-avatar: var(--radius-full);
|
||||
|
||||
--label-margin: var(--s-1);
|
||||
}
|
||||
15
apps/web/src/lib/utils/dates.ts
Normal file
15
apps/web/src/lib/utils/dates.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
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());
|
||||
}
|
||||
5
apps/web/src/lib/utils/e2e.ts
Normal file
5
apps/web/src/lib/utils/e2e.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
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");
|
||||
29
apps/web/src/lib/utils/users.ts
Normal file
29
apps/web/src/lib/utils/users.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { logger } from "@sendou/utils/logger";
|
||||
|
||||
const DISCORD_ID_MIN_LENGTH = 17;
|
||||
|
||||
// snowflake logic from https://github.dev/vegeta897/snow-stamp/blob/main/src/util.js
|
||||
const DISCORD_EPOCH = 1420070400000;
|
||||
|
||||
/** Converts a Discord snowflake ID string into the `Date` it encodes. */
|
||||
export function convertSnowflakeToDate(snowflake: string) {
|
||||
const milliseconds = BigInt(snowflake) >> 22n;
|
||||
return new Date(Number(milliseconds) + DISCORD_EPOCH);
|
||||
}
|
||||
|
||||
const AGED_CRITERIA = 1000 * 60 * 60 * 24 * 30 * 3; // 3 months
|
||||
|
||||
/** Whether the user's Discord account is old enough to unlock features gated on account age. */
|
||||
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;
|
||||
}
|
||||
7
apps/web/src/routes/+error.svelte
Normal file
7
apps/web/src/routes/+error.svelte
Normal file
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { page } from "$app/state";
|
||||
</script>
|
||||
|
||||
<main data-testid="error-page">
|
||||
<h2>Error {page.status}</h2>
|
||||
</main>
|
||||
12
apps/web/src/routes/+layout.svelte
Normal file
12
apps/web/src/routes/+layout.svelte
Normal file
@@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
import "#lib/styles/fonts.css";
|
||||
import "#lib/styles/vars.css";
|
||||
import "#lib/styles/normalize.css";
|
||||
import "#lib/styles/common.css";
|
||||
import "#lib/styles/utils.css";
|
||||
import "#lib/styles/flags.css";
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
{@render children()}
|
||||
1
apps/web/src/routes/leaderboards/+page.svelte
Normal file
1
apps/web/src/routes/leaderboards/+page.svelte
Normal file
@@ -0,0 +1 @@
|
||||
<h1>Leaderboards</h1>
|
||||
9
apps/web/tsconfig.json
Normal file
9
apps/web/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "$app/tsconfig",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": false,
|
||||
"types": ["$app/types", "node", "vite/client"]
|
||||
},
|
||||
"include": ["src", "scripts", "vite.config.ts"]
|
||||
}
|
||||
23
apps/web/vite.config.ts
Normal file
23
apps/web/vite.config.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { paraglideVitePlugin } from "@inlang/paraglide-js";
|
||||
import adapter from "@sveltejs/adapter-node";
|
||||
import { sveltekit } from "@sveltejs/kit/vite";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig(({ mode }) => ({
|
||||
plugins: [
|
||||
paraglideVitePlugin({
|
||||
project: "./project.inlang",
|
||||
outdir: "./src/lib/paraglide",
|
||||
strategy: ["cookie", "preferredLanguage", "baseLocale"],
|
||||
outputStructure: mode === "production" ? "message-modules" : "locale-modules",
|
||||
}),
|
||||
sveltekit({
|
||||
adapter: adapter(),
|
||||
compilerOptions: { experimental: { async: true } },
|
||||
experimental: { remoteFunctions: true },
|
||||
}),
|
||||
],
|
||||
test: {
|
||||
include: ["src/**/*.test.ts"],
|
||||
},
|
||||
}));
|
||||
966
pnpm-lock.yaml
generated
966
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user