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 000000000..9ca4e6a77 Binary files /dev/null and b/apps/web/src/lib/styles/fonts/lexend-latin-ext.woff2 differ 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 000000000..0968c152f Binary files /dev/null and b/apps/web/src/lib/styles/fonts/lexend-latin.woff2 differ 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: {}