From a9c18e0ddad2d96b8ebf21ba8eaf6788609d91b8 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sun, 16 Aug 2026 11:30:13 +0300 Subject: [PATCH] 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 --- apps/web/.gitignore | 6 + apps/web/package.json | 44 + apps/web/project.inlang/settings.json | 12 + apps/web/scripts/build-with-all-locales.ts | 35 + apps/web/scripts/combine-locales.ts | 80 ++ apps/web/src/app.d.ts | 15 + apps/web/src/app.html | 21 + apps/web/src/hooks.server.ts | 34 + apps/web/src/lib/config.ts | 104 ++ apps/web/src/lib/db/tables-json.ts | 34 + .../src/lib/features/admin/admin-constants.ts | 12 + .../src/lib/features/auth/session.server.ts | 147 +++ apps/web/src/lib/features/auth/user-types.ts | 23 + apps/web/src/lib/features/auth/user.server.ts | 35 + .../web/src/lib/features/ban/banned.server.ts | 32 + .../LeaderboardRepository.server.ts | 507 ++++++++ .../leaderboards/leaderboards-constants.ts | 29 + .../leaderboards/leaderboards-core.server.ts | 235 ++++ .../leaderboards/leaderboards-schemas.ts | 21 + .../leaderboards/leaderboards-utils.ts | 40 + .../leaderboards/leaderboards.remote.ts | 121 ++ .../lib/features/leaderboards/top-ten.json | 278 +++++ apps/web/src/lib/features/mmr/Seasons.ts | 238 ++++ .../features/mmr/SkillRepository.server.ts | 11 + .../web/src/lib/features/mmr/mmr-constants.ts | 26 + apps/web/src/lib/features/mmr/mmr-utils.ts | 38 + .../web/src/lib/features/mmr/tiered.server.ts | 148 +++ .../src/lib/features/theme/theme-constants.ts | 36 + .../web/src/lib/features/theme/theme-types.ts | 5 + .../src/lib/features/theme/theme.server.ts | 50 + .../user-page/UserRepository.server.ts | 79 ++ apps/web/src/lib/modules/i18n/languages.ts | 41 + .../lib/modules/permissions/guards.server.ts | 12 + .../lib/modules/permissions/mapper.server.ts | 78 ++ apps/web/src/lib/modules/permissions/types.ts | 21 + apps/web/src/lib/modules/permissions/utils.ts | 66 ++ apps/web/src/lib/server/cache.ts | 80 ++ apps/web/src/lib/server/config.ts | 111 ++ .../lib/server/db/empty-values-noop-plugin.ts | 58 + apps/web/src/lib/server/db/json-columns.ts | 51 + apps/web/src/lib/server/db/json-selections.ts | 153 +++ .../src/lib/server/db/node-sqlite-dialect.ts | 456 ++++++++ apps/web/src/lib/server/db/sql.ts | 178 +++ apps/web/src/lib/server/db/tables.ts | 284 +++++ apps/web/src/lib/server/db/write-tracker.ts | 41 + apps/web/src/lib/server/kysely.ts | 273 +++++ apps/web/src/lib/styles/common.css | 659 +++++++++++ apps/web/src/lib/styles/flags.css | 1018 +++++++++++++++++ apps/web/src/lib/styles/fonts.css | 32 + .../lib/styles/fonts/lexend-latin-ext.woff2 | Bin 0 -> 34548 bytes .../src/lib/styles/fonts/lexend-latin.woff2 | Bin 0 -> 39692 bytes apps/web/src/lib/styles/normalize.css | 217 ++++ apps/web/src/lib/styles/utils.css | 537 +++++++++ apps/web/src/lib/styles/vars.css | 313 +++++ apps/web/src/lib/utils/dates.ts | 15 + apps/web/src/lib/utils/e2e.ts | 5 + apps/web/src/lib/utils/users.ts | 29 + apps/web/src/routes/+error.svelte | 7 + apps/web/src/routes/+layout.svelte | 12 + apps/web/src/routes/leaderboards/+page.svelte | 1 + apps/web/tsconfig.json | 9 + apps/web/vite.config.ts | 23 + pnpm-lock.yaml | 966 +++++++++++++++- 63 files changed, 8206 insertions(+), 36 deletions(-) create mode 100644 apps/web/.gitignore create mode 100644 apps/web/package.json create mode 100644 apps/web/project.inlang/settings.json create mode 100644 apps/web/scripts/build-with-all-locales.ts create mode 100644 apps/web/scripts/combine-locales.ts create mode 100644 apps/web/src/app.d.ts create mode 100644 apps/web/src/app.html create mode 100644 apps/web/src/hooks.server.ts create mode 100644 apps/web/src/lib/config.ts create mode 100644 apps/web/src/lib/db/tables-json.ts create mode 100644 apps/web/src/lib/features/admin/admin-constants.ts create mode 100644 apps/web/src/lib/features/auth/session.server.ts create mode 100644 apps/web/src/lib/features/auth/user-types.ts create mode 100644 apps/web/src/lib/features/auth/user.server.ts create mode 100644 apps/web/src/lib/features/ban/banned.server.ts create mode 100644 apps/web/src/lib/features/leaderboards/LeaderboardRepository.server.ts create mode 100644 apps/web/src/lib/features/leaderboards/leaderboards-constants.ts create mode 100644 apps/web/src/lib/features/leaderboards/leaderboards-core.server.ts create mode 100644 apps/web/src/lib/features/leaderboards/leaderboards-schemas.ts create mode 100644 apps/web/src/lib/features/leaderboards/leaderboards-utils.ts create mode 100644 apps/web/src/lib/features/leaderboards/leaderboards.remote.ts create mode 100644 apps/web/src/lib/features/leaderboards/top-ten.json create mode 100644 apps/web/src/lib/features/mmr/Seasons.ts create mode 100644 apps/web/src/lib/features/mmr/SkillRepository.server.ts create mode 100644 apps/web/src/lib/features/mmr/mmr-constants.ts create mode 100644 apps/web/src/lib/features/mmr/mmr-utils.ts create mode 100644 apps/web/src/lib/features/mmr/tiered.server.ts create mode 100644 apps/web/src/lib/features/theme/theme-constants.ts create mode 100644 apps/web/src/lib/features/theme/theme-types.ts create mode 100644 apps/web/src/lib/features/theme/theme.server.ts create mode 100644 apps/web/src/lib/features/user-page/UserRepository.server.ts create mode 100644 apps/web/src/lib/modules/i18n/languages.ts create mode 100644 apps/web/src/lib/modules/permissions/guards.server.ts create mode 100644 apps/web/src/lib/modules/permissions/mapper.server.ts create mode 100644 apps/web/src/lib/modules/permissions/types.ts create mode 100644 apps/web/src/lib/modules/permissions/utils.ts create mode 100644 apps/web/src/lib/server/cache.ts create mode 100644 apps/web/src/lib/server/config.ts create mode 100644 apps/web/src/lib/server/db/empty-values-noop-plugin.ts create mode 100644 apps/web/src/lib/server/db/json-columns.ts create mode 100644 apps/web/src/lib/server/db/json-selections.ts create mode 100644 apps/web/src/lib/server/db/node-sqlite-dialect.ts create mode 100644 apps/web/src/lib/server/db/sql.ts create mode 100644 apps/web/src/lib/server/db/tables.ts create mode 100644 apps/web/src/lib/server/db/write-tracker.ts create mode 100644 apps/web/src/lib/server/kysely.ts create mode 100644 apps/web/src/lib/styles/common.css create mode 100644 apps/web/src/lib/styles/flags.css create mode 100644 apps/web/src/lib/styles/fonts.css create mode 100644 apps/web/src/lib/styles/fonts/lexend-latin-ext.woff2 create mode 100644 apps/web/src/lib/styles/fonts/lexend-latin.woff2 create mode 100644 apps/web/src/lib/styles/normalize.css create mode 100644 apps/web/src/lib/styles/utils.css create mode 100644 apps/web/src/lib/styles/vars.css create mode 100644 apps/web/src/lib/utils/dates.ts create mode 100644 apps/web/src/lib/utils/e2e.ts create mode 100644 apps/web/src/lib/utils/users.ts create mode 100644 apps/web/src/routes/+error.svelte create mode 100644 apps/web/src/routes/+layout.svelte create mode 100644 apps/web/src/routes/leaderboards/+page.svelte create mode 100644 apps/web/tsconfig.json create mode 100644 apps/web/vite.config.ts diff --git a/apps/web/.gitignore b/apps/web/.gitignore new file mode 100644 index 000000000..daa700841 --- /dev/null +++ b/apps/web/.gitignore @@ -0,0 +1,6 @@ +.svelte-kit/ +build/ +messages/ +src/lib/paraglide/ +project.inlang/cache/ +project.inlang/settings.json.backup diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 000000000..f13573e36 --- /dev/null +++ b/apps/web/package.json @@ -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" + } +} diff --git a/apps/web/project.inlang/settings.json b/apps/web/project.inlang/settings.json new file mode 100644 index 000000000..67d14e4f8 --- /dev/null +++ b/apps/web/project.inlang/settings.json @@ -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" + } +} diff --git a/apps/web/scripts/build-with-all-locales.ts b/apps/web/scripts/build-with-all-locales.ts new file mode 100644 index 000000000..f602f3ad6 --- /dev/null +++ b/apps/web/scripts/build-with-all-locales.ts @@ -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); +} diff --git a/apps/web/scripts/combine-locales.ts b/apps/web/scripts/combine-locales.ts new file mode 100644 index 000000000..350673e71 --- /dev/null +++ b/apps/web/scripts/combine-locales.ts @@ -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, prefix = "") { + const flattened: Record = {}; + + 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, 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 = { + $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."); diff --git a/apps/web/src/app.d.ts b/apps/web/src/app.d.ts new file mode 100644 index 000000000..279455a72 --- /dev/null +++ b/apps/web/src/app.d.ts @@ -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 {}; diff --git a/apps/web/src/app.html b/apps/web/src/app.html new file mode 100644 index 000000000..0121188ca --- /dev/null +++ b/apps/web/src/app.html @@ -0,0 +1,21 @@ + + + + + + + + + + %sveltekit.head% + + + %sveltekit.body% + + diff --git a/apps/web/src/hooks.server.ts b/apps/web/src/hooks.server.ts new file mode 100644 index 000000000..9978adaaa --- /dev/null +++ b/apps/web/src/hooks.server.ts @@ -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} ` : ""); + }, + }); + }, + ); +}; diff --git a/apps/web/src/lib/config.ts b/apps/web/src/lib/config.ts new file mode 100644 index 000000000..b3de26a09 --- /dev/null +++ b/apps/web/src/lib/config.ts @@ -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) + : {}; + +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.`, + ); +} diff --git a/apps/web/src/lib/db/tables-json.ts b/apps/web/src/lib/db/tables-json.ts new file mode 100644 index 000000000..9b4c0cf77 --- /dev/null +++ b/apps/web/src/lib/db/tables-json.ts @@ -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; +} diff --git a/apps/web/src/lib/features/admin/admin-constants.ts b/apps/web/src/lib/features/admin/admin-constants.ts new file mode 100644 index 000000000..8dc740eaf --- /dev/null +++ b/apps/web/src/lib/features/admin/admin-constants.ts @@ -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]; diff --git a/apps/web/src/lib/features/auth/session.server.ts b/apps/web/src/lib/features/auth/session.server.ts new file mode 100644 index 000000000..0cb15fc27 --- /dev/null +++ b/apps/web/src/lib/features/auth/session.server.ts @@ -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; + +/** 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; +} diff --git a/apps/web/src/lib/features/auth/user-types.ts b/apps/web/src/lib/features/auth/user-types.ts new file mode 100644 index 000000000..57cd5a9b9 --- /dev/null +++ b/apps/web/src/lib/features/auth/user-types.ts @@ -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[]; +} diff --git a/apps/web/src/lib/features/auth/user.server.ts b/apps/web/src/lib/features/auth/user.server.ts new file mode 100644 index 000000000..06cfc2660 --- /dev/null +++ b/apps/web/src/lib/features/auth/user.server.ts @@ -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; +} diff --git a/apps/web/src/lib/features/ban/banned.server.ts b/apps/web/src/lib/features/ban/banned.server.ts new file mode 100644 index 000000000..cb893bd7a --- /dev/null +++ b/apps/web/src/lib/features/ban/banned.server.ts @@ -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 +> | 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(); +} diff --git a/apps/web/src/lib/features/leaderboards/LeaderboardRepository.server.ts b/apps/web/src/lib/features/leaderboards/LeaderboardRepository.server.ts new file mode 100644 index 000000000..eb7a427c1 --- /dev/null +++ b/apps/web/src/lib/features/leaderboards/LeaderboardRepository.server.ts @@ -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(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(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 +>; +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; +}): 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().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(); + 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) { + 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 +>[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`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 +>[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 { + 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`"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`${ref("merged.packedUserWeapon")} / ${sql.lit( + USER_WEAPON_PACK_FACTOR, + )}`.as("userId"), + sql`${ref("merged.packedUserWeapon")} % ${sql.lit( + USER_WEAPON_PACK_FACTOR, + )}`.as("weaponSplId"), + fn.countAll().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])); +} diff --git a/apps/web/src/lib/features/leaderboards/leaderboards-constants.ts b/apps/web/src/lib/features/leaderboards/leaderboards-constants.ts new file mode 100644 index 000000000..989755cea --- /dev/null +++ b/apps/web/src/lib/features/leaderboards/leaderboards-constants.ts @@ -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]; diff --git a/apps/web/src/lib/features/leaderboards/leaderboards-core.server.ts b/apps/web/src/lib/features/leaderboards/leaderboards-core.server.ts new file mode 100644 index 000000000..ca29480f2 --- /dev/null +++ b/apps/web/src/lib/features/leaderboards/leaderboards-core.server.ts @@ -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( + entries: T[], + season: number, +) { + const tiers = await freshUserSkills(season); + + const encounteredTiers = new Set(); + 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( + 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( + 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, 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, + }; +} diff --git a/apps/web/src/lib/features/leaderboards/leaderboards-schemas.ts b/apps/web/src/lib/features/leaderboards/leaderboards-schemas.ts new file mode 100644 index 000000000..ae02bc2bd --- /dev/null +++ b/apps/web/src/lib/features/leaderboards/leaderboards-schemas.ts @@ -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), + ), +}); diff --git a/apps/web/src/lib/features/leaderboards/leaderboards-utils.ts b/apps/web/src/lib/features/leaderboards/leaderboards-utils.ts new file mode 100644 index 000000000..cc186eecd --- /dev/null +++ b/apps/web/src/lib/features/leaderboards/leaderboards-utils.ts @@ -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; + +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]; +} diff --git a/apps/web/src/lib/features/leaderboards/leaderboards.remote.ts b/apps/web/src/lib/features/leaderboards/leaderboards.remote.ts new file mode 100644 index 000000000..2e4779d61 --- /dev/null +++ b/apps/web/src/lib/features/leaderboards/leaderboards.remote.ts @@ -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(); + }, +); diff --git a/apps/web/src/lib/features/leaderboards/top-ten.json b/apps/web/src/lib/features/leaderboards/top-ten.json new file mode 100644 index 000000000..24f72277a --- /dev/null +++ b/apps/web/src/lib/features/leaderboards/top-ten.json @@ -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 + } + } + ] +] diff --git a/apps/web/src/lib/features/mmr/Seasons.ts b/apps/web/src/lib/features/mmr/Seasons.ts new file mode 100644 index 000000000..e6382d646 --- /dev/null +++ b/apps/web/src/lib/features/mmr/Seasons.ts @@ -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(); +} diff --git a/apps/web/src/lib/features/mmr/SkillRepository.server.ts b/apps/web/src/lib/features/mmr/SkillRepository.server.ts new file mode 100644 index 000000000..4dc9de3d1 --- /dev/null +++ b/apps/web/src/lib/features/mmr/SkillRepository.server.ts @@ -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(); +} diff --git a/apps/web/src/lib/features/mmr/mmr-constants.ts b/apps/web/src/lib/features/mmr/mmr-constants.ts new file mode 100644 index 000000000..ba9599f7a --- /dev/null +++ b/apps/web/src/lib/features/mmr/mmr-constants.ts @@ -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; diff --git a/apps/web/src/lib/features/mmr/mmr-utils.ts b/apps/web/src/lib/features/mmr/mmr-utils.ts new file mode 100644 index 000000000..d5e349347 --- /dev/null +++ b/apps/web/src/lib/features/mmr/mmr-utils.ts @@ -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) + ); +} diff --git a/apps/web/src/lib/features/mmr/tiered.server.ts b/apps/web/src/lib/features/mmr/tiered.server.ts new file mode 100644 index 000000000..ab96e8380 --- /dev/null +++ b/apps/web/src/lib/features/mmr/tiered.server.ts @@ -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; + 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>, + 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 }; +} diff --git a/apps/web/src/lib/features/theme/theme-constants.ts b/apps/web/src/lib/features/theme/theme-constants.ts new file mode 100644 index 000000000..eb4ff165a --- /dev/null +++ b/apps/web/src/lib/features/theme/theme-constants.ts @@ -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]; diff --git a/apps/web/src/lib/features/theme/theme-types.ts b/apps/web/src/lib/features/theme/theme-types.ts new file mode 100644 index 000000000..0e87da6f4 --- /dev/null +++ b/apps/web/src/lib/features/theme/theme-types.ts @@ -0,0 +1,5 @@ +import type { CustomThemeVar } from "./theme-constants.ts"; + +export type CustomTheme = Omit, "--_chat-h"> & { + "--_chat-h": number | null; +}; diff --git a/apps/web/src/lib/features/theme/theme.server.ts b/apps/web/src/lib/features/theme/theme.server.ts new file mode 100644 index 000000000..9388f4178 --- /dev/null +++ b/apps/web/src/lib/features/theme/theme.server.ts @@ -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: "/" }); +} diff --git a/apps/web/src/lib/features/user-page/UserRepository.server.ts b/apps/web/src/lib/features/user-page/UserRepository.server.ts new file mode 100644 index 000000000..52a2de01a --- /dev/null +++ b/apps/web/src/lib/features/user-page/UserRepository.server.ts @@ -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 = new Map(); + + for (const row of rows) { + result.set(row.userId, row); + } + + return result; +} diff --git a/apps/web/src/lib/modules/i18n/languages.ts b/apps/web/src/lib/modules/i18n/languages.ts new file mode 100644 index 000000000..ad13dbf2b --- /dev/null +++ b/apps/web/src/lib/modules/i18n/languages.ts @@ -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"]; diff --git a/apps/web/src/lib/modules/permissions/guards.server.ts b/apps/web/src/lib/modules/permissions/guards.server.ts new file mode 100644 index 000000000..d277758ed --- /dev/null +++ b/apps/web/src/lib/modules/permissions/guards.server.ts @@ -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"); + } +} diff --git a/apps/web/src/lib/modules/permissions/mapper.server.ts b/apps/web/src/lib/modules/permissions/mapper.server.ts new file mode 100644 index 000000000..1837a526e --- /dev/null +++ b/apps/web/src/lib/modules/permissions/mapper.server.ts @@ -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 = []; + + 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; +} diff --git a/apps/web/src/lib/modules/permissions/types.ts b/apps/web/src/lib/modules/permissions/types.ts new file mode 100644 index 000000000..9d3aeb4bd --- /dev/null +++ b/apps/web/src/lib/modules/permissions/types.ts @@ -0,0 +1,21 @@ +export type Permissions = Record; + +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 diff --git a/apps/web/src/lib/modules/permissions/utils.ts b/apps/web/src/lib/modules/permissions/utils.ts new file mode 100644 index 000000000..a36118f23 --- /dev/null +++ b/apps/web/src/lib/modules/permissions/utils.ts @@ -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)[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; +} diff --git a/apps/web/src/lib/server/cache.ts b/apps/web/src/lib/server/cache.ts new file mode 100644 index 000000000..26e1b97dd --- /dev/null +++ b/apps/web/src/lib/server/cache.ts @@ -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 { + private readonly max: number; + private readonly map = new Map(); + + 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> | undefined; +} + +export const cache = (global.__lruCache = global.__lruCache + ? global.__lruCache + : new LRUCache>({ max: 5000 })); + +export const ttl = (ms: number) => (ServerConfig.disableCache ? 0 : ms); + +export function syncCached(key: string, getFreshValue: () => T) { + if (cache.has(key)) { + return cache.get(key) as T; + } + + const value = getFreshValue(); + cache.set(key, value as CacheEntry); + + 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, +}; diff --git a/apps/web/src/lib/server/config.ts b/apps/web/src/lib/server/config.ts new file mode 100644 index 000000000..0af1b93b4 --- /dev/null +++ b/apps/web/src/lib/server/config.ts @@ -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( + 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.`, + ); +} diff --git a/apps/web/src/lib/server/db/empty-values-noop-plugin.ts b/apps/web/src/lib/server/db/empty-values-noop-plugin.ts new file mode 100644 index 000000000..ade5c6f96 --- /dev/null +++ b/apps/web/src/lib/server/db/empty-values-noop-plugin.ts @@ -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> { + 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")), + }); +} diff --git a/apps/web/src/lib/server/db/json-columns.ts b/apps/web/src/lib/server/db/json-columns.ts new file mode 100644 index 000000000..faf5584fd --- /dev/null +++ b/apps/web/src/lib/server/db/json-columns.ts @@ -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 = 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", +]); diff --git a/apps/web/src/lib/server/db/json-selections.ts b/apps/web/src/lib/server/db/json-selections.ts new file mode 100644 index 000000000..808602270 --- /dev/null +++ b/apps/web/src/lib/server/db/json-selections.ts @@ -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 = 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 { + // 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>; + +function outputNames(select: SelectQueryNode): ReadonlySet { + const sources = sourceOutputNames(select); + const names = new Set(); + + 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>(); + + 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)) + ); +} diff --git a/apps/web/src/lib/server/db/node-sqlite-dialect.ts b/apps/web/src/lib/server/db/node-sqlite-dialect.ts new file mode 100644 index 000000000..ade7e7881 --- /dev/null +++ b/apps/web/src/lib/server/db/node-sqlite-dialect.ts @@ -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 = 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; + /** + * 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; +} + +/** + * 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): SqliteIntrospector { + return new SqliteIntrospector(db); + } +} + +class NodeSqliteDriver implements Driver { + readonly #config: NodeSqliteDialectConfig; + #connection?: NodeSqliteConnection; + + constructor(config: NodeSqliteDialectConfig) { + this.#config = config; + } + + async init(): Promise { + this.#connection = new NodeSqliteConnection(this.#config); + } + + async acquireConnection(): Promise { + return this.#connection!; + } + + async beginTransaction(connection: DatabaseConnection): Promise { + await connection.executeQuery(CompiledQuery.raw("begin")); + } + + async commitTransaction(connection: DatabaseConnection): Promise { + await connection.executeQuery(CompiledQuery.raw("commit")); + } + + async rollbackTransaction(connection: DatabaseConnection): Promise { + await connection.executeQuery(CompiledQuery.raw("rollback")); + } + + async savepoint( + connection: DatabaseConnection, + savepointName: string, + compileQuery: QueryCompiler["compileQuery"], + ): Promise { + await connection.executeQuery( + compileQuery( + savepointCommand("savepoint", savepointName), + createQueryId(), + ), + ); + } + + async rollbackToSavepoint( + connection: DatabaseConnection, + savepointName: string, + compileQuery: QueryCompiler["compileQuery"], + ): Promise { + await connection.executeQuery( + compileQuery( + savepointCommand("rollback to", savepointName), + createQueryId(), + ), + ); + } + + async releaseSavepoint( + connection: DatabaseConnection, + savepointName: string, + compileQuery: QueryCompiler["compileQuery"], + ): Promise { + await connection.executeQuery( + compileQuery(savepointCommand("release", savepointName), createQueryId()), + ); + } + + async releaseConnection(): Promise { + // the single connection is never handed back to a pool + } + + async destroy(): Promise { + 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; + byOutputName: ReadonlySet; +} + +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; + readonly #computedJsonColumns?: ( + query: RootOperationNode, + ) => ReadonlySet; + readonly #cache = new Map(); + + constructor(config: NodeSqliteDialectConfig) { + this.#database = config.database; + this.#cacheStatements = config.cacheStatements ?? false; + this.#jsonColumns = config.jsonColumns; + this.#computedJsonColumns = config.computedJsonColumns; + } + + async executeQuery(compiledQuery: CompiledQuery): Promise> { + const prepared = this.#preparedStatementFor(compiledQuery); + const parameters = compiledQuery.parameters as SQLInputValue[]; + + if (prepared.columnNames.length > 0) { + return { rows: readRows(prepared, parameters) }; + } + + const { changes, lastInsertRowid } = prepared.statement.run(...parameters); + + return { + insertId: BigInt(lastInsertRowid), + numAffectedRows: BigInt(changes), + rows: [], + }; + } + + async *streamQuery( + compiledQuery: CompiledQuery, + ): AsyncIterableIterator> { + 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(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( + 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(rawRows.length); + for (let i = 0; i < rawRows.length; i++) { + rows[i] = toRow(prepared, rawRows[i]); + } + + return rows; +} + +function toRow(prepared: PreparedStatement, rawRow: SQLOutputValue[]): R { + const { columnNames, jsonColumnFlags } = prepared; + + const row: Record = {}; + 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 { + 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), + ]); +} diff --git a/apps/web/src/lib/server/db/sql.ts b/apps/web/src/lib/server/db/sql.ts new file mode 100644 index 000000000..6e34773f9 --- /dev/null +++ b/apps/web/src/lib/server/db/sql.ts @@ -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({ + 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() || ""); +} diff --git a/apps/web/src/lib/server/db/tables.ts b/apps/web/src/lib/server/db/tables.ts new file mode 100644 index 000000000..fc9cbdb8a --- /dev/null +++ b/apps/web/src/lib/server/db/tables.ts @@ -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 extends ColumnType + ? ColumnType + : ColumnType; + +/** 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; + +export type { PeakXP, UserPreferences, XRankPlacementRegion }; + +export interface User { + /** 1 = permabanned, timestamp = ban active till then */ + banned: Generated; + bannedReason: string | null; + country: string | null; + customTheme: JSONColumnTypeNullable; + customUrl: string | null; + discordAvatar: string | null; + customAvatarImgId: number | null; + discordId: string; + discordName: string; + customName: string | null; + /** coalesce(customName, discordName) */ + username: ColumnType; + /** Name the user is shown under in tournaments, set by organizers of established organizations. `null` = their `username` is used. */ + tournamentName: string | null; + id: GeneratedAlways; + inGameName: string | null; + isArtist: Generated; + isVideoAdder: Generated; + isTournamentOrganizer: Generated; + isApiAccesser: Generated; + languages: JSONColumnTypeNullable; + patronTier: number | null; + preferences: JSONColumnTypeNullable; + 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; +} + +/** 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; + customUrl: string; + /** Soft delete marker. Always `null` when selected via the `Team` view, which filters these rows out. */ + deletedAt: number | null; + id: GeneratedAlways; + 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; + isOwner: Generated; + isManager: Generated; + /** 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; + teamId: number; + userId: number; + isMainTeam: Generated; +} + +/** Read-only. See {@link DB} for how this view relates to `UnvalidatedUserSubmittedImage`. */ +export interface UserSubmittedImage { + id: GeneratedAlways; + submitterUserId: number | null; + url: string; + /** Never `null` in practice, the view filters unvalidated rows out. */ + validatedAt: number | null; +} + +export interface UnvalidatedUserSubmittedImage { + id: GeneratedAlways; + 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; + /** 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; + season: number; + /** The team's roster, same as `Skill.identifier`. */ + identifier: SkillTeamIdentifier; + skippedByUserId: number; + createdAt: Generated; +} + +export interface SplatoonPlayer { + id: GeneratedAlways; + splId: string; + userId: number | null; + /** Players best XP across both divisions. Denormalized for performance. */ + peakXp: JSONColumnTypeNullable; +} + +export interface XRankPlacement { + badges: string; + bannerSplId: number; + id: GeneratedAlways; + 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; +} + +export interface GroupMatch { + alphaGroupId: number; + bravoGroupId: number; + chatCode: string | null; + confirmedAt: number | null; + confirmedByUserId: number | null; + createdAt: Generated; + id: GeneratedAlways; + cancelRequestedByUserId: number | null; + cancelAcceptedByUserId: number | null; + noScreen: Generated; +} + +export interface GroupMember { + createdAt: Generated; + groupId: number; + missedReadyCheckAt: number | null; + note: string | null; + userId: number; +} + +export interface Tournament { + id: GeneratedAlways; + /** Is the tournament finalized meaning all the matches are played and TO has locked it making it read-only */ + isFinalized: Generated; +} + +export interface TournamentStage { + id: GeneratedAlways; + name: string; + number: number; + tournamentId: number; + createdAt: Generated; +} + +export interface TournamentMatch { + chatCode: string | null; + groupId: number; + id: GeneratedAlways; + number: number; + roundId: number; + stageId: number; + startedAt: number | null; +} + +export interface TournamentResult { + isHighlight: Generated; + 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 }; +export type TablesInsertable = { [P in keyof DB]: Insertable }; + +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; +} diff --git a/apps/web/src/lib/server/db/write-tracker.ts b/apps/web/src/lib/server/db/write-tracker.ts new file mode 100644 index 000000000..ff06b6ab1 --- /dev/null +++ b/apps/web/src/lib/server/db/write-tracker.ts @@ -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> { + 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; +} diff --git a/apps/web/src/lib/server/kysely.ts b/apps/web/src/lib/server/kysely.ts new file mode 100644 index 000000000..e6ec1ffdc --- /dev/null +++ b/apps/web/src/lib/server/kysely.ts @@ -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 extends { alias: infer A extends string } + ? A + : "User"; + +type PrefixedUserColumn = O extends { + prefix: infer P extends string; +} + ? `${P}${Capitalize}` + : C; + +type UserIdColumn = O extends { idAs: infer I extends string } + ? I + : PrefixedUserColumn; + +type CommonUserSelectResult = readonly [ + `${UserTableAlias}.id as ${UserIdColumn}`, + `${UserTableAlias}.username as ${PrefixedUserColumn}`, + `${UserTableAlias}.discordId as ${PrefixedUserColumn}`, + `${UserTableAlias}.discordAvatar as ${PrefixedUserColumn}`, + `${UserTableAlias}.customUrl as ${PrefixedUserColumn}`, + AliasedRawBuilder>, +]; + +/** + * 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( + eb: ExpressionBuilder, + options?: O, +): CommonUserSelectResult { + 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; +} + +/** + * 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, + alias = "User", +) { + return concatUserSubmittedImagePrefix( + eb + .selectFrom("UserSubmittedImage") + .select("UserSubmittedImage.url") + .whereRef( + "UserSubmittedImage.id", + "=", + sql.ref(`${alias}.customAvatarImgId`), + ) + .$asScalar(), + ).$castTo(); +} + +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`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({ + 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, + 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( + expr: Expression, +) { + // 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`(${sql.lit( + `${USER_SUBMITTED_IMAGE_ROOT}/`, + )} || ${expr})`; +} + +type SelectQueryBuilderExpression = Parameters< + typeof sqliteJsonArrayFrom +>[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( + expr: SelectQueryBuilderExpression, +): ReturnType> { + return sql`(select coalesce(json_group_array(json_object(${sql.join( + jsonObjectArgs(expr, "agg"), + )})), '[]') from ${expr} as agg)` as ReturnType< + typeof sqliteJsonArrayFrom + >; +} + +/** Drop-in replacement for kysely's sqlite `jsonObjectFrom`, see {@link jsonArrayFrom}. */ +export function jsonObjectFrom( + expr: SelectQueryBuilderExpression, +): ReturnType> { + return sql`(select json_object(${sql.join( + jsonObjectArgs(expr, "obj"), + )}) from ${expr} as obj)` as ReturnType>; +} + +/** Drop-in replacement for kysely's sqlite `jsonBuildObject`, see {@link jsonArrayFrom}. */ +export function jsonBuildObject>>( + obj: O, +): ReturnType> { + 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>; +} + +/** + * 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(expr: Expression): RawBuilder { + return sql`json(${expr})`; +} + +function jsonObjectArgs( + expr: SelectQueryBuilderExpression, + table: string, +) { + const args: Expression[] = []; + + 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; +} diff --git a/apps/web/src/lib/styles/common.css b/apps/web/src/lib/styles/common.css new file mode 100644 index 000000000..056886ddf --- /dev/null +++ b/apps/web/src/lib/styles/common.css @@ -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; + } +} diff --git a/apps/web/src/lib/styles/flags.css b/apps/web/src/lib/styles/flags.css new file mode 100644 index 000000000..ed688eb12 --- /dev/null +++ b/apps/web/src/lib/styles/flags.css @@ -0,0 +1,1018 @@ +@layer base { + .twf { + display: inline-block; + /* the twemoji svgs have no intrinsic size so `auto` sized them to the box, but Firefox + drops nested svgs when rasterizing an image export, so the raster assets are used instead */ + background-size: contain; + background-repeat: no-repeat; + min-width: 2.5rem; + max-width: 2.5rem; + min-height: 2.5rem; + max-height: 2.5rem; + vertical-align: -0.25em; + } + + .twf-s { + min-width: 1em; + max-width: 1em; + min-height: 1em; + max-height: 1em; + vertical-align: -0.1em; + } + + .twf-af { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1e6-1f1eb.png"); + } + + .twf-ax { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1e6-1f1fd.png"); + } + + .twf-al { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1e6-1f1f1.png"); + } + + .twf-dz { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1e9-1f1ff.png"); + } + + .twf-as { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1e6-1f1f8.png"); + } + + .twf-ad { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1e6-1f1e9.png"); + } + + .twf-ao { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1e6-1f1f4.png"); + } + + .twf-ai { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1e6-1f1ee.png"); + } + + .twf-aq { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1e6-1f1f6.png"); + } + + .twf-ag { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1e6-1f1ec.png"); + } + + .twf-ar { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1e6-1f1f7.png"); + } + + .twf-am { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1e6-1f1f2.png"); + } + + .twf-aw { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1e6-1f1fc.png"); + } + + .twf-au { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1e6-1f1fa.png"); + } + + .twf-at { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1e6-1f1f9.png"); + } + + .twf-az { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1e6-1f1ff.png"); + } + + .twf-bs { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1e7-1f1f8.png"); + } + + .twf-bh { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1e7-1f1ed.png"); + } + + .twf-bd { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1e7-1f1e9.png"); + } + + .twf-bb { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1e7-1f1e7.png"); + } + + .twf-by { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1e7-1f1fe.png"); + } + + .twf-be { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1e7-1f1ea.png"); + } + + .twf-bz { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1e7-1f1ff.png"); + } + + .twf-bj { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1e7-1f1ef.png"); + } + + .twf-bm { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1e7-1f1f2.png"); + } + + .twf-bt { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1e7-1f1f9.png"); + } + + .twf-bo { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1e7-1f1f4.png"); + } + + .twf-ba { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1e7-1f1e6.png"); + } + + .twf-bw { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1e7-1f1fc.png"); + } + + .twf-bv { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1e7-1f1fb.png"); + } + + .twf-br { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1e7-1f1f7.png"); + } + + .twf-io { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1ee-1f1f4.png"); + } + + .twf-bn { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1e7-1f1f3.png"); + } + + .twf-bg { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1e7-1f1ec.png"); + } + + .twf-bf { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1e7-1f1eb.png"); + } + + .twf-bi { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1e7-1f1ee.png"); + } + + .twf-kh { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f0-1f1ed.png"); + } + + .twf-cm { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1e8-1f1f2.png"); + } + + .twf-ca { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1e8-1f1e6.png"); + } + + .twf-cv { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1e8-1f1fb.png"); + } + + .twf-ky { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f0-1f1fe.png"); + } + + .twf-cf { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1e8-1f1eb.png"); + } + + .twf-td { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f9-1f1e9.png"); + } + + .twf-cl { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1e8-1f1f1.png"); + } + + .twf-cn { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1e8-1f1f3.png"); + } + + .twf-cx { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1e8-1f1fd.png"); + } + + .twf-cc { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1e8-1f1e8.png"); + } + + .twf-co { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1e8-1f1f4.png"); + } + + .twf-km { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f0-1f1f2.png"); + } + + .twf-cg { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1e8-1f1ec.png"); + } + + .twf-cd { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1e8-1f1e9.png"); + } + + .twf-ck { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1e8-1f1f0.png"); + } + + .twf-cr { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1e8-1f1f7.png"); + } + + .twf-ci { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1e8-1f1ee.png"); + } + + .twf-hr { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1ed-1f1f7.png"); + } + + .twf-cu { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1e8-1f1fa.png"); + } + + .twf-cy { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1e8-1f1fe.png"); + } + + .twf-cz { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1e8-1f1ff.png"); + } + + .twf-dk { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1e9-1f1f0.png"); + } + + .twf-dj { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1e9-1f1ef.png"); + } + + .twf-dm { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1e9-1f1f2.png"); + } + + .twf-do { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1e9-1f1f4.png"); + } + + .twf-ec { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1ea-1f1e8.png"); + } + + .twf-eg { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1ea-1f1ec.png"); + } + + .twf-sv { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f8-1f1fb.png"); + } + + .twf-gq { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1ec-1f1f6.png"); + } + + .twf-er { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1ea-1f1f7.png"); + } + + .twf-ee { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1ea-1f1ea.png"); + } + + .twf-et { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1ea-1f1f9.png"); + } + + .twf-eu { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1ea-1f1fa.png"); + } + + .twf-fk { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1eb-1f1f0.png"); + } + + .twf-fo { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1eb-1f1f4.png"); + } + + .twf-fj { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1eb-1f1ef.png"); + } + + .twf-fi { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1eb-1f1ee.png"); + } + + .twf-fr { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1eb-1f1f7.png"); + } + + .twf-gf { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1ec-1f1eb.png"); + } + + .twf-pf { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f5-1f1eb.png"); + } + + .twf-tf { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f9-1f1eb.png"); + } + + .twf-ga { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1ec-1f1e6.png"); + } + + .twf-gm { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1ec-1f1f2.png"); + } + + .twf-ge { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1ec-1f1ea.png"); + } + + .twf-de { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1e9-1f1ea.png"); + } + + .twf-gh { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1ec-1f1ed.png"); + } + + .twf-gi { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1ec-1f1ee.png"); + } + + .twf-gr { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1ec-1f1f7.png"); + } + + .twf-gl { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1ec-1f1f1.png"); + } + + .twf-gd { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1ec-1f1e9.png"); + } + + .twf-gp { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1ec-1f1f5.png"); + } + + .twf-gu { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1ec-1f1fa.png"); + } + + .twf-gt { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1ec-1f1f9.png"); + } + + .twf-gg { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1ec-1f1ec.png"); + } + + .twf-gn { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1ec-1f1f3.png"); + } + + .twf-gw { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1ec-1f1fc.png"); + } + + .twf-gy { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1ec-1f1fe.png"); + } + + .twf-ht { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1ed-1f1f9.png"); + } + + .twf-hm { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1ed-1f1f2.png"); + } + + .twf-va { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1fb-1f1e6.png"); + } + + .twf-hn { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1ed-1f1f3.png"); + } + + .twf-hk { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1ed-1f1f0.png"); + } + + .twf-hu { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1ed-1f1fa.png"); + } + + .twf-is { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1ee-1f1f8.png"); + } + + .twf-in { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1ee-1f1f3.png"); + } + + .twf-id { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1ee-1f1e9.png"); + } + + .twf-ir { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1ee-1f1f7.png"); + } + + .twf-iq { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1ee-1f1f6.png"); + } + + .twf-ie { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1ee-1f1ea.png"); + } + + .twf-im { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1ee-1f1f2.png"); + } + + .twf-il { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1ee-1f1f1.png"); + } + + .twf-it { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1ee-1f1f9.png"); + } + + .twf-jm { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1ef-1f1f2.png"); + } + + .twf-jp { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1ef-1f1f5.png"); + } + + .twf-je { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1ef-1f1ea.png"); + } + + .twf-jo { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1ef-1f1f4.png"); + } + + .twf-kz { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f0-1f1ff.png"); + } + + .twf-ke { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f0-1f1ea.png"); + } + + .twf-ki { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f0-1f1ee.png"); + } + + .twf-kp { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f0-1f1f5.png"); + } + + .twf-kr { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f0-1f1f7.png"); + } + + .twf-kw { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f0-1f1fc.png"); + } + + .twf-kg { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f0-1f1ec.png"); + } + + .twf-la { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f1-1f1e6.png"); + } + + .twf-lv { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f1-1f1fb.png"); + } + + .twf-lb { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f1-1f1e7.png"); + } + + .twf-ls { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f1-1f1f8.png"); + } + + .twf-lr { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f1-1f1f7.png"); + } + + .twf-ly { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f1-1f1fe.png"); + } + + .twf-li { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f1-1f1ee.png"); + } + + .twf-lt { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f1-1f1f9.png"); + } + + .twf-lu { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f1-1f1fa.png"); + } + + .twf-mo { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f2-1f1f4.png"); + } + + .twf-mk { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f2-1f1f0.png"); + } + + .twf-mg { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f2-1f1ec.png"); + } + + .twf-mw { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f2-1f1fc.png"); + } + + .twf-my { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f2-1f1fe.png"); + } + + .twf-mv { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f2-1f1fb.png"); + } + + .twf-ml { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f2-1f1f1.png"); + } + + .twf-mt { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f2-1f1f9.png"); + } + + .twf-mh { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f2-1f1ed.png"); + } + + .twf-mq { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f2-1f1f6.png"); + } + + .twf-mr { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f2-1f1f7.png"); + } + + .twf-mu { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f2-1f1fa.png"); + } + + .twf-yt { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1fe-1f1f9.png"); + } + + .twf-mx { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f2-1f1fd.png"); + } + + .twf-fm { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1eb-1f1f2.png"); + } + + .twf-md { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f2-1f1e9.png"); + } + + .twf-mc { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f2-1f1e8.png"); + } + + .twf-mn { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f2-1f1f3.png"); + } + + .twf-ms { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f2-1f1f8.png"); + } + + .twf-ma { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f2-1f1e6.png"); + } + + .twf-mz { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f2-1f1ff.png"); + } + + .twf-mm { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f2-1f1f2.png"); + } + + .twf-na { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f3-1f1e6.png"); + } + + .twf-nr { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f3-1f1f7.png"); + } + + .twf-np { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f3-1f1f5.png"); + } + + .twf-nl { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f3-1f1f1.png"); + } + + .twf-an { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1e6-1f1f3.png"); + } + + .twf-nc { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f3-1f1e8.png"); + } + + .twf-nz { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f3-1f1ff.png"); + } + + .twf-ni { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f3-1f1ee.png"); + } + + .twf-ne { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f3-1f1ea.png"); + } + + .twf-ng { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f3-1f1ec.png"); + } + + .twf-nu { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f3-1f1fa.png"); + } + + .twf-nf { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f3-1f1eb.png"); + } + + .twf-mp { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f2-1f1f5.png"); + } + + .twf-no { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f3-1f1f4.png"); + } + + .twf-om { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f4-1f1f2.png"); + } + + .twf-pk { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f5-1f1f0.png"); + } + + .twf-pw { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f5-1f1fc.png"); + } + + .twf-ps { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f5-1f1f8.png"); + } + + .twf-pa { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f5-1f1e6.png"); + } + + .twf-pg { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f5-1f1ec.png"); + } + + .twf-py { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f5-1f1fe.png"); + } + + .twf-pe { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f5-1f1ea.png"); + } + + .twf-ph { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f5-1f1ed.png"); + } + + .twf-pn { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f5-1f1f3.png"); + } + + .twf-pl { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f5-1f1f1.png"); + } + + .twf-pt { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f5-1f1f9.png"); + } + + .twf-pr { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f5-1f1f7.png"); + } + + .twf-qa { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f6-1f1e6.png"); + } + + .twf-re { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f7-1f1ea.png"); + } + + .twf-ro { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f7-1f1f4.png"); + } + + .twf-rs { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f7-1f1f8.png"); + } + + .twf-ru { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f7-1f1fa.png"); + } + + .twf-rw { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f7-1f1fc.png"); + } + + .twf-sh { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f8-1f1ed.png"); + } + + .twf-kn { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f0-1f1f3.png"); + } + + .twf-lc { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f1-1f1e8.png"); + } + + .twf-pm { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f5-1f1f2.png"); + } + + .twf-vc { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1fb-1f1e8.png"); + } + + .twf-ws { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1fc-1f1f8.png"); + } + + .twf-sm { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f8-1f1f2.png"); + } + + .twf-st { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f8-1f1f9.png"); + } + + .twf-sa { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f8-1f1e6.png"); + } + + .twf-sn { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f8-1f1f3.png"); + } + + .twf-cs { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1e8-1f1f8.png"); + } + + .twf-sc { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f8-1f1e8.png"); + } + + .twf-sl { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f8-1f1f1.png"); + } + + .twf-sg { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f8-1f1ec.png"); + } + + .twf-sk { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f8-1f1f0.png"); + } + + .twf-si { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f8-1f1ee.png"); + } + + .twf-sb { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f8-1f1e7.png"); + } + + .twf-so { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f8-1f1f4.png"); + } + + .twf-za { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1ff-1f1e6.png"); + } + + .twf-gs { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1ec-1f1f8.png"); + } + + .twf-es { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1ea-1f1f8.png"); + } + + .twf-lk { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f1-1f1f0.png"); + } + + .twf-sd { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f8-1f1e9.png"); + } + + .twf-sr { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f8-1f1f7.png"); + } + + .twf-sj { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f8-1f1ef.png"); + } + + .twf-sz { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f8-1f1ff.png"); + } + + .twf-se { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f8-1f1ea.png"); + } + + .twf-ch { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1e8-1f1ed.png"); + } + + .twf-sy { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f8-1f1fe.png"); + } + + .twf-tw { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f9-1f1fc.png"); + } + + .twf-tj { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f9-1f1ef.png"); + } + + .twf-tz { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f9-1f1ff.png"); + } + + .twf-th { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f9-1f1ed.png"); + } + + .twf-tl { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f9-1f1f1.png"); + } + + .twf-tg { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f9-1f1ec.png"); + } + + .twf-tk { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f9-1f1f0.png"); + } + + .twf-to { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f9-1f1f4.png"); + } + + .twf-tt { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f9-1f1f9.png"); + } + + .twf-tn { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f9-1f1f3.png"); + } + + .twf-tr { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f9-1f1f7.png"); + } + + .twf-tm { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f9-1f1f2.png"); + } + + .twf-tc { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f9-1f1e8.png"); + } + + .twf-tv { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1f9-1f1fb.png"); + } + + .twf-ug { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1fa-1f1ec.png"); + } + + .twf-ua { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1fa-1f1e6.png"); + } + + .twf-ae { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1e6-1f1ea.png"); + } + + .twf-gb { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1ec-1f1e7.png"); + } + + .twf-us { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1fa-1f1f8.png"); + } + + .twf-um { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1fa-1f1f2.png"); + } + + .twf-uy { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1fa-1f1fe.png"); + } + + .twf-uz { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1fa-1f1ff.png"); + } + + .twf-vu { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1fb-1f1fa.png"); + } + + .twf-ve { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1fb-1f1ea.png"); + } + + .twf-vn { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1fb-1f1f3.png"); + } + + .twf-vg { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1fb-1f1ec.png"); + } + + .twf-vi { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1fb-1f1ee.png"); + } + + .twf-wf { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1fc-1f1eb.png"); + } + + .twf-eh { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1ea-1f1ed.png"); + } + + .twf-ye { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1fe-1f1ea.png"); + } + + .twf-zm { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1ff-1f1f2.png"); + } + + .twf-zw { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1ff-1f1fc.png"); + } + + .twf-gb-wls { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f3f4-e0067-e0062-e0077-e006c-e0073-e007f.png"); + } + + .twf-gb-eng { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f3f4-e0067-e0062-e0065-e006e-e0067-e007f.png"); + } + + .twf-gb-sct { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f3f4-e0067-e0062-e0073-e0063-e0074-e007f.png"); + } + + .twf-gb-nir { + background-image: url("https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f1ec-1f1e7.png"); + } +} diff --git a/apps/web/src/lib/styles/fonts.css b/apps/web/src/lib/styles/fonts.css new file mode 100644 index 000000000..bc01b4ffa --- /dev/null +++ b/apps/web/src/lib/styles/fonts.css @@ -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; +} diff --git a/apps/web/src/lib/styles/fonts/lexend-latin-ext.woff2 b/apps/web/src/lib/styles/fonts/lexend-latin-ext.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..9ca4e6a77da9c4ec45bf62dde7451283b3736733 GIT binary patch literal 34548 zcmV)9K*hgzPew8T0RR910EYAc6951J0YX3k0EU790RR9100000000000000000000 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 z zR0yL~VOWL1S>y&1{TxT8n~I>cQd(iLipz!o#>WOQphXA=Zum$*MDrrooQN`(2vkrt zfd(|A=)f_E0Kz>gps5!F3{_%+HpRvATOl^+ilvIjlNwFdIQX=eI_+-#_2I(-Ja+AH zsA@~eW4Lq$r<*Z`0qjQ+A+F=GxaKE>z#d7uVjR>@OE-jrV%b*0(300eAJ=L+n6tbW zW!<-)s&K&=*1@IkPr3C!Ja&r}n;@xz|j>MB2vA*@mBBh4&IKH%X`J=^0mF6We zZvC|xg$#mpKWb!dtLU1-?GzW6!Zq?NuUehQ?XjU6ylzGbDGW;(z#~D%NgO@cLaWy5 z&$e9#qp=NN2Ma=K=gh2sq^sO?1nml7o)hT%9JmT>O!G#BNjYz>bcnD*io1wEIc2MV zL&-}4^Yu684AZh5x7FzJx~zbEyz!O$x@x~4MwM#V5;%ASL?mPsR5bMJoj9Nd6AQZx z2Nw^&1_2=vF-d2xA|nUYq^Lz%n~Iu-mX4l*v0Fh*b(z(&u(Gk|z>&z(l-!)TSjLqb zcOE<~=Ve7}h08Ubmg1$Mz_d+gV&6CzZYa1rU`M2Qw-z1Z|7inCF? zO%iOD=s#O*CCO+Osbndc&1;`D=`zU3Wmc=dOP-?0B}cAp^5iS9y%!bc?islM=uDoF zLc?_4@UM|dl_^(YkG=M(wBLdJJ@he$99HE>0o7{Msw>!_-)PXNNwXHMj%q99nB#?= z&|XA`PF=d4q;#ski|Q%nv|bxFm6TOf)oc}aNkdah8>FMFrw=yhC3g*>uQ+I=eY!U2`TWIr0+9rfDO4Jr!DO*HTpnK_6sf9-B~o>nhNhObj;@}*fuWJHiK$sk zoXXs?YgVkXb8vEjxp{c`_yq)oghfQf#MMct*Pv08X2nCY3Wps8q-WmITsX#vJzTQx zrxtLZBtFijydBOyNhbaol>%CdNcVT0_Zze3-;2V*@1N5S=NXC({qZ-&g)ToN{HqlT zFPJmoJ-Pjt7pVxyiMbiU>jp*H+%pd#U61mZZR@*u4Lx1+Qj~VYA03ZsOQfGCWg&$~ zSyG9n%KR=1a(_-39~DLQsN8FXUC#f|TeU{2+QVUbayvJBsY=DF6ho{E(_s*)EsBS; z9?$^SJwWdWq z1@7pj;InNY_hJuMztSm}u0v&d7`wC=jqxOkJJ~(b_V*TjBPT8D>~cr3F`=hKw6|dH zu}CP4-3cyl=r0#taoKAhaPiNi6T9Fr*>dIoc^(6Wax##i?##~4*=^Y{)LSU~q|c$A zQig-b%Wx)oEm_4K&kEf<8Du4&u)oIW1#sRn}j!|Pz6X!j0hb-p}AGHrp^=ARR%81bF~czun951 zvf3NWD1hj;0>W}&4RqU(tXt)+H_^kek7|?+%u#(I8qT9}Gp&G$U?W!{fvM9(NPkTKcaU?1Jr_TfJHh+{m%5wDQ$clb*D+Lv3tH$A|PKaXXL>f89t z9!djR>5!7aZ=D4s!i@9j`TO@T#<+Zlep^=KXtBQx zl@kbA%5TcRb%Z5}nV&_C5}`;n-ju!!M4Kv@fI3%t@EFkwe;A5~<8>P!O?D(gq_h12 z^8qU~VmYbBgPu^GsE(s74r(-4NzCzqX2qs)G>d1=S>MYn5bE9d-MwIvw~>{Iy@#8$Jt|ZCJ6ul09LV8y#N3J literal 0 HcmV?d00001 diff --git a/apps/web/src/lib/styles/fonts/lexend-latin.woff2 b/apps/web/src/lib/styles/fonts/lexend-latin.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..0968c152f1c0d617a90bd843fa3d510c336af085 GIT binary patch literal 39692 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 literal 0 HcmV?d00001 diff --git a/apps/web/src/lib/styles/normalize.css b/apps/web/src/lib/styles/normalize.css new file mode 100644 index 000000000..8b0b25525 --- /dev/null +++ b/apps/web/src/lib/styles/normalize.css @@ -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; + } +} diff --git a/apps/web/src/lib/styles/utils.css b/apps/web/src/lib/styles/utils.css new file mode 100644 index 000000000..ea6e9328a --- /dev/null +++ b/apps/web/src/lib/styles/utils.css @@ -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; + } +} diff --git a/apps/web/src/lib/styles/vars.css b/apps/web/src/lib/styles/vars.css new file mode 100644 index 000000000..2ef18328a --- /dev/null +++ b/apps/web/src/lib/styles/vars.css @@ -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); +} diff --git a/apps/web/src/lib/utils/dates.ts b/apps/web/src/lib/utils/dates.ts new file mode 100644 index 000000000..4fe28cd1b --- /dev/null +++ b/apps/web/src/lib/utils/dates.ts @@ -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()); +} diff --git a/apps/web/src/lib/utils/e2e.ts b/apps/web/src/lib/utils/e2e.ts new file mode 100644 index 000000000..b562b7f2e --- /dev/null +++ b/apps/web/src/lib/utils/e2e.ts @@ -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"); diff --git a/apps/web/src/lib/utils/users.ts b/apps/web/src/lib/utils/users.ts new file mode 100644 index 000000000..cb2d52c86 --- /dev/null +++ b/apps/web/src/lib/utils/users.ts @@ -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; +} diff --git a/apps/web/src/routes/+error.svelte b/apps/web/src/routes/+error.svelte new file mode 100644 index 000000000..4cca49657 --- /dev/null +++ b/apps/web/src/routes/+error.svelte @@ -0,0 +1,7 @@ + + +
+

Error {page.status}

+
diff --git a/apps/web/src/routes/+layout.svelte b/apps/web/src/routes/+layout.svelte new file mode 100644 index 000000000..faa2f563b --- /dev/null +++ b/apps/web/src/routes/+layout.svelte @@ -0,0 +1,12 @@ + + +{@render children()} diff --git a/apps/web/src/routes/leaderboards/+page.svelte b/apps/web/src/routes/leaderboards/+page.svelte new file mode 100644 index 000000000..7112d2f99 --- /dev/null +++ b/apps/web/src/routes/leaderboards/+page.svelte @@ -0,0 +1 @@ +

Leaderboards

diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json new file mode 100644 index 000000000..ace17cd16 --- /dev/null +++ b/apps/web/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "$app/tsconfig", + "compilerOptions": { + "strict": true, + "noUncheckedIndexedAccess": false, + "types": ["$app/types", "node", "vite/client"] + }, + "include": ["src", "scripts", "vite.config.ts"] +} diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts new file mode 100644 index 000000000..8def3c3c9 --- /dev/null +++ b/apps/web/vite.config.ts @@ -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"], + }, +})); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 62b42adc8..20f65308c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,70 @@ importers: specifier: 2.5.7 version: 2.5.7 + apps/web: + dependencies: + '@epic-web/cachified': + specifier: 5.6.3 + version: 5.6.3 + '@sendou/in-game-lists': + specifier: workspace:* + version: link:../../packages/in-game-lists + '@sendou/utils': + specifier: workspace:* + version: link:../../packages/utils + date-fns: + specifier: 4.4.0 + version: 4.4.0 + kysely: + specifier: 0.29.0 + version: 0.29.0(patch_hash=a3e94339939b1be5b70610601e96fff19ed5678aab525de24b52dfc5212c686a) + lru-cache: + specifier: ^11.2.1 + version: 11.5.2 + remeda: + specifier: 2.39.0 + version: 2.39.0 + sql-formatter: + specifier: ^15.8.2 + version: 15.8.2 + valibot: + specifier: ^1.4.2 + version: 1.4.2(typescript@6.0.3) + devDependencies: + '@inlang/paraglide-js': + specifier: ^2.24.1 + version: 2.24.1(typescript@6.0.3)(vite@8.2.1(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) + '@sveltejs/adapter-node': + specifier: 6.0.0-next.10 + version: 6.0.0-next.10(@sveltejs/kit@3.0.0-next.23(@sveltejs/vite-plugin-svelte@7.3.0(svelte@5.56.9)(vite@8.2.1(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))(svelte@5.56.9)(typescript@6.0.3)(vite@8.2.1(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))) + '@sveltejs/kit': + specifier: 3.0.0-next.23 + version: 3.0.0-next.23(@sveltejs/vite-plugin-svelte@7.3.0(svelte@5.56.9)(vite@8.2.1(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))(svelte@5.56.9)(typescript@6.0.3)(vite@8.2.1(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) + '@sveltejs/vite-plugin-svelte': + specifier: ^7.3.0 + version: 7.3.0(svelte@5.56.9)(vite@8.2.1(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) + '@types/node': + specifier: 26.1.2 + version: 26.1.2 + svelte: + specifier: ^5.56.9 + version: 5.56.9 + svelte-check: + specifier: ^4.7.6 + version: 4.7.6(picomatch@4.0.5)(svelte@5.56.9)(typescript@6.0.3) + tsx: + specifier: ^4.20.5 + version: 4.23.12 + typescript: + specifier: ~6.0.3 + version: 6.0.3 + vite: + specifier: ^8.2.1 + version: 8.2.1(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0) + vitest: + specifier: 4.1.10 + version: 4.1.10(@types/node@26.1.2)(@vitest/browser-playwright@4.1.10)(@vitest/ui@4.1.10)(vite@8.2.1(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) + apps/web-react: dependencies: '@aws-sdk/client-s3': @@ -198,7 +262,7 @@ importers: version: 2.5.0(react@19.2.8) web-haptics: specifier: 0.0.6 - version: 0.0.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 0.0.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(svelte@5.56.9) web-push: specifier: 3.6.7 version: 3.6.7 @@ -217,7 +281,7 @@ importers: version: 1.62.1 '@react-router/dev': specifier: 8.3.0 - version: 8.3.0(@react-router/serve@8.1.0(patch_hash=0073d0acad5d92eea7ef30ed24d736df9f4d59e204f8358ecb3b06b9ea9d8738)(react-router@8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(typescript@7.0.2))(react-router@8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(typescript@7.0.2)(vite@8.2.0(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0)) + version: 8.3.0(@react-router/serve@8.1.0(patch_hash=0073d0acad5d92eea7ef30ed24d736df9f4d59e204f8358ecb3b06b9ea9d8738)(react-router@8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(typescript@7.0.2))(react-router@8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(typescript@7.0.2)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) '@types/node': specifier: 26.1.2 version: 26.1.2 @@ -235,7 +299,7 @@ importers: version: 3.6.4 '@vitest/browser-playwright': specifier: 4.1.10 - version: 4.1.10(playwright@1.62.1)(vite@8.2.0(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0))(vitest@4.1.10) + version: 4.1.10(playwright@1.62.1)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(vitest@4.1.10) '@vitest/ui': specifier: 4.1.10 version: 4.1.10(vitest@4.1.10) @@ -265,16 +329,16 @@ importers: version: 7.0.2 vite: specifier: 8.2.0 - version: 8.2.0(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0) + version: 8.2.0(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0) vite-node: specifier: 6.0.0 - version: 6.0.0(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0) + version: 6.0.0(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0) vite-plugin-babel: specifier: 1.7.3 - version: 1.7.3(@babel/core@7.29.7)(vite@8.2.0(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0)) + version: 1.7.3(@babel/core@7.29.7)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) vitest: specifier: 4.1.10 - version: 4.1.10(@types/node@26.1.2)(@vitest/browser-playwright@4.1.10)(@vitest/ui@4.1.10)(vite@8.2.0(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0)) + version: 4.1.10(@types/node@26.1.2)(@vitest/browser-playwright@4.1.10)(@vitest/ui@4.1.10)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) vitest-browser-react: specifier: 2.2.0 version: 2.2.0(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.10) @@ -299,7 +363,7 @@ importers: version: 7.0.2 vitest: specifier: 4.1.10 - version: 4.1.10(@types/node@26.1.2)(@vitest/browser-playwright@4.1.10)(@vitest/ui@4.1.10)(vite@8.2.0(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0)) + version: 4.1.10(@types/node@26.1.2)(@vitest/browser-playwright@4.1.10)(@vitest/ui@4.1.10)(vite@8.2.1(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) packages/in-game-lists: devDependencies: @@ -308,7 +372,7 @@ importers: version: 7.0.2 vitest: specifier: 4.1.10 - version: 4.1.10(@types/node@26.1.2)(@vitest/browser-playwright@4.1.10)(@vitest/ui@4.1.10)(vite@8.2.0(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0)) + version: 4.1.10(@types/node@26.1.2)(@vitest/browser-playwright@4.1.10)(@vitest/ui@4.1.10)(vite@8.2.1(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) packages/map-list-generator: dependencies: @@ -327,7 +391,7 @@ importers: version: 7.0.2 vitest: specifier: 4.1.10 - version: 4.1.10(@types/node@26.1.2)(@vitest/browser-playwright@4.1.10)(@vitest/ui@4.1.10)(vite@8.2.0(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0)) + version: 4.1.10(@types/node@26.1.2)(@vitest/browser-playwright@4.1.10)(@vitest/ui@4.1.10)(vite@8.2.1(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) packages/scanner-core: dependencies: @@ -346,7 +410,7 @@ importers: version: 7.0.2 vitest: specifier: 4.1.10 - version: 4.1.10(@types/node@26.1.2)(@vitest/browser-playwright@4.1.10)(@vitest/ui@4.1.10)(vite@8.2.0(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0)) + version: 4.1.10(@types/node@26.1.2)(@vitest/browser-playwright@4.1.10)(@vitest/ui@4.1.10)(vite@8.2.1(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) packages/tournament-engine: dependencies: @@ -368,7 +432,7 @@ importers: version: 7.0.2 vitest: specifier: 4.1.10 - version: 4.1.10(@types/node@26.1.2)(@vitest/browser-playwright@4.1.10)(@vitest/ui@4.1.10)(vite@8.2.0(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0)) + version: 4.1.10(@types/node@26.1.2)(@vitest/browser-playwright@4.1.10)(@vitest/ui@4.1.10)(vite@8.2.1(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) packages/utils: dependencies: @@ -384,7 +448,7 @@ importers: version: 7.0.2 vitest: specifier: 4.1.10 - version: 4.1.10(@types/node@26.1.2)(@vitest/browser-playwright@4.1.10)(@vitest/ui@4.1.10)(vite@8.2.0(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0)) + version: 4.1.10(@types/node@26.1.2)(@vitest/browser-playwright@4.1.10)(@vitest/ui@4.1.10)(vite@8.2.1(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) tooling/codemods: devDependencies: @@ -734,6 +798,162 @@ packages: '@epic-web/invariant@1.0.0': resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==} + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@faker-js/faker@10.5.0': resolution: {integrity: sha512-bsxD8WLS5lIj7aaoCx1YJkktqYj5vlBUE6HWzu2Q51ksrGJ0H737ECCKlFU7Yf8Br45z9t99frBp/J7kzbMPAg==} engines: {node: ^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0, npm: '>=10'} @@ -915,6 +1135,25 @@ packages: cpu: [x64] os: [win32] + '@inlang/paraglide-js@2.24.1': + resolution: {integrity: sha512-r/gak0kY2yuqgz0RFxccb7A89fgcLpiqoEbYfv3nxlqyhdW2v8NajE1A2xiXrv/iAo7WCX4zk8EJAhL69rcp0g==} + hasBin: true + peerDependencies: + typescript: '>=5.6' + vite: '>=5.0.0' + peerDependenciesMeta: + typescript: + optional: true + vite: + optional: true + + '@inlang/recommend-sherlock@0.2.1': + resolution: {integrity: sha512-ckv8HvHy/iTqaVAEKrr+gnl+p3XFNwe5D2+6w6wJk2ORV2XkcRkKOJ/XsTUJbPSiyi4PI+p+T3bqbmNx/rDUlg==} + + '@inlang/sdk@3.0.1': + resolution: {integrity: sha512-QvyMYjCqF7CnrTp8ZaPiuwpmxsoQPIDgfqGeWfqi4FDxBREye4QdyaZS4DGwIlCZXybF/YMj0TtL2uBfbmajug==} + engines: {node: '>=20.0.0'} + '@internationalized/date@3.12.3': resolution: {integrity: sha512-fuLX+3ZKLsxI73y8b01EG/WjHb6gE6weCqlfawPO27kBWGMh9G1yH6Csv1uU7/cac9H2GHmOMt6CjmuQ1aia4Q==} @@ -943,6 +1182,29 @@ packages: '@kurkle/color@0.3.4': resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==} + '@lix-js/sdk-darwin-arm64@0.12.2': + resolution: {integrity: sha512-0mBRIfgZfaey1/d0mijv7fBIGPeaTIMCDOPr3s4RUlHy/j+llNmAyDS2iNlalFh9nQGys7JNzo2Hv3FQbdKmug==} + cpu: [arm64] + os: [darwin] + + '@lix-js/sdk-linux-arm64@0.12.2': + resolution: {integrity: sha512-817uJlC2KTIKDaqQvALTu03hEJpNxEA+9d33FNs8sOm9pAguOWiAK1zGA2SD0IgS9Q8NqQeABLb84OesW5/NCA==} + cpu: [arm64] + os: [linux] + + '@lix-js/sdk-linux-x64@0.12.2': + resolution: {integrity: sha512-rA7lw1jBr6azBHR5Sh/RzOKSAlRcmVea7VeI4VQqpy0PZs+5tWoK9iVC41V4oCIKssRv2wrkbvyP3CqInhoknQ==} + cpu: [x64] + os: [linux] + + '@lix-js/sdk-win32-x64@0.12.2': + resolution: {integrity: sha512-y9IikCdrYx6cFnfqeUvYKPw6KBY016U2F2wYDILYZDZndccK2xw7lJChX163w9PewMwY3GsC/UqRBi5yqFAJ2w==} + cpu: [x64] + os: [win32] + + '@lix-js/sdk@0.12.2': + resolution: {integrity: sha512-sKGOPdVKdChS5q60F4VQE4ZTcf0B7ZJM3KGCVQFN2suVNKhHcllbsCiwRYuM5WL7Yu1ql7YexltJ2FCsDqKePg==} + '@mjackson/headers@0.10.0': resolution: {integrity: sha512-U1Eu1gF979k7ZoIBsJyD+T5l9MjtPONsZfoXfktsQHPJD0s7SokBGx+tLKDLsOY+gzVYAWS0yRFDNY8cgbQzWQ==} @@ -1832,6 +2094,9 @@ packages: '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@sinclair/typebox@0.31.30': + resolution: {integrity: sha512-MGsM7bVmHg3sUKCphlu3SGQ+T+5JTbygZWYArfKQCW5anJeACHeqLhcnf+R7XlCWZ+QR3C8JTBx3kCKJTN69ow==} + '@smithy/core@3.32.0': resolution: {integrity: sha512-NAiCSC78fzbNIEWoheoF74Ob5ZorLijCHpMY26Fqvqg/+9LuyIqMfHDg2p8Yk1rqOyowtiL3y7WX0AW+teL6zw==} engines: {node: '>=18.0.0'} @@ -1856,6 +2121,10 @@ packages: resolution: {integrity: sha512-Aw4joiM0ZdErpo39lCj8phT2lxoiKZV+KZzBxnnQhWVtU2Is/WffQSL04uUWRcXUse9Ln8vXZK6V/FwqRVnQpg==} engines: {node: '>=18.0.0'} + '@sqlite.org/sqlite-wasm@3.48.0-build4': + resolution: {integrity: sha512-hI6twvUkzOmyGZhQMza1gpfqErZxXRw6JEsiVjUbo7tFanVD+8Oil0Ih3l2nGzHdxPI41zFmfUQG7GHqhciKZQ==} + hasBin: true + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -2589,6 +2858,43 @@ packages: engines: {node: '>=0.10.0', npm: '>2.7.0'} os: [aix, darwin, freebsd, linux, macos, openbsd, sunos, win32, windows] + '@sveltejs/acorn-typescript@1.0.13': + resolution: {integrity: sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ==} + peerDependencies: + acorn: ^8.9.0 + + '@sveltejs/adapter-node@6.0.0-next.10': + resolution: {integrity: sha512-eYvk01tqPixXDkw5MCsMJZUU/9gbqWIAE9ASUYDtIaESmUcT/QBXo15Gd5NHy4ow4pelXVP/wJnQKa2RxOg10g==} + peerDependencies: + '@sveltejs/kit': ^3.0.0-next.0 + + '@sveltejs/kit@3.0.0-next.23': + resolution: {integrity: sha512-vQEp3qR99VrVlRe+jLKUAAelj75xTf/TBdY40LM0fA0JiQiy+rT8vD4GKgMG86WjOk63E3Bas15aQnYApKyfvQ==} + engines: {node: '>=22.17'} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.0.0 + '@sveltejs/vite-plugin-svelte': ^7.0.0 + svelte: ^5.56.4 + typescript: ^6.0.0 + vite: ^8.0.12 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + typescript: + optional: true + + '@sveltejs/load-config@0.2.3': + resolution: {integrity: sha512-VT3qmUb8pRV2QrZjd8iAmtg8lf4W0TIjZbvXtz5MKei/q96teWZgGJyyidJzOjzZzvdq616eSRVeMYIQChUTAQ==} + engines: {node: '>= 18.0.0'} + + '@sveltejs/vite-plugin-svelte@7.3.0': + resolution: {integrity: sha512-QbRoJyD92e9R0ufeQIWRHrCC0ObcqSv/aBDdrQMoU+sypav3cDx5wytdQ6GLdXjEMO6xjrXGzfkUygng8JMv0A==} + engines: {node: ^20.19 || ^22.12 || >=24} + peerDependencies: + svelte: ^5.46.4 + vite: ^8.0.0-beta.7 || ^8.0.0 + '@swc/helpers@0.5.23': resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} @@ -2825,6 +3131,9 @@ packages: '@types/react@19.2.18': resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/use-sync-external-store@0.0.6': resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} @@ -3011,6 +3320,11 @@ packages: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + engines: {node: '>=0.4.0'} + hasBin: true + agent-base@7.1.4: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} @@ -3037,6 +3351,13 @@ packages: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} + aria-query@5.3.1: + resolution: {integrity: sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==} + engines: {node: '>= 0.4'} + + array-timsort@1.0.3: + resolution: {integrity: sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==} + asn1.js@5.4.1: resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==} @@ -3044,6 +3365,10 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + axobject-query@4.1.0: + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} + babel-dead-code-elimination@1.0.12: resolution: {integrity: sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==} @@ -3120,6 +3445,10 @@ packages: resolution: {integrity: sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==} engines: {pnpm: '>=8'} + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + chokidar@5.0.0: resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} engines: {node: '>= 20.19.0'} @@ -3145,9 +3474,17 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + commander@11.1.0: + resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} + engines: {node: '>=16'} + commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + comment-json@4.6.2: + resolution: {integrity: sha512-R2rze/hDX30uul4NZoIZ76ImSJLFxn/1/ZxtKC1L77y2X1k+yYu1joKbAtMA2Fg3hZrTOiw0I5mwVMo0cf250w==} + engines: {node: '>= 6'} + compressible@2.0.18: resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==} engines: {node: '>= 0.6'} @@ -3162,6 +3499,10 @@ packages: confbox@0.2.4: resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + consola@3.4.0: + resolution: {integrity: sha512-EiPU8G6dQG0GFHNR8ljnZFki/8a+cQwEQ+7wpxdChl02Q8HXlwEZWD5lqAF8vC2sEC3Tehr8hy7vErz88LHyUA==} + engines: {node: ^14.18.0 || >=16.10.0} + content-disposition@1.1.0: resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} engines: {node: '>=18'} @@ -3188,6 +3529,10 @@ packages: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} + cookie@2.0.1: + resolution: {integrity: sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==} + engines: {node: '>=22'} + core-js@3.49.0: resolution: {integrity: sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==} @@ -3234,6 +3579,10 @@ packages: babel-plugin-macros: optional: true + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -3249,6 +3598,9 @@ packages: detect-node-es@1.1.0: resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + devalue@5.9.0: + resolution: {integrity: sha512-RWrqdArjvPbsATEhOPUo6Wndc/iWnkWKlhIrdlF3zMMYo/c3CVtoaVAyLtWxz5h8nSlkHzxnzV2uLydPXmtF+A==} + discontinuous-range@1.0.0: resolution: {integrity: sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ==} @@ -3294,6 +3646,11 @@ packages: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -3305,11 +3662,22 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} + esm-env@1.2.2: + resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==} + esprima@4.0.1: resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} engines: {node: '>=4'} hasBin: true + esrap@2.3.4: + resolution: {integrity: sha512-CHnJXZDfBeOAjLIGNy/0BEsmDu5+irHgUXRW9pGptxCbO3uyjJ98crilzFgiBqizW94UodjX3GAPLg/dhEnUfA==} + peerDependencies: + '@typescript-eslint/types': ^8.2.0 + peerDependenciesMeta: + '@typescript-eslint/types': + optional: true + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -3538,6 +3906,9 @@ packages: is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-reference@3.0.3: + resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==} + isbot@5.2.1: resolution: {integrity: sha512-dJ+LpKyClQZ7NG+j3OensC/mAZkGpukE9YUrgPYvAZj2doVL0edfDgywTUh5CXa0o+nW9a1V9e5+CJTX8+SxRw==} engines: {node: '>=18'} @@ -3584,6 +3955,10 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + kysely@0.28.17: + resolution: {integrity: sha512-nbD8lB9EB3wNdMhOCdx5Li8DxnLbvKByylRLcJ1h+4SkrowVeECAyZlyiKMThF7xFdRz0jSQ2MoJr+wXux2y0Q==} + engines: {node: '>=20.0.0'} + kysely@0.29.0: resolution: {integrity: sha512-LrQfPUeTW7MXbMvT62moEMnpMTuj9TO3lqjCeLKjM975PJ4Alrl/43f2tlDX7xOsNptKgH4LSNGwIbXwEkLg4g==} engines: {node: '>=22.0.0'} @@ -3668,6 +4043,9 @@ packages: linkifyjs@4.3.2: resolution: {integrity: sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==} + locate-character@3.0.0: + resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==} + lodash.isequal@4.5.0: resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead. @@ -3685,6 +4063,10 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -3764,6 +4146,10 @@ packages: resolution: {integrity: sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==} engines: {node: '>= 0.8.0'} + mri@1.2.0: + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} + engines: {node: '>=4'} + mrmime@2.0.1: resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} engines: {node: '>=10'} @@ -4138,6 +4524,10 @@ packages: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + readdirp@5.0.0: resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} engines: {node: '>= 20.19.0'} @@ -4197,6 +4587,10 @@ packages: runes2@1.1.4: resolution: {integrity: sha512-LNPnEDPOOU4ehF71m5JoQyzT2yxwD6ZreFJ7MxZUAoMKNMY1XrAo60H1CUoX5ncSm0rIuKlqn9JZNRrRkNou2g==} + sade@1.8.1: + resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} + engines: {node: '>=6'} + safe-buffer@5.1.2: resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} @@ -4303,6 +4697,11 @@ packages: resolution: {integrity: sha512-kTYRg5FIcvsDtYUG2Qn9pYT6xKwiLJN5TTIvc5Mur6hIg4pSfdpHu8Yyu5bqESLHnVM3mXzD446cb2+uEaKZXg==} hasBin: true + sqlite-wasm-kysely@0.3.0: + resolution: {integrity: sha512-TzjBNv7KwRw6E3pdKdlRyZiTmUIE0UttT/Sl56MVwVARl/u5gp978KepazCJZewFUnlWHz9i3NQd4kOtP/Afdg==} + peerDependencies: + kysely: '*' + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -4343,6 +4742,18 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + svelte-check@4.7.6: + resolution: {integrity: sha512-t2scM//ZuVbSY/T2w6FSBw1v9s2NEmh/g+sy1lqtosW5ylBV5AF4wFb1Ts9Kf3MbfPDUDJDZ9L436YT0SPTdvw==} + engines: {node: '>= 18.0.0'} + hasBin: true + peerDependencies: + svelte: ^4.0.0 || ^5.0.0-next.0 + typescript: ^5.0.0 || ^6.0.0 + + svelte@5.56.9: + resolution: {integrity: sha512-VT8kSnlEg8069w7AiCcAk3Yf5xvMnrGTagVOmU/OpOLHaHnNqXhWZCH/4EVga/bT/HtWhvE6/fHrXLErx7OnJA==} + engines: {node: '>=18'} + swr@2.5.0: resolution: {integrity: sha512-W0GomadRJe9OfzIoRX0kZHhDSaRRfdiOUeH6uazgR05SibBhDNwfdTbhAxmFtObvkf59fFymo22mooKukosvNA==} peerDependencies: @@ -4392,6 +4803,11 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsx@4.23.12: + resolution: {integrity: sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==} + engines: {node: '>=18.0.0'} + hasBin: true + twgl.js@7.0.0: resolution: {integrity: sha512-9HHZ8emdZb2nKPU0FDIGDlkp6AD9rWFv7ThqdbmUqyBhD3WTQpLN+89w4B+YshGwD1fL6PMVpVKZqC0pikgmcA==} @@ -4406,6 +4822,11 @@ packages: types-ramda@0.31.0: resolution: {integrity: sha512-vaoC35CRC3xvL8Z6HkshDbi6KWM1ezK0LHN0YyxXWUn9HKzBNg/T3xSGlJZjCYspnOD3jE7bcizsp0bUXZDxnQ==} + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + typescript@7.0.2: resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} engines: {node: '>=16.20.0'} @@ -4429,12 +4850,19 @@ packages: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} + unplugin@2.3.11: + resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==} + engines: {node: '>=18.12.0'} + update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true peerDependencies: browserslist: '>= 4.21.0' + urlpattern-polyfill@10.1.0: + resolution: {integrity: sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==} + use-callback-ref@1.3.3: resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} engines: {node: '>=10'} @@ -4463,6 +4891,10 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + uuid@14.0.1: + resolution: {integrity: sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==} + hasBin: true + valibot@1.4.2: resolution: {integrity: sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==} peerDependencies: @@ -4529,6 +4961,57 @@ packages: yaml: optional: true + vite@8.2.1: + resolution: {integrity: sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.4.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitefu@1.1.3: + resolution: {integrity: sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + vite: + optional: true + vitest-browser-react@2.2.0: resolution: {integrity: sha512-oY3KM6305kwJMa6nHo92vVtkOsih7mjEf12dLKuphaF+9ywWPEc+qanIBd394SZ6m5LadVEaG6dicvvizOzmjA==} peerDependencies: @@ -4613,6 +5096,9 @@ packages: engines: {node: '>= 16'} hasBin: true + webpack-virtual-modules@0.6.2: + resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -4669,6 +5155,9 @@ packages: yup@1.7.1: resolution: {integrity: sha512-GKHFX2nXul2/4Dtfxhozv701jLQHdf6J34YDh2cEkpqoo8le5Mg6/LrdseVLrFarmFygZTlfIhHx/QKfb/QWXw==} + zimmerframe@1.1.4: + resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==} + zod@4.4.3: resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} @@ -5130,6 +5619,84 @@ snapshots: '@epic-web/invariant@1.0.0': {} + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + '@faker-js/faker@10.5.0': {} '@floating-ui/core@1.7.5': @@ -5255,6 +5822,31 @@ snapshots: '@img/sharp-win32-x64@0.35.3': optional: true + '@inlang/paraglide-js@2.24.1(typescript@6.0.3)(vite@8.2.1(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))': + dependencies: + '@inlang/recommend-sherlock': 0.2.1 + '@inlang/sdk': 3.0.1 + commander: 11.1.0 + consola: 3.4.0 + json5: 2.2.3 + unplugin: 2.3.11 + urlpattern-polyfill: 10.1.0 + optionalDependencies: + typescript: 6.0.3 + vite: 8.2.1(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0) + + '@inlang/recommend-sherlock@0.2.1': + dependencies: + comment-json: 4.6.2 + + '@inlang/sdk@3.0.1': + dependencies: + '@lix-js/sdk': 0.12.2 + '@sinclair/typebox': 0.31.30 + kysely: 0.28.17 + sqlite-wasm-kysely: 0.3.0(kysely@0.28.17) + uuid: 14.0.1 + '@internationalized/date@3.12.3': dependencies: '@swc/helpers': 0.5.23 @@ -5288,6 +5880,27 @@ snapshots: '@kurkle/color@0.3.4': {} + '@lix-js/sdk-darwin-arm64@0.12.2': + optional: true + + '@lix-js/sdk-linux-arm64@0.12.2': + optional: true + + '@lix-js/sdk-linux-x64@0.12.2': + optional: true + + '@lix-js/sdk-win32-x64@0.12.2': + optional: true + + '@lix-js/sdk@0.12.2': + dependencies: + fflate: 0.8.3 + optionalDependencies: + '@lix-js/sdk-darwin-arm64': 0.12.2 + '@lix-js/sdk-linux-arm64': 0.12.2 + '@lix-js/sdk-linux-x64': 0.12.2 + '@lix-js/sdk-win32-x64': 0.12.2 + '@mjackson/headers@0.10.0': {} '@napi-rs/canvas-android-arm64@1.0.3': @@ -5869,7 +6482,7 @@ snapshots: '@radix-ui/rect@1.1.1': {} - '@react-router/dev@8.3.0(@react-router/serve@8.1.0(patch_hash=0073d0acad5d92eea7ef30ed24d736df9f4d59e204f8358ecb3b06b9ea9d8738)(react-router@8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(typescript@7.0.2))(react-router@8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(typescript@7.0.2)(vite@8.2.0(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0))': + '@react-router/dev@8.3.0(@react-router/serve@8.1.0(patch_hash=0073d0acad5d92eea7ef30ed24d736df9f4d59e204f8358ecb3b06b9ea9d8738)(react-router@8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(typescript@7.0.2))(react-router@8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(typescript@7.0.2)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))': dependencies: '@babel/core': 7.29.7 '@babel/generator': 7.29.7 @@ -5898,7 +6511,7 @@ snapshots: semver: 7.8.5 tinyglobby: 0.2.17 valibot: 1.4.2(typescript@7.0.2) - vite: 8.2.0(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0) + vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0) optionalDependencies: '@react-router/serve': 8.1.0(patch_hash=0073d0acad5d92eea7ef30ed24d736df9f4d59e204f8358ecb3b06b9ea9d8738)(react-router@8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(typescript@7.0.2) typescript: 7.0.2 @@ -5994,6 +6607,8 @@ snapshots: '@rolldown/pluginutils@1.0.1': {} + '@sinclair/typebox@0.31.30': {} + '@smithy/core@3.32.0': dependencies: '@smithy/types': 4.17.0 @@ -6027,6 +6642,8 @@ snapshots: dependencies: tslib: 2.8.1 + '@sqlite.org/sqlite-wasm@3.48.0-build4': {} + '@standard-schema/spec@1.1.0': {} '@stdlib/array-float32@0.2.3': @@ -6882,6 +7499,44 @@ snapshots: '@stdlib/utils-constructor-name': 0.2.3 '@stdlib/utils-global': 0.2.3 + '@sveltejs/acorn-typescript@1.0.13(acorn@8.18.0)': + dependencies: + acorn: 8.18.0 + + '@sveltejs/adapter-node@6.0.0-next.10(@sveltejs/kit@3.0.0-next.23(@sveltejs/vite-plugin-svelte@7.3.0(svelte@5.56.9)(vite@8.2.1(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))(svelte@5.56.9)(typescript@6.0.3)(vite@8.2.1(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))': + dependencies: + '@sveltejs/kit': 3.0.0-next.23(@sveltejs/vite-plugin-svelte@7.3.0(svelte@5.56.9)(vite@8.2.1(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))(svelte@5.56.9)(typescript@6.0.3)(vite@8.2.1(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) + rolldown: 1.2.3 + + '@sveltejs/kit@3.0.0-next.23(@sveltejs/vite-plugin-svelte@7.3.0(svelte@5.56.9)(vite@8.2.1(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))(svelte@5.56.9)(typescript@6.0.3)(vite@8.2.1(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + '@standard-schema/spec': 1.1.0 + '@sveltejs/acorn-typescript': 1.0.13(acorn@8.18.0) + '@sveltejs/vite-plugin-svelte': 7.3.0(svelte@5.56.9)(vite@8.2.1(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) + acorn: 8.18.0 + cookie: 2.0.1 + devalue: 5.9.0 + esm-env: 1.2.2 + magic-string: 1.1.0 + mrmime: 2.0.1 + sirv: 3.0.2 + svelte: 5.56.9 + vite: 8.2.1(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0) + optionalDependencies: + typescript: 6.0.3 + + '@sveltejs/load-config@0.2.3': {} + + '@sveltejs/vite-plugin-svelte@7.3.0(svelte@5.56.9)(vite@8.2.1(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))': + dependencies: + deepmerge: 4.3.1 + magic-string: 1.1.0 + obug: 2.1.3 + svelte: 5.56.9 + vite: 8.2.1(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0) + vitefu: 1.1.3(vite@8.2.1(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) + '@swc/helpers@0.5.23': dependencies: tslib: 2.8.1 @@ -7173,6 +7828,8 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/trusted-types@2.0.7': {} + '@types/use-sync-external-store@0.0.6': {} '@types/web-push@3.6.4': @@ -7246,29 +7903,43 @@ snapshots: '@use-gesture/core': 10.3.1 react: 19.2.8 - '@vitest/browser-playwright@4.1.10(playwright@1.62.1)(vite@8.2.0(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0))(vitest@4.1.10)': + '@vitest/browser-playwright@4.1.10(playwright@1.62.1)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(vitest@4.1.10)': dependencies: - '@vitest/browser': 4.1.10(vite@8.2.0(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0))(vitest@4.1.10) - '@vitest/mocker': 4.1.10(vite@8.2.0(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0)) + '@vitest/browser': 4.1.10(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(vitest@4.1.10) + '@vitest/mocker': 4.1.10(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) playwright: 1.62.1 tinyrainbow: 3.1.0 - vitest: 4.1.10(@types/node@26.1.2)(@vitest/browser-playwright@4.1.10)(@vitest/ui@4.1.10)(vite@8.2.0(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0)) + vitest: 4.1.10(@types/node@26.1.2)(@vitest/browser-playwright@4.1.10)(@vitest/ui@4.1.10)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) transitivePeerDependencies: - bufferutil - msw - utf-8-validate - vite - '@vitest/browser@4.1.10(vite@8.2.0(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0))(vitest@4.1.10)': + '@vitest/browser-playwright@4.1.10(playwright@1.62.1)(vite@8.2.1(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(vitest@4.1.10)': + dependencies: + '@vitest/browser': 4.1.10(vite@8.2.1(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(vitest@4.1.10) + '@vitest/mocker': 4.1.10(vite@8.2.1(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) + playwright: 1.62.1 + tinyrainbow: 3.1.0 + vitest: 4.1.10(@types/node@26.1.2)(@vitest/browser-playwright@4.1.10)(@vitest/ui@4.1.10)(vite@8.2.1(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + optional: true + + '@vitest/browser@4.1.10(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(vitest@4.1.10)': dependencies: '@blazediff/core': 1.9.1 - '@vitest/mocker': 4.1.10(vite@8.2.0(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0)) + '@vitest/mocker': 4.1.10(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) '@vitest/utils': 4.1.10 magic-string: 0.30.21 pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.1.0 - vitest: 4.1.10(@types/node@26.1.2)(@vitest/browser-playwright@4.1.10)(@vitest/ui@4.1.10)(vite@8.2.0(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0)) + vitest: 4.1.10(@types/node@26.1.2)(@vitest/browser-playwright@4.1.10)(@vitest/ui@4.1.10)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) ws: 8.21.0 transitivePeerDependencies: - bufferutil @@ -7276,6 +7947,24 @@ snapshots: - utf-8-validate - vite + '@vitest/browser@4.1.10(vite@8.2.1(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(vitest@4.1.10)': + dependencies: + '@blazediff/core': 1.9.1 + '@vitest/mocker': 4.1.10(vite@8.2.1(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) + '@vitest/utils': 4.1.10 + magic-string: 0.30.21 + pngjs: 7.0.0 + sirv: 3.0.2 + tinyrainbow: 3.1.0 + vitest: 4.1.10(@types/node@26.1.2)(@vitest/browser-playwright@4.1.10)(@vitest/ui@4.1.10)(vite@8.2.1(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) + ws: 8.21.0 + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + optional: true + '@vitest/expect@4.1.10': dependencies: '@standard-schema/spec': 1.1.0 @@ -7285,13 +7974,21 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.10(vite@8.2.0(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0))': + '@vitest/mocker@4.1.10(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.10 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.2.0(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0) + vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0) + + '@vitest/mocker@4.1.10(vite@8.2.1(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))': + dependencies: + '@vitest/spy': 4.1.10 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.2.1(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0) '@vitest/pretty-format@4.1.10': dependencies: @@ -7320,7 +8017,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vitest: 4.1.10(@types/node@26.1.2)(@vitest/browser-playwright@4.1.10)(@vitest/ui@4.1.10)(vite@8.2.0(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0)) + vitest: 4.1.10(@types/node@26.1.2)(@vitest/browser-playwright@4.1.10)(@vitest/ui@4.1.10)(vite@8.2.1(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) '@vitest/utils@4.1.10': dependencies: @@ -7335,6 +8032,8 @@ snapshots: mime-types: 3.0.2 negotiator: 1.0.0 + acorn@8.18.0: {} + agent-base@7.1.4: {} ansi-regex@5.0.1: {} @@ -7359,6 +8058,10 @@ snapshots: dependencies: tslib: 2.8.1 + aria-query@5.3.1: {} + + array-timsort@1.0.3: {} + asn1.js@5.4.1: dependencies: bn.js: 4.12.3 @@ -7368,6 +8071,8 @@ snapshots: assertion-error@2.0.1: {} + axobject-query@4.1.0: {} + babel-dead-code-elimination@1.0.12: dependencies: '@babel/core': 7.29.7 @@ -7453,6 +8158,10 @@ snapshots: dependencies: '@kurkle/color': 0.3.4 + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + chokidar@5.0.0: dependencies: readdirp: 5.0.0 @@ -7475,8 +8184,15 @@ snapshots: color-name@1.1.4: {} + commander@11.1.0: {} + commander@2.20.3: {} + comment-json@4.6.2: + dependencies: + array-timsort: 1.0.3 + esprima: 4.0.1 + compressible@2.0.18: dependencies: mime-db: 1.54.0 @@ -7500,6 +8216,8 @@ snapshots: confbox@0.2.4: {} + consola@3.4.0: {} + content-disposition@1.1.0: {} content-type@1.0.5: {} @@ -7514,6 +8232,8 @@ snapshots: cookie@0.7.2: {} + cookie@2.0.1: {} + core-js@3.49.0: {} crelt@1.0.6: {} @@ -7543,6 +8263,8 @@ snapshots: dedent@1.7.2: {} + deepmerge@4.3.1: {} + depd@2.0.0: {} dequal@2.0.3: {} @@ -7551,6 +8273,8 @@ snapshots: detect-node-es@1.1.0: {} + devalue@5.9.0: {} + discontinuous-range@1.0.0: {} dunder-proto@1.0.1: @@ -7585,14 +8309,49 @@ snapshots: dependencies: es-errors: 1.3.0 + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + escalade@3.2.0: {} escape-html@1.0.3: {} escape-string-regexp@4.0.0: {} + esm-env@1.2.2: {} + esprima@4.0.1: {} + esrap@2.3.4: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.9 @@ -7821,6 +8580,10 @@ snapshots: is-promise@4.0.0: {} + is-reference@3.0.3: + dependencies: + '@types/estree': 1.0.9 + isbot@5.2.1: {} isexe@2.0.0: {} @@ -7873,6 +8636,8 @@ snapshots: yaml: 2.9.0 zod: 4.4.3 + kysely@0.28.17: {} + kysely@0.29.0(patch_hash=a3e94339939b1be5b70610601e96fff19ed5678aab525de24b52dfc5212c686a): {} lightningcss-android-arm64@1.33.0: @@ -7930,6 +8695,8 @@ snapshots: linkifyjs@4.3.2: {} + locate-character@3.0.0: {} + lodash.isequal@4.5.0: {} lodash.throttle@4.1.1: {} @@ -7942,6 +8709,8 @@ snapshots: dependencies: js-tokens: 4.0.0 + lru-cache@11.5.2: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -8008,6 +8777,8 @@ snapshots: transitivePeerDependencies: - supports-color + mri@1.2.0: {} + mrmime@2.0.1: {} ms@2.0.0: {} @@ -8436,6 +9207,8 @@ snapshots: string_decoder: 1.3.0 util-deprecate: 1.0.2 + readdirp@4.1.2: {} + readdirp@5.0.0: {} rematrix@0.2.2: {} @@ -8503,6 +9276,10 @@ snapshots: runes2@1.1.4: {} + sade@1.8.1: + dependencies: + mri: 1.2.0 + safe-buffer@5.1.2: {} safe-buffer@5.2.1: {} @@ -8644,6 +9421,11 @@ snapshots: argparse: 2.0.1 nearley: 2.20.1 + sqlite-wasm-kysely@0.3.0(kysely@0.28.17): + dependencies: + '@sqlite.org/sqlite-wasm': 3.48.0-build4 + kysely: 0.28.17 + stackback@0.0.2: {} statuses@2.0.2: {} @@ -8679,6 +9461,40 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + svelte-check@4.7.6(picomatch@4.0.5)(svelte@5.56.9)(typescript@6.0.3): + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@sveltejs/load-config': 0.2.3 + chokidar: 4.0.3 + fdir: 6.5.0(picomatch@4.0.5) + picocolors: 1.1.1 + sade: 1.8.1 + svelte: 5.56.9 + typescript: 6.0.3 + transitivePeerDependencies: + - picomatch + + svelte@5.56.9: + dependencies: + '@jridgewell/remapping': 2.3.5 + '@jridgewell/sourcemap-codec': 1.5.5 + '@sveltejs/acorn-typescript': 1.0.13(acorn@8.18.0) + '@types/estree': 1.0.9 + '@types/trusted-types': 2.0.7 + acorn: 8.18.0 + aria-query: 5.3.1 + axobject-query: 4.1.0 + clsx: 2.1.1 + devalue: 5.9.0 + esm-env: 1.2.2 + esrap: 2.3.4 + is-reference: 3.0.3 + locate-character: 3.0.0 + magic-string: 0.30.21 + zimmerframe: 1.1.4 + transitivePeerDependencies: + - '@typescript-eslint/types' + swr@2.5.0(react@19.2.8): dependencies: dequal: 2.0.3 @@ -8741,6 +9557,12 @@ snapshots: tslib@2.8.1: {} + tsx@4.23.12: + dependencies: + esbuild: 0.28.2 + optionalDependencies: + fsevents: 2.3.3 + twgl.js@7.0.0: {} type-fest@2.19.0: {} @@ -8755,6 +9577,8 @@ snapshots: dependencies: ts-toolbelt: 9.6.0 + typescript@6.0.3: {} + typescript@7.0.2: optionalDependencies: '@typescript/typescript-aix-ppc64': 7.0.2 @@ -8788,12 +9612,21 @@ snapshots: unpipe@1.0.0: {} + unplugin@2.3.11: + dependencies: + '@jridgewell/remapping': 2.3.5 + acorn: 8.18.0 + picomatch: 4.0.5 + webpack-virtual-modules: 0.6.2 + update-browserslist-db@1.2.3(browserslist@4.28.7): dependencies: browserslist: 4.28.7 escalade: 3.2.0 picocolors: 1.1.1 + urlpattern-polyfill@10.1.0: {} + use-callback-ref@1.3.3(@types/react@19.2.18)(react@19.2.8): dependencies: react: 19.2.8 @@ -8815,19 +9648,25 @@ snapshots: util-deprecate@1.0.2: {} + uuid@14.0.1: {} + + valibot@1.4.2(typescript@6.0.3): + optionalDependencies: + typescript: 6.0.3 + valibot@1.4.2(typescript@7.0.2): optionalDependencies: typescript: 7.0.2 vary@1.1.2: {} - vite-node@6.0.0(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0): + vite-node@6.0.0(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0): dependencies: cac: 7.0.0 es-module-lexer: 2.3.1 obug: 2.1.3 pathe: 2.0.3 - vite: 8.2.0(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0) + vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0) transitivePeerDependencies: - '@types/node' - '@vitejs/devtools' @@ -8842,12 +9681,12 @@ snapshots: - tsx - yaml - vite-plugin-babel@1.7.3(@babel/core@7.29.7)(vite@8.2.0(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0)): + vite-plugin-babel@1.7.3(@babel/core@7.29.7)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)): dependencies: '@babel/core': 7.29.7 - vite: 8.2.0(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0) + vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0) - vite@8.2.0(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0): + vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0): dependencies: lightningcss: 1.33.0 picomatch: 4.0.5 @@ -8856,23 +9695,44 @@ snapshots: tinyglobby: 0.2.17 optionalDependencies: '@types/node': 26.1.2 + esbuild: 0.28.2 fsevents: 2.3.3 jiti: 2.7.0 + tsx: 4.23.12 yaml: 2.9.0 + vite@8.2.1(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.5 + postcss: 8.5.26 + rolldown: 1.2.3 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 26.1.2 + esbuild: 0.28.2 + fsevents: 2.3.3 + jiti: 2.7.0 + tsx: 4.23.12 + yaml: 2.9.0 + + vitefu@1.1.3(vite@8.2.1(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)): + optionalDependencies: + vite: 8.2.1(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0) + vitest-browser-react@2.2.0(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.10): dependencies: react: 19.2.8 react-dom: 19.2.8(react@19.2.8) - vitest: 4.1.10(@types/node@26.1.2)(@vitest/browser-playwright@4.1.10)(@vitest/ui@4.1.10)(vite@8.2.0(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0)) + vitest: 4.1.10(@types/node@26.1.2)(@vitest/browser-playwright@4.1.10)(@vitest/ui@4.1.10)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) optionalDependencies: '@types/react': 19.2.18 '@types/react-dom': 19.2.4(@types/react@19.2.18) - vitest@4.1.10(@types/node@26.1.2)(@vitest/browser-playwright@4.1.10)(@vitest/ui@4.1.10)(vite@8.2.0(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0)): + vitest@4.1.10(@types/node@26.1.2)(@vitest/browser-playwright@4.1.10)(@vitest/ui@4.1.10)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.10 - '@vitest/mocker': 4.1.10(vite@8.2.0(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0)) + '@vitest/mocker': 4.1.10(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.10 '@vitest/runner': 4.1.10 '@vitest/snapshot': 4.1.10 @@ -8889,11 +9749,40 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.2.0(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0) + vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 26.1.2 - '@vitest/browser-playwright': 4.1.10(playwright@1.62.1)(vite@8.2.0(@types/node@26.1.2)(jiti@2.7.0)(yaml@2.9.0))(vitest@4.1.10) + '@vitest/browser-playwright': 4.1.10(playwright@1.62.1)(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(vitest@4.1.10) + '@vitest/ui': 4.1.10(vitest@4.1.10) + transitivePeerDependencies: + - msw + + vitest@4.1.10(@types/node@26.1.2)(@vitest/browser-playwright@4.1.10)(@vitest/ui@4.1.10)(vite@8.2.1(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)): + dependencies: + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(vite@8.2.1(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + es-module-lexer: 2.3.1 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.3 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 8.2.1(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 26.1.2 + '@vitest/browser-playwright': 4.1.10(playwright@1.62.1)(vite@8.2.1(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(vitest@4.1.10) '@vitest/ui': 4.1.10(vitest@4.1.10) transitivePeerDependencies: - msw @@ -8902,10 +9791,11 @@ snapshots: walk-up-path@4.0.0: {} - web-haptics@0.0.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + web-haptics@0.0.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(svelte@5.56.9): optionalDependencies: react: 19.2.8 react-dom: 19.2.8(react@19.2.8) + svelte: 5.56.9 web-push@3.6.7: dependencies: @@ -8917,6 +9807,8 @@ snapshots: transitivePeerDependencies: - supports-color + webpack-virtual-modules@0.6.2: {} + which@2.0.2: dependencies: isexe: 2.0.0 @@ -8963,4 +9855,6 @@ snapshots: toposort: 2.0.2 type-fest: 2.19.0 + zimmerframe@1.1.4: {} + zod@4.4.3: {}