16
.env.example
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
DB_URL="postgresql://user:password@db:5432/esports"
|
||||||
|
|
||||||
|
# Drizzle
|
||||||
|
# Replace with your DB credentials!
|
||||||
|
DATABASE_URL="postgres://user:password@host:port/db-name"
|
||||||
|
|
||||||
|
ORIGIN=""
|
||||||
|
|
||||||
|
# Better Auth
|
||||||
|
# For production use 32 characters and generated with high entropy
|
||||||
|
# https://www.better-auth.com/docs/installation
|
||||||
|
BETTER_AUTH_SECRET=""
|
||||||
|
# GitHub OAuth
|
||||||
|
# https://www.better-auth.com/docs/authentication/github
|
||||||
|
GITHUB_CLIENT_ID=""
|
||||||
|
GITHUB_CLIENT_SECRET=""
|
||||||
26
.gitignore
vendored
@@ -1 +1,25 @@
|
|||||||
node_modules/
|
node_modules
|
||||||
|
|
||||||
|
# Output
|
||||||
|
.output
|
||||||
|
.vercel
|
||||||
|
.netlify
|
||||||
|
.wrangler
|
||||||
|
/.svelte-kit
|
||||||
|
/build
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Env
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
!.env.test
|
||||||
|
|
||||||
|
# Vite
|
||||||
|
vite.config.js.timestamp-*
|
||||||
|
vite.config.ts.timestamp-*
|
||||||
|
# Playwright
|
||||||
|
test-results
|
||||||
|
|||||||
10
.prettierignore
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# Package Managers
|
||||||
|
package-lock.json
|
||||||
|
pnpm-lock.yaml
|
||||||
|
yarn.lock
|
||||||
|
bun.lock
|
||||||
|
bun.lockb
|
||||||
|
|
||||||
|
# Miscellaneous
|
||||||
|
/static/
|
||||||
|
/drizzle/
|
||||||
16
.prettierrc
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"useTabs": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "none",
|
||||||
|
"printWidth": 100,
|
||||||
|
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": "*.svelte",
|
||||||
|
"options": {
|
||||||
|
"parser": "svelte"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tailwindStylesheet": "./src/routes/layout.css"
|
||||||
|
}
|
||||||
5
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"files.associations": {
|
||||||
|
"*.css": "tailwindcss"
|
||||||
|
}
|
||||||
|
}
|
||||||
42
README.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# sv
|
||||||
|
|
||||||
|
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
|
||||||
|
|
||||||
|
## Creating a project
|
||||||
|
|
||||||
|
If you're seeing this, you've probably already done this step. Congrats!
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# create a new project
|
||||||
|
npx sv create my-app
|
||||||
|
```
|
||||||
|
|
||||||
|
To recreate this project with the same configuration:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# recreate this project
|
||||||
|
bun x sv create --template minimal --types ts --add prettier eslint vitest="usages:unit,component" playwright tailwindcss="plugins:typography" sveltekit-adapter="adapter:node" devtools-json drizzle="database:postgresql+postgresql:postgres.js+docker:no" better-auth="demo:password,github" --install bun .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Developing
|
||||||
|
|
||||||
|
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# or start the server and open the app in a new browser tab
|
||||||
|
npm run dev -- --open
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
To create a production version of your app:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
You can preview the production build with `npm run preview`.
|
||||||
|
|
||||||
|
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
||||||
840
bun.lock
@@ -4,23 +4,847 @@
|
|||||||
"workspaces": {
|
"workspaces": {
|
||||||
"": {
|
"": {
|
||||||
"name": "esports",
|
"name": "esports",
|
||||||
"devDependencies": {
|
"dependencies": {
|
||||||
"@types/bun": "latest",
|
"bancho.js": "^0.12.3",
|
||||||
|
"postgres": "^3.4.8",
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5",
|
"@eslint/compat": "^2.0.2",
|
||||||
|
"@eslint/js": "^9.39.2",
|
||||||
|
"@playwright/test": "^1.58.1",
|
||||||
|
"@sveltejs/adapter-node": "^5.5.2",
|
||||||
|
"@sveltejs/kit": "^2.50.2",
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||||
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
|
"@types/node": "^22",
|
||||||
|
"@vitest/browser-playwright": "^4.0.18",
|
||||||
|
"better-auth": "^1.4.18",
|
||||||
|
"drizzle-kit": "^0.31.8",
|
||||||
|
"drizzle-orm": "^0.45.1",
|
||||||
|
"eslint": "^9.39.2",
|
||||||
|
"eslint-config-prettier": "^10.1.8",
|
||||||
|
"eslint-plugin-svelte": "^3.14.0",
|
||||||
|
"globals": "^17.3.0",
|
||||||
|
"playwright": "^1.58.1",
|
||||||
|
"prettier": "^3.8.1",
|
||||||
|
"prettier-plugin-svelte": "^3.4.1",
|
||||||
|
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||||
|
"svelte": "^5.51.0",
|
||||||
|
"svelte-check": "^4.3.6",
|
||||||
|
"tailwindcss": "^4.1.18",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"typescript-eslint": "^8.54.0",
|
||||||
|
"vite": "^7.3.1",
|
||||||
|
"vite-plugin-devtools-json": "^1.0.0",
|
||||||
|
"vitest": "^4.0.18",
|
||||||
|
"vitest-browser-svelte": "^2.0.2",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages": {
|
"packages": {
|
||||||
"@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="],
|
"@better-auth/core": ["@better-auth/core@1.4.19", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "zod": "^4.3.5" }, "peerDependencies": { "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21", "better-call": "1.1.8", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1" } }, "sha512-uADLHG1jc5BnEJi7f6ijUN5DmPPRSj++7m/G19z3UqA3MVCo4Y4t1MMa4IIxLCqGDFv22drdfxescgW+HnIowA=="],
|
||||||
|
|
||||||
"@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="],
|
"@better-auth/telemetry": ["@better-auth/telemetry@1.4.19", "", { "dependencies": { "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21" }, "peerDependencies": { "@better-auth/core": "1.4.19" } }, "sha512-ApGNS7olCTtDpKF8Ow3Z+jvFAirOj7c4RyFUpu8axklh3mH57ndpfUAUjhgA8UVoaaH/mnm/Tl884BlqiewLyw=="],
|
||||||
|
|
||||||
"bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="],
|
"@better-auth/utils": ["@better-auth/utils@0.3.0", "", {}, "sha512-W+Adw6ZA6mgvnSnhOki270rwJ42t4XzSK6YWGF//BbVXL6SwCLWfyzBc1lN2m/4RM28KubdBKQ4X5VMoLRNPQw=="],
|
||||||
|
|
||||||
|
"@better-fetch/fetch": ["@better-fetch/fetch@1.1.21", "", {}, "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A=="],
|
||||||
|
|
||||||
|
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="],
|
||||||
|
|
||||||
|
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
|
||||||
|
|
||||||
|
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
|
||||||
|
|
||||||
|
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
|
||||||
|
|
||||||
|
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
|
||||||
|
|
||||||
|
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
|
||||||
|
|
||||||
|
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
|
||||||
|
|
||||||
|
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
|
||||||
|
|
||||||
|
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
|
||||||
|
|
||||||
|
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
|
||||||
|
|
||||||
|
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
|
||||||
|
|
||||||
|
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
|
||||||
|
|
||||||
|
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
|
||||||
|
|
||||||
|
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
|
||||||
|
|
||||||
|
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
|
||||||
|
|
||||||
|
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="],
|
||||||
|
|
||||||
|
"@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="],
|
||||||
|
|
||||||
|
"@eslint/compat": ["@eslint/compat@2.0.2", "", { "dependencies": { "@eslint/core": "^1.1.0" }, "peerDependencies": { "eslint": "^8.40 || 9 || 10" }, "optionalPeers": ["eslint"] }, "sha512-pR1DoD0h3HfF675QZx0xsyrsU8q70Z/plx7880NOhS02NuWLgBCOMDL787nUeQ7EWLkxv3bPQJaarjcPQb2Dwg=="],
|
||||||
|
|
||||||
|
"@eslint/config-array": ["@eslint/config-array@0.21.1", "", { "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.2" } }, "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA=="],
|
||||||
|
|
||||||
|
"@eslint/config-helpers": ["@eslint/config-helpers@0.4.2", "", { "dependencies": { "@eslint/core": "^0.17.0" } }, "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw=="],
|
||||||
|
|
||||||
|
"@eslint/core": ["@eslint/core@1.1.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-/nr9K9wkr3P1EzFTdFdMoLuo1PmIxjmwvPozwoSodjNBdefGujXQUF93u1DDZpEaTuDvMsIQddsd35BwtrW9Xw=="],
|
||||||
|
|
||||||
|
"@eslint/eslintrc": ["@eslint/eslintrc@3.3.4", "", { "dependencies": { "ajv": "^6.14.0", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.1", "minimatch": "^3.1.3", "strip-json-comments": "^3.1.1" } }, "sha512-4h4MVF8pmBsncB60r0wSJiIeUKTSD4m7FmTFThG8RHlsg9ajqckLm9OraguFGZE4vVdpiI1Q4+hFnisopmG6gQ=="],
|
||||||
|
|
||||||
|
"@eslint/js": ["@eslint/js@9.39.3", "", {}, "sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw=="],
|
||||||
|
|
||||||
|
"@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="],
|
||||||
|
|
||||||
|
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="],
|
||||||
|
|
||||||
|
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
|
||||||
|
|
||||||
|
"@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="],
|
||||||
|
|
||||||
|
"@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="],
|
||||||
|
|
||||||
|
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
|
||||||
|
|
||||||
|
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||||
|
|
||||||
|
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||||
|
|
||||||
|
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||||
|
|
||||||
|
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||||
|
|
||||||
|
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||||
|
|
||||||
|
"@noble/ciphers": ["@noble/ciphers@2.1.1", "", {}, "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw=="],
|
||||||
|
|
||||||
|
"@noble/hashes": ["@noble/hashes@2.0.1", "", {}, "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw=="],
|
||||||
|
|
||||||
|
"@playwright/test": ["@playwright/test@1.58.2", "", { "dependencies": { "playwright": "1.58.2" }, "bin": { "playwright": "cli.js" } }, "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA=="],
|
||||||
|
|
||||||
|
"@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
|
||||||
|
|
||||||
|
"@rollup/plugin-commonjs": ["@rollup/plugin-commonjs@29.0.0", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "commondir": "^1.0.1", "estree-walker": "^2.0.2", "fdir": "^6.2.0", "is-reference": "1.2.1", "magic-string": "^0.30.3", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^2.68.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-U2YHaxR2cU/yAiwKJtJRhnyLk7cifnQw0zUpISsocBDoHDJn+HTV74ABqnwr5bEgWUwFZC9oFL6wLe21lHu5eQ=="],
|
||||||
|
|
||||||
|
"@rollup/plugin-json": ["@rollup/plugin-json@6.1.0", "", { "dependencies": { "@rollup/pluginutils": "^5.1.0" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA=="],
|
||||||
|
|
||||||
|
"@rollup/plugin-node-resolve": ["@rollup/plugin-node-resolve@16.0.3", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "@types/resolve": "1.20.2", "deepmerge": "^4.2.2", "is-module": "^1.0.0", "resolve": "^1.22.1" }, "peerDependencies": { "rollup": "^2.78.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg=="],
|
||||||
|
|
||||||
|
"@rollup/pluginutils": ["@rollup/pluginutils@5.3.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.59.0", "", { "os": "android", "cpu": "arm" }, "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.59.0", "", { "os": "android", "cpu": "arm64" }, "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.59.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.59.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.59.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.59.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.59.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.59.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.59.0", "", { "os": "none", "cpu": "arm64" }, "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.59.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.59.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA=="],
|
||||||
|
|
||||||
|
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||||
|
|
||||||
|
"@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.9", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA=="],
|
||||||
|
|
||||||
|
"@sveltejs/adapter-node": ["@sveltejs/adapter-node@5.5.4", "", { "dependencies": { "@rollup/plugin-commonjs": "^29.0.0", "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^16.0.0", "rollup": "^4.59.0" }, "peerDependencies": { "@sveltejs/kit": "^2.4.0" } }, "sha512-45X92CXW+2J8ZUzPv3eLlKWEzINKiiGeFWTjyER4ZN4sGgNoaoeSkCY/QYNxHpPXy71QPsctwccBo9jJs0ySPQ=="],
|
||||||
|
|
||||||
|
"@sveltejs/kit": ["@sveltejs/kit@2.53.3", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/cookie": "^0.6.0", "acorn": "^8.14.1", "cookie": "^0.6.0", "devalue": "^5.6.3", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "set-cookie-parser": "^3.0.0", "sirv": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.3.3", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" }, "optionalPeers": ["@opentelemetry/api", "typescript"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-tshOeBUid2v5LAblUpatIdFm5Cyykbw2EiKWOunAAX0A/oJaR7DOdC9wLR5Qqh9zUf3QUISA2m9A3suBdQSYQg=="],
|
||||||
|
|
||||||
|
"@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@6.2.4", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", "deepmerge": "^4.3.1", "magic-string": "^0.30.21", "obug": "^2.1.0", "vitefu": "^1.1.1" }, "peerDependencies": { "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA=="],
|
||||||
|
|
||||||
|
"@sveltejs/vite-plugin-svelte-inspector": ["@sveltejs/vite-plugin-svelte-inspector@5.0.2", "", { "dependencies": { "obug": "^2.1.0" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig=="],
|
||||||
|
|
||||||
|
"@tailwindcss/node": ["@tailwindcss/node@4.2.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.31.1", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.1" } }, "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.1", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.1", "@tailwindcss/oxide-darwin-arm64": "4.2.1", "@tailwindcss/oxide-darwin-x64": "4.2.1", "@tailwindcss/oxide-freebsd-x64": "4.2.1", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", "@tailwindcss/oxide-linux-x64-musl": "4.2.1", "@tailwindcss/oxide-wasm32-wasi": "4.2.1", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" } }, "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.1", "", { "os": "android", "cpu": "arm64" }, "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1", "", { "os": "linux", "cpu": "arm" }, "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.1", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.1", "", { "os": "win32", "cpu": "x64" }, "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ=="],
|
||||||
|
|
||||||
|
"@tailwindcss/typography": ["@tailwindcss/typography@0.5.19", "", { "dependencies": { "postcss-selector-parser": "6.0.10" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg=="],
|
||||||
|
|
||||||
|
"@tailwindcss/vite": ["@tailwindcss/vite@4.2.1", "", { "dependencies": { "@tailwindcss/node": "4.2.1", "@tailwindcss/oxide": "4.2.1", "tailwindcss": "4.2.1" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w=="],
|
||||||
|
|
||||||
|
"@testing-library/svelte-core": ["@testing-library/svelte-core@1.0.0", "", { "peerDependencies": { "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0" } }, "sha512-VkUePoLV6oOYwSUvX6ShA8KLnJqZiYMIbP2JW2t0GLWLkJxKGvuH5qrrZBV/X7cXFnLGuFQEC7RheYiZOW68KQ=="],
|
||||||
|
|
||||||
|
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
|
||||||
|
|
||||||
|
"@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
|
||||||
|
|
||||||
|
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
|
||||||
|
|
||||||
|
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||||
|
|
||||||
|
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
||||||
|
|
||||||
|
"@types/node": ["@types/node@22.19.13", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-akNQMv0wW5uyRpD2v2IEyRSZiR+BeGuoB6L310EgGObO44HSMNT8z1xzio28V8qOrgYaopIDNA18YgdXd+qTiw=="],
|
||||||
|
|
||||||
|
"@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="],
|
||||||
|
|
||||||
|
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
|
||||||
|
|
||||||
|
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.56.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/type-utils": "8.56.1", "@typescript-eslint/utils": "8.56.1", "@typescript-eslint/visitor-keys": "8.56.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.56.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A=="],
|
||||||
|
|
||||||
|
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.56.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", "@typescript-eslint/typescript-estree": "8.56.1", "@typescript-eslint/visitor-keys": "8.56.1", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg=="],
|
||||||
|
|
||||||
|
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.56.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.56.1", "@typescript-eslint/types": "^8.56.1", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ=="],
|
||||||
|
|
||||||
|
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.56.1", "", { "dependencies": { "@typescript-eslint/types": "8.56.1", "@typescript-eslint/visitor-keys": "8.56.1" } }, "sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w=="],
|
||||||
|
|
||||||
|
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.56.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ=="],
|
||||||
|
|
||||||
|
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.56.1", "", { "dependencies": { "@typescript-eslint/types": "8.56.1", "@typescript-eslint/typescript-estree": "8.56.1", "@typescript-eslint/utils": "8.56.1", "debug": "^4.4.3", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg=="],
|
||||||
|
|
||||||
|
"@typescript-eslint/types": ["@typescript-eslint/types@8.56.1", "", {}, "sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw=="],
|
||||||
|
|
||||||
|
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.56.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.56.1", "@typescript-eslint/tsconfig-utils": "8.56.1", "@typescript-eslint/types": "8.56.1", "@typescript-eslint/visitor-keys": "8.56.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg=="],
|
||||||
|
|
||||||
|
"@typescript-eslint/utils": ["@typescript-eslint/utils@8.56.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", "@typescript-eslint/typescript-estree": "8.56.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA=="],
|
||||||
|
|
||||||
|
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.56.1", "", { "dependencies": { "@typescript-eslint/types": "8.56.1", "eslint-visitor-keys": "^5.0.0" } }, "sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw=="],
|
||||||
|
|
||||||
|
"@vitest/browser": ["@vitest/browser@4.0.18", "", { "dependencies": { "@vitest/mocker": "4.0.18", "@vitest/utils": "4.0.18", "magic-string": "^0.30.21", "pixelmatch": "7.1.0", "pngjs": "^7.0.0", "sirv": "^3.0.2", "tinyrainbow": "^3.0.3", "ws": "^8.18.3" }, "peerDependencies": { "vitest": "4.0.18" } }, "sha512-gVQqh7paBz3gC+ZdcCmNSWJMk70IUjDeVqi+5m5vYpEHsIwRgw3Y545jljtajhkekIpIp5Gg8oK7bctgY0E2Ng=="],
|
||||||
|
|
||||||
|
"@vitest/browser-playwright": ["@vitest/browser-playwright@4.0.18", "", { "dependencies": { "@vitest/browser": "4.0.18", "@vitest/mocker": "4.0.18", "tinyrainbow": "^3.0.3" }, "peerDependencies": { "playwright": "*", "vitest": "4.0.18" } }, "sha512-gfajTHVCiwpxRj1qh0Sh/5bbGLG4F/ZH/V9xvFVoFddpITfMta9YGow0W6ZpTTORv2vdJuz9TnrNSmjKvpOf4g=="],
|
||||||
|
|
||||||
|
"@vitest/expect": ["@vitest/expect@4.0.18", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" } }, "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ=="],
|
||||||
|
|
||||||
|
"@vitest/mocker": ["@vitest/mocker@4.0.18", "", { "dependencies": { "@vitest/spy": "4.0.18", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ=="],
|
||||||
|
|
||||||
|
"@vitest/pretty-format": ["@vitest/pretty-format@4.0.18", "", { "dependencies": { "tinyrainbow": "^3.0.3" } }, "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw=="],
|
||||||
|
|
||||||
|
"@vitest/runner": ["@vitest/runner@4.0.18", "", { "dependencies": { "@vitest/utils": "4.0.18", "pathe": "^2.0.3" } }, "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw=="],
|
||||||
|
|
||||||
|
"@vitest/snapshot": ["@vitest/snapshot@4.0.18", "", { "dependencies": { "@vitest/pretty-format": "4.0.18", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA=="],
|
||||||
|
|
||||||
|
"@vitest/spy": ["@vitest/spy@4.0.18", "", {}, "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw=="],
|
||||||
|
|
||||||
|
"@vitest/utils": ["@vitest/utils@4.0.18", "", { "dependencies": { "@vitest/pretty-format": "4.0.18", "tinyrainbow": "^3.0.3" } }, "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA=="],
|
||||||
|
|
||||||
|
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||||
|
|
||||||
|
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
|
||||||
|
|
||||||
|
"ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="],
|
||||||
|
|
||||||
|
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||||
|
|
||||||
|
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||||
|
|
||||||
|
"aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="],
|
||||||
|
|
||||||
|
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
|
||||||
|
|
||||||
|
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
|
||||||
|
|
||||||
|
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||||
|
|
||||||
|
"bancho.js": ["bancho.js@0.12.3", "", { "dependencies": { "nodesu": "^0.7.2", "rate-limiter-flexible": "^2.3.12" }, "peerDependencies": { "weak-value-map": "^1.0.1" }, "optionalPeers": ["weak-value-map"] }, "sha512-0y4AJ7ehY2i4k3eTffXY35DqxXTbH/902BYTDGP03gstrWq33Ad2HU2PpyOSIlhWVRMJrBA/Ntx6UBvWeKdQXg=="],
|
||||||
|
|
||||||
|
"better-auth": ["better-auth@1.4.19", "", { "dependencies": { "@better-auth/core": "1.4.19", "@better-auth/telemetry": "1.4.19", "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21", "@noble/ciphers": "^2.0.0", "@noble/hashes": "^2.0.0", "better-call": "1.1.8", "defu": "^6.1.4", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1", "zod": "^4.3.5" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", "drizzle-orm": ">=0.41.0", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-3RlZJcA0+NH25wYD85vpIGwW9oSTuEmLIaGbT8zg41w/Pa2hVWHKedjoUHHJtnzkBXzDb+CShkLnSw7IThDdqQ=="],
|
||||||
|
|
||||||
|
"better-call": ["better-call@1.1.8", "", { "dependencies": { "@better-auth/utils": "^0.3.0", "@better-fetch/fetch": "^1.1.4", "rou3": "^0.7.10", "set-cookie-parser": "^2.7.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-XMQ2rs6FNXasGNfMjzbyroSwKwYbZ/T3IxruSS6U2MJRsSYh3wYtG3o6H00ZlKZ/C/UPOAD97tqgQJNsxyeTXw=="],
|
||||||
|
|
||||||
|
"brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
|
||||||
|
|
||||||
|
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||||
|
|
||||||
|
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
|
||||||
|
|
||||||
|
"chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
|
||||||
|
|
||||||
|
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||||
|
|
||||||
|
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
|
||||||
|
|
||||||
|
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||||
|
|
||||||
|
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
||||||
|
|
||||||
|
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||||
|
|
||||||
|
"commondir": ["commondir@1.0.1", "", {}, "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg=="],
|
||||||
|
|
||||||
|
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
|
||||||
|
|
||||||
|
"cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="],
|
||||||
|
|
||||||
|
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||||
|
|
||||||
|
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
|
||||||
|
|
||||||
|
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||||
|
|
||||||
|
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
|
||||||
|
|
||||||
|
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
|
||||||
|
|
||||||
|
"defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="],
|
||||||
|
|
||||||
|
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||||
|
|
||||||
|
"devalue": ["devalue@5.6.3", "", {}, "sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg=="],
|
||||||
|
|
||||||
|
"drizzle-kit": ["drizzle-kit@0.31.9", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-GViD3IgsXn7trFyBUUHyTFBpH/FsHTxYJ66qdbVggxef4UBPHRYxQaRzYLTuekYnk9i5FIEL9pbBIwMqX/Uwrg=="],
|
||||||
|
|
||||||
|
"drizzle-orm": ["drizzle-orm@0.45.1", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-Te0FOdKIistGNPMq2jscdqngBRfBpC8uMFVwqjf6gtTVJHIQ/dosgV/CLBU2N4ZJBsXL5savCba9b0YJskKdcA=="],
|
||||||
|
|
||||||
|
"enhanced-resolve": ["enhanced-resolve@5.19.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg=="],
|
||||||
|
|
||||||
|
"es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
|
||||||
|
|
||||||
|
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
|
||||||
|
|
||||||
|
"esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="],
|
||||||
|
|
||||||
|
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
|
||||||
|
|
||||||
|
"eslint": ["eslint@9.39.3", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.1", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.39.3", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg=="],
|
||||||
|
|
||||||
|
"eslint-config-prettier": ["eslint-config-prettier@10.1.8", "", { "peerDependencies": { "eslint": ">=7.0.0" }, "bin": { "eslint-config-prettier": "bin/cli.js" } }, "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w=="],
|
||||||
|
|
||||||
|
"eslint-plugin-svelte": ["eslint-plugin-svelte@3.15.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.6.1", "@jridgewell/sourcemap-codec": "^1.5.0", "esutils": "^2.0.3", "globals": "^16.0.0", "known-css-properties": "^0.37.0", "postcss": "^8.4.49", "postcss-load-config": "^3.1.4", "postcss-safe-parser": "^7.0.0", "semver": "^7.6.3", "svelte-eslint-parser": "^1.4.0" }, "peerDependencies": { "eslint": "^8.57.1 || ^9.0.0 || ^10.0.0", "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" }, "optionalPeers": ["svelte"] }, "sha512-QKB7zqfuB8aChOfBTComgDptMf2yxiJx7FE04nneCmtQzgTHvY8UJkuh8J2Rz7KB9FFV9aTHX6r7rdYGvG8T9Q=="],
|
||||||
|
|
||||||
|
"eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="],
|
||||||
|
|
||||||
|
"eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
|
||||||
|
|
||||||
|
"esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="],
|
||||||
|
|
||||||
|
"espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="],
|
||||||
|
|
||||||
|
"esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="],
|
||||||
|
|
||||||
|
"esrap": ["esrap@2.2.3", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" } }, "sha512-8fOS+GIGCQZl/ZIlhl59htOlms6U8NvX6ZYgYHpRU/b6tVSh3uHkOHZikl3D4cMbYM0JlpBe+p/BkZEi8J9XIQ=="],
|
||||||
|
|
||||||
|
"esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
|
||||||
|
|
||||||
|
"estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
|
||||||
|
|
||||||
|
"estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||||
|
|
||||||
|
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
|
||||||
|
|
||||||
|
"expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="],
|
||||||
|
|
||||||
|
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||||
|
|
||||||
|
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
|
||||||
|
|
||||||
|
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
|
||||||
|
|
||||||
|
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||||
|
|
||||||
|
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
|
||||||
|
|
||||||
|
"find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
|
||||||
|
|
||||||
|
"flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="],
|
||||||
|
|
||||||
|
"flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="],
|
||||||
|
|
||||||
|
"fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
|
||||||
|
|
||||||
|
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||||
|
|
||||||
|
"get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="],
|
||||||
|
|
||||||
|
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
|
||||||
|
|
||||||
|
"globals": ["globals@17.3.0", "", {}, "sha512-yMqGUQVVCkD4tqjOJf3TnrvaaHDMYp4VlUSObbkIiuCPe/ofdMBFIAcBbCSRFWOnos6qRiTVStDwqPLUclaxIw=="],
|
||||||
|
|
||||||
|
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||||
|
|
||||||
|
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
|
||||||
|
|
||||||
|
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||||
|
|
||||||
|
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||||
|
|
||||||
|
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
|
||||||
|
|
||||||
|
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
|
||||||
|
|
||||||
|
"is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
|
||||||
|
|
||||||
|
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
||||||
|
|
||||||
|
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
||||||
|
|
||||||
|
"is-module": ["is-module@1.0.0", "", {}, "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g=="],
|
||||||
|
|
||||||
|
"is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="],
|
||||||
|
|
||||||
|
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||||
|
|
||||||
|
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||||
|
|
||||||
|
"jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="],
|
||||||
|
|
||||||
|
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
|
||||||
|
|
||||||
|
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
|
||||||
|
|
||||||
|
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
|
||||||
|
|
||||||
|
"json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
|
||||||
|
|
||||||
|
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
|
||||||
|
|
||||||
|
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
||||||
|
|
||||||
|
"known-css-properties": ["known-css-properties@0.37.0", "", {}, "sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ=="],
|
||||||
|
|
||||||
|
"kysely": ["kysely@0.28.11", "", {}, "sha512-zpGIFg0HuoC893rIjYX1BETkVWdDnzTzF5e0kWXJFg5lE0k1/LfNWBejrcnOFu8Q2Rfq/hTDTU7XLUM8QOrpzg=="],
|
||||||
|
|
||||||
|
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
|
||||||
|
|
||||||
|
"lightningcss": ["lightningcss@1.31.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.31.1", "lightningcss-darwin-arm64": "1.31.1", "lightningcss-darwin-x64": "1.31.1", "lightningcss-freebsd-x64": "1.31.1", "lightningcss-linux-arm-gnueabihf": "1.31.1", "lightningcss-linux-arm64-gnu": "1.31.1", "lightningcss-linux-arm64-musl": "1.31.1", "lightningcss-linux-x64-gnu": "1.31.1", "lightningcss-linux-x64-musl": "1.31.1", "lightningcss-win32-arm64-msvc": "1.31.1", "lightningcss-win32-x64-msvc": "1.31.1" } }, "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ=="],
|
||||||
|
|
||||||
|
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.31.1", "", { "os": "android", "cpu": "arm64" }, "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg=="],
|
||||||
|
|
||||||
|
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.31.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg=="],
|
||||||
|
|
||||||
|
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.31.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA=="],
|
||||||
|
|
||||||
|
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.31.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.31.1", "", { "os": "linux", "cpu": "arm" }, "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA=="],
|
||||||
|
|
||||||
|
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.31.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w=="],
|
||||||
|
|
||||||
|
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.31.1", "", { "os": "win32", "cpu": "x64" }, "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw=="],
|
||||||
|
|
||||||
|
"lilconfig": ["lilconfig@2.1.0", "", {}, "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ=="],
|
||||||
|
|
||||||
|
"limiter": ["limiter@1.1.5", "", {}, "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA=="],
|
||||||
|
|
||||||
|
"locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="],
|
||||||
|
|
||||||
|
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
|
||||||
|
|
||||||
|
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
|
||||||
|
|
||||||
|
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||||
|
|
||||||
|
"minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
|
||||||
|
|
||||||
|
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
|
||||||
|
|
||||||
|
"mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
|
||||||
|
|
||||||
|
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||||
|
|
||||||
|
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||||
|
|
||||||
|
"nanostores": ["nanostores@1.1.1", "", {}, "sha512-EYJqS25r2iBeTtGQCHidXl1VfZ1jXM7Q04zXJOrMlxVVmD0ptxJaNux92n1mJ7c5lN3zTq12MhH/8x59nP+qmg=="],
|
||||||
|
|
||||||
|
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
|
||||||
|
|
||||||
|
"nodesu": ["nodesu@0.7.3", "", { "dependencies": { "limiter": "^1.1.0" } }, "sha512-RTHkaRyt+alXkzp8TDH1r4dtkp38jeqFyGgFBCT6uSqcGIOUdh7EqghwN1PrcGVqTjPPjWUCnqoNay+JOMw3bg=="],
|
||||||
|
|
||||||
|
"obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
|
||||||
|
|
||||||
|
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
|
||||||
|
|
||||||
|
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
|
||||||
|
|
||||||
|
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
|
||||||
|
|
||||||
|
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
|
||||||
|
|
||||||
|
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
|
||||||
|
|
||||||
|
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||||
|
|
||||||
|
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
|
||||||
|
|
||||||
|
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||||
|
|
||||||
|
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||||
|
|
||||||
|
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||||
|
|
||||||
|
"pixelmatch": ["pixelmatch@7.1.0", "", { "dependencies": { "pngjs": "^7.0.0" }, "bin": { "pixelmatch": "bin/pixelmatch" } }, "sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng=="],
|
||||||
|
|
||||||
|
"playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="],
|
||||||
|
|
||||||
|
"playwright-core": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="],
|
||||||
|
|
||||||
|
"pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="],
|
||||||
|
|
||||||
|
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
||||||
|
|
||||||
|
"postcss-load-config": ["postcss-load-config@3.1.4", "", { "dependencies": { "lilconfig": "^2.0.5", "yaml": "^1.10.2" }, "peerDependencies": { "postcss": ">=8.0.9", "ts-node": ">=9.0.0" }, "optionalPeers": ["postcss", "ts-node"] }, "sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg=="],
|
||||||
|
|
||||||
|
"postcss-safe-parser": ["postcss-safe-parser@7.0.1", "", { "peerDependencies": { "postcss": "^8.4.31" } }, "sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A=="],
|
||||||
|
|
||||||
|
"postcss-scss": ["postcss-scss@4.0.9", "", { "peerDependencies": { "postcss": "^8.4.29" } }, "sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A=="],
|
||||||
|
|
||||||
|
"postcss-selector-parser": ["postcss-selector-parser@6.0.10", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w=="],
|
||||||
|
|
||||||
|
"postgres": ["postgres@3.4.8", "", {}, "sha512-d+JFcLM17njZaOLkv6SCev7uoLaBtfK86vMUXhW1Z4glPWh4jozno9APvW/XKFJ3CCxVoC7OL38BqRydtu5nGg=="],
|
||||||
|
|
||||||
|
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
|
||||||
|
|
||||||
|
"prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="],
|
||||||
|
|
||||||
|
"prettier-plugin-svelte": ["prettier-plugin-svelte@3.5.0", "", { "peerDependencies": { "prettier": "^3.0.0", "svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0" } }, "sha512-2lLO/7EupnjO/95t+XZesXs8Bf3nYLIDfCo270h5QWbj/vjLqmrQ1LiRk9LPggxSDsnVYfehamZNf+rgQYApZg=="],
|
||||||
|
|
||||||
|
"prettier-plugin-tailwindcss": ["prettier-plugin-tailwindcss@0.7.2", "", { "peerDependencies": { "@ianvs/prettier-plugin-sort-imports": "*", "@prettier/plugin-hermes": "*", "@prettier/plugin-oxc": "*", "@prettier/plugin-pug": "*", "@shopify/prettier-plugin-liquid": "*", "@trivago/prettier-plugin-sort-imports": "*", "@zackad/prettier-plugin-twig": "*", "prettier": "^3.0", "prettier-plugin-astro": "*", "prettier-plugin-css-order": "*", "prettier-plugin-jsdoc": "*", "prettier-plugin-marko": "*", "prettier-plugin-multiline-arrays": "*", "prettier-plugin-organize-attributes": "*", "prettier-plugin-organize-imports": "*", "prettier-plugin-sort-imports": "*", "prettier-plugin-svelte": "*" }, "optionalPeers": ["@ianvs/prettier-plugin-sort-imports", "@prettier/plugin-hermes", "@prettier/plugin-oxc", "@prettier/plugin-pug", "@shopify/prettier-plugin-liquid", "@trivago/prettier-plugin-sort-imports", "@zackad/prettier-plugin-twig", "prettier-plugin-astro", "prettier-plugin-css-order", "prettier-plugin-jsdoc", "prettier-plugin-marko", "prettier-plugin-multiline-arrays", "prettier-plugin-organize-attributes", "prettier-plugin-organize-imports", "prettier-plugin-sort-imports", "prettier-plugin-svelte"] }, "sha512-LkphyK3Fw+q2HdMOoiEHWf93fNtYJwfamoKPl7UwtjFQdei/iIBoX11G6j706FzN3ymX9mPVi97qIY8328vdnA=="],
|
||||||
|
|
||||||
|
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||||
|
|
||||||
|
"rate-limiter-flexible": ["rate-limiter-flexible@2.4.2", "", {}, "sha512-rMATGGOdO1suFyf/mI5LYhts71g1sbdhmd6YvdiXO2gJnd42Tt6QS4JUKJKSWVVkMtBacm6l40FR7Trjo6Iruw=="],
|
||||||
|
|
||||||
|
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
|
||||||
|
|
||||||
|
"resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
|
||||||
|
|
||||||
|
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
|
||||||
|
|
||||||
|
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
||||||
|
|
||||||
|
"rollup": ["rollup@4.59.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg=="],
|
||||||
|
|
||||||
|
"rou3": ["rou3@0.7.12", "", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="],
|
||||||
|
|
||||||
|
"sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="],
|
||||||
|
|
||||||
|
"semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||||
|
|
||||||
|
"set-cookie-parser": ["set-cookie-parser@3.0.1", "", {}, "sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q=="],
|
||||||
|
|
||||||
|
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
||||||
|
|
||||||
|
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||||
|
|
||||||
|
"siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
|
||||||
|
|
||||||
|
"sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="],
|
||||||
|
|
||||||
|
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||||
|
|
||||||
|
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||||
|
|
||||||
|
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
|
||||||
|
|
||||||
|
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
|
||||||
|
|
||||||
|
"std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
|
||||||
|
|
||||||
|
"strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
|
||||||
|
|
||||||
|
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||||
|
|
||||||
|
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
||||||
|
|
||||||
|
"svelte": ["svelte@5.53.5", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.6.3", "esm-env": "^1.2.1", "esrap": "^2.2.2", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-YkqERnF05g8KLdDZwZrF8/i1eSbj6Eoat8Jjr2IfruZz9StLuBqo8sfCSzjosNKd+ZrQ8DkKZDjpO5y3ht1Pow=="],
|
||||||
|
|
||||||
|
"svelte-check": ["svelte-check@4.4.4", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-F1pGqXc710Oi/wTI4d/x7d6lgPwwfx1U6w3Q35n4xsC2e8C/yN2sM1+mWxjlMcpAfWucjlq4vPi+P4FZ8a14sQ=="],
|
||||||
|
|
||||||
|
"svelte-eslint-parser": ["svelte-eslint-parser@1.5.1", "", { "dependencies": { "eslint-scope": "^8.2.0", "eslint-visitor-keys": "^4.0.0", "espree": "^10.0.0", "postcss": "^8.4.49", "postcss-scss": "^4.0.9", "postcss-selector-parser": "^7.0.0", "semver": "^7.7.2" }, "peerDependencies": { "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" }, "optionalPeers": ["svelte"] }, "sha512-UbY7DYoDg+x4AKLUcX5xWuEWylgmm8ZD2Z89YT/AK6Wm/ckeMTnOMwr6AVC99znXbRC26xzWEPhSgmB62E07Gg=="],
|
||||||
|
|
||||||
|
"tailwindcss": ["tailwindcss@4.2.1", "", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="],
|
||||||
|
|
||||||
|
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
|
||||||
|
|
||||||
|
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
|
||||||
|
|
||||||
|
"tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
|
||||||
|
|
||||||
|
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||||
|
|
||||||
|
"tinyrainbow": ["tinyrainbow@3.0.3", "", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="],
|
||||||
|
|
||||||
|
"totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="],
|
||||||
|
|
||||||
|
"ts-api-utils": ["ts-api-utils@2.4.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA=="],
|
||||||
|
|
||||||
|
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
|
||||||
|
|
||||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||||
|
|
||||||
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
"typescript-eslint": ["typescript-eslint@8.56.1", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.56.1", "@typescript-eslint/parser": "8.56.1", "@typescript-eslint/typescript-estree": "8.56.1", "@typescript-eslint/utils": "8.56.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-U4lM6pjmBX7J5wk4szltF7I1cGBHXZopnAXCMXb3+fZ3B/0Z3hq3wS/CCUB2NZBNAExK92mCU2tEohWuwVMsDQ=="],
|
||||||
|
|
||||||
|
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||||
|
|
||||||
|
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
||||||
|
|
||||||
|
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||||
|
|
||||||
|
"uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="],
|
||||||
|
|
||||||
|
"vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.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" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
|
||||||
|
|
||||||
|
"vite-plugin-devtools-json": ["vite-plugin-devtools-json@1.0.0", "", { "dependencies": { "uuid": "^11.1.0" }, "peerDependencies": { "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-MobvwqX76Vqt/O4AbnNMNWoXWGrKUqZbphCUle/J2KXH82yKQiunOeKnz/nqEPosPsoWWPP9FtNuPBSYpiiwkw=="],
|
||||||
|
|
||||||
|
"vitefu": ["vitefu@1.1.2", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw=="],
|
||||||
|
|
||||||
|
"vitest": ["vitest@4.0.18", "", { "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", "@vitest/pretty-format": "4.0.18", "@vitest/runner": "4.0.18", "@vitest/snapshot": "4.0.18", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^3.10.0", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.0.18", "@vitest/browser-preview": "4.0.18", "@vitest/browser-webdriverio": "4.0.18", "@vitest/ui": "4.0.18", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ=="],
|
||||||
|
|
||||||
|
"vitest-browser-svelte": ["vitest-browser-svelte@2.0.2", "", { "dependencies": { "@testing-library/svelte-core": "^1.0.0" }, "peerDependencies": { "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0", "vitest": "^4.0.0" } }, "sha512-OLJVYoIYflwToFIy3s41pZ9mVp6dwXfYd8IIsWoc57g8DyN3SxsNJ5GB1xWFPxLFlKM+1MPExjPxLaqdELrfRQ=="],
|
||||||
|
|
||||||
|
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||||
|
|
||||||
|
"why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
|
||||||
|
|
||||||
|
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
|
||||||
|
|
||||||
|
"ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="],
|
||||||
|
|
||||||
|
"yaml": ["yaml@1.10.2", "", {}, "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg=="],
|
||||||
|
|
||||||
|
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||||
|
|
||||||
|
"zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="],
|
||||||
|
|
||||||
|
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
|
||||||
|
|
||||||
|
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
|
||||||
|
|
||||||
|
"@eslint/config-helpers/@eslint/core": ["@eslint/core@0.17.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="],
|
||||||
|
|
||||||
|
"@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
|
||||||
|
|
||||||
|
"@eslint/plugin-kit/@eslint/core": ["@eslint/core@0.17.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="],
|
||||||
|
|
||||||
|
"@rollup/plugin-commonjs/is-reference": ["is-reference@1.2.1", "", { "dependencies": { "@types/estree": "*" } }, "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||||
|
|
||||||
|
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
|
||||||
|
|
||||||
|
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="],
|
||||||
|
|
||||||
|
"@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
|
||||||
|
|
||||||
|
"@vitest/mocker/estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
|
||||||
|
|
||||||
|
"better-call/set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
|
||||||
|
|
||||||
|
"eslint/@eslint/core": ["@eslint/core@0.17.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="],
|
||||||
|
|
||||||
|
"eslint-plugin-svelte/globals": ["globals@16.5.0", "", {}, "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ=="],
|
||||||
|
|
||||||
|
"rollup/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||||
|
|
||||||
|
"svelte-eslint-parser/postcss-selector-parser": ["postcss-selector-parser@7.1.1", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg=="],
|
||||||
|
|
||||||
|
"vite/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="],
|
||||||
|
|
||||||
|
"vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.18.20", "", { "os": "android", "cpu": "x64" }, "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.18.20", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.18.20", "", { "os": "darwin", "cpu": "x64" }, "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.18.20", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.18.20", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.18.20", "", { "os": "linux", "cpu": "arm" }, "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.18.20", "", { "os": "linux", "cpu": "arm64" }, "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.18.20", "", { "os": "linux", "cpu": "ia32" }, "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.18.20", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.18.20", "", { "os": "linux", "cpu": "s390x" }, "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.18.20", "", { "os": "linux", "cpu": "x64" }, "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.18.20", "", { "os": "none", "cpu": "x64" }, "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.18.20", "", { "os": "openbsd", "cpu": "x64" }, "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.18.20", "", { "os": "sunos", "cpu": "x64" }, "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.18.20", "", { "os": "win32", "cpu": "arm64" }, "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.18.20", "", { "os": "win32", "cpu": "ia32" }, "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g=="],
|
||||||
|
|
||||||
|
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="],
|
||||||
|
|
||||||
|
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="],
|
||||||
|
|
||||||
|
"vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="],
|
||||||
|
|
||||||
|
"vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="],
|
||||||
|
|
||||||
|
"vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="],
|
||||||
|
|
||||||
|
"vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="],
|
||||||
|
|
||||||
|
"vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="],
|
||||||
|
|
||||||
|
"vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="],
|
||||||
|
|
||||||
|
"vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="],
|
||||||
|
|
||||||
|
"vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="],
|
||||||
|
|
||||||
|
"vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="],
|
||||||
|
|
||||||
|
"vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="],
|
||||||
|
|
||||||
|
"vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="],
|
||||||
|
|
||||||
|
"vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="],
|
||||||
|
|
||||||
|
"vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="],
|
||||||
|
|
||||||
|
"vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="],
|
||||||
|
|
||||||
|
"vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="],
|
||||||
|
|
||||||
|
"vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="],
|
||||||
|
|
||||||
|
"vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="],
|
||||||
|
|
||||||
|
"vite/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="],
|
||||||
|
|
||||||
|
"vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="],
|
||||||
|
|
||||||
|
"vite/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="],
|
||||||
|
|
||||||
|
"vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="],
|
||||||
|
|
||||||
|
"vite/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="],
|
||||||
|
|
||||||
|
"vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="],
|
||||||
|
|
||||||
|
"vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="],
|
||||||
|
|
||||||
|
"vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="],
|
||||||
|
|
||||||
|
"vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="],
|
||||||
|
|
||||||
|
"@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
19
compose.yml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: password
|
||||||
|
POSTGRES_DB: vash_esports
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
11
drizzle.config.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { defineConfig } from 'drizzle-kit';
|
||||||
|
|
||||||
|
if (!process.env.DATABASE_URL) throw new Error('DATABASE_URL is not set');
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: './src/lib/server/db/schema.ts',
|
||||||
|
dialect: 'postgresql',
|
||||||
|
dbCredentials: { url: process.env.DATABASE_URL },
|
||||||
|
verbose: true,
|
||||||
|
strict: true
|
||||||
|
});
|
||||||
6
e2e/demo.test.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
test('home page has expected h1', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page.locator('h1')).toBeVisible();
|
||||||
|
});
|
||||||
39
eslint.config.js
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import prettier from 'eslint-config-prettier';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { includeIgnoreFile } from '@eslint/compat';
|
||||||
|
import js from '@eslint/js';
|
||||||
|
import svelte from 'eslint-plugin-svelte';
|
||||||
|
import { defineConfig } from 'eslint/config';
|
||||||
|
import globals from 'globals';
|
||||||
|
import ts from 'typescript-eslint';
|
||||||
|
import svelteConfig from './svelte.config.js';
|
||||||
|
|
||||||
|
const gitignorePath = path.resolve(import.meta.dirname, '.gitignore');
|
||||||
|
|
||||||
|
export default defineConfig(
|
||||||
|
includeIgnoreFile(gitignorePath),
|
||||||
|
js.configs.recommended,
|
||||||
|
...ts.configs.recommended,
|
||||||
|
...svelte.configs.recommended,
|
||||||
|
prettier,
|
||||||
|
...svelte.configs.prettier,
|
||||||
|
{
|
||||||
|
languageOptions: { globals: { ...globals.browser, ...globals.node } },
|
||||||
|
rules: {
|
||||||
|
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
|
||||||
|
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
|
||||||
|
'no-undef': 'off'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
projectService: true,
|
||||||
|
extraFileExtensions: ['.svelte'],
|
||||||
|
parser: ts.parser,
|
||||||
|
svelteConfig
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
68
package.json
@@ -1,12 +1,60 @@
|
|||||||
{
|
{
|
||||||
"name": "esports",
|
"name": "esports",
|
||||||
"module": "index.ts",
|
"private": true,
|
||||||
"type": "module",
|
"version": "0.0.1",
|
||||||
"private": true,
|
"type": "module",
|
||||||
"devDependencies": {
|
"scripts": {
|
||||||
"@types/bun": "latest"
|
"dev": "vite dev",
|
||||||
},
|
"build": "vite build",
|
||||||
"peerDependencies": {
|
"preview": "vite preview",
|
||||||
"typescript": "^5"
|
"prepare": "svelte-kit sync || echo ''",
|
||||||
}
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
|
"lint": "prettier --check . && eslint .",
|
||||||
|
"format": "prettier --write .",
|
||||||
|
"test:unit": "vitest",
|
||||||
|
"test": "npm run test:unit -- --run && npm run test:e2e",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
|
"db:push": "drizzle-kit push",
|
||||||
|
"db:generate": "drizzle-kit generate",
|
||||||
|
"db:migrate": "drizzle-kit migrate",
|
||||||
|
"db:studio": "drizzle-kit studio",
|
||||||
|
"auth:schema": "npx @better-auth/cli generate --config src/lib/server/auth.ts --output src/lib/server/db/auth.schema.ts --yes"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/compat": "^2.0.2",
|
||||||
|
"@eslint/js": "^9.39.2",
|
||||||
|
"@playwright/test": "^1.58.1",
|
||||||
|
"@sveltejs/adapter-node": "^5.5.2",
|
||||||
|
"@sveltejs/kit": "^2.50.2",
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||||
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
|
"@types/node": "^22",
|
||||||
|
"@vitest/browser-playwright": "^4.0.18",
|
||||||
|
"better-auth": "^1.4.18",
|
||||||
|
"drizzle-kit": "^0.31.8",
|
||||||
|
"drizzle-orm": "^0.45.1",
|
||||||
|
"eslint": "^9.39.2",
|
||||||
|
"eslint-config-prettier": "^10.1.8",
|
||||||
|
"eslint-plugin-svelte": "^3.14.0",
|
||||||
|
"globals": "^17.3.0",
|
||||||
|
"playwright": "^1.58.1",
|
||||||
|
"prettier": "^3.8.1",
|
||||||
|
"prettier-plugin-svelte": "^3.4.1",
|
||||||
|
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||||
|
"svelte": "^5.51.0",
|
||||||
|
"svelte-check": "^4.3.6",
|
||||||
|
"tailwindcss": "^4.1.18",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"typescript-eslint": "^8.54.0",
|
||||||
|
"vite": "^7.3.1",
|
||||||
|
"vite-plugin-devtools-json": "^1.0.0",
|
||||||
|
"vitest": "^4.0.18",
|
||||||
|
"vitest-browser-svelte": "^2.0.2"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"bancho.js": "^0.12.3",
|
||||||
|
"postgres": "^3.4.8"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
6
playwright.config.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { defineConfig } from '@playwright/test';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
webServer: { command: 'npm run build && npm run preview', port: 4173 },
|
||||||
|
testDir: 'e2e'
|
||||||
|
});
|
||||||
22
src/app.d.ts
vendored
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
// src/app.d.ts
|
||||||
|
import type { User, Session } from 'better-auth/minimal';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
namespace App {
|
||||||
|
interface Locals {
|
||||||
|
user?: User & { role: string };
|
||||||
|
session?: Session;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Error {
|
||||||
|
message: string;
|
||||||
|
code?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// interface PageData {}
|
||||||
|
// interface PageState {}
|
||||||
|
// interface Platform {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
18
src/app.html
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700;800&family=Geist+Mono:wght@400;500&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
<title>Vash Esports</title>
|
||||||
|
%sveltekit.head%
|
||||||
|
</head>
|
||||||
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
7
src/demo.spec.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
|
describe('sum test', () => {
|
||||||
|
it('adds 1 + 2 to equal 3', () => {
|
||||||
|
expect(1 + 2).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
30
src/hooks.server.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
// src/hooks.server.ts
|
||||||
|
import type { Handle } from '@sveltejs/kit';
|
||||||
|
import { building } from '$app/environment';
|
||||||
|
import { auth } from '$lib/server/auth';
|
||||||
|
import { svelteKitHandler } from 'better-auth/svelte-kit';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { user as userTable } from '$lib/server/db/auth.schema';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
const handleBetterAuth: Handle = async ({ event, resolve }) => {
|
||||||
|
const session = await auth.api.getSession({ headers: event.request.headers });
|
||||||
|
|
||||||
|
if (session) {
|
||||||
|
event.locals.session = session.session;
|
||||||
|
|
||||||
|
// Load fresh role from DB (better-auth doesn't include custom columns)
|
||||||
|
const dbUser = await db.query.user.findFirst({
|
||||||
|
where: eq(userTable.id, session.user.id)
|
||||||
|
});
|
||||||
|
|
||||||
|
event.locals.user = {
|
||||||
|
...session.user,
|
||||||
|
role: dbUser?.role ?? 'player'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return svelteKitHandler({ event, resolve, auth, building });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const handle: Handle = handleBetterAuth;
|
||||||
1
src/lib/assets/favicon.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
50
src/lib/components/Header.svelte
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
|
||||||
|
const user = $derived(page.data?.user);
|
||||||
|
|
||||||
|
const roleBadge: Record<string, { label: string; color: string }> = {
|
||||||
|
referee: { label: 'REF', color: 'bg-yellow-500/20 text-yellow-400' },
|
||||||
|
admin: { label: 'ADMIN', color: 'bg-red-500/20 text-red-400' }
|
||||||
|
};
|
||||||
|
|
||||||
|
const badge = $derived(user?.role ? roleBadge[user.role] : null);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<header
|
||||||
|
class="sticky top-0 z-30 flex h-14 items-center justify-between border-b border-border bg-surface-900/80 px-6 backdrop-blur-md"
|
||||||
|
>
|
||||||
|
<div></div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
{#if user}
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
{#if badge}
|
||||||
|
<span class="rounded px-1.5 py-0.5 text-[10px] font-700 {badge.color}">
|
||||||
|
{badge.label}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if user.image}
|
||||||
|
<img src={user.image} alt={user.name} class="h-7 w-7 rounded-full" />
|
||||||
|
{/if}
|
||||||
|
<span class="font-500 text-sm text-text-primary">{user.name}</span>
|
||||||
|
<form method="post" action="/settings?/logout" use:enhance>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded-md border border-border px-2.5 py-1 text-xs text-text-secondary transition-colors hover:border-red-500/30 hover:text-red-400"
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<a
|
||||||
|
href="/login"
|
||||||
|
class="font-600 rounded-md bg-accent px-3.5 py-1.5 text-sm text-surface-900 transition-colors hover:bg-accent-hover"
|
||||||
|
>
|
||||||
|
Sign in with osu!
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
83
src/lib/components/Sidebar.svelte
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/state';
|
||||||
|
|
||||||
|
const user = $derived(page.data?.user);
|
||||||
|
|
||||||
|
const nav = [
|
||||||
|
{ href: '/', label: 'Dashboard', icon: '⌂' },
|
||||||
|
{ href: '/matches', label: 'Matches', icon: '⚔' },
|
||||||
|
{ href: '/mappools', label: 'Mappools', icon: '♫' },
|
||||||
|
{ href: '/teams', label: 'Teams', icon: '⚑' }
|
||||||
|
];
|
||||||
|
|
||||||
|
function isActive(href: string) {
|
||||||
|
if (href === '/') return page.url.pathname === '/';
|
||||||
|
return page.url.pathname.startsWith(href);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isStaff = $derived(user?.role === 'referee' || user?.role === 'admin');
|
||||||
|
const isAdmin = $derived(user?.role === 'admin');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<aside
|
||||||
|
class="fixed top-0 left-0 z-40 flex h-full w-56 flex-col border-r border-border bg-surface-800"
|
||||||
|
>
|
||||||
|
<!-- Logo -->
|
||||||
|
<a href="/" class="flex items-center gap-2.5 px-5 py-5">
|
||||||
|
<div
|
||||||
|
class="font-800 flex h-8 w-8 items-center justify-center rounded-md bg-accent text-sm text-surface-900"
|
||||||
|
>
|
||||||
|
V
|
||||||
|
</div>
|
||||||
|
<span class="font-700 text-base tracking-tight text-text-primary">Vash Esports</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Nav -->
|
||||||
|
<nav class="mt-2 flex flex-1 flex-col gap-0.5 px-3">
|
||||||
|
{#each nav as item}
|
||||||
|
<a
|
||||||
|
href={item.href}
|
||||||
|
class="font-500 flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors {isActive(
|
||||||
|
item.href
|
||||||
|
)
|
||||||
|
? 'bg-accent-dim text-accent'
|
||||||
|
: 'text-text-secondary hover:bg-surface-700 hover:text-text-primary'}"
|
||||||
|
>
|
||||||
|
<span class="text-base">{item.icon}</span>
|
||||||
|
{item.label}
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#if isAdmin}
|
||||||
|
<div class="mt-4 mb-1 px-3">
|
||||||
|
<p class="text-[10px] font-600 text-text-secondary uppercase tracking-widest">Staff</p>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href="/admin"
|
||||||
|
class="font-500 flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors {isActive(
|
||||||
|
'/admin'
|
||||||
|
)
|
||||||
|
? 'bg-red-500/10 text-red-400'
|
||||||
|
: 'text-text-secondary hover:bg-surface-700 hover:text-text-primary'}"
|
||||||
|
>
|
||||||
|
<span class="text-base">⚡</span>
|
||||||
|
Admin
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Bottom -->
|
||||||
|
<div class="border-t border-border p-3">
|
||||||
|
<a
|
||||||
|
href="/settings"
|
||||||
|
class="font-500 flex items-center gap-3 rounded-md px-3 py-2 text-sm text-text-secondary transition-colors hover:bg-surface-700 hover:text-text-primary {isActive(
|
||||||
|
'/settings'
|
||||||
|
)
|
||||||
|
? 'bg-accent-dim text-accent'
|
||||||
|
: ''}"
|
||||||
|
>
|
||||||
|
<span class="text-base">⚙</span>
|
||||||
|
Settings
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
1
src/lib/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
// place files you want to import through the `$lib` alias in this folder.
|
||||||
94
src/lib/server/auth.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
// src/lib/server/auth.ts
|
||||||
|
import { betterAuth } from 'better-auth/minimal';
|
||||||
|
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
|
||||||
|
import { sveltekitCookies } from 'better-auth/svelte-kit';
|
||||||
|
import { env } from '$env/dynamic/private';
|
||||||
|
import { getRequestEvent } from '$app/server';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { genericOAuth } from 'better-auth/plugins';
|
||||||
|
import { team, teamMember } from './db/schema';
|
||||||
|
|
||||||
|
export const auth = betterAuth({
|
||||||
|
baseURL: env.ORIGIN,
|
||||||
|
secret: env.BETTER_AUTH_SECRET,
|
||||||
|
database: drizzleAdapter(db, { provider: 'pg' }),
|
||||||
|
emailAndPassword: { enabled: true },
|
||||||
|
databaseHooks: {
|
||||||
|
user: {
|
||||||
|
create: {
|
||||||
|
after: async (user) => {
|
||||||
|
const [personalTeam] = await db
|
||||||
|
.insert(team)
|
||||||
|
.values({
|
||||||
|
name: user.name,
|
||||||
|
isPersonal: true,
|
||||||
|
ownerId: user.id,
|
||||||
|
avatarUrl: user.image
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
await db.insert(teamMember).values({
|
||||||
|
teamId: personalTeam.id,
|
||||||
|
userId: user.id,
|
||||||
|
role: 'owner'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
genericOAuth({
|
||||||
|
config: [
|
||||||
|
{
|
||||||
|
providerId: 'osu',
|
||||||
|
clientId: env.OSU_CLIENT_ID!,
|
||||||
|
clientSecret: env.OSU_CLIENT_SECRET!,
|
||||||
|
authorizationUrl: 'https://osu.ppy.sh/oauth/authorize',
|
||||||
|
tokenUrl: 'https://osu.ppy.sh/oauth/token',
|
||||||
|
userInfoUrl: 'https://osu.ppy.sh/api/v2/me',
|
||||||
|
scopes: ['identify', 'public'],
|
||||||
|
pkce: false,
|
||||||
|
authentication: 'post',
|
||||||
|
// ── FIX: Use ORIGIN env var instead of hardcoded localhost ──
|
||||||
|
redirectURI: `${env.ORIGIN}/api/auth/oauth2/callback/osu`,
|
||||||
|
getToken: async ({ code, redirectURI }) => {
|
||||||
|
const res = await fetch('https://osu.ppy.sh/oauth/token', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded'
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({
|
||||||
|
client_id: env.OSU_CLIENT_ID!,
|
||||||
|
client_secret: env.OSU_CLIENT_SECRET!,
|
||||||
|
code,
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
redirect_uri: redirectURI
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
console.error('osu! token exchange failed:', data);
|
||||||
|
throw new Error('Token exchange failed');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
accessToken: data.access_token,
|
||||||
|
refreshToken: data.refresh_token,
|
||||||
|
accessTokenExpiresAt: new Date(Date.now() + data.expires_in * 1000),
|
||||||
|
scopes: ['identify', 'public'],
|
||||||
|
raw: data
|
||||||
|
};
|
||||||
|
},
|
||||||
|
mapProfileToUser(profile) {
|
||||||
|
return {
|
||||||
|
name: profile.username,
|
||||||
|
image: profile.avatar_url,
|
||||||
|
email: `${profile.id}@osu.local`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
sveltekitCookies(getRequestEvent) // must be last
|
||||||
|
]
|
||||||
|
});
|
||||||
281
src/lib/server/bancho/client.ts
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
import { env } from '$env/dynamic/private';
|
||||||
|
|
||||||
|
function sleep(ms: number) {
|
||||||
|
return new Promise<void>((r) => setTimeout(r, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Singleton bancho.js connection ──────────────────────────────────────
|
||||||
|
|
||||||
|
let client: any = null;
|
||||||
|
let connecting: Promise<void> | null = null;
|
||||||
|
|
||||||
|
async function getClient() {
|
||||||
|
if (client?.isConnected?.()) return client;
|
||||||
|
if (connecting) {
|
||||||
|
await connecting;
|
||||||
|
return client!;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!env.OSU_IRC_USERNAME || !env.OSU_IRC_PASSWORD) {
|
||||||
|
throw new Error('OSU_IRC_USERNAME and OSU_IRC_PASSWORD must be set in .env');
|
||||||
|
}
|
||||||
|
|
||||||
|
const banchoModule = await import('bancho.js');
|
||||||
|
const BanchoClient =
|
||||||
|
banchoModule.BanchoClient ??
|
||||||
|
banchoModule.default?.BanchoClient ??
|
||||||
|
banchoModule.default;
|
||||||
|
client = new BanchoClient({
|
||||||
|
username: env.OSU_IRC_USERNAME,
|
||||||
|
password: env.OSU_IRC_PASSWORD
|
||||||
|
});
|
||||||
|
|
||||||
|
connecting = client.connect().then(() => {
|
||||||
|
console.log('[Bancho] Connected as', env.OSU_IRC_USERNAME);
|
||||||
|
connecting = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
await connecting;
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Types ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface PlayerScore {
|
||||||
|
username: string;
|
||||||
|
score: number;
|
||||||
|
passed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tournament Lobby ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export class TournamentLobby {
|
||||||
|
private channel: any = null;
|
||||||
|
private channelName = '';
|
||||||
|
public osuMatchId = 0;
|
||||||
|
|
||||||
|
// Score collection
|
||||||
|
private collectedScores: PlayerScore[] = [];
|
||||||
|
private matchFinishedResolve: ((scores: PlayerScore[]) => void) | null = null;
|
||||||
|
|
||||||
|
// Ready tracking
|
||||||
|
private allReadyResolve: (() => void) | null = null;
|
||||||
|
|
||||||
|
// Event callbacks (set by orchestrator)
|
||||||
|
public onRollResult: ((username: string, value: number) => void) | null = null;
|
||||||
|
public onPickCommand: ((username: string, slotLabel: string) => void) | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new multiplayer lobby via !mp make.
|
||||||
|
*/
|
||||||
|
async create(name: string): Promise<{ matchId: number; channel: string }> {
|
||||||
|
const c = await getClient();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(
|
||||||
|
() => reject(new Error('Lobby creation timed out')),
|
||||||
|
15000
|
||||||
|
);
|
||||||
|
|
||||||
|
const banchoBot = c.getUser('BanchoBot');
|
||||||
|
|
||||||
|
const onPM = (msg: any) => {
|
||||||
|
const text: string = msg.message ?? String(msg);
|
||||||
|
const m = text.match(/https:\/\/osu\.ppy\.sh\/mp\/(\d+)/);
|
||||||
|
if (m) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
banchoBot.removeListener('message', onPM);
|
||||||
|
|
||||||
|
this.osuMatchId = parseInt(m[1]);
|
||||||
|
this.channelName = `#mp_${this.osuMatchId}`;
|
||||||
|
|
||||||
|
this.joinChannel(c).then(() =>
|
||||||
|
resolve({ matchId: this.osuMatchId, channel: this.channelName })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
banchoBot.on('message', onPM);
|
||||||
|
banchoBot.sendMessage(`!mp make ${name}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async joinChannel(c: any) {
|
||||||
|
this.channel = c.getChannel(this.channelName);
|
||||||
|
await this.channel.join();
|
||||||
|
|
||||||
|
this.channel.on('message', (msg: any) => {
|
||||||
|
const text: string = msg.message ?? String(msg);
|
||||||
|
const sender: string = msg.user?.ircUsername ?? '';
|
||||||
|
|
||||||
|
if (sender === 'BanchoBot') {
|
||||||
|
this.parseBanchoMessage(text);
|
||||||
|
} else if (sender) {
|
||||||
|
this.parsePlayerMessage(sender, text);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Message Parsing ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private parseBanchoMessage(text: string) {
|
||||||
|
// Player score: "<user> finished playing (Score: 1,234,567, ... PASSED)"
|
||||||
|
const scoreMatch = text.match(
|
||||||
|
/^(.+?) finished playing \(Score: ([\d,]+).*?(PASSED|FAILED)\)/i
|
||||||
|
);
|
||||||
|
if (scoreMatch) {
|
||||||
|
this.collectedScores.push({
|
||||||
|
username: scoreMatch[1].trim(),
|
||||||
|
score: parseInt(scoreMatch[2].replace(/,/g, '')),
|
||||||
|
passed: scoreMatch[3] === 'PASSED'
|
||||||
|
});
|
||||||
|
console.log(`[Bancho] Score: ${scoreMatch[1].trim()} = ${scoreMatch[2]}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Game finished
|
||||||
|
if (text.includes('The match has finished!')) {
|
||||||
|
console.log('[Bancho] Game finished. Scores:', this.collectedScores);
|
||||||
|
if (this.matchFinishedResolve) {
|
||||||
|
this.matchFinishedResolve([...this.collectedScores]);
|
||||||
|
this.matchFinishedResolve = null;
|
||||||
|
}
|
||||||
|
this.collectedScores = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Roll result: "Stan rolls 42 point(s)"
|
||||||
|
const rollMatch = text.match(/^(.+?) rolls (\d+) point\(s\)/);
|
||||||
|
if (rollMatch) {
|
||||||
|
const username = rollMatch[1].trim();
|
||||||
|
const value = parseInt(rollMatch[2]);
|
||||||
|
console.log(`[Bancho] Roll: ${username} = ${value}`);
|
||||||
|
if (this.onRollResult) {
|
||||||
|
this.onRollResult(username, value);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// All players ready
|
||||||
|
if (text.includes('All players are ready')) {
|
||||||
|
console.log('[Bancho] All players ready');
|
||||||
|
if (this.allReadyResolve) {
|
||||||
|
this.allReadyResolve();
|
||||||
|
this.allReadyResolve = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private parsePlayerMessage(sender: string, text: string) {
|
||||||
|
// !pick NM1, !pick HD2, !pick TB, etc.
|
||||||
|
const pickMatch = text.match(/^!pick\s+([A-Z]{2})(\d*)/i);
|
||||||
|
if (pickMatch) {
|
||||||
|
const category = pickMatch[1].toUpperCase();
|
||||||
|
const num = pickMatch[2] ? parseInt(pickMatch[2]) : 1;
|
||||||
|
const label = `${category}${num}`;
|
||||||
|
console.log(`[Bancho] Pick command from ${sender}: ${label}`);
|
||||||
|
if (this.onPickCommand) {
|
||||||
|
this.onPickCommand(sender, label);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── IRC Commands ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async send(cmd: string) {
|
||||||
|
if (!this.channel) throw new Error('Lobby not created');
|
||||||
|
console.log(`[Bancho] ${this.channelName} > ${cmd}`);
|
||||||
|
await this.channel.sendMessage(cmd);
|
||||||
|
}
|
||||||
|
|
||||||
|
async invite(username: string) {
|
||||||
|
await this.send(`!mp invite ${username.replace(/ /g, '_')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async setProperties(teamMode: number, scoreMode: number, size: number) {
|
||||||
|
await this.send(`!mp set ${teamMode} ${scoreMode} ${size}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async setMap(beatmapId: string | number, playmode = 0) {
|
||||||
|
await this.send(`!mp map ${beatmapId} ${playmode}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async setMods(mods: string[]) {
|
||||||
|
const filtered = mods.filter((m) => !['NM', 'TB', 'None', 'NF'].includes(m));
|
||||||
|
if (mods.includes('FM') || mods.includes('Freemod')) {
|
||||||
|
await this.send('!mp mods Freemod NF');
|
||||||
|
} else if (filtered.length > 0) {
|
||||||
|
await this.send(`!mp mods NF ${filtered.join(' ')}`);
|
||||||
|
} else {
|
||||||
|
await this.send('!mp mods NF');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async startGame(delaySec = 5) {
|
||||||
|
await this.send(`!mp start ${delaySec}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async close() {
|
||||||
|
try {
|
||||||
|
await this.send('!mp close');
|
||||||
|
} catch {
|
||||||
|
/* lobby may already be closed */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async chat(message: string) {
|
||||||
|
await this.send(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Async Waiters ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for all players to click "Ready" in osu! client.
|
||||||
|
* BanchoBot sends "All players are ready" when they do.
|
||||||
|
*/
|
||||||
|
waitForReady(timeoutMs = 180_000): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
this.allReadyResolve = null;
|
||||||
|
reject(new Error('Timed out waiting for players to ready'));
|
||||||
|
}, timeoutMs);
|
||||||
|
this.allReadyResolve = () => {
|
||||||
|
clearTimeout(t);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for "The match has finished!" and return all player scores.
|
||||||
|
*/
|
||||||
|
waitForScores(timeoutMs = 600_000): Promise<PlayerScore[]> {
|
||||||
|
this.collectedScores = [];
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
this.matchFinishedResolve = null;
|
||||||
|
reject(new Error('Timed out waiting for match scores'));
|
||||||
|
}, timeoutMs);
|
||||||
|
this.matchFinishedResolve = (scores) => {
|
||||||
|
clearTimeout(t);
|
||||||
|
resolve(scores);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Active lobby registry (in-memory, per server instance) ──────────────
|
||||||
|
|
||||||
|
const lobbies = new Map<string, TournamentLobby>();
|
||||||
|
|
||||||
|
export function getLobby(matchId: string) {
|
||||||
|
return lobbies.get(matchId);
|
||||||
|
}
|
||||||
|
export function setLobby(matchId: string, lobby: TournamentLobby) {
|
||||||
|
lobbies.set(matchId, lobby);
|
||||||
|
}
|
||||||
|
export function removeLobby(matchId: string) {
|
||||||
|
lobbies.delete(matchId);
|
||||||
|
}
|
||||||
96
src/lib/server/db/auth.schema.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
// src/lib/server/db/auth.schema.ts
|
||||||
|
import { relations } from 'drizzle-orm';
|
||||||
|
import { pgTable, text, timestamp, boolean, index } from 'drizzle-orm/pg-core';
|
||||||
|
|
||||||
|
export const user = pgTable('user', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
email: text('email').notNull().unique(),
|
||||||
|
emailVerified: boolean('email_verified').default(false).notNull(),
|
||||||
|
image: text('image'),
|
||||||
|
// ── Staff role: 'player' | 'referee' | 'admin' ──
|
||||||
|
role: text('role').default('player').notNull(),
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at')
|
||||||
|
.defaultNow()
|
||||||
|
.$onUpdate(() => new Date())
|
||||||
|
.notNull()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const session = pgTable(
|
||||||
|
'session',
|
||||||
|
{
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
expiresAt: timestamp('expires_at').notNull(),
|
||||||
|
token: text('token').notNull().unique(),
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at')
|
||||||
|
.$onUpdate(() => new Date())
|
||||||
|
.notNull(),
|
||||||
|
ipAddress: text('ip_address'),
|
||||||
|
userAgent: text('user_agent'),
|
||||||
|
userId: text('user_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => user.id, { onDelete: 'cascade' })
|
||||||
|
},
|
||||||
|
(table) => [index('session_userId_idx').on(table.userId)]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const account = pgTable(
|
||||||
|
'account',
|
||||||
|
{
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
accountId: text('account_id').notNull(),
|
||||||
|
providerId: text('provider_id').notNull(),
|
||||||
|
userId: text('user_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => user.id, { onDelete: 'cascade' }),
|
||||||
|
accessToken: text('access_token'),
|
||||||
|
refreshToken: text('refresh_token'),
|
||||||
|
idToken: text('id_token'),
|
||||||
|
accessTokenExpiresAt: timestamp('access_token_expires_at'),
|
||||||
|
refreshTokenExpiresAt: timestamp('refresh_token_expires_at'),
|
||||||
|
scope: text('scope'),
|
||||||
|
password: text('password'),
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at')
|
||||||
|
.$onUpdate(() => new Date())
|
||||||
|
.notNull()
|
||||||
|
},
|
||||||
|
(table) => [index('account_userId_idx').on(table.userId)]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const verification = pgTable(
|
||||||
|
'verification',
|
||||||
|
{
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
identifier: text('identifier').notNull(),
|
||||||
|
value: text('value').notNull(),
|
||||||
|
expiresAt: timestamp('expires_at').notNull(),
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at')
|
||||||
|
.defaultNow()
|
||||||
|
.$onUpdate(() => new Date())
|
||||||
|
.notNull()
|
||||||
|
},
|
||||||
|
(table) => [index('verification_identifier_idx').on(table.identifier)]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const userRelations = relations(user, ({ many }) => ({
|
||||||
|
sessions: many(session),
|
||||||
|
accounts: many(account)
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const sessionRelations = relations(session, ({ one }) => ({
|
||||||
|
user: one(user, {
|
||||||
|
fields: [session.userId],
|
||||||
|
references: [user.id]
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const accountRelations = relations(account, ({ one }) => ({
|
||||||
|
user: one(user, {
|
||||||
|
fields: [account.userId],
|
||||||
|
references: [user.id]
|
||||||
|
})
|
||||||
|
}));
|
||||||
10
src/lib/server/db/index.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||||
|
import postgres from 'postgres';
|
||||||
|
import * as schema from './schema';
|
||||||
|
import { env } from '$env/dynamic/private';
|
||||||
|
|
||||||
|
if (!env.DATABASE_URL) throw new Error('DATABASE_URL is not set');
|
||||||
|
|
||||||
|
const client = postgres(env.DATABASE_URL);
|
||||||
|
|
||||||
|
export const db = drizzle(client, { schema });
|
||||||
249
src/lib/server/db/schema.ts
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
// src/lib/server/db/schema.ts
|
||||||
|
//
|
||||||
|
// CHANGE: Add `role` column to user table in auth.schema.ts (see that file).
|
||||||
|
// This file adds the permission helper utilities.
|
||||||
|
|
||||||
|
import {
|
||||||
|
pgTable,
|
||||||
|
integer,
|
||||||
|
text,
|
||||||
|
uuid,
|
||||||
|
boolean,
|
||||||
|
timestamp,
|
||||||
|
jsonb,
|
||||||
|
index,
|
||||||
|
uniqueIndex,
|
||||||
|
real
|
||||||
|
} from 'drizzle-orm/pg-core';
|
||||||
|
import { relations } from 'drizzle-orm';
|
||||||
|
|
||||||
|
export * from './auth.schema';
|
||||||
|
|
||||||
|
// ── Teams ───────────────────────────────────────────────────────────────
|
||||||
|
export const team = pgTable('team', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
isPersonal: boolean('is_personal').default(false).notNull(),
|
||||||
|
ownerId: text('owner_id'),
|
||||||
|
avatarUrl: text('avatar_url'),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull()
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Team Members ────────────────────────────────────────────────────────
|
||||||
|
export const teamMember = pgTable('team_member', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
teamId: uuid('team_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => team.id, { onDelete: 'cascade' }),
|
||||||
|
userId: text('user_id').notNull(),
|
||||||
|
role: text('role').default('member'),
|
||||||
|
joinedAt: timestamp('joined_at', { withTimezone: true }).defaultNow()
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Mappools ────────────────────────────────────────────────────────────
|
||||||
|
export const mappool = pgTable('mappool', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
createdBy: text('created_by'),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull()
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Mappool Slots ───────────────────────────────────────────────────────
|
||||||
|
export const mappoolSlot = pgTable('mappool_slot', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
mappoolId: uuid('mappool_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => mappool.id, { onDelete: 'cascade' }),
|
||||||
|
category: text('category').notNull(),
|
||||||
|
orderInCategory: integer('order_in_category').notNull(),
|
||||||
|
beatmapId: text('beatmap_id').notNull(),
|
||||||
|
starRating: real('star_rating'),
|
||||||
|
mods: text('mods').array().default([]).notNull()
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Matches ─────────────────────────────────────────────────────────────
|
||||||
|
export const match = pgTable(
|
||||||
|
'match',
|
||||||
|
{
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
name: text('name'),
|
||||||
|
state: text('state').default('CREATED').notNull(),
|
||||||
|
config: jsonb('config').notNull(),
|
||||||
|
mappoolId: uuid('mappool_id').references(() => mappool.id),
|
||||||
|
osuLobbyId: integer('osu_lobby_id'),
|
||||||
|
scheduledAt: timestamp('scheduled_at', { withTimezone: true }),
|
||||||
|
startedAt: timestamp('started_at', { withTimezone: true }),
|
||||||
|
finishedAt: timestamp('finished_at', { withTimezone: true }),
|
||||||
|
winnerId: uuid('winner_id').references(() => team.id),
|
||||||
|
createdBy: text('created_by'),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull()
|
||||||
|
},
|
||||||
|
(t) => [index('idx_match_state').on(t.state)]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Match Participants (a team in a match) ──────────────────────────────
|
||||||
|
export const matchParticipant = pgTable(
|
||||||
|
'match_participant',
|
||||||
|
{
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
matchId: uuid('match_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => match.id, { onDelete: 'cascade' }),
|
||||||
|
teamId: uuid('team_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => team.id),
|
||||||
|
slot: integer('slot').notNull(),
|
||||||
|
score: integer('score').default(0).notNull(),
|
||||||
|
rollValue: integer('roll_value'),
|
||||||
|
pickOrder: integer('pick_order')
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
index('idx_mp_match_team').on(t.matchId, t.teamId),
|
||||||
|
uniqueIndex('idx_mp_match_slot').on(t.matchId, t.slot),
|
||||||
|
index('idx_mp_match').on(t.matchId)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Match Participant Players (a player fielded for this match) ─────────
|
||||||
|
export const matchParticipantPlayer = pgTable(
|
||||||
|
'match_participant_player',
|
||||||
|
{
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
participantId: uuid('participant_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => matchParticipant.id, { onDelete: 'cascade' }),
|
||||||
|
userId: text('user_id').notNull()
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
uniqueIndex('idx_mpp_participant_user').on(t.participantId, t.userId),
|
||||||
|
index('idx_mpp_participant').on(t.participantId)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Match Games ─────────────────────────────────────────────────────────
|
||||||
|
export const matchGame = pgTable(
|
||||||
|
'match_game',
|
||||||
|
{
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
matchId: uuid('match_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => match.id, { onDelete: 'cascade' }),
|
||||||
|
gameNumber: integer('game_number').notNull(),
|
||||||
|
mappoolSlotId: uuid('mappool_slot_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => mappoolSlot.id),
|
||||||
|
pickedByParticipantId: uuid('picked_by_participant_id').references(() => matchParticipant.id),
|
||||||
|
winnerParticipantId: uuid('winner_participant_id').references(() => matchParticipant.id),
|
||||||
|
state: text('state').default('PENDING').notNull(),
|
||||||
|
startedAt: timestamp('started_at', { withTimezone: true }),
|
||||||
|
finishedAt: timestamp('finished_at', { withTimezone: true })
|
||||||
|
},
|
||||||
|
(t) => [index('idx_match_game_match').on(t.matchId)]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Match Game Scores (per-player per-map) ──────────────────────────────
|
||||||
|
export const matchGameScore = pgTable(
|
||||||
|
'match_game_score',
|
||||||
|
{
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
matchGameId: uuid('match_game_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => matchGame.id, { onDelete: 'cascade' }),
|
||||||
|
playerId: uuid('player_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => matchParticipantPlayer.id),
|
||||||
|
score: integer('score').default(0).notNull(),
|
||||||
|
accuracy: real('accuracy').default(0).notNull(),
|
||||||
|
maxCombo: integer('max_combo').default(0).notNull(),
|
||||||
|
count300: integer('count_300').default(0).notNull(),
|
||||||
|
count100: integer('count_100').default(0).notNull(),
|
||||||
|
count50: integer('count_50').default(0).notNull(),
|
||||||
|
countMiss: integer('count_miss').default(0).notNull(),
|
||||||
|
mods: text('mods').array().default([]).notNull(),
|
||||||
|
passed: boolean('passed').default(false).notNull()
|
||||||
|
},
|
||||||
|
(t) => [index('idx_mgs_game').on(t.matchGameId)]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Match Queue ─────────────────────────────────────────────────────────
|
||||||
|
export const matchQueue = pgTable(
|
||||||
|
'match_queue',
|
||||||
|
{
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
userId: text('user_id').notNull(),
|
||||||
|
teamId: uuid('team_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => team.id),
|
||||||
|
elo: integer('elo').default(1000).notNull(),
|
||||||
|
joinedAt: timestamp('joined_at', { withTimezone: true }).defaultNow().notNull()
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
uniqueIndex('idx_queue_user').on(t.userId),
|
||||||
|
index('idx_queue_elo').on(t.elo)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Player ELO ──────────────────────────────────────────────────────────
|
||||||
|
export const playerRating = pgTable('player_rating', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
userId: text('user_id').notNull().unique(),
|
||||||
|
elo: integer('elo').default(1000).notNull(),
|
||||||
|
wins: integer('wins').default(0).notNull(),
|
||||||
|
losses: integer('losses').default(0).notNull(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Relations ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const teamRelations = relations(team, ({ many }) => ({
|
||||||
|
members: many(teamMember)
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const teamMemberRelations = relations(teamMember, ({ one }) => ({
|
||||||
|
team: one(team, { fields: [teamMember.teamId], references: [team.id] })
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const mappoolRelations = relations(mappool, ({ many }) => ({
|
||||||
|
slots: many(mappoolSlot)
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const mappoolSlotRelations = relations(mappoolSlot, ({ one }) => ({
|
||||||
|
mappool: one(mappool, { fields: [mappoolSlot.mappoolId], references: [mappool.id] })
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const matchRelations = relations(match, ({ one, many }) => ({
|
||||||
|
mappool: one(mappool, { fields: [match.mappoolId], references: [mappool.id] }),
|
||||||
|
participants: many(matchParticipant),
|
||||||
|
games: many(matchGame)
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const matchParticipantRelations = relations(matchParticipant, ({ one, many }) => ({
|
||||||
|
match: one(match, { fields: [matchParticipant.matchId], references: [match.id] }),
|
||||||
|
team: one(team, { fields: [matchParticipant.teamId], references: [team.id] }),
|
||||||
|
players: many(matchParticipantPlayer)
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const matchParticipantPlayerRelations = relations(matchParticipantPlayer, ({ one }) => ({
|
||||||
|
participant: one(matchParticipant, {
|
||||||
|
fields: [matchParticipantPlayer.participantId],
|
||||||
|
references: [matchParticipant.id]
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const matchGameRelations = relations(matchGame, ({ one, many }) => ({
|
||||||
|
match: one(match, { fields: [matchGame.matchId], references: [match.id] }),
|
||||||
|
slot: one(mappoolSlot, { fields: [matchGame.mappoolSlotId], references: [mappoolSlot.id] }),
|
||||||
|
scores: many(matchGameScore)
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const matchGameScoreRelations = relations(matchGameScore, ({ one }) => ({
|
||||||
|
game: one(matchGame, { fields: [matchGameScore.matchGameId], references: [matchGame.id] }),
|
||||||
|
player: one(matchParticipantPlayer, {
|
||||||
|
fields: [matchGameScore.playerId],
|
||||||
|
references: [matchParticipantPlayer.id]
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const matchQueueRelations = relations(matchQueue, ({ one }) => ({
|
||||||
|
team: one(team, { fields: [matchQueue.teamId], references: [team.id] })
|
||||||
|
}));
|
||||||
523
src/lib/server/match/engine.ts
Normal file
@@ -0,0 +1,523 @@
|
|||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import {
|
||||||
|
match,
|
||||||
|
matchParticipant,
|
||||||
|
matchParticipantPlayer,
|
||||||
|
matchGame,
|
||||||
|
matchGameScore,
|
||||||
|
matchQueue,
|
||||||
|
playerRating,
|
||||||
|
teamMember,
|
||||||
|
} from '$lib/server/db/schema';
|
||||||
|
import { eq, asc, sql } from 'drizzle-orm';
|
||||||
|
import { MATCH_STATES, GAME_STATES, type MatchConfig } from './types';
|
||||||
|
|
||||||
|
// ── Queue ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function joinQueue(userId: string, teamId: string) {
|
||||||
|
let rating = await db.query.playerRating.findFirst({
|
||||||
|
where: eq(playerRating.userId, userId)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!rating) {
|
||||||
|
[rating] = await db
|
||||||
|
.insert(playerRating)
|
||||||
|
.values({ userId, elo: 1000 })
|
||||||
|
.returning();
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await db.query.matchQueue.findFirst({
|
||||||
|
where: eq(matchQueue.userId, userId)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) throw new Error('Already in queue');
|
||||||
|
|
||||||
|
await db.insert(matchQueue).values({
|
||||||
|
userId,
|
||||||
|
teamId,
|
||||||
|
elo: rating.elo
|
||||||
|
});
|
||||||
|
|
||||||
|
return tryMatchFromQueue();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function leaveQueue(userId: string) {
|
||||||
|
await db.delete(matchQueue).where(eq(matchQueue.userId, userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getQueueStatus(userId: string) {
|
||||||
|
const entry = await db.query.matchQueue.findFirst({
|
||||||
|
where: eq(matchQueue.userId, userId)
|
||||||
|
});
|
||||||
|
|
||||||
|
const count = await db
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(matchQueue);
|
||||||
|
|
||||||
|
return {
|
||||||
|
inQueue: !!entry,
|
||||||
|
queueSize: Number(count[0].count),
|
||||||
|
joinedAt: entry?.joinedAt ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tryMatchFromQueue() {
|
||||||
|
const queue = await db.query.matchQueue.findMany({
|
||||||
|
orderBy: asc(matchQueue.elo)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (queue.length < 2) return null;
|
||||||
|
|
||||||
|
// Find closest ELO pair
|
||||||
|
let bestPair: [typeof queue[0], typeof queue[0]] | null = null;
|
||||||
|
let smallestGap = Infinity;
|
||||||
|
|
||||||
|
for (let i = 0; i < queue.length - 1; i++) {
|
||||||
|
const gap = Math.abs(queue[i].elo - queue[i + 1].elo);
|
||||||
|
if (gap < smallestGap) {
|
||||||
|
smallestGap = gap;
|
||||||
|
bestPair = [queue[i], queue[i + 1]];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!bestPair) return null;
|
||||||
|
|
||||||
|
// Pick mappool based on average player rating
|
||||||
|
const avgElo = (bestPair[0].elo + bestPair[1].elo) / 2;
|
||||||
|
const selectedPool = await selectMappoolForRating(avgElo);
|
||||||
|
|
||||||
|
if (!selectedPool) throw new Error('No mappools available');
|
||||||
|
|
||||||
|
// Remove both from queue
|
||||||
|
await db.delete(matchQueue).where(eq(matchQueue.userId, bestPair[0].userId));
|
||||||
|
await db.delete(matchQueue).where(eq(matchQueue.userId, bestPair[1].userId));
|
||||||
|
|
||||||
|
const created = await createMatch({
|
||||||
|
name: 'Ranked Match',
|
||||||
|
config: { bestOf: 7, teamSize: 1, scoringType: 'score' },
|
||||||
|
mappoolId: selectedPool.id,
|
||||||
|
teams: [bestPair[0].teamId, bestPair[1].teamId],
|
||||||
|
createdBy: 'system'
|
||||||
|
});
|
||||||
|
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectMappoolForRating(avgElo: number) {
|
||||||
|
// Get all mappools with their slots
|
||||||
|
const pools = await db.query.mappool.findMany({
|
||||||
|
with: { slots: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (pools.length === 0) return null;
|
||||||
|
|
||||||
|
// Map ELO range (e.g. 800-1200) to star rating range (e.g. 3-7)
|
||||||
|
// Simple linear: ELO 800 → 3★, ELO 1200 → 7★
|
||||||
|
const targetStars = 3 + ((avgElo - 800) / 400) * 4;
|
||||||
|
const clampedTarget = Math.max(2, Math.min(8, targetStars));
|
||||||
|
|
||||||
|
let bestPool = pools[0];
|
||||||
|
let bestDiff = Infinity;
|
||||||
|
|
||||||
|
for (const pool of pools) {
|
||||||
|
if (pool.slots.length === 0) continue;
|
||||||
|
|
||||||
|
const avgStars =
|
||||||
|
pool.slots.reduce((sum, s) => sum + (s.starRating ?? 0), 0) / pool.slots.length;
|
||||||
|
const diff = Math.abs(avgStars - clampedTarget);
|
||||||
|
|
||||||
|
if (diff < bestDiff) {
|
||||||
|
bestDiff = diff;
|
||||||
|
bestPool = pool;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bestPool;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Create Match ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function createMatch(opts: {
|
||||||
|
name: string;
|
||||||
|
config: MatchConfig;
|
||||||
|
mappoolId: string;
|
||||||
|
teams: string[];
|
||||||
|
createdBy: string;
|
||||||
|
}) {
|
||||||
|
const { name, config, mappoolId, teams, createdBy } = opts;
|
||||||
|
|
||||||
|
const [created] = await db
|
||||||
|
.insert(match)
|
||||||
|
.values({
|
||||||
|
name,
|
||||||
|
config,
|
||||||
|
mappoolId,
|
||||||
|
createdBy,
|
||||||
|
state: MATCH_STATES.CREATED
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
for (let i = 0; i < teams.length; i++) {
|
||||||
|
const [participant] = await db
|
||||||
|
.insert(matchParticipant)
|
||||||
|
.values({
|
||||||
|
matchId: created.id,
|
||||||
|
teamId: teams[i],
|
||||||
|
slot: i + 1
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
const members = await db.query.teamMember.findMany({
|
||||||
|
where: eq(teamMember.teamId, teams[i])
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const member of members) {
|
||||||
|
await db.insert(matchParticipantPlayer).values({
|
||||||
|
participantId: participant.id,
|
||||||
|
userId: member.userId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return getMatchFull(created.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── State Transitions ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function moveToLobby(matchId: string, osuLobbyId?: number) {
|
||||||
|
const m = await getMatchOrThrow(matchId);
|
||||||
|
assertState(m.state, MATCH_STATES.CREATED);
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(match)
|
||||||
|
.set({
|
||||||
|
state: MATCH_STATES.LOBBY,
|
||||||
|
osuLobbyId: osuLobbyId ?? null,
|
||||||
|
startedAt: new Date()
|
||||||
|
})
|
||||||
|
.where(eq(match.id, matchId));
|
||||||
|
|
||||||
|
return getMatchFull(matchId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function moveToRolling(matchId: string) {
|
||||||
|
const m = await getMatchOrThrow(matchId);
|
||||||
|
assertState(m.state, MATCH_STATES.LOBBY);
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(match)
|
||||||
|
.set({ state: MATCH_STATES.ROLLING })
|
||||||
|
.where(eq(match.id, matchId));
|
||||||
|
|
||||||
|
return getMatchFull(matchId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Roll ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function submitRoll(matchId: string, participantId: string, value: number) {
|
||||||
|
const m = await getMatchOrThrow(matchId);
|
||||||
|
assertState(m.state, MATCH_STATES.ROLLING);
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(matchParticipant)
|
||||||
|
.set({ rollValue: value })
|
||||||
|
.where(eq(matchParticipant.id, participantId));
|
||||||
|
|
||||||
|
const participants = await db.query.matchParticipant.findMany({
|
||||||
|
where: eq(matchParticipant.matchId, matchId)
|
||||||
|
});
|
||||||
|
|
||||||
|
const allRolled = participants.every(
|
||||||
|
(p) => (p.id === participantId ? true : p.rollValue !== null)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (allRolled) {
|
||||||
|
const sorted = [...participants]
|
||||||
|
.map((p) => ({
|
||||||
|
...p,
|
||||||
|
rollValue: p.id === participantId ? value : p.rollValue!
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.rollValue - a.rollValue);
|
||||||
|
|
||||||
|
for (let i = 0; i < sorted.length; i++) {
|
||||||
|
await db
|
||||||
|
.update(matchParticipant)
|
||||||
|
.set({ pickOrder: i + 1 })
|
||||||
|
.where(eq(matchParticipant.id, sorted[i].id));
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(match)
|
||||||
|
.set({ state: MATCH_STATES.PICKING })
|
||||||
|
.where(eq(match.id, matchId));
|
||||||
|
}
|
||||||
|
|
||||||
|
return getMatchFull(matchId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pick Map ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function pickMap(matchId: string, participantId: string, mappoolSlotId: string) {
|
||||||
|
const m = await getMatchOrThrow(matchId);
|
||||||
|
assertState(m.state, MATCH_STATES.PICKING);
|
||||||
|
|
||||||
|
const participants = await db.query.matchParticipant.findMany({
|
||||||
|
where: eq(matchParticipant.matchId, matchId)
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalGames = await db.query.matchGame.findMany({
|
||||||
|
where: eq(matchGame.matchId, matchId)
|
||||||
|
});
|
||||||
|
|
||||||
|
const expectedPicker = getExpectedPicker(participants, totalGames.length);
|
||||||
|
if (expectedPicker.id !== participantId) {
|
||||||
|
throw new Error('Not your turn to pick');
|
||||||
|
}
|
||||||
|
|
||||||
|
const alreadyPlayed = totalGames.some((g) => g.mappoolSlotId === mappoolSlotId);
|
||||||
|
if (alreadyPlayed) {
|
||||||
|
throw new Error('This map has already been played');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [game] = await db
|
||||||
|
.insert(matchGame)
|
||||||
|
.values({
|
||||||
|
matchId,
|
||||||
|
gameNumber: totalGames.length + 1,
|
||||||
|
mappoolSlotId,
|
||||||
|
pickedByParticipantId: participantId,
|
||||||
|
state: GAME_STATES.PLAYING,
|
||||||
|
startedAt: new Date()
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(match)
|
||||||
|
.set({ state: MATCH_STATES.PLAYING })
|
||||||
|
.where(eq(match.id, matchId));
|
||||||
|
|
||||||
|
return game;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Submit Scores ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function submitGameScores(
|
||||||
|
matchGameId: string,
|
||||||
|
scores: {
|
||||||
|
playerId: string;
|
||||||
|
score: number;
|
||||||
|
accuracy?: number;
|
||||||
|
maxCombo?: number;
|
||||||
|
count300?: number;
|
||||||
|
count100?: number;
|
||||||
|
count50?: number;
|
||||||
|
countMiss?: number;
|
||||||
|
mods?: string[];
|
||||||
|
passed?: boolean;
|
||||||
|
}[]
|
||||||
|
) {
|
||||||
|
const game = await db.query.matchGame.findFirst({
|
||||||
|
where: eq(matchGame.id, matchGameId)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!game) throw new Error('Game not found');
|
||||||
|
if (game.state !== GAME_STATES.PLAYING) throw new Error(`Game is ${game.state}, not PLAYING`);
|
||||||
|
|
||||||
|
for (const s of scores) {
|
||||||
|
await db.insert(matchGameScore).values({
|
||||||
|
matchGameId,
|
||||||
|
playerId: s.playerId,
|
||||||
|
score: s.score,
|
||||||
|
accuracy: s.accuracy ?? 0,
|
||||||
|
maxCombo: s.maxCombo ?? 0,
|
||||||
|
count300: s.count300 ?? 0,
|
||||||
|
count100: s.count100 ?? 0,
|
||||||
|
count50: s.count50 ?? 0,
|
||||||
|
countMiss: s.countMiss ?? 0,
|
||||||
|
mods: s.mods ?? [],
|
||||||
|
passed: s.passed ?? true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const allScores = await db.query.matchGameScore.findMany({
|
||||||
|
where: eq(matchGameScore.matchGameId, matchGameId),
|
||||||
|
with: { player: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
const participantScores = new Map<string, number>();
|
||||||
|
for (const s of allScores) {
|
||||||
|
const pid = s.player.participantId;
|
||||||
|
participantScores.set(pid, (participantScores.get(pid) ?? 0) + s.score);
|
||||||
|
}
|
||||||
|
|
||||||
|
let winnerId: string | null = null;
|
||||||
|
let highScore = -1;
|
||||||
|
for (const [pid, total] of participantScores) {
|
||||||
|
if (total > highScore) {
|
||||||
|
highScore = total;
|
||||||
|
winnerId = pid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(matchGame)
|
||||||
|
.set({
|
||||||
|
state: GAME_STATES.FINISHED,
|
||||||
|
winnerParticipantId: winnerId,
|
||||||
|
finishedAt: new Date()
|
||||||
|
})
|
||||||
|
.where(eq(matchGame.id, matchGameId));
|
||||||
|
|
||||||
|
if (winnerId) {
|
||||||
|
const participant = await db.query.matchParticipant.findFirst({
|
||||||
|
where: eq(matchParticipant.id, winnerId)
|
||||||
|
});
|
||||||
|
if (participant) {
|
||||||
|
await db
|
||||||
|
.update(matchParticipant)
|
||||||
|
.set({ score: participant.score + 1 })
|
||||||
|
.where(eq(matchParticipant.id, winnerId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const m = await getMatchOrThrow(game.matchId);
|
||||||
|
const config = m.config as MatchConfig;
|
||||||
|
const winsNeeded = Math.ceil(config.bestOf / 2);
|
||||||
|
|
||||||
|
const participants = await db.query.matchParticipant.findMany({
|
||||||
|
where: eq(matchParticipant.matchId, game.matchId)
|
||||||
|
});
|
||||||
|
|
||||||
|
const matchWinner = participants.find((p) => p.score >= winsNeeded);
|
||||||
|
|
||||||
|
if (matchWinner) {
|
||||||
|
await db
|
||||||
|
.update(match)
|
||||||
|
.set({
|
||||||
|
state: MATCH_STATES.FINISHED,
|
||||||
|
winnerId: matchWinner.teamId,
|
||||||
|
finishedAt: new Date()
|
||||||
|
})
|
||||||
|
.where(eq(match.id, game.matchId));
|
||||||
|
|
||||||
|
await updateElo(participants, matchWinner.id);
|
||||||
|
} else {
|
||||||
|
await db
|
||||||
|
.update(match)
|
||||||
|
.set({ state: MATCH_STATES.PICKING })
|
||||||
|
.where(eq(match.id, game.matchId));
|
||||||
|
}
|
||||||
|
|
||||||
|
return getMatchFull(game.matchId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ELO ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function updateElo(
|
||||||
|
participants: { id: string; teamId: string }[],
|
||||||
|
winnerId: string
|
||||||
|
) {
|
||||||
|
for (const p of participants) {
|
||||||
|
const players = await db.query.matchParticipantPlayer.findMany({
|
||||||
|
where: eq(matchParticipantPlayer.participantId, p.id)
|
||||||
|
});
|
||||||
|
|
||||||
|
const isWinner = p.id === winnerId;
|
||||||
|
|
||||||
|
for (const player of players) {
|
||||||
|
let rating = await db.query.playerRating.findFirst({
|
||||||
|
where: eq(playerRating.userId, player.userId)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!rating) {
|
||||||
|
[rating] = await db
|
||||||
|
.insert(playerRating)
|
||||||
|
.values({ userId: player.userId, elo: 1000 })
|
||||||
|
.returning();
|
||||||
|
}
|
||||||
|
|
||||||
|
const K = 32;
|
||||||
|
const expected = 0.5;
|
||||||
|
const actual = isWinner ? 1 : 0;
|
||||||
|
const newElo = Math.round(rating.elo + K * (actual - expected));
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(playerRating)
|
||||||
|
.set({
|
||||||
|
elo: newElo,
|
||||||
|
wins: isWinner ? rating.wins + 1 : rating.wins,
|
||||||
|
losses: isWinner ? rating.losses : rating.losses + 1,
|
||||||
|
updatedAt: new Date()
|
||||||
|
})
|
||||||
|
.where(eq(playerRating.userId, player.userId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cancel Match ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function cancelMatch(matchId: string) {
|
||||||
|
await db
|
||||||
|
.update(match)
|
||||||
|
.set({
|
||||||
|
state: MATCH_STATES.CANCELLED,
|
||||||
|
finishedAt: new Date()
|
||||||
|
})
|
||||||
|
.where(eq(match.id, matchId));
|
||||||
|
|
||||||
|
return getMatchFull(matchId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function getExpectedPicker(
|
||||||
|
participants: { id: string; pickOrder: number | null; score: number }[],
|
||||||
|
gameCount: number
|
||||||
|
) {
|
||||||
|
const sorted = [...participants].sort((a, b) => (a.pickOrder ?? 99) - (b.pickOrder ?? 99));
|
||||||
|
const pickerIndex = gameCount % sorted.length;
|
||||||
|
return sorted[pickerIndex];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMatchFull(matchId: string) {
|
||||||
|
const m = await db.query.match.findFirst({
|
||||||
|
where: eq(match.id, matchId),
|
||||||
|
with: {
|
||||||
|
mappool: {
|
||||||
|
with: { slots: true }
|
||||||
|
},
|
||||||
|
participants: {
|
||||||
|
with: {
|
||||||
|
team: true,
|
||||||
|
players: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
games: {
|
||||||
|
with: {
|
||||||
|
slot: true,
|
||||||
|
scores: {
|
||||||
|
with: { player: true }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
orderBy: (g, { asc }) => [asc(g.gameNumber)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!m) throw new Error('Match not found');
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getMatchOrThrow(matchId: string) {
|
||||||
|
const m = await db.query.match.findFirst({
|
||||||
|
where: eq(match.id, matchId)
|
||||||
|
});
|
||||||
|
if (!m) throw new Error('Match not found');
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertState(current: string, expected: string) {
|
||||||
|
if (current !== expected) {
|
||||||
|
throw new Error(`Match is ${current}, expected ${expected}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
331
src/lib/server/match/orchestrator.ts
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { match, matchGame, mappoolSlot, user } from '$lib/server/db/schema';
|
||||||
|
import { eq, and } from 'drizzle-orm';
|
||||||
|
import { MATCH_STATES, type MatchConfig } from './types';
|
||||||
|
import { getMatchFull, submitRoll, pickMap, submitGameScores } from './engine';
|
||||||
|
import { TournamentLobby, setLobby, getLobby, removeLobby } from '../bancho/client';
|
||||||
|
|
||||||
|
function sleep(ms: number) {
|
||||||
|
return new Promise<void>((r) => setTimeout(r, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create IRC lobby → configure → invite players → wire up chat handlers → move to ROLLING.
|
||||||
|
*/
|
||||||
|
export async function initMatchLobby(matchId: string) {
|
||||||
|
const m = await getMatchFull(matchId);
|
||||||
|
const config = m.config as MatchConfig;
|
||||||
|
const lobby = new TournamentLobby();
|
||||||
|
|
||||||
|
const lobbyName = `VASH: (${m.participants[0]?.team.name}) vs (${m.participants[1]?.team.name})`;
|
||||||
|
console.log(`[Orchestrator] Creating lobby: ${lobbyName}`);
|
||||||
|
|
||||||
|
const { matchId: osuId, channel } = await lobby.create(lobbyName);
|
||||||
|
console.log(`[Orchestrator] Created ${channel} (osu mp/${osuId})`);
|
||||||
|
|
||||||
|
setLobby(matchId, lobby);
|
||||||
|
|
||||||
|
// CREATED → LOBBY
|
||||||
|
await db
|
||||||
|
.update(match)
|
||||||
|
.set({ state: MATCH_STATES.LOBBY, osuLobbyId: osuId, startedAt: new Date() })
|
||||||
|
.where(eq(match.id, matchId));
|
||||||
|
|
||||||
|
// Configure: HeadToHead, correct score mode, correct size
|
||||||
|
const scoreMode =
|
||||||
|
config.scoringType === 'score_v2' ? 3
|
||||||
|
: config.scoringType === 'accuracy' ? 1
|
||||||
|
: config.scoringType === 'combo' ? 2
|
||||||
|
: 0;
|
||||||
|
await lobby.setProperties(0, scoreMode, config.teamSize * 2);
|
||||||
|
|
||||||
|
// Remove password so players can join via mp link (and rejoin after disconnect)
|
||||||
|
await lobby.send('!mp password');
|
||||||
|
await sleep(500);
|
||||||
|
|
||||||
|
// Invite all players from both teams (with dedup + delays)
|
||||||
|
const invited = new Set<string>();
|
||||||
|
for (const p of m.participants) {
|
||||||
|
for (const pl of p.players) {
|
||||||
|
const u = await db.query.user.findFirst({ where: eq(user.id, pl.userId) });
|
||||||
|
if (u?.name && !invited.has(u.name.toLowerCase())) {
|
||||||
|
await lobby.invite(u.name);
|
||||||
|
invited.add(u.name.toLowerCase());
|
||||||
|
console.log(`[Orchestrator] Invited ${u.name}`);
|
||||||
|
await sleep(300);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wire up IRC chat handlers for rolls and picks
|
||||||
|
setupChatHandlers(matchId, lobby);
|
||||||
|
|
||||||
|
await lobby.chat(
|
||||||
|
`Welcome! BO${config.bestOf}. Type !roll to decide pick order, or use the web UI.`
|
||||||
|
);
|
||||||
|
|
||||||
|
// LOBBY → ROLLING
|
||||||
|
await db.update(match).set({ state: MATCH_STATES.ROLLING }).where(eq(match.id, matchId));
|
||||||
|
|
||||||
|
return { osuMatchId: osuId, channel };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wire up IRC event callbacks so players can !roll and !pick from osu! chat.
|
||||||
|
*/
|
||||||
|
function setupChatHandlers(matchId: string, lobby: TournamentLobby) {
|
||||||
|
// ── Handle !roll from IRC ───────────────────────────────────────────
|
||||||
|
lobby.onRollResult = async (username: string, value: number) => {
|
||||||
|
try {
|
||||||
|
const m = await getMatchFull(matchId);
|
||||||
|
if (m.state !== MATCH_STATES.ROLLING) {
|
||||||
|
await lobby.chat(`Rolls are not active right now.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find participant linked to this username
|
||||||
|
let targetParticipant: { id: string; rollValue: number | null } | null = null;
|
||||||
|
|
||||||
|
for (const p of m.participants) {
|
||||||
|
for (const pl of p.players) {
|
||||||
|
const u = await db.query.user.findFirst({ where: eq(user.id, pl.userId) });
|
||||||
|
if (u?.name?.toLowerCase() === username.toLowerCase().replace(/_/g, ' ')) {
|
||||||
|
// If this participant already rolled, try finding another one (same-team testing)
|
||||||
|
if (p.rollValue === null) {
|
||||||
|
targetParticipant = p;
|
||||||
|
break;
|
||||||
|
} else if (!targetParticipant) {
|
||||||
|
// Keep looking for an unrolled one
|
||||||
|
const unrolled = m.participants.find((pp) => pp.rollValue === null);
|
||||||
|
if (unrolled) targetParticipant = unrolled;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (targetParticipant?.rollValue === null) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetParticipant || targetParticipant.rollValue !== null) {
|
||||||
|
await lobby.chat(`${username}: already rolled or not in this match.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await submitRoll(matchId, targetParticipant.id, value);
|
||||||
|
console.log(`[Orchestrator] IRC roll: ${username} = ${value}`);
|
||||||
|
|
||||||
|
// Check if all rolled → announce pick order
|
||||||
|
const updated = await getMatchFull(matchId);
|
||||||
|
if (updated.state === MATCH_STATES.PICKING) {
|
||||||
|
const sorted = [...updated.participants].sort(
|
||||||
|
(a, b) => (a.pickOrder ?? 99) - (b.pickOrder ?? 99)
|
||||||
|
);
|
||||||
|
await lobby.chat(
|
||||||
|
`Rolls complete! ${sorted[0]?.team.name} picks first. Use !pick <slot> (e.g. !pick NM1) or pick in web UI.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[Orchestrator] IRC roll error:', err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Handle !pick from IRC ───────────────────────────────────────────
|
||||||
|
lobby.onPickCommand = async (username: string, slotLabel: string) => {
|
||||||
|
try {
|
||||||
|
const m = await getMatchFull(matchId);
|
||||||
|
if (m.state !== MATCH_STATES.PICKING) {
|
||||||
|
await lobby.chat(`Picks are not active right now.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse slot label: "NM1" → category=NM, orderInCategory=1
|
||||||
|
const labelMatch = slotLabel.match(/^([A-Z]{2})(\d+)$/);
|
||||||
|
if (!labelMatch) {
|
||||||
|
await lobby.chat(`${username}: Invalid slot. Use format like NM1, HD2, DT1.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const category = labelMatch[1];
|
||||||
|
const order = parseInt(labelMatch[2]);
|
||||||
|
|
||||||
|
// Find the expected picker
|
||||||
|
const sorted = [...m.participants].sort(
|
||||||
|
(a, b) => (a.pickOrder ?? 99) - (b.pickOrder ?? 99)
|
||||||
|
);
|
||||||
|
const expectedIdx = m.games.length % sorted.length;
|
||||||
|
const expectedPicker = sorted[expectedIdx];
|
||||||
|
|
||||||
|
// Verify it's this player's turn to pick
|
||||||
|
let isPickerTurn = false;
|
||||||
|
if (expectedPicker) {
|
||||||
|
for (const pl of expectedPicker.players) {
|
||||||
|
const u = await db.query.user.findFirst({ where: eq(user.id, pl.userId) });
|
||||||
|
if (u?.name?.toLowerCase() === username.toLowerCase().replace(/_/g, ' ')) {
|
||||||
|
isPickerTurn = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For same-team testing, allow any participant
|
||||||
|
if (!isPickerTurn && m.participants[0]?.teamId === m.participants[1]?.teamId) {
|
||||||
|
isPickerTurn = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPickerTurn) {
|
||||||
|
await lobby.chat(`${username}: It's not your turn to pick.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the mappool slot
|
||||||
|
const slot = m.mappool?.slots?.find(
|
||||||
|
(s) => s.category === category && s.orderInCategory === order
|
||||||
|
);
|
||||||
|
if (!slot) {
|
||||||
|
await lobby.chat(`${username}: Slot ${slotLabel} not found in this mappool.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already played
|
||||||
|
const alreadyPlayed = m.games.some((g) => g.mappoolSlotId === slot.id);
|
||||||
|
if (alreadyPlayed) {
|
||||||
|
await lobby.chat(`${username}: ${slotLabel} has already been played.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute the pick
|
||||||
|
const game = await pickMap(matchId, expectedPicker!.id, slot.id);
|
||||||
|
console.log(`[Orchestrator] IRC pick: ${username} picked ${slotLabel}`);
|
||||||
|
|
||||||
|
// Set map in lobby and wait for ready
|
||||||
|
playPickedMap(matchId, game.id).catch((err) =>
|
||||||
|
console.error('[Orchestrator] IRC play failed:', err.message)
|
||||||
|
);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[Orchestrator] IRC pick error:', err.message);
|
||||||
|
await lobby.chat(`Error: ${err.message}`).catch(() => {});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called after a map is picked (from web UI or IRC).
|
||||||
|
* Sets map + mods in IRC, waits for ready, starts game, collects scores.
|
||||||
|
*/
|
||||||
|
export async function playPickedMap(matchId: string, matchGameId: string) {
|
||||||
|
const lobby = getLobby(matchId);
|
||||||
|
if (!lobby) {
|
||||||
|
console.warn('[Orchestrator] No IRC lobby for', matchId, '— skipping IRC');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const game = await db.query.matchGame.findFirst({
|
||||||
|
where: eq(matchGame.id, matchGameId),
|
||||||
|
with: { slot: true }
|
||||||
|
});
|
||||||
|
if (!game) throw new Error('Game not found');
|
||||||
|
|
||||||
|
const slot = game.slot;
|
||||||
|
|
||||||
|
// Set beatmap
|
||||||
|
await lobby.setMap(slot.beatmapId);
|
||||||
|
await sleep(1000);
|
||||||
|
|
||||||
|
// Set mods (always NF + slot mods)
|
||||||
|
await lobby.setMods(slot.mods);
|
||||||
|
await sleep(500);
|
||||||
|
|
||||||
|
await lobby.chat(
|
||||||
|
`Playing ${slot.category}${slot.orderInCategory}. Ready up! (Game starts when all players are ready)`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wait for all players to ready up in osu!, then start
|
||||||
|
try {
|
||||||
|
await lobby.waitForReady();
|
||||||
|
await lobby.chat('All ready — starting in 5s!');
|
||||||
|
await lobby.startGame(5);
|
||||||
|
} catch (err: any) {
|
||||||
|
// If ready times out, inform and don't start
|
||||||
|
console.warn('[Orchestrator] Ready timeout:', err.message);
|
||||||
|
await lobby.chat('Ready timed out. Use the web UI to force start, or ready up!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-blocking: wait for scores then process
|
||||||
|
collectScores(matchId, matchGameId, lobby).catch((err) =>
|
||||||
|
console.error('[Orchestrator] Score collection failed:', err)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force start the current game without waiting for ready.
|
||||||
|
* Called from the web UI "Force Start" button.
|
||||||
|
*/
|
||||||
|
export async function forceStartGame(matchId: string) {
|
||||||
|
const lobby = getLobby(matchId);
|
||||||
|
if (!lobby) return;
|
||||||
|
|
||||||
|
await lobby.chat('Force starting in 10s!');
|
||||||
|
await lobby.startGame(10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Background task: waits for IRC scores, writes them to DB, advances match state.
|
||||||
|
*/
|
||||||
|
async function collectScores(matchId: string, matchGameId: string, lobby: TournamentLobby) {
|
||||||
|
const ircScores = await lobby.waitForScores();
|
||||||
|
console.log(`[Orchestrator] Scores for game ${matchGameId}:`, ircScores);
|
||||||
|
|
||||||
|
// Map IRC usernames → matchParticipantPlayer IDs
|
||||||
|
const m = await getMatchFull(matchId);
|
||||||
|
const scores: { playerId: string; score: number; passed: boolean }[] = [];
|
||||||
|
|
||||||
|
for (const irc of ircScores) {
|
||||||
|
for (const participant of m.participants) {
|
||||||
|
for (const player of participant.players) {
|
||||||
|
const u = await db.query.user.findFirst({ where: eq(user.id, player.userId) });
|
||||||
|
// IRC uses underscores for spaces in usernames
|
||||||
|
const ircName = irc.username.toLowerCase().replace(/_/g, ' ');
|
||||||
|
const dbName = u?.name?.toLowerCase();
|
||||||
|
if (dbName && (dbName === ircName || dbName === irc.username.toLowerCase())) {
|
||||||
|
scores.push({ playerId: player.id, score: irc.score, passed: irc.passed });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scores.length === 0) {
|
||||||
|
console.warn('[Orchestrator] No scores matched any IRC usernames');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await submitGameScores(matchGameId, scores);
|
||||||
|
|
||||||
|
// Check if match finished or needs next pick
|
||||||
|
const updated = await getMatchFull(matchId);
|
||||||
|
if (updated.state === MATCH_STATES.FINISHED) {
|
||||||
|
const winner = updated.participants.find((p) => p.teamId === updated.winnerId);
|
||||||
|
await lobby.chat(`GG! ${winner?.team.name ?? '?'} wins the match!`);
|
||||||
|
await sleep(5000);
|
||||||
|
await lobby.close();
|
||||||
|
removeLobby(matchId);
|
||||||
|
} else {
|
||||||
|
// Announce next picker
|
||||||
|
const sorted = [...updated.participants].sort(
|
||||||
|
(a, b) => (a.pickOrder ?? 99) - (b.pickOrder ?? 99)
|
||||||
|
);
|
||||||
|
const nextIdx = updated.games.length % sorted.length;
|
||||||
|
const nextPicker = sorted[nextIdx];
|
||||||
|
await lobby.chat(
|
||||||
|
`${nextPicker?.team.name}'s turn to pick. Use !pick <slot> (e.g. !pick HD1) or pick in web UI.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close the IRC lobby for a match.
|
||||||
|
*/
|
||||||
|
export async function closeLobby(matchId: string) {
|
||||||
|
const lobby = getLobby(matchId);
|
||||||
|
if (lobby) {
|
||||||
|
await lobby.close();
|
||||||
|
removeLobby(matchId);
|
||||||
|
}
|
||||||
|
}
|
||||||
24
src/lib/server/match/types.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
export interface MatchConfig {
|
||||||
|
bestOf: number;
|
||||||
|
teamSize: number;
|
||||||
|
scoringType: 'score' | 'score_v2' | 'accuracy' | 'combo';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MATCH_STATES = {
|
||||||
|
CREATED: 'CREATED',
|
||||||
|
LOBBY: 'LOBBY',
|
||||||
|
ROLLING: 'ROLLING',
|
||||||
|
PICKING: 'PICKING',
|
||||||
|
PLAYING: 'PLAYING',
|
||||||
|
FINISHED: 'FINISHED',
|
||||||
|
CANCELLED: 'CANCELLED'
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const GAME_STATES = {
|
||||||
|
PENDING: 'PENDING',
|
||||||
|
PLAYING: 'PLAYING',
|
||||||
|
FINISHED: 'FINISHED'
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type MatchState = (typeof MATCH_STATES)[keyof typeof MATCH_STATES];
|
||||||
|
export type GameState = (typeof GAME_STATES)[keyof typeof GAME_STATES];
|
||||||
136
src/lib/server/osu/api.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
// src/lib/server/osu/api.ts
|
||||||
|
import { env } from '$env/dynamic/private';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { and, eq } from 'drizzle-orm';
|
||||||
|
import { account } from '$lib/server/db/auth.schema';
|
||||||
|
import { getCached, setCache } from './cache';
|
||||||
|
|
||||||
|
let clientToken: string | null = null;
|
||||||
|
let clientTokenExpiresAt = 0;
|
||||||
|
|
||||||
|
async function getClientToken(): Promise<string> {
|
||||||
|
if (clientToken && Date.now() < clientTokenExpiresAt - 60_000) {
|
||||||
|
return clientToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch('https://osu.ppy.sh/oauth/token', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded'
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({
|
||||||
|
client_id: env.OSU_CLIENT_ID!,
|
||||||
|
client_secret: env.OSU_CLIENT_SECRET!,
|
||||||
|
grant_type: 'client_credentials',
|
||||||
|
scope: 'identify public'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Failed to get osu! client token: ${res.status} ${res.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
clientToken = data.access_token;
|
||||||
|
clientTokenExpiresAt = Date.now() + data.expires_in * 1000;
|
||||||
|
return clientToken!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getUserToken(userId: string): Promise<string | null> {
|
||||||
|
const acc = await db.query.account.findFirst({
|
||||||
|
where: and(eq(account.userId, userId), eq(account.providerId, 'osu'))
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!acc?.accessToken) return null;
|
||||||
|
|
||||||
|
if (acc.accessTokenExpiresAt && acc.accessTokenExpiresAt < new Date()) {
|
||||||
|
if (!acc.refreshToken) return null;
|
||||||
|
|
||||||
|
const res = await fetch('https://osu.ppy.sh/oauth/token', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded'
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({
|
||||||
|
client_id: env.OSU_CLIENT_ID!,
|
||||||
|
client_secret: env.OSU_CLIENT_SECRET!,
|
||||||
|
grant_type: 'refresh_token',
|
||||||
|
refresh_token: acc.refreshToken,
|
||||||
|
scope: 'identify public'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) return null;
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
await db
|
||||||
|
.update(account)
|
||||||
|
.set({
|
||||||
|
accessToken: data.access_token,
|
||||||
|
refreshToken: data.refresh_token,
|
||||||
|
accessTokenExpiresAt: new Date(Date.now() + data.expires_in * 1000)
|
||||||
|
})
|
||||||
|
.where(eq(account.id, acc.id));
|
||||||
|
|
||||||
|
return data.access_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc.accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function osuFetch(path: string, token?: string) {
|
||||||
|
const t = token ?? (await getClientToken());
|
||||||
|
const res = await fetch(`https://osu.ppy.sh/api/v2${path}`, {
|
||||||
|
headers: { Authorization: `Bearer ${t}` }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`osu! API ${path}: ${res.status} ${res.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public API (with caching) ───────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getBeatmap(beatmapId: string | number) {
|
||||||
|
const cacheKey = `beatmap:${beatmapId}`;
|
||||||
|
const cached = getCached(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const data = await osuFetch(`/beatmaps/${beatmapId}`);
|
||||||
|
setCache(cacheKey, data);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBeatmapset(beatmapsetId: string | number) {
|
||||||
|
const cacheKey = `beatmapset:${beatmapsetId}`;
|
||||||
|
const cached = getCached(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const data = await osuFetch(`/beatmapsets/${beatmapsetId}`);
|
||||||
|
setCache(cacheKey, data);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUser(userId: string | number) {
|
||||||
|
const cacheKey = `osu_user:${userId}`;
|
||||||
|
const cached = getCached(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const data = await osuFetch(`/users/${userId}`);
|
||||||
|
setCache(cacheKey, data, 5 * 60 * 1000); // 5 min for user data
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserScores(
|
||||||
|
userId: string | number,
|
||||||
|
type: 'best' | 'recent' | 'firsts' = 'best',
|
||||||
|
limit = 10
|
||||||
|
) {
|
||||||
|
return osuFetch(`/users/${userId}/scores/${type}?limit=${limit}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { getUserToken, getClientToken };
|
||||||
42
src/lib/server/osu/cache.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
// src/lib/server/osu/cache.ts
|
||||||
|
//
|
||||||
|
// Simple in-memory cache for osu! beatmap metadata.
|
||||||
|
// TTL: 10 minutes. Max entries: 500.
|
||||||
|
// Avoids hammering the osu! API on every page load.
|
||||||
|
|
||||||
|
interface CacheEntry<T> {
|
||||||
|
data: T;
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CACHE_TTL = 10 * 60 * 1000; // 10 minutes
|
||||||
|
const MAX_ENTRIES = 500;
|
||||||
|
|
||||||
|
const cache = new Map<string, CacheEntry<any>>();
|
||||||
|
|
||||||
|
export function getCached<T>(key: string): T | null {
|
||||||
|
const entry = cache.get(key);
|
||||||
|
if (!entry) return null;
|
||||||
|
if (Date.now() > entry.expiresAt) {
|
||||||
|
cache.delete(key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return entry.data as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setCache<T>(key: string, data: T, ttl = CACHE_TTL): void {
|
||||||
|
// Evict oldest entries if at capacity
|
||||||
|
if (cache.size >= MAX_ENTRIES) {
|
||||||
|
const firstKey = cache.keys().next().value;
|
||||||
|
if (firstKey) cache.delete(firstKey);
|
||||||
|
}
|
||||||
|
cache.set(key, { data, expiresAt: Date.now() + ttl });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function invalidateCache(key: string): void {
|
||||||
|
cache.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearCache(): void {
|
||||||
|
cache.clear();
|
||||||
|
}
|
||||||
56
src/lib/server/permissions.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
// src/lib/server/permissions.ts
|
||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { user } from '$lib/server/db/auth.schema';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
export type UserRole = 'player' | 'referee' | 'admin';
|
||||||
|
|
||||||
|
const ROLE_HIERARCHY: Record<UserRole, number> = {
|
||||||
|
player: 0,
|
||||||
|
referee: 1,
|
||||||
|
admin: 2
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a role meets the minimum required level.
|
||||||
|
*/
|
||||||
|
export function hasRole(userRole: string | undefined, minRole: UserRole): boolean {
|
||||||
|
const level = ROLE_HIERARCHY[userRole as UserRole] ?? 0;
|
||||||
|
const required = ROLE_HIERARCHY[minRole];
|
||||||
|
return level >= required;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Throw 403 if user doesn't have the required role.
|
||||||
|
* Use in +page.server.ts load/actions.
|
||||||
|
*/
|
||||||
|
export function requireRole(
|
||||||
|
locals: App.Locals,
|
||||||
|
minRole: UserRole,
|
||||||
|
message = 'Insufficient permissions'
|
||||||
|
) {
|
||||||
|
if (!locals.user) {
|
||||||
|
error(401, 'Not logged in');
|
||||||
|
}
|
||||||
|
if (!hasRole(locals.user.role, minRole)) {
|
||||||
|
error(403, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Promote/demote a user's global role. Only admins can do this.
|
||||||
|
*/
|
||||||
|
export async function setUserRole(userId: string, role: UserRole) {
|
||||||
|
await db.update(user).set({ role }).where(eq(user.id, userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a user's role from the database (for fresh reads).
|
||||||
|
*/
|
||||||
|
export async function getUserRole(userId: string): Promise<UserRole> {
|
||||||
|
const u = await db.query.user.findFirst({
|
||||||
|
where: eq(user.id, userId)
|
||||||
|
});
|
||||||
|
return (u?.role as UserRole) ?? 'player';
|
||||||
|
}
|
||||||
16
src/routes/(app)/+layout.server.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
// src/routes/(app)/+layout.server.ts
|
||||||
|
import type { LayoutServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: LayoutServerLoad = async ({ locals }) => {
|
||||||
|
return {
|
||||||
|
user: locals.user
|
||||||
|
? {
|
||||||
|
id: locals.user.id,
|
||||||
|
name: locals.user.name,
|
||||||
|
email: locals.user.email,
|
||||||
|
image: locals.user.image,
|
||||||
|
role: locals.user.role ?? 'player'
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
};
|
||||||
|
};
|
||||||
48
src/routes/(app)/+layout.svelte
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import './layout.css';
|
||||||
|
import Sidebar from '$lib/components/Sidebar.svelte';
|
||||||
|
import Header from '$lib/components/Header.svelte';
|
||||||
|
import { page } from '$app/state';
|
||||||
|
|
||||||
|
let { children } = $props();
|
||||||
|
|
||||||
|
const user = $derived(page.data?.user);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if user}
|
||||||
|
<!-- Authenticated layout with sidebar -->
|
||||||
|
<div class="flex min-h-dvh">
|
||||||
|
<Sidebar />
|
||||||
|
<div class="ml-56 flex flex-1 flex-col">
|
||||||
|
<Header />
|
||||||
|
<main class="flex-1 p-6">
|
||||||
|
{@render children()}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Unauthenticated: clean layout, no sidebar -->
|
||||||
|
<div class="min-h-dvh">
|
||||||
|
<header
|
||||||
|
class="flex h-14 items-center justify-between border-b border-border bg-surface-900/80 px-6 backdrop-blur-md"
|
||||||
|
>
|
||||||
|
<a href="/" class="flex items-center gap-2.5">
|
||||||
|
<div
|
||||||
|
class="font-800 flex h-8 w-8 items-center justify-center rounded-md bg-accent text-sm text-surface-900"
|
||||||
|
>
|
||||||
|
V
|
||||||
|
</div>
|
||||||
|
<span class="font-700 text-base tracking-tight text-text-primary">Vash Esports</span>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/login"
|
||||||
|
class="font-600 rounded-md bg-accent px-3.5 py-1.5 text-sm text-surface-900 transition-colors hover:bg-accent-hover"
|
||||||
|
>
|
||||||
|
Sign in with osu!
|
||||||
|
</a>
|
||||||
|
</header>
|
||||||
|
<main class="p-6">
|
||||||
|
{@render children()}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
49
src/routes/(app)/+page.server.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
// src/routes/(app)/+page.server.ts
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { match, team, mappool } from '$lib/server/db/schema';
|
||||||
|
import { eq, desc, count, inArray } from 'drizzle-orm';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ locals }) => {
|
||||||
|
// ── NOT LOGGED IN → landing page (no redirect loop!) ──
|
||||||
|
if (!locals.user) {
|
||||||
|
return {
|
||||||
|
authenticated: false as const,
|
||||||
|
recentMatches: [],
|
||||||
|
liveMatches: [],
|
||||||
|
stats: { matches: 0, finished: 0, teams: 0, mappools: 0 }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── LOGGED IN → dashboard data ──
|
||||||
|
const recentMatches = await db.query.match.findMany({
|
||||||
|
with: { participants: { with: { team: true } } },
|
||||||
|
orderBy: desc(match.createdAt),
|
||||||
|
limit: 5
|
||||||
|
});
|
||||||
|
|
||||||
|
const liveMatches = await db.query.match.findMany({
|
||||||
|
with: { participants: { with: { team: true } } },
|
||||||
|
where: inArray(match.state, ['LOBBY', 'ROLLING', 'PICKING', 'PLAYING'])
|
||||||
|
});
|
||||||
|
|
||||||
|
const [matchCount] = await db.select({ count: count() }).from(match);
|
||||||
|
const [teamCount] = await db.select({ count: count() }).from(team);
|
||||||
|
const [poolCount] = await db.select({ count: count() }).from(mappool);
|
||||||
|
const [finishedCount] = await db
|
||||||
|
.select({ count: count() })
|
||||||
|
.from(match)
|
||||||
|
.where(eq(match.state, 'FINISHED'));
|
||||||
|
|
||||||
|
return {
|
||||||
|
authenticated: true as const,
|
||||||
|
recentMatches,
|
||||||
|
liveMatches,
|
||||||
|
stats: {
|
||||||
|
matches: matchCount.count,
|
||||||
|
finished: finishedCount.count,
|
||||||
|
teams: teamCount.count,
|
||||||
|
mappools: poolCount.count
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
312
src/routes/(app)/+page.svelte
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let { data } = $props();
|
||||||
|
|
||||||
|
const liveStates = ['LOBBY', 'ROLLING', 'PICKING', 'PLAYING'];
|
||||||
|
|
||||||
|
const stateConfig: Record<string, { label: string; color: string; dot?: string }> = {
|
||||||
|
CREATED: { label: 'Created', color: 'text-text-secondary' },
|
||||||
|
LOBBY: { label: 'In Lobby', color: 'text-yellow-400' },
|
||||||
|
ROLLING: { label: 'Rolling', color: 'text-yellow-400', dot: 'bg-yellow-400' },
|
||||||
|
PICKING: { label: 'Picking', color: 'text-blue-400', dot: 'bg-blue-400' },
|
||||||
|
PLAYING: { label: 'Live', color: 'text-green-400', dot: 'bg-green-400' },
|
||||||
|
FINISHED: { label: 'Finished', color: 'text-text-secondary' },
|
||||||
|
CANCELLED: { label: 'Cancelled', color: 'text-red-400' }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Queue state
|
||||||
|
let queueStatus = $state<{ inQueue: boolean; queueSize: number } | null>(null);
|
||||||
|
let queueLoading = $state(false);
|
||||||
|
|
||||||
|
async function fetchQueueStatus() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/queue');
|
||||||
|
if (res.ok) queueStatus = await res.json();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function joinQueue() {
|
||||||
|
queueLoading = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/queue', { method: 'POST' });
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.matched && data.match) {
|
||||||
|
window.location.href = `/matches/${data.match.id}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await fetchQueueStatus();
|
||||||
|
} catch {}
|
||||||
|
queueLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function leaveQueue() {
|
||||||
|
queueLoading = true;
|
||||||
|
try {
|
||||||
|
await fetch('/api/queue', { method: 'DELETE' });
|
||||||
|
await fetchQueueStatus();
|
||||||
|
} catch {}
|
||||||
|
queueLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poll queue status while on dashboard
|
||||||
|
$effect(() => {
|
||||||
|
if (data.user) {
|
||||||
|
fetchQueueStatus();
|
||||||
|
const interval = setInterval(fetchQueueStatus, 5000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function timeAgo(date: string | Date) {
|
||||||
|
const d = new Date(date);
|
||||||
|
const diff = Date.now() - d.getTime();
|
||||||
|
const mins = Math.floor(diff / 60000);
|
||||||
|
if (mins < 1) return 'just now';
|
||||||
|
if (mins < 60) return `${mins}m ago`;
|
||||||
|
const hours = Math.floor(mins / 60);
|
||||||
|
if (hours < 24) return `${hours}h ago`;
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
return `${days}d ago`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- ── LANDING PAGE (not logged in) ── -->
|
||||||
|
{#if !data.user}
|
||||||
|
<div class="flex min-h-[70vh] flex-col items-center justify-center text-center">
|
||||||
|
<div
|
||||||
|
class="font-800 mb-6 flex h-16 w-16 items-center justify-center rounded-xl bg-accent text-2xl text-surface-900"
|
||||||
|
>
|
||||||
|
V
|
||||||
|
</div>
|
||||||
|
<h1 class="text-4xl font-800 tracking-tight">
|
||||||
|
Welcome to <span class="text-accent">Vash Esports</span>
|
||||||
|
</h1>
|
||||||
|
<p class="mt-3 max-w-md text-text-secondary">
|
||||||
|
Automated tournament match management for osu! — lobbies, mappools, picks, bans, and
|
||||||
|
ELO tracking all in one place.
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href="/login"
|
||||||
|
class="font-600 mt-8 rounded-lg bg-accent px-6 py-3 text-surface-900 transition-colors hover:bg-accent-hover"
|
||||||
|
>
|
||||||
|
Sign in with osu!
|
||||||
|
</a>
|
||||||
|
<p class="mt-4 text-xs text-text-secondary">
|
||||||
|
Uses your osu! account. No extra registration needed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── DASHBOARD (logged in) ── -->
|
||||||
|
{:else}
|
||||||
|
<div class="mx-auto max-w-5xl">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-700 tracking-tight">
|
||||||
|
Welcome back, <span class="text-accent">{data.user.name}</span>
|
||||||
|
</h1>
|
||||||
|
<p class="mt-1 text-sm text-text-secondary">Automated esports match management for osu!</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats -->
|
||||||
|
<div class="mt-6 grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
|
<div class="rounded-lg border border-border bg-surface-800 p-4">
|
||||||
|
<p class="text-2xl font-800 tabular-nums text-text-primary">{data.stats.matches}</p>
|
||||||
|
<p class="mt-1 text-xs text-text-secondary">Total Matches</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg border border-border bg-surface-800 p-4">
|
||||||
|
<p class="text-2xl font-800 tabular-nums text-green-400">{data.stats.finished}</p>
|
||||||
|
<p class="mt-1 text-xs text-text-secondary">Completed</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg border border-border bg-surface-800 p-4">
|
||||||
|
<p class="text-2xl font-800 tabular-nums text-text-primary">{data.stats.teams}</p>
|
||||||
|
<p class="mt-1 text-xs text-text-secondary">Teams</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg border border-border bg-surface-800 p-4">
|
||||||
|
<p class="text-2xl font-800 tabular-nums text-text-primary">{data.stats.mappools}</p>
|
||||||
|
<p class="mt-1 text-xs text-text-secondary">Mappools</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Live Matches -->
|
||||||
|
{#if data.liveMatches.length > 0}
|
||||||
|
<div class="mt-8">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="relative h-2 w-2">
|
||||||
|
<div class="absolute inset-0 animate-ping rounded-full bg-green-400 opacity-75"></div>
|
||||||
|
<div class="relative h-2 w-2 rounded-full bg-green-400"></div>
|
||||||
|
</div>
|
||||||
|
<h2 class="text-sm font-600">Live Now</h2>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 flex flex-col gap-2">
|
||||||
|
{#each data.liveMatches as m}
|
||||||
|
{@const p1 = m.participants[0]}
|
||||||
|
{@const p2 = m.participants[1]}
|
||||||
|
{@const sc = stateConfig[m.state]}
|
||||||
|
<a
|
||||||
|
href="/matches/{m.id}"
|
||||||
|
class="flex items-center gap-4 rounded-lg border border-green-500/20 bg-surface-800 p-4 transition-all hover:border-green-500/40 hover:bg-surface-700"
|
||||||
|
>
|
||||||
|
<div class="flex flex-1 items-center gap-3">
|
||||||
|
{#if p1?.team.avatarUrl}
|
||||||
|
<img src={p1.team.avatarUrl} alt="" class="h-8 w-8 rounded-full" />
|
||||||
|
{/if}
|
||||||
|
<span class="text-sm font-600">{p1?.team.name ?? '?'}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-xl font-800 tabular-nums">{p1?.score ?? 0}</span>
|
||||||
|
<span class="text-xs text-text-secondary">vs</span>
|
||||||
|
<span class="text-xl font-800 tabular-nums">{p2?.score ?? 0}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-1 items-center justify-end gap-3">
|
||||||
|
<span class="text-sm font-600">{p2?.team.name ?? '?'}</span>
|
||||||
|
{#if p2?.team.avatarUrl}
|
||||||
|
<img src={p2.team.avatarUrl} alt="" class="h-8 w-8 rounded-full" />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<span class="flex items-center gap-1.5 text-xs font-500 {sc?.color ?? ''}">
|
||||||
|
{#if sc?.dot}
|
||||||
|
<span class="h-1.5 w-1.5 rounded-full {sc.dot}"></span>
|
||||||
|
{/if}
|
||||||
|
{sc?.label}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Queue -->
|
||||||
|
<div class="mt-6 rounded-lg border border-border bg-surface-800 p-5">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-sm font-600">Ranked Queue</h2>
|
||||||
|
<p class="mt-0.5 text-xs text-text-secondary">Find a match at your skill level</p>
|
||||||
|
</div>
|
||||||
|
{#if queueStatus?.inQueue}
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="flex items-center gap-2 text-xs text-yellow-400">
|
||||||
|
<span class="h-1.5 w-1.5 animate-pulse rounded-full bg-yellow-400"></span>
|
||||||
|
In queue ({queueStatus.queueSize} searching)
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onclick={leaveQueue}
|
||||||
|
disabled={queueLoading}
|
||||||
|
class="rounded-md border border-red-500/30 px-3 py-1.5 text-xs text-red-400 hover:bg-red-500/10 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Leave Queue
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
onclick={joinQueue}
|
||||||
|
disabled={queueLoading}
|
||||||
|
class="rounded-md bg-accent px-4 py-2 text-sm font-600 text-surface-900 transition-colors hover:bg-accent-hover disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{queueLoading ? 'Joining...' : 'Find Match'}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8 grid gap-4 lg:grid-cols-3">
|
||||||
|
<!-- Quick Actions -->
|
||||||
|
<div class="lg:col-span-1">
|
||||||
|
<h2 class="text-sm font-600">Quick Actions</h2>
|
||||||
|
<div class="mt-3 flex flex-col gap-2">
|
||||||
|
<a
|
||||||
|
href="/matches"
|
||||||
|
class="flex items-center gap-3 rounded-lg border border-border bg-surface-800 p-3 transition-colors hover:border-accent/40 hover:bg-surface-700"
|
||||||
|
>
|
||||||
|
<span class="flex h-8 w-8 items-center justify-center rounded-md bg-accent/10 text-accent"
|
||||||
|
>⚔</span
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-600">New Match</p>
|
||||||
|
<p class="text-xs text-text-secondary">Start a 1v1</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/mappools"
|
||||||
|
class="flex items-center gap-3 rounded-lg border border-border bg-surface-800 p-3 transition-colors hover:border-accent/40 hover:bg-surface-700"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="flex h-8 w-8 items-center justify-center rounded-md bg-purple-500/10 text-purple-400"
|
||||||
|
>♫</span
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-600">Mappools</p>
|
||||||
|
<p class="text-xs text-text-secondary">Build a pool</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/teams"
|
||||||
|
class="flex items-center gap-3 rounded-lg border border-border bg-surface-800 p-3 transition-colors hover:border-accent/40 hover:bg-surface-700"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="flex h-8 w-8 items-center justify-center rounded-md bg-blue-500/10 text-blue-400"
|
||||||
|
>⚑</span
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-600">Teams</p>
|
||||||
|
<p class="text-xs text-text-secondary">Manage rosters</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent Activity -->
|
||||||
|
<div class="lg:col-span-2">
|
||||||
|
<h2 class="text-sm font-600">Recent Matches</h2>
|
||||||
|
<div class="mt-3 flex flex-col gap-2">
|
||||||
|
{#each data.recentMatches as m}
|
||||||
|
{@const p1 = m.participants[0]}
|
||||||
|
{@const p2 = m.participants[1]}
|
||||||
|
{@const config = m.config as { bestOf: number }}
|
||||||
|
{@const sc = stateConfig[m.state]}
|
||||||
|
<a
|
||||||
|
href="/matches/{m.id}"
|
||||||
|
class="flex items-center gap-3 rounded-lg border border-border bg-surface-800 p-3 transition-colors hover:border-accent/30 hover:bg-surface-700"
|
||||||
|
>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-sm font-600">{p1?.team.name ?? '?'}</span>
|
||||||
|
{#if m.state === 'FINISHED'}
|
||||||
|
<span
|
||||||
|
class="text-xs font-700 tabular-nums {(p1?.score ?? 0) > (p2?.score ?? 0)
|
||||||
|
? 'text-green-400'
|
||||||
|
: 'text-text-secondary'}">{p1?.score ?? 0}</span
|
||||||
|
>
|
||||||
|
<span class="text-xs text-text-secondary">-</span>
|
||||||
|
<span
|
||||||
|
class="text-xs font-700 tabular-nums {(p2?.score ?? 0) > (p1?.score ?? 0)
|
||||||
|
? 'text-green-400'
|
||||||
|
: 'text-text-secondary'}">{p2?.score ?? 0}</span
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
<span class="text-xs text-text-secondary">vs</span>
|
||||||
|
{/if}
|
||||||
|
<span class="text-sm font-600">{p2?.team.name ?? '?'}</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-0.5 text-xs text-text-secondary">
|
||||||
|
{m.name ? `${m.name} · ` : ''}BO{config.bestOf}
|
||||||
|
· {timeAgo(m.finishedAt ?? m.createdAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if m.state === 'FINISHED' && m.winnerId}
|
||||||
|
{@const winner = m.participants.find((p: any) => p.teamId === m.winnerId)}
|
||||||
|
<span class="text-xs text-green-400">🏆 {winner?.team.name}</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<span class="text-xs {sc?.color ?? 'text-text-secondary'}">{sc?.label}</span>
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<div class="rounded-lg border border-dashed border-border py-8 text-center">
|
||||||
|
<p class="text-sm text-text-secondary">
|
||||||
|
No matches yet. <a href="/matches" class="text-accent hover:underline">Create one</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
61
src/routes/(app)/admin/+page.server.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
// src/routes/(app)/admin/+page.server.ts
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { user } from '$lib/server/db/auth.schema';
|
||||||
|
import { playerRating } from '$lib/server/db/schema';
|
||||||
|
import { eq, desc } from 'drizzle-orm';
|
||||||
|
import { redirect, error } from '@sveltejs/kit';
|
||||||
|
import { requireRole, setUserRole } from '$lib/server/permissions';
|
||||||
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ locals }) => {
|
||||||
|
requireRole(locals, 'admin');
|
||||||
|
|
||||||
|
const users = await db.query.user.findMany({
|
||||||
|
orderBy: desc(user.createdAt)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch ratings for all users
|
||||||
|
const usersWithRatings = await Promise.all(
|
||||||
|
users.map(async (u) => {
|
||||||
|
const rating = await db.query.playerRating.findFirst({
|
||||||
|
where: eq(playerRating.userId, u.id)
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
id: u.id,
|
||||||
|
name: u.name,
|
||||||
|
email: u.email,
|
||||||
|
image: u.image,
|
||||||
|
role: u.role ?? 'player',
|
||||||
|
createdAt: u.createdAt,
|
||||||
|
elo: rating?.elo ?? 1000,
|
||||||
|
wins: rating?.wins ?? 0,
|
||||||
|
losses: rating?.losses ?? 0
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return { users: usersWithRatings };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
setRole: async ({ request, locals }) => {
|
||||||
|
requireRole(locals, 'admin');
|
||||||
|
|
||||||
|
const form = await request.formData();
|
||||||
|
const userId = form.get('userId')?.toString();
|
||||||
|
const role = form.get('role')?.toString();
|
||||||
|
|
||||||
|
if (!userId || !role) return { error: 'User ID and role are required' };
|
||||||
|
if (!['player', 'referee', 'admin'].includes(role)) {
|
||||||
|
return { error: 'Invalid role' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent demoting yourself
|
||||||
|
if (userId === locals.user!.id && role !== 'admin') {
|
||||||
|
return { error: "You can't demote yourself" };
|
||||||
|
}
|
||||||
|
|
||||||
|
await setUserRole(userId, role as any);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
};
|
||||||
103
src/routes/(app)/admin/+page.svelte
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
|
||||||
|
let { data } = $props();
|
||||||
|
|
||||||
|
let actionError = $state('');
|
||||||
|
|
||||||
|
const roleBadge: Record<string, { label: string; color: string }> = {
|
||||||
|
player: { label: 'Player', color: 'bg-surface-600 text-text-secondary border-border' },
|
||||||
|
referee: {
|
||||||
|
label: 'Referee',
|
||||||
|
color: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30'
|
||||||
|
},
|
||||||
|
admin: { label: 'Admin', color: 'bg-red-500/20 text-red-400 border-red-500/30' }
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-4xl">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-700 tracking-tight">Admin Panel</h1>
|
||||||
|
<p class="mt-1 text-sm text-text-secondary">
|
||||||
|
{data.users.length} registered user{data.users.length !== 1 ? 's' : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if actionError}
|
||||||
|
<p class="mt-3 text-sm text-red-400">{actionError}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Users Table -->
|
||||||
|
<div class="mt-6 rounded-lg border border-border bg-surface-800">
|
||||||
|
<div class="grid grid-cols-[auto_1fr_auto_auto_auto] gap-4 border-b border-border px-4 py-3">
|
||||||
|
<span class="text-xs font-600 text-text-secondary">Avatar</span>
|
||||||
|
<span class="text-xs font-600 text-text-secondary">User</span>
|
||||||
|
<span class="text-xs font-600 text-text-secondary">ELO</span>
|
||||||
|
<span class="text-xs font-600 text-text-secondary">W/L</span>
|
||||||
|
<span class="text-xs font-600 text-text-secondary">Role</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#each data.users as u}
|
||||||
|
{@const badge = roleBadge[u.role] ?? roleBadge.player}
|
||||||
|
<div
|
||||||
|
class="grid grid-cols-[auto_1fr_auto_auto_auto] items-center gap-4 border-b border-border/50 px-4 py-3 last:border-b-0"
|
||||||
|
>
|
||||||
|
<!-- Avatar -->
|
||||||
|
{#if u.image}
|
||||||
|
<img src={u.image} alt="" class="h-8 w-8 rounded-full" />
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="flex h-8 w-8 items-center justify-center rounded-full bg-surface-600 text-xs font-700 text-text-secondary"
|
||||||
|
>
|
||||||
|
{u.name.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Name -->
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="truncate text-sm font-500">{u.name}</p>
|
||||||
|
<p class="truncate text-xs text-text-secondary">{u.email}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ELO -->
|
||||||
|
<span class="text-sm font-600 tabular-nums text-text-primary">{u.elo}</span>
|
||||||
|
|
||||||
|
<!-- W/L -->
|
||||||
|
<span class="text-xs tabular-nums text-text-secondary">
|
||||||
|
<span class="text-green-400">{u.wins}W</span> /
|
||||||
|
<span class="text-red-400">{u.losses}L</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Role selector -->
|
||||||
|
<form
|
||||||
|
method="post"
|
||||||
|
action="?/setRole"
|
||||||
|
use:enhance={() => {
|
||||||
|
actionError = '';
|
||||||
|
return async ({ result }) => {
|
||||||
|
if (
|
||||||
|
result.type === 'failure' ||
|
||||||
|
(result.type === 'success' && (result.data as any)?.error)
|
||||||
|
) {
|
||||||
|
actionError = (result.data as any)?.error ?? 'Failed to update role';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
class="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="userId" value={u.id} />
|
||||||
|
<select
|
||||||
|
name="role"
|
||||||
|
value={u.role}
|
||||||
|
onchange={(e) => e.currentTarget.form?.requestSubmit()}
|
||||||
|
class="rounded-md border px-2 py-1 text-xs font-500 {badge.color} cursor-pointer bg-transparent focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="player" class="bg-surface-800 text-text-primary">Player</option>
|
||||||
|
<option value="referee" class="bg-surface-800 text-text-primary">Referee</option>
|
||||||
|
<option value="admin" class="bg-surface-800 text-text-primary">Admin</option>
|
||||||
|
</select>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
43
src/routes/(app)/layout.css
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
@import 'tailwindcss';
|
||||||
|
@plugin '@tailwindcss/typography';
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--color-surface-900: #0a0a0f;
|
||||||
|
--color-surface-800: #12121a;
|
||||||
|
--color-surface-700: #1a1a25;
|
||||||
|
--color-surface-600: #24243a;
|
||||||
|
--color-accent: #ff66ab;
|
||||||
|
--color-accent-hover: #ff85be;
|
||||||
|
--color-accent-dim: #ff66ab33;
|
||||||
|
--color-text-primary: #eeeef0;
|
||||||
|
--color-text-secondary: #8888a0;
|
||||||
|
--color-border: #2a2a3d;
|
||||||
|
--font-display: 'Geist', system-ui, sans-serif;
|
||||||
|
--font-body: 'Geist', system-ui, sans-serif;
|
||||||
|
--font-mono: 'Geist Mono', ui-monospace, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
background-color: var(--color-surface-900);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-height: 100dvh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: var(--color-surface-900);
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--color-surface-600);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--color-accent);
|
||||||
|
}
|
||||||
32
src/routes/(app)/mappools/+page.server.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
|
import { mappool } from '$lib/server/db/schema';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ locals }) => {
|
||||||
|
if (!locals.user) redirect(302, '/');
|
||||||
|
|
||||||
|
const mappools = await db.query.mappool.findMany({
|
||||||
|
with: { slots: true },
|
||||||
|
orderBy: (m, { desc }) => [desc(m.createdAt)]
|
||||||
|
});
|
||||||
|
|
||||||
|
return { mappools };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
create: async ({ request, locals }) => {
|
||||||
|
if (!locals.user) redirect(302, '/');
|
||||||
|
|
||||||
|
const form = await request.formData();
|
||||||
|
const name = form.get('name')?.toString()?.trim();
|
||||||
|
if (!name) return { error: 'Name is required' };
|
||||||
|
|
||||||
|
const [created] = await db
|
||||||
|
.insert(mappool)
|
||||||
|
.values({ name, createdBy: locals.user.id })
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
redirect(303, `/mappools/${created.id}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
66
src/routes/(app)/mappools/+page.svelte
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
|
||||||
|
let { data } = $props();
|
||||||
|
let creating = $state(false);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-4xl">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-700 tracking-tight">Mappools</h1>
|
||||||
|
<p class="mt-1 text-sm text-text-secondary">Create and manage your beatmap pools</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onclick={() => (creating = !creating)}
|
||||||
|
class="rounded-md bg-accent px-4 py-2 text-sm font-600 text-surface-900 transition-colors hover:bg-accent-hover"
|
||||||
|
>
|
||||||
|
{creating ? 'Cancel' : 'New Pool'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if creating}
|
||||||
|
<form
|
||||||
|
method="post"
|
||||||
|
action="?/create"
|
||||||
|
use:enhance
|
||||||
|
class="mt-4 flex gap-3 rounded-lg border border-border bg-surface-800 p-4"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="name"
|
||||||
|
placeholder="Pool name (e.g. Grand Finals Mappool)"
|
||||||
|
required
|
||||||
|
class="flex-1 rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary placeholder:text-text-secondary/50 focus:border-accent focus:outline-none"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded-md bg-accent px-4 py-2 text-sm font-600 text-surface-900 transition-colors hover:bg-accent-hover"
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="mt-6 flex flex-col gap-3">
|
||||||
|
{#each data.mappools as pool}
|
||||||
|
<a
|
||||||
|
href="/mappools/{pool.id}"
|
||||||
|
class="group flex items-center justify-between rounded-lg border border-border bg-surface-800 p-4 transition-colors hover:border-accent/40 hover:bg-surface-700"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-sm font-600">{pool.name}</h2>
|
||||||
|
<p class="mt-1 text-xs text-text-secondary">
|
||||||
|
{pool.slots.length} map{pool.slots.length !== 1 ? 's' : ''}
|
||||||
|
· Created {new Date(pool.createdAt).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span class="text-text-secondary transition-colors group-hover:text-accent">→</span>
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<div class="rounded-lg border border-dashed border-border py-12 text-center">
|
||||||
|
<p class="text-sm text-text-secondary">No mappools yet. Create one to get started.</p>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
118
src/routes/(app)/mappools/[id]/+page.server.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { mappool, mappoolSlot } from '$lib/server/db/schema';
|
||||||
|
import { eq, and } from 'drizzle-orm';
|
||||||
|
import { error, redirect } from '@sveltejs/kit';
|
||||||
|
import { getBeatmap } from '$lib/server/osu/api';
|
||||||
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ params, locals }) => {
|
||||||
|
if (!locals.user) redirect(302, '/');
|
||||||
|
|
||||||
|
const pool = await db.query.mappool.findFirst({
|
||||||
|
where: eq(mappool.id, params.id),
|
||||||
|
with: {
|
||||||
|
slots: {
|
||||||
|
orderBy: (s, { asc }) => [asc(s.category), asc(s.orderInCategory)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!pool) error(404, 'Mappool not found');
|
||||||
|
|
||||||
|
// Fetch beatmap metadata for all slots
|
||||||
|
const slotsWithBeatmaps = await Promise.all(
|
||||||
|
pool.slots.map(async (slot) => {
|
||||||
|
try {
|
||||||
|
const beatmap = await getBeatmap(slot.beatmapId);
|
||||||
|
return {
|
||||||
|
...slot,
|
||||||
|
beatmap: {
|
||||||
|
title: beatmap.beatmapset.title,
|
||||||
|
artist: beatmap.beatmapset.artist,
|
||||||
|
version: beatmap.version,
|
||||||
|
starRating: beatmap.difficulty_rating,
|
||||||
|
bpm: beatmap.bpm,
|
||||||
|
totalLength: beatmap.total_length,
|
||||||
|
coverUrl: beatmap.beatmapset.covers['card@2x'],
|
||||||
|
url: beatmap.url
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
...slot,
|
||||||
|
beatmap: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
pool: { ...pool, slots: slotsWithBeatmaps }
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
addSlot: async ({ params, request, locals }) => {
|
||||||
|
if (!locals.user) redirect(302, '/');
|
||||||
|
|
||||||
|
const form = await request.formData();
|
||||||
|
let beatmapId = form.get('beatmapId')?.toString()?.trim();
|
||||||
|
const category = form.get('category')?.toString()?.trim()?.toUpperCase();
|
||||||
|
|
||||||
|
if (!beatmapId || !category) {
|
||||||
|
return { error: 'Beatmap ID and category are required' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const urlMatch = beatmapId.match(/beatmaps\/(\d+)/);
|
||||||
|
const setMatch = beatmapId.match(/beatmapsets\/\d+#\w+\/(\d+)/);
|
||||||
|
if (urlMatch) beatmapId = urlMatch[1];
|
||||||
|
else if (setMatch) beatmapId = setMatch[1];
|
||||||
|
|
||||||
|
if (!/^\d+$/.test(beatmapId)) {
|
||||||
|
return { error: 'Invalid beatmap ID' };
|
||||||
|
}
|
||||||
|
|
||||||
|
let beatmap;
|
||||||
|
try {
|
||||||
|
beatmap = await getBeatmap(beatmapId);
|
||||||
|
} catch {
|
||||||
|
return { error: 'Beatmap not found on osu!' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await db.query.mappoolSlot.findMany({
|
||||||
|
where: and(
|
||||||
|
eq(mappoolSlot.mappoolId, params.id),
|
||||||
|
eq(mappoolSlot.category, category)
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.insert(mappoolSlot).values({
|
||||||
|
mappoolId: params.id,
|
||||||
|
beatmapId,
|
||||||
|
category,
|
||||||
|
orderInCategory: existing.length + 1,
|
||||||
|
starRating: beatmap.difficulty_rating,
|
||||||
|
mods: category === 'NM' || category === 'TB' || category === 'FM' ? [] : [category]
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
|
||||||
|
removeSlot: async ({ request, locals }) => {
|
||||||
|
if (!locals.user) redirect(302, '/');
|
||||||
|
|
||||||
|
const form = await request.formData();
|
||||||
|
const slotId = form.get('slotId')?.toString();
|
||||||
|
if (!slotId) return { error: 'Missing slot ID' };
|
||||||
|
|
||||||
|
await db.delete(mappoolSlot).where(eq(mappoolSlot.id, slotId));
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
|
||||||
|
deletePool: async ({ params, locals }) => {
|
||||||
|
if (!locals.user) redirect(302, '/');
|
||||||
|
|
||||||
|
await db.delete(mappool).where(eq(mappool.id, params.id));
|
||||||
|
redirect(303, '/mappools');
|
||||||
|
}
|
||||||
|
};
|
||||||
205
src/routes/(app)/mappools/[id]/+page.svelte
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import { invalidateAll } from '$app/navigation';
|
||||||
|
|
||||||
|
let { data } = $props();
|
||||||
|
|
||||||
|
const categories = ['NM', 'HD', 'HR', 'DT', 'FM', 'TB'];
|
||||||
|
|
||||||
|
let addingCategory = $state('NM');
|
||||||
|
let beatmapInput = $state('');
|
||||||
|
let addError = $state('');
|
||||||
|
let searching = $state(false);
|
||||||
|
|
||||||
|
// Group slots by category
|
||||||
|
const grouped = $derived(() => {
|
||||||
|
const groups: Record<string, typeof data.pool.slots> = {};
|
||||||
|
for (const cat of categories) {
|
||||||
|
const slots = data.pool.slots.filter((s) => s.category === cat);
|
||||||
|
if (slots.length > 0) groups[cat] = slots;
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
});
|
||||||
|
|
||||||
|
function parseBeatmapId(input: string): string | null {
|
||||||
|
// Accept raw ID or osu! URL
|
||||||
|
const urlMatch = input.match(/beatmaps\/(\d+)/);
|
||||||
|
if (urlMatch) return urlMatch[1];
|
||||||
|
const match = input.match(/^(\d+)$/);
|
||||||
|
return match ? match[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLength(seconds: number): string {
|
||||||
|
const m = Math.floor(seconds / 60);
|
||||||
|
const s = seconds % 60;
|
||||||
|
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryColors: Record<string, string> = {
|
||||||
|
NM: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
|
||||||
|
HD: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30',
|
||||||
|
HR: 'bg-red-500/20 text-red-400 border-red-500/30',
|
||||||
|
DT: 'bg-purple-500/20 text-purple-400 border-purple-500/30',
|
||||||
|
FM: 'bg-green-500/20 text-green-400 border-green-500/30',
|
||||||
|
TB: 'bg-pink-500/20 text-pink-400 border-pink-500/30'
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-4xl">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<a href="/mappools" class="text-text-secondary transition-colors hover:text-text-primary">←</a
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-700 tracking-tight">{data.pool.name}</h1>
|
||||||
|
<p class="mt-1 text-sm text-text-secondary">
|
||||||
|
{data.pool.slots.length} map{data.pool.slots.length !== 1 ? 's' : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" action="?/deletePool" use:enhance>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
onclick={(e) => {
|
||||||
|
if (!confirm('Delete this mappool?')) e.preventDefault();
|
||||||
|
}}
|
||||||
|
class="rounded-md border border-red-500/30 px-3 py-1.5 text-sm text-red-400 transition-colors hover:bg-red-500/10"
|
||||||
|
>
|
||||||
|
Delete Pool
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add Map -->
|
||||||
|
<form
|
||||||
|
method="post"
|
||||||
|
action="?/addSlot"
|
||||||
|
use:enhance={() => {
|
||||||
|
searching = true;
|
||||||
|
addError = '';
|
||||||
|
return async ({ result, update }) => {
|
||||||
|
searching = false;
|
||||||
|
if (result.type === 'success') {
|
||||||
|
beatmapInput = '';
|
||||||
|
await update();
|
||||||
|
} else if (result.type === 'failure') {
|
||||||
|
addError = (result.data as { error?: string })?.error ?? 'Failed to add map'; }
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
class="mt-6 rounded-lg border border-border bg-surface-800 p-4"
|
||||||
|
>
|
||||||
|
<h2 class="text-sm font-600">Add Map</h2>
|
||||||
|
<div class="mt-3 flex gap-3">
|
||||||
|
<select
|
||||||
|
name="category"
|
||||||
|
bind:value={addingCategory}
|
||||||
|
class="rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none"
|
||||||
|
>
|
||||||
|
{#each categories as cat}
|
||||||
|
<option value={cat}>{cat}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="beatmapId"
|
||||||
|
bind:value={beatmapInput}
|
||||||
|
placeholder="Beatmap ID or URL (e.g. 75 or https://osu.ppy.sh/beatmaps/75)"
|
||||||
|
class="flex-1 rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary placeholder:text-text-secondary/50 focus:border-accent focus:outline-none"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={searching || !beatmapInput.trim()}
|
||||||
|
class="rounded-md bg-accent px-4 py-2 text-sm font-600 text-surface-900 transition-colors hover:bg-accent-hover disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{searching ? 'Adding...' : 'Add'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if addError}
|
||||||
|
<p class="mt-2 text-sm text-red-400">{addError}</p>
|
||||||
|
{/if}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Map List -->
|
||||||
|
<div class="mt-6 flex flex-col gap-6">
|
||||||
|
{#each Object.entries(grouped()) as [category, slots]}
|
||||||
|
<div>
|
||||||
|
<div class="mb-2 flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
class="rounded border px-2 py-0.5 text-xs font-600 {categoryColors[category] ?? 'bg-surface-600 text-text-secondary border-border'}"
|
||||||
|
>
|
||||||
|
{category}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-text-secondary">{slots.length} map{slots.length !== 1 ? 's' : ''}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
{#each slots as slot, i}
|
||||||
|
<div
|
||||||
|
class="group flex items-center gap-3 rounded-lg border border-border bg-surface-800 p-2 transition-colors hover:border-accent/30"
|
||||||
|
>
|
||||||
|
<!-- Cover -->
|
||||||
|
{#if slot.beatmap?.coverUrl}
|
||||||
|
<img
|
||||||
|
src={slot.beatmap.coverUrl}
|
||||||
|
alt=""
|
||||||
|
class="h-12 w-24 rounded object-cover"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div class="flex h-12 w-24 items-center justify-center rounded bg-surface-700 text-xs text-text-secondary">
|
||||||
|
No cover
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Info -->
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
{#if slot.beatmap}
|
||||||
|
<a
|
||||||
|
href={slot.beatmap.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
class="truncate text-sm font-500 hover:text-accent"
|
||||||
|
>
|
||||||
|
{slot.beatmap.artist} - {slot.beatmap.title}
|
||||||
|
</a>
|
||||||
|
<div class="mt-0.5 flex items-center gap-3 text-xs text-text-secondary">
|
||||||
|
<span>[{slot.beatmap.version}]</span>
|
||||||
|
<span>★ {slot.beatmap.starRating.toFixed(2)}</span>
|
||||||
|
<span>{slot.beatmap.bpm} BPM</span>
|
||||||
|
<span>{formatLength(slot.beatmap.totalLength)}</span>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="text-sm text-text-secondary">Beatmap #{slot.beatmapId}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Slot label -->
|
||||||
|
<span class="text-xs font-600 text-text-secondary">
|
||||||
|
{category}{i + 1}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Delete -->
|
||||||
|
<form method="post" action="?/removeSlot" use:enhance>
|
||||||
|
<input type="hidden" name="slotId" value={slot.id} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded p-1 text-text-secondary opacity-0 transition-all hover:bg-red-500/10 hover:text-red-400 group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="rounded-lg border border-dashed border-border py-12 text-center">
|
||||||
|
<p class="text-sm text-text-secondary">No maps yet. Add your first map above.</p>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
71
src/routes/(app)/matches/+page.server.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { match } from '$lib/server/db/schema';
|
||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import { desc } from 'drizzle-orm';
|
||||||
|
import { createMatch } from '$lib/server/match/engine';
|
||||||
|
import { initMatchLobby } from '$lib/server/match/orchestrator';
|
||||||
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ locals }) => {
|
||||||
|
if (!locals.user) redirect(302, '/');
|
||||||
|
|
||||||
|
const matches = await db.query.match.findMany({
|
||||||
|
with: { participants: { with: { team: true } } },
|
||||||
|
orderBy: desc(match.createdAt),
|
||||||
|
limit: 50
|
||||||
|
});
|
||||||
|
|
||||||
|
const teams = await db.query.team.findMany({
|
||||||
|
orderBy: (t, { asc }) => [asc(t.name)]
|
||||||
|
});
|
||||||
|
|
||||||
|
const mappools = await db.query.mappool.findMany({
|
||||||
|
with: { slots: true },
|
||||||
|
orderBy: (m, { desc }) => [desc(m.createdAt)]
|
||||||
|
});
|
||||||
|
|
||||||
|
return { matches, teams, mappools };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
createMatch: async ({ request, locals }) => {
|
||||||
|
if (!locals.user) redirect(302, '/');
|
||||||
|
|
||||||
|
const form = await request.formData();
|
||||||
|
const name = form.get('name')?.toString()?.trim() || 'Custom Match';
|
||||||
|
const team1Id = form.get('team1')?.toString();
|
||||||
|
const team2Id = form.get('team2')?.toString();
|
||||||
|
const mappoolId = form.get('mappool')?.toString();
|
||||||
|
const bestOf = parseInt(form.get('bestOf')?.toString() ?? '7');
|
||||||
|
|
||||||
|
console.log('[Matches] CREATE:', { name, team1Id, team2Id, mappoolId, bestOf });
|
||||||
|
|
||||||
|
if (!team1Id || !team2Id || !mappoolId) {
|
||||||
|
return { error: 'All fields are required' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same team is allowed for testing/dev
|
||||||
|
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = await createMatch({
|
||||||
|
name,
|
||||||
|
config: { bestOf, teamSize: 1, scoringType: 'score' },
|
||||||
|
mappoolId,
|
||||||
|
teams: [team1Id, team2Id],
|
||||||
|
createdBy: locals.user.id
|
||||||
|
});
|
||||||
|
console.log('[Matches] Created:', result.id);
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('[Matches] Error:', e);
|
||||||
|
return { error: e.message };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create IRC lobby in background (don't block redirect)
|
||||||
|
initMatchLobby(result.id).catch((err) => {
|
||||||
|
console.error('[Matches] IRC lobby failed:', err.message);
|
||||||
|
});
|
||||||
|
|
||||||
|
redirect(303, `/matches/${result.id}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
275
src/routes/(app)/matches/+page.svelte
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
|
||||||
|
let { data } = $props();
|
||||||
|
|
||||||
|
let showCreate = $state(false);
|
||||||
|
let createError = $state('');
|
||||||
|
|
||||||
|
const stateConfig: Record<string, { label: string; color: string; dot?: string }> = {
|
||||||
|
CREATED: { label: 'Created', color: 'text-text-secondary border-border' },
|
||||||
|
LOBBY: { label: 'In Lobby', color: 'text-yellow-400 border-yellow-500/30' },
|
||||||
|
ROLLING: { label: 'Rolling', color: 'text-yellow-400 border-yellow-500/30', dot: 'bg-yellow-400' },
|
||||||
|
PICKING: { label: 'Picking', color: 'text-blue-400 border-blue-500/30', dot: 'bg-blue-400' },
|
||||||
|
PLAYING: { label: 'Live', color: 'text-green-400 border-green-500/30', dot: 'bg-green-400' },
|
||||||
|
FINISHED: { label: 'Finished', color: 'text-text-secondary border-border' },
|
||||||
|
CANCELLED: { label: 'Cancelled', color: 'text-red-400 border-red-500/30' }
|
||||||
|
};
|
||||||
|
|
||||||
|
const liveStates = ['LOBBY', 'ROLLING', 'PICKING', 'PLAYING'];
|
||||||
|
|
||||||
|
const liveMatches = $derived(data.matches.filter((m: any) => liveStates.includes(m.state)));
|
||||||
|
const recentMatches = $derived(data.matches.filter((m: any) => !liveStates.includes(m.state)));
|
||||||
|
|
||||||
|
function teamDisplay(m: any) {
|
||||||
|
const p1 = m.participants[0];
|
||||||
|
const p2 = m.participants[1];
|
||||||
|
return { p1, p2 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeAgo(date: string | Date) {
|
||||||
|
const d = new Date(date);
|
||||||
|
const diff = Date.now() - d.getTime();
|
||||||
|
const mins = Math.floor(diff / 60000);
|
||||||
|
if (mins < 1) return 'just now';
|
||||||
|
if (mins < 60) return `${mins}m ago`;
|
||||||
|
const hours = Math.floor(mins / 60);
|
||||||
|
if (hours < 24) return `${hours}h ago`;
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
return `${days}d ago`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-5xl">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-700 tracking-tight">Matches</h1>
|
||||||
|
<p class="mt-1 text-sm text-text-secondary">
|
||||||
|
{data.matches.length} match{data.matches.length !== 1 ? 'es' : ''}
|
||||||
|
{#if liveMatches.length > 0}
|
||||||
|
· <span class="text-green-400">{liveMatches.length} live</span>
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onclick={() => (showCreate = !showCreate)}
|
||||||
|
class="rounded-md bg-accent px-4 py-2 text-sm font-600 text-surface-900 transition-colors hover:bg-accent-hover"
|
||||||
|
>
|
||||||
|
{showCreate ? 'Cancel' : 'New Match'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create Match Modal -->
|
||||||
|
{#if showCreate}
|
||||||
|
<form
|
||||||
|
method="post"
|
||||||
|
action="?/createMatch"
|
||||||
|
use:enhance={() => {
|
||||||
|
createError = '';
|
||||||
|
return async ({ result, update }) => {
|
||||||
|
if (result.type === 'failure' || (result.type === 'success' && (result.data as any)?.error)) {
|
||||||
|
createError = (result.data as any)?.error ?? 'Failed to create match';
|
||||||
|
} else {
|
||||||
|
await update();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
class="mt-4 rounded-lg border border-accent/20 bg-surface-800 p-5"
|
||||||
|
>
|
||||||
|
<h2 class="text-sm font-600">Create Match</h2>
|
||||||
|
|
||||||
|
{#if createError}
|
||||||
|
<p class="mt-2 text-sm text-red-400">{createError}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="mt-4 grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label for="name" class="text-xs font-500 text-text-secondary">Match Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
placeholder="e.g. Semifinals M1"
|
||||||
|
class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary placeholder:text-text-secondary/50 focus:border-accent focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="bestOf" class="text-xs font-500 text-text-secondary">Best Of</label>
|
||||||
|
<select
|
||||||
|
id="bestOf"
|
||||||
|
name="bestOf"
|
||||||
|
class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none"
|
||||||
|
>
|
||||||
|
{#each [3, 5, 7, 9, 11, 13] as n}
|
||||||
|
<option value={n} selected={n === 7}>BO{n} (first to {Math.ceil(n / 2)})</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="team1" class="text-xs font-500 text-text-secondary">Team 1</label>
|
||||||
|
<select
|
||||||
|
id="team1"
|
||||||
|
name="team1"
|
||||||
|
required
|
||||||
|
class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="">Select team...</option>
|
||||||
|
{#each data.teams as t}
|
||||||
|
<option value={t.id}>{t.name}{t.isPersonal ? ' (solo)' : ''}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="team2" class="text-xs font-500 text-text-secondary">Team 2</label>
|
||||||
|
<select
|
||||||
|
id="team2"
|
||||||
|
name="team2"
|
||||||
|
required
|
||||||
|
class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="">Select team...</option>
|
||||||
|
{#each data.teams as t}
|
||||||
|
<option value={t.id}>{t.name}{t.isPersonal ? ' (solo)' : ''}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-span-2">
|
||||||
|
<label for="mappool" class="text-xs font-500 text-text-secondary">Mappool</label>
|
||||||
|
<select
|
||||||
|
id="mappool"
|
||||||
|
name="mappool"
|
||||||
|
required
|
||||||
|
class="mt-1 w-full rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="">Select mappool...</option>
|
||||||
|
{#each data.mappools as p}
|
||||||
|
<option value={p.id}>{p.name} ({p.slots.length} maps)</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 flex justify-end">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded-md bg-accent px-5 py-2 text-sm font-600 text-surface-900 transition-colors hover:bg-accent-hover"
|
||||||
|
>
|
||||||
|
Create & Start
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Live Matches -->
|
||||||
|
{#if liveMatches.length > 0}
|
||||||
|
<div class="mt-6">
|
||||||
|
<h2 class="flex items-center gap-2 text-sm font-600">
|
||||||
|
<div class="relative h-2 w-2">
|
||||||
|
<div class="absolute inset-0 animate-ping rounded-full bg-green-400 opacity-75"></div>
|
||||||
|
<div class="relative h-2 w-2 rounded-full bg-green-400"></div>
|
||||||
|
</div>
|
||||||
|
Live Now
|
||||||
|
</h2>
|
||||||
|
<div class="mt-3 flex flex-col gap-2">
|
||||||
|
{#each liveMatches as m}
|
||||||
|
{@const { p1, p2 } = teamDisplay(m)}
|
||||||
|
{@const sc = stateConfig[m.state]}
|
||||||
|
<a
|
||||||
|
href="/matches/{m.id}"
|
||||||
|
class="group flex items-center gap-4 rounded-lg border border-green-500/20 bg-surface-800 p-4 transition-all hover:border-green-500/40 hover:bg-surface-700"
|
||||||
|
>
|
||||||
|
<div class="flex flex-1 items-center gap-3">
|
||||||
|
{#if p1?.team.avatarUrl}
|
||||||
|
<img src={p1.team.avatarUrl} alt="" class="h-8 w-8 rounded-full" />
|
||||||
|
{/if}
|
||||||
|
<span class="text-sm font-600">{p1?.team.name ?? '?'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-xl font-800 tabular-nums">{p1?.score ?? 0}</span>
|
||||||
|
<span class="text-xs font-600 text-text-secondary">vs</span>
|
||||||
|
<span class="text-xl font-800 tabular-nums">{p2?.score ?? 0}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-1 items-center justify-end gap-3">
|
||||||
|
<span class="text-sm font-600">{p2?.team.name ?? '?'}</span>
|
||||||
|
{#if p2?.team.avatarUrl}
|
||||||
|
<img src={p2.team.avatarUrl} alt="" class="h-8 w-8 rounded-full" />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="flex items-center gap-1.5 rounded border px-2 py-0.5 text-xs font-500 {sc?.color ?? 'border-border'}">
|
||||||
|
{#if sc?.dot}
|
||||||
|
<span class="h-1.5 w-1.5 rounded-full {sc.dot}"></span>
|
||||||
|
{/if}
|
||||||
|
{sc?.label ?? m.state}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- All Matches -->
|
||||||
|
<div class="mt-6">
|
||||||
|
{#if liveMatches.length > 0}
|
||||||
|
<h2 class="text-sm font-600 text-text-secondary">Past Matches</h2>
|
||||||
|
{/if}
|
||||||
|
<div class="mt-3 flex flex-col gap-2">
|
||||||
|
{#each recentMatches as m}
|
||||||
|
{@const { p1, p2 } = teamDisplay(m)}
|
||||||
|
{@const config = m.config as { bestOf: number }}
|
||||||
|
{@const sc = stateConfig[m.state]}
|
||||||
|
<a
|
||||||
|
href="/matches/{m.id}"
|
||||||
|
class="group flex items-center gap-4 rounded-lg border border-border bg-surface-800 p-3 transition-colors hover:border-accent/30 hover:bg-surface-700"
|
||||||
|
>
|
||||||
|
<!-- Match info -->
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-sm font-600">{p1?.team.name ?? '?'}</span>
|
||||||
|
{#if m.state === 'FINISHED'}
|
||||||
|
<span class="text-xs font-700 tabular-nums {(p1?.score ?? 0) > (p2?.score ?? 0) ? 'text-green-400' : 'text-text-secondary'}">{p1?.score ?? 0}</span>
|
||||||
|
<span class="text-xs text-text-secondary">-</span>
|
||||||
|
<span class="text-xs font-700 tabular-nums {(p2?.score ?? 0) > (p1?.score ?? 0) ? 'text-green-400' : 'text-text-secondary'}">{p2?.score ?? 0}</span>
|
||||||
|
{:else}
|
||||||
|
<span class="text-xs text-text-secondary">vs</span>
|
||||||
|
{/if}
|
||||||
|
<span class="text-sm font-600">{p2?.team.name ?? '?'}</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-0.5 text-xs text-text-secondary">
|
||||||
|
{m.name ? `${m.name} · ` : ''}BO{config.bestOf}
|
||||||
|
{#if m.finishedAt}
|
||||||
|
· {timeAgo(m.finishedAt)}
|
||||||
|
{:else if m.createdAt}
|
||||||
|
· {timeAgo(m.createdAt)}
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Winner indicator -->
|
||||||
|
{#if m.state === 'FINISHED' && m.winnerId}
|
||||||
|
{@const winner = m.participants.find((p: any) => p.teamId === m.winnerId)}
|
||||||
|
<span class="text-xs font-500 text-green-400">🏆 {winner?.team.name}</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- State -->
|
||||||
|
<span class="rounded border px-2 py-0.5 text-xs font-500 {sc?.color ?? 'border-border'}">
|
||||||
|
{sc?.label ?? m.state}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
{#if liveMatches.length === 0}
|
||||||
|
<div class="rounded-lg border border-dashed border-border py-12 text-center">
|
||||||
|
<p class="text-sm text-text-secondary">No matches yet. Create one to get started.</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
162
src/routes/(app)/matches/[id]/+page.server.ts
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import { user } from '$lib/server/db/schema';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { getMatchFull, submitRoll, pickMap, cancelMatch } from '$lib/server/match/engine';
|
||||||
|
import { playPickedMap, closeLobby, forceStartGame } from '$lib/server/match/orchestrator';
|
||||||
|
import { getLobby } from '$lib/server/bancho/client';
|
||||||
|
import { error, redirect } from '@sveltejs/kit';
|
||||||
|
import { getBeatmap } from '$lib/server/osu/api';
|
||||||
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ params, locals }) => {
|
||||||
|
if (!locals.user) redirect(302, '/');
|
||||||
|
|
||||||
|
let m;
|
||||||
|
try {
|
||||||
|
m = await getMatchFull(params.id);
|
||||||
|
} catch {
|
||||||
|
error(404, 'Match not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build beatmap metadata cache for all slots in the mappool
|
||||||
|
const beatmapCache: Record<string, any> = {};
|
||||||
|
if (m.mappool?.slots) {
|
||||||
|
await Promise.allSettled(
|
||||||
|
m.mappool.slots.map(async (slot) => {
|
||||||
|
try {
|
||||||
|
const bm = await getBeatmap(slot.beatmapId);
|
||||||
|
beatmapCache[slot.beatmapId] = {
|
||||||
|
title: bm.beatmapset.title,
|
||||||
|
artist: bm.beatmapset.artist,
|
||||||
|
version: bm.version,
|
||||||
|
starRating: bm.difficulty_rating,
|
||||||
|
bpm: bm.bpm,
|
||||||
|
totalLength: bm.total_length,
|
||||||
|
coverUrl: bm.beatmapset.covers['card@2x'],
|
||||||
|
listCoverUrl: bm.beatmapset.covers['list@2x']
|
||||||
|
};
|
||||||
|
} catch { /* skip */ }
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { match: m, beatmapCache, userId: locals.user.id };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
reinvite: async ({ params, locals }) => {
|
||||||
|
if (!locals.user) redirect(302, '/');
|
||||||
|
|
||||||
|
const lobby = getLobby(params.id);
|
||||||
|
if (!lobby) return { error: 'No active IRC lobby for this match' };
|
||||||
|
|
||||||
|
const m = await getMatchFull(params.id);
|
||||||
|
|
||||||
|
const invited: string[] = [];
|
||||||
|
for (const p of m.participants) {
|
||||||
|
for (const pl of p.players) {
|
||||||
|
const u = await db.query.user.findFirst({ where: eq(user.id, pl.userId) });
|
||||||
|
if (u?.name) {
|
||||||
|
await lobby.invite(u.name);
|
||||||
|
invited.push(u.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { reinvited: invited };
|
||||||
|
},
|
||||||
|
|
||||||
|
roll: async ({ params, locals }) => {
|
||||||
|
if (!locals.user) redirect(302, '/');
|
||||||
|
|
||||||
|
const m = await getMatchFull(params.id);
|
||||||
|
|
||||||
|
// Find the participant linked to the current user
|
||||||
|
let myParticipant = m.participants.find((p) =>
|
||||||
|
p.players.some((pl) => pl.userId === locals.user!.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!myParticipant) {
|
||||||
|
return { error: 'You are not in this match' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// For same-team testing: if user is on both sides, pick the one that hasn't rolled yet
|
||||||
|
if (myParticipant.rollValue !== null) {
|
||||||
|
const unrolled = m.participants.find((p) => p.rollValue === null);
|
||||||
|
if (unrolled) {
|
||||||
|
myParticipant = unrolled;
|
||||||
|
} else {
|
||||||
|
return { error: 'All participants have already rolled' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = Math.floor(Math.random() * 100) + 1;
|
||||||
|
await submitRoll(params.id, myParticipant.id, value);
|
||||||
|
|
||||||
|
// Announce in IRC lobby
|
||||||
|
const lobby = getLobby(params.id);
|
||||||
|
if (lobby) {
|
||||||
|
lobby.chat(`${myParticipant.team?.name ?? 'Player'} rolled ${value} (via web)`).catch(() => {});
|
||||||
|
|
||||||
|
// Check if all rolled → announce pick order
|
||||||
|
const updated = await getMatchFull(params.id);
|
||||||
|
if (updated.state === 'PICKING') {
|
||||||
|
const sorted = [...updated.participants].sort(
|
||||||
|
(a: any, b: any) => (a.pickOrder ?? 99) - (b.pickOrder ?? 99)
|
||||||
|
);
|
||||||
|
lobby.chat(
|
||||||
|
`Rolls complete! ${sorted[0]?.team.name} picks first. Use !pick <slot> (e.g. !pick NM1) or pick in web UI.`
|
||||||
|
).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { rolled: value, participantId: myParticipant.id };
|
||||||
|
},
|
||||||
|
|
||||||
|
pick: async ({ params, request, locals }) => {
|
||||||
|
if (!locals.user) redirect(302, '/');
|
||||||
|
|
||||||
|
const form = await request.formData();
|
||||||
|
const slotId = form.get('slotId')?.toString();
|
||||||
|
if (!slotId) return { error: 'No map selected' };
|
||||||
|
|
||||||
|
const m = await getMatchFull(params.id);
|
||||||
|
|
||||||
|
// Find the expected picker based on pick order and game count
|
||||||
|
const sorted = [...m.participants].sort(
|
||||||
|
(a, b) => (a.pickOrder ?? 99) - (b.pickOrder ?? 99)
|
||||||
|
);
|
||||||
|
const expectedIdx = m.games.length % sorted.length;
|
||||||
|
const expectedPicker = sorted[expectedIdx];
|
||||||
|
|
||||||
|
if (!expectedPicker) return { error: 'Cannot determine picker' };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const game = await pickMap(params.id, expectedPicker.id, slotId);
|
||||||
|
|
||||||
|
// Set map + mods in IRC lobby and start game (background)
|
||||||
|
playPickedMap(params.id, game.id).catch((err) =>
|
||||||
|
console.error('[Match] IRC play failed:', err.message)
|
||||||
|
);
|
||||||
|
|
||||||
|
return { picked: true, gameId: game.id };
|
||||||
|
} catch (e: any) {
|
||||||
|
return { error: e.message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
forceStart: async ({ params, locals }) => {
|
||||||
|
if (!locals.user) redirect(302, '/');
|
||||||
|
await forceStartGame(params.id);
|
||||||
|
return { started: true };
|
||||||
|
},
|
||||||
|
|
||||||
|
cancel: async ({ params, locals }) => {
|
||||||
|
if (!locals.user) redirect(302, '/');
|
||||||
|
|
||||||
|
await cancelMatch(params.id);
|
||||||
|
await closeLobby(params.id);
|
||||||
|
redirect(303, '/matches');
|
||||||
|
}
|
||||||
|
};
|
||||||
410
src/routes/(app)/matches/[id]/+page.svelte
Normal file
@@ -0,0 +1,410 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import { invalidateAll } from '$app/navigation';
|
||||||
|
|
||||||
|
let { data } = $props();
|
||||||
|
|
||||||
|
let m = $derived(data.match);
|
||||||
|
let p1 = $derived(m.participants[0]);
|
||||||
|
let p2 = $derived(m.participants[1]);
|
||||||
|
|
||||||
|
const config = $derived(m.config as { bestOf: number; teamSize: number; scoringType: string });
|
||||||
|
const winsNeeded = $derived(Math.ceil(config.bestOf / 2));
|
||||||
|
|
||||||
|
const playedSlotIds = $derived(new Set(m.games.map((g: any) => g.mappoolSlotId)));
|
||||||
|
|
||||||
|
// Pick order
|
||||||
|
const sortedByPick = $derived(
|
||||||
|
[...m.participants].sort((a: any, b: any) => (a.pickOrder ?? 99) - (b.pickOrder ?? 99))
|
||||||
|
);
|
||||||
|
const expectedPickerIdx = $derived(m.games.length % sortedByPick.length);
|
||||||
|
const expectedPicker = $derived(sortedByPick[expectedPickerIdx]);
|
||||||
|
|
||||||
|
const canRoll = $derived(
|
||||||
|
m.state === 'ROLLING' && m.participants.some((p: any) => p.rollValue === null)
|
||||||
|
);
|
||||||
|
|
||||||
|
const catColors: Record<string, string> = {
|
||||||
|
NM: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
|
||||||
|
HD: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30',
|
||||||
|
HR: 'bg-red-500/20 text-red-400 border-red-500/30',
|
||||||
|
DT: 'bg-purple-500/20 text-purple-400 border-purple-500/30',
|
||||||
|
FM: 'bg-green-500/20 text-green-400 border-green-500/30',
|
||||||
|
TB: 'bg-pink-500/20 text-pink-400 border-pink-500/30'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Group mappool slots by category
|
||||||
|
const groupedSlots = $derived(() => {
|
||||||
|
if (!m.mappool?.slots) return {};
|
||||||
|
const groups: Record<string, any[]> = {};
|
||||||
|
for (const slot of m.mappool.slots) {
|
||||||
|
if (!groups[slot.category]) groups[slot.category] = [];
|
||||||
|
groups[slot.category].push(slot);
|
||||||
|
}
|
||||||
|
for (const cat of Object.keys(groups)) {
|
||||||
|
groups[cat].sort((a: any, b: any) => a.orderInCategory - b.orderInCategory);
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
});
|
||||||
|
|
||||||
|
let rolling = $state(false);
|
||||||
|
let picking = $state(false);
|
||||||
|
let pickError = $state('');
|
||||||
|
let forceStarting = $state(false);
|
||||||
|
|
||||||
|
// Poll while match is active (every 3s)
|
||||||
|
let pollInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (['CREATED', 'LOBBY', 'ROLLING', 'PICKING', 'PLAYING'].includes(m.state)) {
|
||||||
|
pollInterval = setInterval(() => invalidateAll(), 3000);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
if (pollInterval) clearInterval(pollInterval);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function formatLength(seconds: number): string {
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
const secs = seconds % 60;
|
||||||
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stateLabel(s: string) {
|
||||||
|
return ({ CREATED: 'Waiting', LOBBY: 'In Lobby', ROLLING: 'Rolling', PICKING: 'Pick Phase', PLAYING: 'Playing', FINISHED: 'Finished', CANCELLED: 'Cancelled' })[s] ?? s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stateBorder(s: string) {
|
||||||
|
return ({ ROLLING: 'border-yellow-500/30', PICKING: 'border-blue-500/30', PLAYING: 'border-green-500/30', FINISHED: 'border-accent/30' })[s] ?? 'border-border';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-5xl">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<a href="/matches" class="text-text-secondary transition-colors hover:text-text-primary">←</a>
|
||||||
|
<div class="flex-1">
|
||||||
|
<h1 class="text-xl font-700 tracking-tight">{m.name || 'Match'}</h1>
|
||||||
|
<p class="mt-0.5 text-xs text-text-secondary">
|
||||||
|
Best of {config.bestOf} · First to {winsNeeded}
|
||||||
|
{#if m.osuLobbyId}
|
||||||
|
· <a href="https://osu.ppy.sh/mp/{m.osuLobbyId}" target="_blank" class="text-accent hover:underline">osu! mp/{m.osuLobbyId}</a>
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span class="rounded border px-2.5 py-1 text-xs font-600 {stateBorder(m.state)}">{stateLabel(m.state)}</span>
|
||||||
|
|
||||||
|
{#if !['FINISHED', 'CANCELLED'].includes(m.state)}
|
||||||
|
<form method="post" action="?/cancel" use:enhance>
|
||||||
|
<button type="submit" onclick={(e) => { if (!confirm('Cancel this match?')) e.preventDefault() }} class="rounded-md border border-red-500/30 px-3 py-1.5 text-xs text-red-400 hover:bg-red-500/10">Cancel</button>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Scoreboard -->
|
||||||
|
{#if p1 && p2}
|
||||||
|
<div class="mt-6 flex items-center gap-4">
|
||||||
|
<!-- Team 1 -->
|
||||||
|
<div class="flex flex-1 items-center gap-4 rounded-lg border p-4 {m.winnerId === p1.teamId ? 'border-green-500/40 bg-green-500/5' : 'border-border bg-surface-800'}">
|
||||||
|
{#if p1.team.avatarUrl}
|
||||||
|
<img src={p1.team.avatarUrl} alt="" class="h-10 w-10 rounded-full" />
|
||||||
|
{/if}
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="text-sm font-600">{p1.team.name}</p>
|
||||||
|
{#if p1.rollValue != null}
|
||||||
|
<p class="text-xs text-text-secondary">Roll: {p1.rollValue}{p1.pickOrder === 1 ? ' ★' : ''}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<span class="text-3xl font-800 tabular-nums {p1.score >= winsNeeded ? 'text-green-400' : 'text-text-primary'}">{p1.score}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="text-lg font-700 text-text-secondary">vs</span>
|
||||||
|
|
||||||
|
<!-- Team 2 -->
|
||||||
|
<div class="flex flex-1 items-center gap-4 rounded-lg border p-4 {m.winnerId === p2.teamId ? 'border-green-500/40 bg-green-500/5' : 'border-border bg-surface-800'}">
|
||||||
|
<span class="text-3xl font-800 tabular-nums {p2.score >= winsNeeded ? 'text-green-400' : 'text-text-primary'}">{p2.score}</span>
|
||||||
|
<div class="flex-1 text-right">
|
||||||
|
<p class="text-sm font-600">{p2.team.name}</p>
|
||||||
|
{#if p2.rollValue != null}
|
||||||
|
<p class="text-xs text-text-secondary">{p2.pickOrder === 1 ? '★ ' : ''}Roll: {p2.rollValue}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if p2.team.avatarUrl}
|
||||||
|
<img src={p2.team.avatarUrl} alt="" class="h-10 w-10 rounded-full" />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Re-invite players -->
|
||||||
|
{#if ['LOBBY', 'ROLLING', 'PICKING', 'PLAYING'].includes(m.state)}
|
||||||
|
<div class="mt-3 flex justify-center">
|
||||||
|
<form method="post" action="?/reinvite" use:enhance={() => {
|
||||||
|
return async ({ result, update }) => {
|
||||||
|
if (result.type === 'success' && (result.data as any)?.reinvited) {
|
||||||
|
// Brief visual feedback — just reload
|
||||||
|
await update();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}}>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded-md border border-border px-3 py-1.5 text-xs text-text-secondary transition-colors hover:border-accent/40 hover:text-accent"
|
||||||
|
>
|
||||||
|
📨 Re-invite players to lobby
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- ROLLING MODAL -->
|
||||||
|
{#if m.state === 'ROLLING'}
|
||||||
|
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm">
|
||||||
|
<div class="w-full max-w-md rounded-xl border border-yellow-500/30 bg-surface-800 p-8 text-center shadow-2xl">
|
||||||
|
<h2 class="text-lg font-700">🎲 Roll Phase</h2>
|
||||||
|
<p class="mt-2 text-sm text-text-secondary">Highest roll picks first</p>
|
||||||
|
|
||||||
|
<div class="mt-6 flex justify-center gap-6">
|
||||||
|
{#each m.participants as p}
|
||||||
|
<div class="rounded-lg border border-border bg-surface-700 px-6 py-4">
|
||||||
|
<p class="text-xs text-text-secondary">{p.team.name}</p>
|
||||||
|
{#if p.rollValue != null}
|
||||||
|
<p class="mt-1 text-2xl font-800 text-yellow-400">{p.rollValue}</p>
|
||||||
|
{:else}
|
||||||
|
<p class="mt-1 text-2xl font-800 text-text-secondary/30">—</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if canRoll}
|
||||||
|
<form method="post" action="?/roll" use:enhance={() => {
|
||||||
|
rolling = true;
|
||||||
|
return async ({ update }) => { rolling = false; await update(); };
|
||||||
|
}}>
|
||||||
|
<button type="submit" disabled={rolling} class="mt-6 rounded-md bg-accent px-8 py-3 text-sm font-600 text-surface-900 transition-colors hover:bg-accent-hover disabled:opacity-50">
|
||||||
|
{rolling ? 'Rolling...' : '🎲 Roll!'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p class="mt-3 text-xs text-text-secondary">or type <code class="rounded bg-surface-700 px-1.5 py-0.5 font-mono text-accent">!roll</code> in osu! chat</p>
|
||||||
|
{:else}
|
||||||
|
<p class="mt-6 text-sm text-text-secondary animate-pulse">Waiting for all rolls...</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- PICK MODAL -->
|
||||||
|
{#if m.state === 'PICKING'}
|
||||||
|
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm">
|
||||||
|
<div class="w-full max-w-3xl max-h-[80vh] overflow-y-auto rounded-xl border border-blue-500/30 bg-surface-800 p-6 shadow-2xl">
|
||||||
|
<div class="text-center">
|
||||||
|
<h2 class="text-lg font-700">🎯 Pick a Map</h2>
|
||||||
|
<p class="mt-1 text-sm">
|
||||||
|
<span class="font-600 text-accent">{expectedPicker?.team.name}</span>'s turn to pick
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-xs text-text-secondary">
|
||||||
|
or type <code class="rounded bg-surface-700 px-1.5 py-0.5 font-mono text-accent">!pick NM1</code> in osu! chat
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if pickError}
|
||||||
|
<p class="mt-3 text-center text-sm text-red-400">{pickError}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="mt-6 flex flex-col gap-4">
|
||||||
|
{#each Object.entries(groupedSlots()) as [category, slots]}
|
||||||
|
<div>
|
||||||
|
<span class="mb-2 inline-block rounded border px-2 py-0.5 text-xs font-600 {catColors[category] ?? 'border-border'}">
|
||||||
|
{category}
|
||||||
|
</span>
|
||||||
|
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||||
|
{#each slots as slot, i}
|
||||||
|
{@const bm = data.beatmapCache[slot.beatmapId]}
|
||||||
|
{@const isPlayed = playedSlotIds.has(slot.id)}
|
||||||
|
|
||||||
|
<form method="post" action="?/pick" use:enhance={() => {
|
||||||
|
picking = true;
|
||||||
|
pickError = '';
|
||||||
|
return async ({ result, update }) => {
|
||||||
|
picking = false;
|
||||||
|
if (result.type === 'failure' || (result.type === 'success' && (result.data as any)?.error)) {
|
||||||
|
pickError = (result.data as any)?.error ?? 'Pick failed';
|
||||||
|
} else {
|
||||||
|
await update();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}}>
|
||||||
|
<input type="hidden" name="slotId" value={slot.id} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isPlayed || picking}
|
||||||
|
class="flex w-full items-center gap-3 rounded-lg border p-2 text-left transition-all {isPlayed
|
||||||
|
? 'border-border/50 bg-surface-900 opacity-30 cursor-not-allowed'
|
||||||
|
: 'border-border bg-surface-700 hover:border-accent hover:bg-surface-600 cursor-pointer'}"
|
||||||
|
>
|
||||||
|
{#if bm?.listCoverUrl}
|
||||||
|
<img src={bm.listCoverUrl} alt="" class="h-10 w-20 rounded object-cover" />
|
||||||
|
{:else}
|
||||||
|
<div class="flex h-10 w-20 items-center justify-center rounded bg-surface-600 text-xs text-text-secondary">?</div>
|
||||||
|
{/if}
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
{#if bm}
|
||||||
|
<p class="truncate text-xs font-500">{bm.artist} - {bm.title}</p>
|
||||||
|
<p class="text-xs text-text-secondary">[{bm.version}] · ★{bm.starRating.toFixed(1)} · {bm.bpm}bpm</p>
|
||||||
|
{:else}
|
||||||
|
<p class="text-xs text-text-secondary">#{slot.beatmapId}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<span class="text-xs font-600 text-text-secondary">{category}{slot.orderInCategory}</span>
|
||||||
|
{#if isPlayed}<span class="text-xs text-text-secondary">✓</span>{/if}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- PLAYING INDICATOR -->
|
||||||
|
{#if m.state === 'PLAYING'}
|
||||||
|
{@const currentGame = m.games[m.games.length - 1]}
|
||||||
|
{@const bm = currentGame ? data.beatmapCache[currentGame.slot?.beatmapId] : null}
|
||||||
|
<div class="mt-6 rounded-lg border border-green-500/30 bg-green-500/5 p-5 text-center">
|
||||||
|
<div class="flex items-center justify-center gap-2">
|
||||||
|
<div class="relative h-3 w-3">
|
||||||
|
<div class="absolute inset-0 animate-ping rounded-full bg-green-400 opacity-75"></div>
|
||||||
|
<div class="relative h-3 w-3 rounded-full bg-green-400"></div>
|
||||||
|
</div>
|
||||||
|
<span class="text-sm font-600 text-green-400">Now Playing</span>
|
||||||
|
</div>
|
||||||
|
{#if bm}
|
||||||
|
<p class="mt-2 text-sm">{bm.artist} - {bm.title} <span class="text-text-secondary">[{bm.version}]</span></p>
|
||||||
|
<p class="mt-1 text-xs text-text-secondary">★{bm.starRating.toFixed(1)} · {bm.bpm}bpm · {formatLength(bm.totalLength)}</p>
|
||||||
|
{/if}
|
||||||
|
<p class="mt-3 text-xs text-text-secondary animate-pulse">
|
||||||
|
Waiting for players to ready up & play in osu!...
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Force Start button -->
|
||||||
|
<form method="post" action="?/forceStart" use:enhance={() => {
|
||||||
|
forceStarting = true;
|
||||||
|
return async ({ update }) => { forceStarting = false; await update(); };
|
||||||
|
}}>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={forceStarting}
|
||||||
|
class="mt-4 rounded-md border border-yellow-500/30 px-4 py-2 text-xs font-600 text-yellow-400 transition-colors hover:bg-yellow-500/10 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{forceStarting ? 'Starting...' : 'Force Start (skip ready)'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- FINISHED MODAL -->
|
||||||
|
{#if m.state === 'FINISHED'}
|
||||||
|
{@const winner = m.participants.find((p: any) => p.teamId === m.winnerId)}
|
||||||
|
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm">
|
||||||
|
<div class="w-full max-w-md rounded-xl border border-accent/30 bg-surface-800 p-8 text-center shadow-2xl">
|
||||||
|
<div class="text-4xl">🏆</div>
|
||||||
|
<h2 class="mt-4 text-2xl font-800">
|
||||||
|
<span class="text-accent">{winner?.team.name}</span> wins!
|
||||||
|
</h2>
|
||||||
|
<p class="mt-2 text-lg font-700 tabular-nums text-text-secondary">
|
||||||
|
{p1?.score} – {p2?.score}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Game summary -->
|
||||||
|
<div class="mt-6 flex flex-col gap-1">
|
||||||
|
{#each m.games as game}
|
||||||
|
{@const bm = data.beatmapCache[game.slot?.beatmapId]}
|
||||||
|
{@const winnerP = m.participants.find((p: any) => p.id === game.winnerParticipantId)}
|
||||||
|
<div class="flex items-center gap-2 rounded bg-surface-700 px-3 py-1.5 text-xs">
|
||||||
|
<span class="font-600 {catColors[game.slot?.category]?.split(' ')[1] ?? 'text-text-secondary'}">{game.slot?.category}{game.slot?.orderInCategory}</span>
|
||||||
|
<span class="flex-1 truncate text-text-secondary">{bm ? `${bm.artist} - ${bm.title}` : `#${game.slot?.beatmapId}`}</span>
|
||||||
|
<span class="font-600 {winnerP?.id === p1?.id ? 'text-blue-400' : 'text-red-400'}">{winnerP?.team.name}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a href="/" class="mt-6 inline-block rounded-md bg-accent px-6 py-2.5 text-sm font-600 text-surface-900 transition-colors hover:bg-accent-hover">
|
||||||
|
Back to Dashboard
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- GAME HISTORY -->
|
||||||
|
{#if m.games.length > 0}
|
||||||
|
<div class="mt-6">
|
||||||
|
<h2 class="text-sm font-600">Games Played</h2>
|
||||||
|
<div class="mt-3 flex flex-col gap-2">
|
||||||
|
{#each m.games as game}
|
||||||
|
{@const bm = data.beatmapCache[game.slot?.beatmapId]}
|
||||||
|
{@const pickerTeam = m.participants.find((p: any) => p.id === game.pickedByParticipantId)?.team}
|
||||||
|
{@const winnerTeam = m.participants.find((p: any) => p.id === game.winnerParticipantId)?.team}
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3 rounded-lg border border-border bg-surface-800 p-3">
|
||||||
|
<span class="w-6 text-center text-xs font-600 text-text-secondary">{game.gameNumber}</span>
|
||||||
|
{#if bm?.listCoverUrl}
|
||||||
|
<img src={bm.listCoverUrl} alt="" class="h-8 w-16 rounded object-cover" />
|
||||||
|
{/if}
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
{#if bm}
|
||||||
|
<p class="truncate text-sm font-500">{bm.artist} - {bm.title} <span class="text-text-secondary">[{bm.version}]</span></p>
|
||||||
|
{/if}
|
||||||
|
<div class="flex items-center gap-2 text-xs text-text-secondary">
|
||||||
|
<span class="rounded border px-1.5 py-0.5 {catColors[game.slot?.category] ?? 'border-border'}">{game.slot?.category}{game.slot?.orderInCategory}</span>
|
||||||
|
{#if pickerTeam}<span>Picked by {pickerTeam.name}</span>{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if game.state === 'FINISHED' && p1 && p2}
|
||||||
|
{@const p1Score = game.scores.filter((s: any) => s.player?.participantId === p1.id).reduce((sum: number, s: any) => sum + s.score, 0)}
|
||||||
|
{@const p2Score = game.scores.filter((s: any) => s.player?.participantId === p2.id).reduce((sum: number, s: any) => sum + s.score, 0)}
|
||||||
|
<div class="flex items-center gap-2 font-mono text-sm tabular-nums">
|
||||||
|
<span class="{p1Score > p2Score ? 'font-700 text-green-400' : 'text-text-secondary'}">{p1Score.toLocaleString()}</span>
|
||||||
|
<span class="text-text-secondary">-</span>
|
||||||
|
<span class="{p2Score > p1Score ? 'font-700 text-green-400' : 'text-text-secondary'}">{p2Score.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
{:else if game.state === 'PLAYING'}
|
||||||
|
<span class="text-xs text-green-400 animate-pulse">Live</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if winnerTeam}
|
||||||
|
<span class="text-xs text-green-400">✓ {winnerTeam.name}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- CREATED / LOBBY waiting states -->
|
||||||
|
{#if m.state === 'CREATED'}
|
||||||
|
<div class="mt-6 rounded-lg border border-border bg-surface-800 p-6 text-center">
|
||||||
|
<p class="text-sm text-text-secondary animate-pulse">Creating osu! lobby...</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if m.state === 'LOBBY'}
|
||||||
|
<div class="mt-6 rounded-lg border border-yellow-500/30 bg-yellow-500/5 p-6 text-center">
|
||||||
|
<p class="text-sm text-text-secondary">Lobby created. Waiting for players to join...</p>
|
||||||
|
{#if m.osuLobbyId}
|
||||||
|
<p class="mt-2 text-xs text-text-secondary">
|
||||||
|
<a href="https://osu.ppy.sh/mp/{m.osuLobbyId}" target="_blank" class="text-accent hover:underline">osu! mp/{m.osuLobbyId}</a>
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if m.state === 'CANCELLED'}
|
||||||
|
<div class="mt-6 rounded-lg border border-red-500/30 bg-red-500/5 p-6 text-center">
|
||||||
|
<p class="text-sm text-red-400">This match was cancelled.</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
46
src/routes/(app)/settings/+page.server.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
// src/routes/(app)/settings/+page.server.ts
|
||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import { auth } from '$lib/server/auth';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { playerRating } from '$lib/server/db/schema';
|
||||||
|
import { account } from '$lib/server/db/auth.schema';
|
||||||
|
import { eq, and } from 'drizzle-orm';
|
||||||
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ locals }) => {
|
||||||
|
if (!locals.user) redirect(302, '/');
|
||||||
|
|
||||||
|
// Get player rating
|
||||||
|
const rating = await db.query.playerRating.findFirst({
|
||||||
|
where: eq(playerRating.userId, locals.user.id)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get linked osu! account info
|
||||||
|
const osuAccount = await db.query.account.findFirst({
|
||||||
|
where: and(eq(account.userId, locals.user.id), eq(account.providerId, 'osu'))
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
profile: {
|
||||||
|
id: locals.user.id,
|
||||||
|
name: locals.user.name,
|
||||||
|
email: locals.user.email,
|
||||||
|
image: locals.user.image,
|
||||||
|
role: locals.user.role ?? 'player',
|
||||||
|
createdAt: locals.user.createdAt
|
||||||
|
},
|
||||||
|
rating: rating
|
||||||
|
? { elo: rating.elo, wins: rating.wins, losses: rating.losses }
|
||||||
|
: { elo: 1000, wins: 0, losses: 0 },
|
||||||
|
hasOsuLinked: !!osuAccount
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
logout: async (event) => {
|
||||||
|
await auth.api.signOut({
|
||||||
|
headers: event.request.headers
|
||||||
|
});
|
||||||
|
redirect(302, '/');
|
||||||
|
}
|
||||||
|
};
|
||||||
114
src/routes/(app)/settings/+page.svelte
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
|
||||||
|
let { data } = $props();
|
||||||
|
|
||||||
|
const roleBadge: Record<string, { label: string; color: string }> = {
|
||||||
|
player: { label: 'Player', color: 'bg-surface-600 text-text-secondary' },
|
||||||
|
referee: { label: 'Referee', color: 'bg-yellow-500/20 text-yellow-400' },
|
||||||
|
admin: { label: 'Admin', color: 'bg-red-500/20 text-red-400' }
|
||||||
|
};
|
||||||
|
|
||||||
|
const badge = $derived(roleBadge[data.profile.role] ?? roleBadge.player);
|
||||||
|
|
||||||
|
const winRate = $derived(
|
||||||
|
data.rating.wins + data.rating.losses > 0
|
||||||
|
? ((data.rating.wins / (data.rating.wins + data.rating.losses)) * 100).toFixed(1)
|
||||||
|
: '—'
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-2xl">
|
||||||
|
<h1 class="text-2xl font-700 tracking-tight">Settings</h1>
|
||||||
|
<p class="mt-1 text-sm text-text-secondary">Your profile and account settings</p>
|
||||||
|
|
||||||
|
<!-- Profile Card -->
|
||||||
|
<div class="mt-6 rounded-lg border border-border bg-surface-800 p-6">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
{#if data.profile.image}
|
||||||
|
<img src={data.profile.image} alt={data.profile.name} class="h-16 w-16 rounded-full" />
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="flex h-16 w-16 items-center justify-center rounded-full bg-surface-600 text-xl font-700 text-text-secondary"
|
||||||
|
>
|
||||||
|
{data.profile.name.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<h2 class="text-lg font-700">{data.profile.name}</h2>
|
||||||
|
<span class="rounded px-2 py-0.5 text-xs font-600 {badge.color}">
|
||||||
|
{badge.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-0.5 text-sm text-text-secondary">{data.profile.email}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Connected Accounts -->
|
||||||
|
<div class="mt-6 border-t border-border pt-4">
|
||||||
|
<h3 class="text-xs font-600 text-text-secondary uppercase tracking-wider">Connected Accounts</h3>
|
||||||
|
<div class="mt-3 flex items-center gap-3 rounded-md bg-surface-700 px-4 py-3">
|
||||||
|
<span class="text-lg">🎮</span>
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="text-sm font-500">osu!</p>
|
||||||
|
<p class="text-xs text-text-secondary">
|
||||||
|
{#if data.hasOsuLinked}
|
||||||
|
Connected as {data.profile.name}
|
||||||
|
{:else}
|
||||||
|
Not connected
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{#if data.hasOsuLinked}
|
||||||
|
<span class="rounded bg-green-500/20 px-2 py-0.5 text-xs font-500 text-green-400">Linked</span>
|
||||||
|
{:else}
|
||||||
|
<a
|
||||||
|
href="/api/auth/osu/login"
|
||||||
|
class="rounded bg-accent px-3 py-1 text-xs font-600 text-surface-900 hover:bg-accent-hover"
|
||||||
|
>
|
||||||
|
Connect
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats Card -->
|
||||||
|
<div class="mt-4 rounded-lg border border-border bg-surface-800 p-6">
|
||||||
|
<h3 class="text-xs font-600 text-text-secondary uppercase tracking-wider">Ranked Stats</h3>
|
||||||
|
<div class="mt-4 grid grid-cols-4 gap-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-2xl font-800 tabular-nums text-accent">{data.rating.elo}</p>
|
||||||
|
<p class="mt-1 text-xs text-text-secondary">ELO Rating</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-2xl font-800 tabular-nums text-green-400">{data.rating.wins}</p>
|
||||||
|
<p class="mt-1 text-xs text-text-secondary">Wins</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-2xl font-800 tabular-nums text-red-400">{data.rating.losses}</p>
|
||||||
|
<p class="mt-1 text-xs text-text-secondary">Losses</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-2xl font-800 tabular-nums text-text-primary">{winRate}{winRate !== '—' ? '%' : ''}</p>
|
||||||
|
<p class="mt-1 text-xs text-text-secondary">Win Rate</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Danger Zone -->
|
||||||
|
<div class="mt-4 rounded-lg border border-border bg-surface-800 p-6">
|
||||||
|
<h3 class="text-xs font-600 text-red-400 uppercase tracking-wider">Account</h3>
|
||||||
|
<div class="mt-4">
|
||||||
|
<form method="post" action="?/logout" use:enhance>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded-md border border-red-500/30 px-4 py-2 text-sm font-500 text-red-400 transition-colors hover:bg-red-500/10"
|
||||||
|
>
|
||||||
|
Sign Out
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
120
src/routes/(app)/teams/+page.server.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { team, teamMember, user } from '$lib/server/db/schema';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ locals }) => {
|
||||||
|
if (!locals.user) redirect(302, '/');
|
||||||
|
|
||||||
|
const teams = await db.query.team.findMany({
|
||||||
|
with: {
|
||||||
|
members: true
|
||||||
|
},
|
||||||
|
orderBy: (t, { desc }) => [desc(t.createdAt)]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch member user details
|
||||||
|
const teamsWithUsers = await Promise.all(
|
||||||
|
teams.map(async (t) => {
|
||||||
|
const membersWithUsers = await Promise.all(
|
||||||
|
t.members.map(async (m) => {
|
||||||
|
const u = await db.query.user.findFirst({
|
||||||
|
where: eq(user.id, m.userId)
|
||||||
|
});
|
||||||
|
return { ...m, user: u ? { id: u.id, name: u.name, image: u.image } : null };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return { ...t, members: membersWithUsers };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return { teams: teamsWithUsers, userId: locals.user.id };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
create: async ({ request, locals }) => {
|
||||||
|
if (!locals.user) redirect(302, '/');
|
||||||
|
|
||||||
|
const form = await request.formData();
|
||||||
|
const name = form.get('name')?.toString()?.trim();
|
||||||
|
|
||||||
|
if (!name) return { error: 'Team name is required' };
|
||||||
|
|
||||||
|
const [created] = await db
|
||||||
|
.insert(team)
|
||||||
|
.values({
|
||||||
|
name,
|
||||||
|
ownerId: locals.user.id,
|
||||||
|
isPersonal: false
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
// Add creator as captain
|
||||||
|
await db.insert(teamMember).values({
|
||||||
|
teamId: created.id,
|
||||||
|
userId: locals.user.id,
|
||||||
|
role: 'captain'
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
|
||||||
|
addMember: async ({ request, locals }) => {
|
||||||
|
if (!locals.user) redirect(302, '/');
|
||||||
|
|
||||||
|
const form = await request.formData();
|
||||||
|
const teamId = form.get('teamId')?.toString();
|
||||||
|
const username = form.get('username')?.toString()?.trim();
|
||||||
|
|
||||||
|
if (!teamId || !username) return { error: 'Team and username are required' };
|
||||||
|
|
||||||
|
// Find user by osu! username
|
||||||
|
const u = await db.query.user.findFirst({
|
||||||
|
where: eq(user.name, username)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!u) return { error: `User "${username}" not found. They need to log in first.` };
|
||||||
|
|
||||||
|
// Check not already member
|
||||||
|
const existing = await db.query.teamMember.findFirst({
|
||||||
|
where: (m, { and, eq }) => and(eq(m.teamId, teamId), eq(m.userId, u.id))
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) return { error: `${username} is already on this team` };
|
||||||
|
|
||||||
|
await db.insert(teamMember).values({
|
||||||
|
teamId,
|
||||||
|
userId: u.id,
|
||||||
|
role: 'member'
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
|
||||||
|
removeMember: async ({ request, locals }) => {
|
||||||
|
if (!locals.user) redirect(302, '/');
|
||||||
|
|
||||||
|
const form = await request.formData();
|
||||||
|
const memberId = form.get('memberId')?.toString();
|
||||||
|
if (!memberId) return { error: 'Missing member ID' };
|
||||||
|
|
||||||
|
await db.delete(teamMember).where(eq(teamMember.id, memberId));
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteTeam: async ({ request, locals }) => {
|
||||||
|
if (!locals.user) redirect(302, '/');
|
||||||
|
|
||||||
|
const form = await request.formData();
|
||||||
|
const teamId = form.get('teamId')?.toString();
|
||||||
|
if (!teamId) return { error: 'Missing team ID' };
|
||||||
|
|
||||||
|
// Only allow owner to delete
|
||||||
|
const t = await db.query.team.findFirst({ where: eq(team.id, teamId) });
|
||||||
|
if (!t || t.ownerId !== locals.user.id) return { error: 'Not authorized' };
|
||||||
|
|
||||||
|
await db.delete(team).where(eq(team.id, teamId));
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
};
|
||||||
229
src/routes/(app)/teams/+page.svelte
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import { invalidateAll } from '$app/navigation';
|
||||||
|
|
||||||
|
let { data } = $props();
|
||||||
|
|
||||||
|
let creating = $state(false);
|
||||||
|
let expandedTeam = $state<string | null>(null);
|
||||||
|
let addingMemberTo = $state<string | null>(null);
|
||||||
|
let memberUsername = $state('');
|
||||||
|
let actionError = $state('');
|
||||||
|
|
||||||
|
const myTeams = $derived(data.teams.filter((t: any) =>
|
||||||
|
t.members.some((m: any) => m.userId === data.userId)
|
||||||
|
));
|
||||||
|
const otherTeams = $derived(data.teams.filter((t: any) =>
|
||||||
|
!t.members.some((m: any) => m.userId === data.userId)
|
||||||
|
));
|
||||||
|
|
||||||
|
function isOwner(t: any) {
|
||||||
|
return t.ownerId === data.userId;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-4xl">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-700 tracking-tight">Teams</h1>
|
||||||
|
<p class="mt-1 text-sm text-text-secondary">
|
||||||
|
{data.teams.length} team{data.teams.length !== 1 ? 's' : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onclick={() => (creating = !creating)}
|
||||||
|
class="rounded-md bg-accent px-4 py-2 text-sm font-600 text-surface-900 transition-colors hover:bg-accent-hover"
|
||||||
|
>
|
||||||
|
{creating ? 'Cancel' : 'New Team'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create Team -->
|
||||||
|
{#if creating}
|
||||||
|
<form
|
||||||
|
method="post"
|
||||||
|
action="?/create"
|
||||||
|
use:enhance={() => {
|
||||||
|
return async ({ result, update }) => {
|
||||||
|
if (result.type === 'success' && (result.data as any)?.success) {
|
||||||
|
creating = false;
|
||||||
|
await update();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
class="mt-4 flex gap-3 rounded-lg border border-border bg-surface-800 p-4"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="name"
|
||||||
|
placeholder="Team name"
|
||||||
|
required
|
||||||
|
class="flex-1 rounded-md border border-border bg-surface-700 px-3 py-2 text-sm text-text-primary placeholder:text-text-secondary/50 focus:border-accent focus:outline-none"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded-md bg-accent px-4 py-2 text-sm font-600 text-surface-900 transition-colors hover:bg-accent-hover"
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if actionError}
|
||||||
|
<p class="mt-3 text-sm text-red-400">{actionError}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- My Teams -->
|
||||||
|
{#if myTeams.length > 0}
|
||||||
|
<div class="mt-6">
|
||||||
|
<h2 class="text-sm font-600 text-text-secondary">My Teams</h2>
|
||||||
|
<div class="mt-3 flex flex-col gap-3">
|
||||||
|
{#each myTeams as t}
|
||||||
|
<div class="rounded-lg border border-border bg-surface-800 transition-colors">
|
||||||
|
<!-- Team header -->
|
||||||
|
<button
|
||||||
|
onclick={() => (expandedTeam = expandedTeam === t.id ? null : t.id)}
|
||||||
|
class="flex w-full items-center gap-4 p-4 text-left transition-colors hover:bg-surface-700"
|
||||||
|
>
|
||||||
|
{#if t.avatarUrl}
|
||||||
|
<img src={t.avatarUrl} alt="" class="h-10 w-10 rounded-full" />
|
||||||
|
{:else}
|
||||||
|
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-surface-600 text-sm font-700 text-text-secondary">
|
||||||
|
{t.name.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-sm font-600">{t.name}</span>
|
||||||
|
{#if t.isPersonal}
|
||||||
|
<span class="rounded bg-surface-600 px-1.5 py-0.5 text-[10px] font-500 text-text-secondary">solo</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<p class="mt-0.5 text-xs text-text-secondary">
|
||||||
|
{t.members.length} member{t.members.length !== 1 ? 's' : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span class="text-sm text-text-secondary transition-transform {expandedTeam === t.id ? 'rotate-90' : ''}">›</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Expanded: members -->
|
||||||
|
{#if expandedTeam === t.id}
|
||||||
|
<div class="border-t border-border px-4 pb-4 pt-3">
|
||||||
|
<!-- Member list -->
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
{#each t.members as member}
|
||||||
|
<div class="flex items-center gap-3 rounded-md bg-surface-700 px-3 py-2">
|
||||||
|
{#if member.user?.image}
|
||||||
|
<img src={member.user.image} alt="" class="h-6 w-6 rounded-full" />
|
||||||
|
{:else}
|
||||||
|
<div class="h-6 w-6 rounded-full bg-surface-600"></div>
|
||||||
|
{/if}
|
||||||
|
<span class="flex-1 text-sm">{member.user?.name ?? 'Unknown'}</span>
|
||||||
|
<span class="text-xs text-text-secondary">{member.role}</span>
|
||||||
|
{#if isOwner(t) && member.userId !== data.userId}
|
||||||
|
<form method="post" action="?/removeMember" use:enhance={() => {
|
||||||
|
return async ({ update }) => { await update(); };
|
||||||
|
}}>
|
||||||
|
<input type="hidden" name="memberId" value={member.id} />
|
||||||
|
<button type="submit" class="text-xs text-red-400 opacity-50 transition-opacity hover:opacity-100">×</button>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add member -->
|
||||||
|
{#if isOwner(t) && !t.isPersonal}
|
||||||
|
{#if addingMemberTo === t.id}
|
||||||
|
<form
|
||||||
|
method="post"
|
||||||
|
action="?/addMember"
|
||||||
|
use:enhance={() => {
|
||||||
|
actionError = '';
|
||||||
|
return async ({ result, update }) => {
|
||||||
|
if (result.type === 'success' && (result.data as any)?.success) {
|
||||||
|
memberUsername = '';
|
||||||
|
addingMemberTo = null;
|
||||||
|
await update();
|
||||||
|
} else if (result.type === 'success' && (result.data as any)?.error) {
|
||||||
|
actionError = (result.data as any).error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
class="mt-3 flex gap-2"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="teamId" value={t.id} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="username"
|
||||||
|
bind:value={memberUsername}
|
||||||
|
placeholder="osu! username"
|
||||||
|
required
|
||||||
|
class="flex-1 rounded-md border border-border bg-surface-600 px-3 py-1.5 text-sm text-text-primary placeholder:text-text-secondary/50 focus:border-accent focus:outline-none"
|
||||||
|
/>
|
||||||
|
<button type="submit" class="rounded-md bg-accent px-3 py-1.5 text-xs font-600 text-surface-900 hover:bg-accent-hover">Add</button>
|
||||||
|
<button type="button" onclick={() => (addingMemberTo = null)} class="rounded-md border border-border px-3 py-1.5 text-xs text-text-secondary hover:bg-surface-600">Cancel</button>
|
||||||
|
</form>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
onclick={() => (addingMemberTo = t.id)}
|
||||||
|
class="mt-3 w-full rounded-md border border-dashed border-border py-2 text-xs text-text-secondary transition-colors hover:border-accent/40 hover:text-accent"
|
||||||
|
>
|
||||||
|
+ Add Member
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Delete team -->
|
||||||
|
{#if isOwner(t) && !t.isPersonal}
|
||||||
|
<form method="post" action="?/deleteTeam" use:enhance>
|
||||||
|
<input type="hidden" name="teamId" value={t.id} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
onclick={(e) => { if (!confirm(`Delete ${t.name}?`)) e.preventDefault() }}
|
||||||
|
class="mt-3 text-xs text-red-400 opacity-50 transition-opacity hover:opacity-100"
|
||||||
|
>
|
||||||
|
Delete team
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Other Teams -->
|
||||||
|
{#if otherTeams.length > 0}
|
||||||
|
<div class="mt-6">
|
||||||
|
<h2 class="text-sm font-600 text-text-secondary">Other Teams</h2>
|
||||||
|
<div class="mt-3 flex flex-col gap-2">
|
||||||
|
{#each otherTeams as t}
|
||||||
|
<div class="flex items-center gap-4 rounded-lg border border-border bg-surface-800 p-3">
|
||||||
|
{#if t.avatarUrl}
|
||||||
|
<img src={t.avatarUrl} alt="" class="h-8 w-8 rounded-full" />
|
||||||
|
{:else}
|
||||||
|
<div class="flex h-8 w-8 items-center justify-center rounded-full bg-surface-600 text-xs font-700 text-text-secondary">
|
||||||
|
{t.name.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="flex-1">
|
||||||
|
<span class="text-sm font-600">{t.name}</span>
|
||||||
|
<p class="text-xs text-text-secondary">{t.members.length} member{t.members.length !== 1 ? 's' : ''}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Empty state -->
|
||||||
|
{#if data.teams.length === 0}
|
||||||
|
<div class="mt-6 rounded-lg border border-dashed border-border py-12 text-center">
|
||||||
|
<p class="text-sm text-text-secondary">No teams yet. Your personal team is created when you sign in.</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
29
src/routes/+error.svelte
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/state';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex min-h-dvh items-center justify-center bg-surface-900">
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-6xl font-800 text-accent">{page.status}</p>
|
||||||
|
<h1 class="mt-4 text-xl font-700 text-text-primary">
|
||||||
|
{#if page.status === 404}
|
||||||
|
Page not found
|
||||||
|
{:else if page.status === 403}
|
||||||
|
Access denied
|
||||||
|
{:else if page.status === 401}
|
||||||
|
Not logged in
|
||||||
|
{:else}
|
||||||
|
Something went wrong
|
||||||
|
{/if}
|
||||||
|
</h1>
|
||||||
|
<p class="mt-2 text-sm text-text-secondary">
|
||||||
|
{page.error?.message ?? 'An unexpected error occurred.'}
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
class="mt-6 inline-block rounded-md bg-accent px-5 py-2.5 text-sm font-600 text-surface-900 transition-colors hover:bg-accent-hover"
|
||||||
|
>
|
||||||
|
Back to Dashboard
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
27
src/routes/api/health/+server.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
// src/routes/api/health/+server.ts
|
||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { sql } from 'drizzle-orm';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async () => {
|
||||||
|
try {
|
||||||
|
// Check DB connectivity
|
||||||
|
await db.execute(sql`SELECT 1`);
|
||||||
|
|
||||||
|
return json({
|
||||||
|
status: 'ok',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
uptime: process.uptime()
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
return json(
|
||||||
|
{
|
||||||
|
status: 'error',
|
||||||
|
error: 'Database connection failed',
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
},
|
||||||
|
{ status: 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
33
src/routes/api/mappools/+server.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { mappool } from '$lib/server/db/schema';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
// List all mappools
|
||||||
|
export const GET: RequestHandler = async ({ locals }) => {
|
||||||
|
if (!locals.user) error(401, 'Not logged in');
|
||||||
|
|
||||||
|
const mappools = await db.query.mappool.findMany({
|
||||||
|
orderBy: (m, { desc }) => [desc(m.createdAt)]
|
||||||
|
});
|
||||||
|
|
||||||
|
return json(mappools);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create a mappool
|
||||||
|
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||||
|
if (!locals.user) error(401, 'Not logged in');
|
||||||
|
|
||||||
|
const { name } = await request.json();
|
||||||
|
if (!name?.trim()) error(400, 'Name is required');
|
||||||
|
|
||||||
|
const [created] = await db
|
||||||
|
.insert(mappool)
|
||||||
|
.values({
|
||||||
|
name: name.trim(),
|
||||||
|
createdBy: locals.user.id
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return json(created, { status: 201 });
|
||||||
|
};
|
||||||
31
src/routes/api/mappools/[id]/+server.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { mappool, mappoolSlot } from '$lib/server/db/schema';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
// Get a mappool with its slots
|
||||||
|
export const GET: RequestHandler = async ({ params, locals }) => {
|
||||||
|
if (!locals.user) error(401, 'Not logged in');
|
||||||
|
|
||||||
|
const pool = await db.query.mappool.findFirst({
|
||||||
|
where: eq(mappool.id, params.id)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!pool) error(404, 'Mappool not found');
|
||||||
|
|
||||||
|
const slots = await db.query.mappoolSlot.findMany({
|
||||||
|
where: eq(mappoolSlot.mappoolId, params.id),
|
||||||
|
orderBy: (s, { asc }) => [asc(s.category), asc(s.orderInCategory)]
|
||||||
|
});
|
||||||
|
|
||||||
|
return json({ ...pool, slots });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Delete a mappool
|
||||||
|
export const DELETE: RequestHandler = async ({ params, locals }) => {
|
||||||
|
if (!locals.user) error(401, 'Not logged in');
|
||||||
|
|
||||||
|
await db.delete(mappool).where(eq(mappool.id, params.id));
|
||||||
|
return json({ ok: true });
|
||||||
|
};
|
||||||
38
src/routes/api/mappools/[id]/slots/+server.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { mappoolSlot } from '$lib/server/db/schema';
|
||||||
|
import { eq, and } from 'drizzle-orm';
|
||||||
|
import { getBeatmap } from '$lib/server/osu/api';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
// Add a slot to a mappool
|
||||||
|
export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||||
|
if (!locals.user) error(401, 'Not logged in');
|
||||||
|
|
||||||
|
const { beatmapId, category, mods } = await request.json();
|
||||||
|
|
||||||
|
if (!beatmapId || !category) {
|
||||||
|
error(400, 'beatmapId and category are required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count existing slots in this category to auto-increment order
|
||||||
|
const existing = await db.query.mappoolSlot.findMany({
|
||||||
|
where: and(
|
||||||
|
eq(mappoolSlot.mappoolId, params.id),
|
||||||
|
eq(mappoolSlot.category, category.toUpperCase())
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
const [created] = await db
|
||||||
|
.insert(mappoolSlot)
|
||||||
|
.values({
|
||||||
|
mappoolId: params.id,
|
||||||
|
beatmapId: String(beatmapId),
|
||||||
|
category: category.toUpperCase(),
|
||||||
|
orderInCategory: existing.length + 1,
|
||||||
|
mods: mods ?? []
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return json(created, { status: 201 });
|
||||||
|
};
|
||||||
12
src/routes/api/mappools/[id]/slots/[slotId]/+server.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { mappoolSlot } from '$lib/server/db/schema';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
export const DELETE: RequestHandler = async ({ params, locals }) => {
|
||||||
|
if (!locals.user) error(401, 'Not logged in');
|
||||||
|
|
||||||
|
await db.delete(mappoolSlot).where(eq(mappoolSlot.id, params.slotId));
|
||||||
|
return json({ ok: true });
|
||||||
|
};
|
||||||
42
src/routes/api/matches/+server.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
// src/routes/api/matches/+server.ts
|
||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { createMatch } from '$lib/server/match/engine';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ locals }) => {
|
||||||
|
if (!locals.user) error(401, 'Not logged in');
|
||||||
|
|
||||||
|
const matches = await db.query.match.findMany({
|
||||||
|
with: {
|
||||||
|
participants: { with: { team: true } }
|
||||||
|
},
|
||||||
|
orderBy: (m, { desc }) => [desc(m.createdAt)]
|
||||||
|
});
|
||||||
|
|
||||||
|
return json(matches);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||||
|
if (!locals.user) error(401, 'Not logged in');
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { name, config, mappoolId, teams } = body;
|
||||||
|
|
||||||
|
if (!name || !config || !mappoolId || !teams?.length) {
|
||||||
|
error(400, 'Missing required fields');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await createMatch({
|
||||||
|
name,
|
||||||
|
config,
|
||||||
|
mappoolId,
|
||||||
|
teams,
|
||||||
|
createdBy: locals.user.id
|
||||||
|
});
|
||||||
|
return json(result, { status: 201 });
|
||||||
|
} catch (e: any) {
|
||||||
|
error(400, e.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
26
src/routes/api/matches/[id]/+server.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
// src/routes/api/matches/[id]/+server.ts
|
||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import { getMatchFull, cancelMatch } from '$lib/server/match/engine';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ params, locals }) => {
|
||||||
|
if (!locals.user) error(401, 'Not logged in');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const m = await getMatchFull(params.id);
|
||||||
|
return json(m);
|
||||||
|
} catch (e: any) {
|
||||||
|
error(404, e.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DELETE: RequestHandler = async ({ params, locals }) => {
|
||||||
|
if (!locals.user) error(401, 'Not logged in');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const m = await cancelMatch(params.id);
|
||||||
|
return json(m);
|
||||||
|
} catch (e: any) {
|
||||||
|
error(400, e.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
17
src/routes/api/matches/[id]/lobby/+server.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
// src/routes/api/matches/[id]/lobby/+server.ts
|
||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import { moveToLobby } from '$lib/server/match/engine';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||||
|
if (!locals.user) error(401, 'Not logged in');
|
||||||
|
|
||||||
|
const body = await request.json().catch(() => ({}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const m = await moveToLobby(params.id, body.osuLobbyId);
|
||||||
|
return json(m);
|
||||||
|
} catch (e: any) {
|
||||||
|
error(400, e.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
20
src/routes/api/matches/[id]/pick/+server.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
// src/routes/api/matches/[id]/pick/+server.ts
|
||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import { pickMap } from '$lib/server/match/engine';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||||
|
if (!locals.user) error(401, 'Not logged in');
|
||||||
|
|
||||||
|
const { participantId, mappoolSlotId } = await request.json();
|
||||||
|
if (!participantId || !mappoolSlotId) {
|
||||||
|
error(400, 'participantId and mappoolSlotId required');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const game = await pickMap(params.id, participantId, mappoolSlotId);
|
||||||
|
return json(game);
|
||||||
|
} catch (e: any) {
|
||||||
|
error(400, e.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
20
src/routes/api/matches/[id]/roll/+server.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
// src/routes/api/matches/[id]/roll/+server.ts
|
||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import { submitRoll } from '$lib/server/match/engine';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||||
|
if (!locals.user) error(401, 'Not logged in');
|
||||||
|
|
||||||
|
const { participantId, value } = await request.json();
|
||||||
|
if (!participantId || value == null) {
|
||||||
|
error(400, 'participantId and value required');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const m = await submitRoll(params.id, participantId, value);
|
||||||
|
return json(m);
|
||||||
|
} catch (e: any) {
|
||||||
|
error(400, e.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
15
src/routes/api/matches/[id]/rolling/+server.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
// src/routes/api/matches/[id]/rolling/+server.ts
|
||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import { moveToRolling } from '$lib/server/match/engine';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ params, locals }) => {
|
||||||
|
if (!locals.user) error(401, 'Not logged in');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const m = await moveToRolling(params.id);
|
||||||
|
return json(m);
|
||||||
|
} catch (e: any) {
|
||||||
|
error(400, e.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
20
src/routes/api/matches/[id]/score/+server.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
// src/routes/api/matches/[id]/score/+server.ts
|
||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import { submitGameScores } from '$lib/server/match/engine';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||||
|
if (!locals.user) error(401, 'Not logged in');
|
||||||
|
|
||||||
|
const { matchGameId, scores } = await request.json();
|
||||||
|
if (!matchGameId || !scores?.length) {
|
||||||
|
error(400, 'matchGameId and scores required');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const m = await submitGameScores(matchGameId, scores);
|
||||||
|
return json(m);
|
||||||
|
} catch (e: any) {
|
||||||
|
error(400, e.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
14
src/routes/api/osu/beatmap/[id]/+server.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import { getBeatmap } from '$lib/server/osu/api';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ params, locals }) => {
|
||||||
|
if (!locals.user) error(401, 'Not logged in');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const beatmap = await getBeatmap(params.id);
|
||||||
|
return json(beatmap);
|
||||||
|
} catch {
|
||||||
|
error(404, 'Beatmap not found');
|
||||||
|
}
|
||||||
|
};
|
||||||
43
src/routes/api/queue/+server.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
// src/routes/api/queue/+server.ts
|
||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import { joinQueue, leaveQueue, getQueueStatus } from '$lib/server/match/engine';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { teamMember } from '$lib/server/db/schema';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ locals }) => {
|
||||||
|
if (!locals.user) error(401, 'Not logged in');
|
||||||
|
|
||||||
|
const status = await getQueueStatus(locals.user.id);
|
||||||
|
return json(status);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ locals }) => {
|
||||||
|
if (!locals.user) error(401, 'Not logged in');
|
||||||
|
|
||||||
|
// Find user's personal team
|
||||||
|
const membership = await db.query.teamMember.findFirst({
|
||||||
|
where: eq(teamMember.userId, locals.user.id),
|
||||||
|
with: { team: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
const personalTeam = membership?.team;
|
||||||
|
if (!personalTeam?.isPersonal) {
|
||||||
|
error(400, 'No personal team found');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await joinQueue(locals.user.id, personalTeam.id);
|
||||||
|
return json({ matched: !!result, match: result });
|
||||||
|
} catch (e: any) {
|
||||||
|
error(400, e.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DELETE: RequestHandler = async ({ locals }) => {
|
||||||
|
if (!locals.user) error(401, 'Not logged in');
|
||||||
|
|
||||||
|
await leaveQueue(locals.user.id);
|
||||||
|
return json({ ok: true });
|
||||||
|
};
|
||||||
5
src/routes/demo/+page.svelte
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<a href={resolve('/demo/better-auth')}>better-auth</a>
|
||||||
20
src/routes/demo/better-auth/+page.server.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import type { Actions } from './$types';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
import { auth } from '$lib/server/auth';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async (event) => {
|
||||||
|
if (!event.locals.user) {
|
||||||
|
return redirect(302, '/demo/better-auth/login');
|
||||||
|
}
|
||||||
|
return { user: event.locals.user };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
signOut: async (event) => {
|
||||||
|
await auth.api.signOut({
|
||||||
|
headers: event.request.headers
|
||||||
|
});
|
||||||
|
return redirect(302, '/demo/better-auth/login');
|
||||||
|
}
|
||||||
|
};
|
||||||
14
src/routes/demo/better-auth/+page.svelte
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import type { PageServerData } from './$types';
|
||||||
|
|
||||||
|
let { data }: { data: PageServerData } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<h1>Hi, {data.user.name}!</h1>
|
||||||
|
<p>Your user ID is {data.user.id}.</p>
|
||||||
|
<form method="post" action="?/signOut" use:enhance>
|
||||||
|
<button class="rounded-md bg-blue-600 px-4 py-2 text-white transition hover:bg-blue-700"
|
||||||
|
>Sign out</button
|
||||||
|
>
|
||||||
|
</form>
|
||||||
78
src/routes/demo/better-auth/login/+page.server.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { fail, redirect } from '@sveltejs/kit';
|
||||||
|
import type { Actions } from './$types';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
import { auth } from '$lib/server/auth';
|
||||||
|
import { APIError } from 'better-auth/api';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async (event) => {
|
||||||
|
if (event.locals.user) {
|
||||||
|
return redirect(302, '/demo/better-auth');
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
signInEmail: async (event) => {
|
||||||
|
const formData = await event.request.formData();
|
||||||
|
const email = formData.get('email')?.toString() ?? '';
|
||||||
|
const password = formData.get('password')?.toString() ?? '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await auth.api.signInEmail({
|
||||||
|
body: {
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
callbackURL: '/auth/verification-success'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof APIError) {
|
||||||
|
return fail(400, { message: error.message || 'Signin failed' });
|
||||||
|
}
|
||||||
|
return fail(500, { message: 'Unexpected error' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect(302, '/demo/better-auth');
|
||||||
|
},
|
||||||
|
signUpEmail: async (event) => {
|
||||||
|
const formData = await event.request.formData();
|
||||||
|
const email = formData.get('email')?.toString() ?? '';
|
||||||
|
const password = formData.get('password')?.toString() ?? '';
|
||||||
|
const name = formData.get('name')?.toString() ?? '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await auth.api.signUpEmail({
|
||||||
|
body: {
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
name,
|
||||||
|
callbackURL: '/auth/verification-success'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof APIError) {
|
||||||
|
return fail(400, { message: error.message || 'Registration failed' });
|
||||||
|
}
|
||||||
|
return fail(500, { message: 'Unexpected error' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect(302, '/demo/better-auth');
|
||||||
|
},
|
||||||
|
signInSocial: async (event) => {
|
||||||
|
const formData = await event.request.formData();
|
||||||
|
const provider = formData.get('provider')?.toString() ?? 'osu';
|
||||||
|
const callbackURL = formData.get('callbackURL')?.toString() ?? '/demo/better-auth';
|
||||||
|
|
||||||
|
const result = await auth.api.signInSocial({
|
||||||
|
body: {
|
||||||
|
provider: provider as 'osu',
|
||||||
|
callbackURL
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.url) {
|
||||||
|
return redirect(302, result.url);
|
||||||
|
}
|
||||||
|
return fail(400, { message: 'Social sign-in failed' });
|
||||||
|
}
|
||||||
|
};
|
||||||
51
src/routes/demo/better-auth/login/+page.svelte
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import type { ActionData } from './$types';
|
||||||
|
|
||||||
|
let { form }: { form: ActionData } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<h1>Login</h1>
|
||||||
|
<form method="post" action="?/signInEmail" use:enhance>
|
||||||
|
<label>
|
||||||
|
Email
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
class="mt-1 rounded-md border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Password
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
class="mt-1 rounded-md border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Name (for registration)
|
||||||
|
<input
|
||||||
|
name="name"
|
||||||
|
class="mt-1 rounded-md border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button class="rounded-md bg-blue-600 px-4 py-2 text-white transition hover:bg-blue-700"
|
||||||
|
>Login</button
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
formaction="?/signUpEmail"
|
||||||
|
class="rounded-md bg-blue-600 px-4 py-2 text-white transition hover:bg-blue-700"
|
||||||
|
>Register</button
|
||||||
|
>
|
||||||
|
</form>
|
||||||
|
<p class="text-red-500">{form?.message ?? ''}</p>
|
||||||
|
|
||||||
|
<hr class="my-4" />
|
||||||
|
<form method="post" action="?/signInSocial" use:enhance>
|
||||||
|
<input type="hidden" name="provider" value="osu" />
|
||||||
|
<input type="hidden" name="callbackURL" value="/demo/better-auth" />
|
||||||
|
<button class="rounded-md bg-blue-600 px-4 py-2 text-white transition hover:bg-blue-700"
|
||||||
|
>Sign in with osu!</button
|
||||||
|
>
|
||||||
|
</form>
|
||||||
18
src/routes/login/+page.server.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import { auth } from '$lib/server/auth';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async (event) => {
|
||||||
|
if (event.locals.user) redirect(302, '/');
|
||||||
|
|
||||||
|
const result = await auth.api.signInSocial({
|
||||||
|
body: {
|
||||||
|
provider: 'osu',
|
||||||
|
callbackURL: '/'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.url) redirect(302, result.url);
|
||||||
|
|
||||||
|
return {};
|
||||||
|
};
|
||||||
BIN
static/android-chrome-192x192.png
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
static/android-chrome-512x512.png
Normal file
|
After Width: | Height: | Size: 156 KiB |
BIN
static/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
static/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 860 B |
BIN
static/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
static/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
3
static/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# allow crawling everything by default
|
||||||
|
User-agent: *
|
||||||
|
Disallow:
|
||||||
1
static/site.webmanifest
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
|
||||||
6
svelte.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import adapter from '@sveltejs/adapter-node';
|
||||||
|
|
||||||
|
/** @type {import('@sveltejs/kit').Config} */
|
||||||
|
const config = { kit: { adapter: adapter() } };
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -1,29 +1,20 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"extends": "./.svelte-kit/tsconfig.json",
|
||||||
// Environment setup & latest features
|
"compilerOptions": {
|
||||||
"lib": ["ESNext"],
|
"rewriteRelativeImportExtensions": true,
|
||||||
"target": "ESNext",
|
"allowJs": true,
|
||||||
"module": "Preserve",
|
"checkJs": true,
|
||||||
"moduleDetection": "force",
|
"esModuleInterop": true,
|
||||||
"jsx": "react-jsx",
|
"forceConsistentCasingInFileNames": true,
|
||||||
"allowJs": true,
|
"resolveJsonModule": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
// Bundler mode
|
"sourceMap": true,
|
||||||
"moduleResolution": "bundler",
|
"strict": true,
|
||||||
"allowImportingTsExtensions": true,
|
"moduleResolution": "bundler"
|
||||||
"verbatimModuleSyntax": true,
|
}
|
||||||
"noEmit": true,
|
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||||
|
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||||
// Best practices
|
//
|
||||||
"strict": true,
|
// To make changes to top-level options such as include and exclude, we recommend extending
|
||||||
"skipLibCheck": true,
|
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
|
||||||
"noFallthroughCasesInSwitch": true,
|
|
||||||
"noUncheckedIndexedAccess": true,
|
|
||||||
"noImplicitOverride": true,
|
|
||||||
|
|
||||||
// Some stricter flags (disabled by default)
|
|
||||||
"noUnusedLocals": false,
|
|
||||||
"noUnusedParameters": false,
|
|
||||||
"noPropertyAccessFromIndexSignature": false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
37
vite.config.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import devtoolsJson from 'vite-plugin-devtools-json';
|
||||||
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
import { playwright } from '@vitest/browser-playwright';
|
||||||
|
import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [tailwindcss(), sveltekit(), devtoolsJson()],
|
||||||
|
test: {
|
||||||
|
expect: { requireAssertions: true },
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
extends: './vite.config.ts',
|
||||||
|
test: {
|
||||||
|
name: 'client',
|
||||||
|
browser: {
|
||||||
|
enabled: true,
|
||||||
|
provider: playwright(),
|
||||||
|
instances: [{ browser: 'chromium', headless: true }]
|
||||||
|
},
|
||||||
|
include: ['src/**/*.svelte.{test,spec}.{js,ts}'],
|
||||||
|
exclude: ['src/lib/server/**']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
extends: './vite.config.ts',
|
||||||
|
test: {
|
||||||
|
name: 'server',
|
||||||
|
environment: 'node',
|
||||||
|
include: ['src/**/*.{test,spec}.{js,ts}'],
|
||||||
|
exclude: ['src/**/*.svelte.{test,spec}.{js,ts}']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||