13 Commits
Author SHA1 Message Date
kyle 5a4fdbbb89 Merge branch 'deep-ketch' into development
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 27s
CI/CD Pipeline / i18n Catalog Check (push) Successful in 41s
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 3m30s
CI/CD Pipeline / Deploy to Kubernetes (push) Failing after 22s
2026-08-23 20:31:49 -07:00
kyle 3761f8be6f badges improvements 2026-08-23 20:31:44 -07:00
kyleandClaude Fable 5 569f29dbe2 style: trim narration comments from recent changes
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0189ASmkMee4F5aJ3jnKeKQv
2026-08-23 20:06:05 -07:00
kyleandClaude Fable 5 ed5eaa057c feat(header): shrink long titles to fit instead of wrapping
The header keeps its size; a title that would overflow scales down to a
floor of 55% and ellipsizes only past that. Re-measures on title change,
resize, and web-font load.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0189ASmkMee4F5aJ3jnKeKQv
2026-08-23 20:06:05 -07:00
kyleandClaude Fable 5 a12d078323 fix(i18n): hydrate in the SSR locale instead of regressing to en
The client's message cache held only en at hydration, so a non-en user
rendered English until the locale's catalog chunk loaded — and stayed
English if that import failed or lost a race. __root beforeLoad now
dehydrates the active locale's compiled catalog with the page and the
provider primes its cache from that payload, so first paint is already in
the user's locale with no dependency on a client-side chunk load.
ensureMessages dedupes concurrent loads and drops rejected ones so a
retry can succeed, and the provider's fallback loader retries instead of
silently stranding the UI in en.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0189ASmkMee4F5aJ3jnKeKQv
2026-08-23 19:47:31 -07:00
kyle d9ccd5acf1 Merge branch 'theme-discovery-presets' into development 2026-08-23 19:26:37 -07:00
kyleandClaude Fable 5 f126767436 fix(pwa): simplify the install prompt copy
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0189ASmkMee4F5aJ3jnKeKQv
2026-08-23 19:03:57 -07:00
kyleandClaude Fable 5 f2e2af729a fix(audit): readable names, single writer, denial rows, redacted args
The activity log recorded Start's hashed function id (a sha256 URL
segment) as the name; the middleware now reads compile-time
serverFnMeta.name with the path segment as fallback. toServerResult
resolves { success:false } instead of throwing, so the middleware logged
failures as successes while toServerResult wrote a duplicate row with its
own dead name parser — the middleware is now the single writer and reads
the envelope's success flag. Admin denials, which threw before the logging
middleware ran, get their own audit row. Arguments are redacted
(phone/otp/token keys) and truncated at 2KB. The admin activities search
binds its filter parameters (likePattern escaping) instead of
interpolating raw input. Adds vitest with node-env tests for the
middleware, redaction, and filter utils, and extends logging coverage to
mutating fns that lacked it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0189ASmkMee4F5aJ3jnKeKQv
2026-08-23 18:27:53 -07:00
kyleandClaude Fable 5 33738e9d71 fix(pocketbase): self-heal admin auth instead of stranding requests
beforeSend now skips its gate for the auth endpoints (no self-await
deadlock), swallows a rejected authPromise and re-authenticates when the
store is invalid, re-stamps Authorization after the gate so requests built
mid-auth carry the fresh token, and guards startTokenRefresh against
stacking intervals. A failed boot auth logs a diagnostic instead of
crashing the process; recovery happens on the next request.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0189ASmkMee4F5aJ3jnKeKQv
2026-08-23 18:27:45 -07:00
kyleandClaude Fable 5 b98e4fda73 fix(pwa): reword the install prompt and keep it off the bottom nav
The banner sat fixed at the viewport bottom at z-index 1000, covering the
authed shell's mobile nav; it now offsets above the nav's geometry when a
signed-in mobile user has one. New casual copy replaces the Twilio line,
the dismiss button gains a localized aria-label, and de/es/ja catalogs
carry the new strings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0189ASmkMee4F5aJ3jnKeKQv
2026-08-23 18:27:40 -07:00
kyleandClaude Fable 5 b0e0cb9e61 fix(ui): defer the visual-viewport read out of render
Reading window during render made the client's first render disagree with
the SSR markup; start at zero and fill in after mount, matching Mantine's
useViewportSize contract.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0189ASmkMee4F5aJ3jnKeKQv
2026-08-23 18:27:35 -07:00
kyle ac99b508f9 Merge branch 'development' into player-picker-single-sele
# Conflicts:
#	src/locales/de/messages.po
#	src/locales/en/messages.po
#	src/locales/es/messages.po
#	src/locales/ja/messages.po
2026-08-23 17:59:55 -07:00
kyleandClaude Fable 5 52d7a56d23 redesign h2h player picker: single slot + Me quick-select
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WgPX3S4EZ8sL7d9KEhGjBk
2026-08-23 17:59:24 -07:00
203 changed files with 2680 additions and 9920 deletions
+9 -12
View File
@@ -31,8 +31,8 @@
"facehash": "^0.0.7", "facehash": "^0.0.7",
"framer-motion": "^12.23.12", "framer-motion": "^12.23.12",
"pocketbase": "^0.26.2", "pocketbase": "^0.26.2",
"react": "^19.3.0", "react": "^19.2.7",
"react-dom": "^19.3.0", "react-dom": "^19.2.7",
"react-imask": "^7.6.1", "react-imask": "^7.6.1",
"redaxios": "^0.5.1", "redaxios": "^0.5.1",
"sonner": "^2.0.7", "sonner": "^2.0.7",
@@ -41,7 +41,6 @@
"twilio": "^5.8.0", "twilio": "^5.8.0",
"vaul": "^1.1.2", "vaul": "^1.1.2",
"web-push": "^3.6.7", "web-push": "^3.6.7",
"web-vitals": "^6.2.0",
"zod": "^4.0.15", "zod": "^4.0.15",
}, },
"devDependencies": { "devDependencies": {
@@ -49,8 +48,8 @@
"@lingui/cli": "^6.5.0", "@lingui/cli": "^6.5.0",
"@lingui/vite-plugin": "^6.5.0", "@lingui/vite-plugin": "^6.5.0",
"@types/node": "^22.5.4", "@types/node": "^22.5.4",
"@types/react": "^19.3.0", "@types/react": "^19.2.17",
"@types/react-dom": "^19.3.0", "@types/react-dom": "^19.2.3",
"@types/web-push": "^3.6.4", "@types/web-push": "^3.6.4",
"@vitejs/plugin-react": "^5.0.0", "@vitejs/plugin-react": "^5.0.0",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
@@ -655,9 +654,9 @@
"@types/node": ["@types/node@22.19.10", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-tF5VOugLS/EuDlTBijk0MqABfP8UxgYazTLo3uIn3b4yJgg26QRbVYJYsDtHrjdDUIRfP70+VfhTTc+CE1yskw=="], "@types/node": ["@types/node@22.19.10", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-tF5VOugLS/EuDlTBijk0MqABfP8UxgYazTLo3uIn3b4yJgg26QRbVYJYsDtHrjdDUIRfP70+VfhTTc+CE1yskw=="],
"@types/react": ["@types/react@19.3.0", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg=="], "@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="],
"@types/react-dom": ["@types/react-dom@19.3.0", "", { "peerDependencies": { "@types/react": "^19.3.0" } }, "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q=="], "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="], "@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="],
@@ -1329,9 +1328,9 @@
"raf-schd": ["raf-schd@4.0.3", "", {}, "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ=="], "raf-schd": ["raf-schd@4.0.3", "", {}, "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ=="],
"react": ["react@19.3.0", "", {}, "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog=="], "react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="],
"react-dom": ["react-dom@19.3.0", "", { "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { "react": "^19.3.0" } }, "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q=="], "react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="],
"react-imask": ["react-imask@7.6.1", "", { "dependencies": { "imask": "^7.6.1", "prop-types": "^15.8.1" }, "peerDependencies": { "react": ">=0.14.0" } }, "sha512-vLNfzcCz62Yzx/GRGh5tiCph9Gbh2cZu+Tz8OiO5it2eNuuhpA0DWhhSlOtVtSJ80+Bx+vFK5De8eQ9AmbkXzA=="], "react-imask": ["react-imask@7.6.1", "", { "dependencies": { "imask": "^7.6.1", "prop-types": "^15.8.1" }, "peerDependencies": { "react": ">=0.14.0" } }, "sha512-vLNfzcCz62Yzx/GRGh5tiCph9Gbh2cZu+Tz8OiO5it2eNuuhpA0DWhhSlOtVtSJ80+Bx+vFK5De8eQ9AmbkXzA=="],
@@ -1397,7 +1396,7 @@
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"scheduler": ["scheduler@0.28.0", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="], "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"scmp": ["scmp@2.1.0", "", {}, "sha512-o/mRQGk9Rcer/jEEw/yw4mwo3EU/NvYvp577/Btqrym9Qy5/MdWGBqipbALgd2lrdWTJ5/gqDusxfnQBxOxT2Q=="], "scmp": ["scmp@2.1.0", "", {}, "sha512-o/mRQGk9Rcer/jEEw/yw4mwo3EU/NvYvp577/Btqrym9Qy5/MdWGBqipbALgd2lrdWTJ5/gqDusxfnQBxOxT2Q=="],
@@ -1591,8 +1590,6 @@
"web-push": ["web-push@3.6.7", "", { "dependencies": { "asn1.js": "^5.3.0", "http_ece": "1.2.0", "https-proxy-agent": "^7.0.0", "jws": "^4.0.0", "minimist": "^1.2.5" }, "bin": { "web-push": "src/cli.js" } }, "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A=="], "web-push": ["web-push@3.6.7", "", { "dependencies": { "asn1.js": "^5.3.0", "http_ece": "1.2.0", "https-proxy-agent": "^7.0.0", "jws": "^4.0.0", "minimist": "^1.2.5" }, "bin": { "web-push": "src/cli.js" } }, "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A=="],
"web-vitals": ["web-vitals@6.2.0", "", {}, "sha512-i2jhLkq6eB38jgJX5OIjEkyzQKgsG7FR56v7GphJNjzKAySgbRbwAfsFC0dDfHROWmCycf912SJpU2QoYIcMVg=="],
"webidl-conversions": ["webidl-conversions@4.0.2", "", {}, "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg=="], "webidl-conversions": ["webidl-conversions@4.0.2", "", {}, "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg=="],
"webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
-6
View File
@@ -55,12 +55,6 @@ spec:
value: "3000" value: "3000"
- name: NODE_ENV - name: NODE_ENV
value: "production" value: "production"
- name: TELEMETRY_EXCLUDE_PHONES
valueFrom:
secretKeyRef:
name: flxn-secrets
key: telemetry_exclude_phones
optional: true
- name: TWILIO_ACCOUNT_SID - name: TWILIO_ACCOUNT_SID
valueFrom: valueFrom:
+4 -5
View File
@@ -43,8 +43,8 @@
"facehash": "^0.0.7", "facehash": "^0.0.7",
"framer-motion": "^12.23.12", "framer-motion": "^12.23.12",
"pocketbase": "^0.26.2", "pocketbase": "^0.26.2",
"react": "^19.3.0", "react": "^19.2.7",
"react-dom": "^19.3.0", "react-dom": "^19.2.7",
"react-imask": "^7.6.1", "react-imask": "^7.6.1",
"redaxios": "^0.5.1", "redaxios": "^0.5.1",
"sonner": "^2.0.7", "sonner": "^2.0.7",
@@ -53,7 +53,6 @@
"twilio": "^5.8.0", "twilio": "^5.8.0",
"vaul": "^1.1.2", "vaul": "^1.1.2",
"web-push": "^3.6.7", "web-push": "^3.6.7",
"web-vitals": "^6.2.0",
"zod": "^4.0.15" "zod": "^4.0.15"
}, },
"devDependencies": { "devDependencies": {
@@ -61,8 +60,8 @@
"@lingui/cli": "^6.5.0", "@lingui/cli": "^6.5.0",
"@lingui/vite-plugin": "^6.5.0", "@lingui/vite-plugin": "^6.5.0",
"@types/node": "^22.5.4", "@types/node": "^22.5.4",
"@types/react": "^19.3.0", "@types/react": "^19.2.17",
"@types/react-dom": "^19.3.0", "@types/react-dom": "^19.2.3",
"@types/web-push": "^3.6.4", "@types/web-push": "^3.6.4",
"@vitejs/plugin-react": "^5.0.0", "@vitejs/plugin-react": "^5.0.0",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
@@ -1,23 +0,0 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_1262591861")
unmarshal({
"indexes": [
"CREATE INDEX `idx_activities_created` ON `activities` (`created`)",
"CREATE INDEX `idx_activities_name_created` ON `activities` (`name`, `created`)",
"CREATE INDEX `idx_activities_player_created` ON `activities` (`player`, `created`)",
"CREATE INDEX `idx_activities_success_created` ON `activities` (`success`, `created`)"
]
}, collection)
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_1262591861")
unmarshal({
"indexes": []
}, collection)
return app.save(collection)
})
@@ -1,167 +0,0 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = new Collection({
"createRule": null,
"deleteRule": null,
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"hidden": false,
"id": "text3208210256",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text_ce_name",
"max": 0,
"min": 0,
"name": "name",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": true,
"system": false,
"type": "text"
},
{
"cascadeDelete": false,
"collectionId": "pbc_3072146508",
"hidden": false,
"id": "relation_ce_player",
"maxSelect": 1,
"minSelect": 0,
"name": "player",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text_ce_session_id",
"max": 0,
"min": 0,
"name": "session_id",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text_ce_path",
"max": 0,
"min": 0,
"name": "path",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text_ce_route_id",
"max": 0,
"min": 0,
"name": "route_id",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"hidden": false,
"id": "number_ce_value",
"max": null,
"min": null,
"name": "value",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "json_ce_props",
"maxSize": 0,
"name": "props",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text_ce_user_agent",
"max": 0,
"min": 0,
"name": "user_agent",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"hidden": false,
"id": "autodate2990389176",
"name": "created",
"onCreate": true,
"onUpdate": false,
"presentable": false,
"system": false,
"type": "autodate"
},
{
"hidden": false,
"id": "autodate3332085495",
"name": "updated",
"onCreate": true,
"onUpdate": true,
"presentable": false,
"system": false,
"type": "autodate"
}
],
"id": "pbc_client_events",
"indexes": [
"CREATE INDEX `idx_client_events_created` ON `client_events` (`created`)",
"CREATE INDEX `idx_client_events_name_created` ON `client_events` (`name`, `created`)",
"CREATE INDEX `idx_client_events_player_created` ON `client_events` (`player`, `created`)",
"CREATE INDEX `idx_client_events_session_id` ON `client_events` (`session_id`)"
],
"listRule": null,
"name": "client_events",
"system": false,
"type": "base",
"updateRule": null,
"viewRule": null
});
return app.save(collection);
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_client_events");
return app.delete(collection);
})
@@ -1,207 +0,0 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = new Collection({
"createRule": null,
"deleteRule": null,
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"hidden": false,
"id": "text3208210256",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text_cerr_message",
"max": 0,
"min": 0,
"name": "message",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": true,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text_cerr_stack",
"max": 0,
"min": 0,
"name": "stack",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"hidden": false,
"id": "select_cerr_source",
"maxSelect": 1,
"name": "source",
"presentable": false,
"required": false,
"system": false,
"type": "select",
"values": [
"window",
"unhandledrejection",
"error-boundary",
"sw"
]
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text_cerr_path",
"max": 0,
"min": 0,
"name": "path",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text_cerr_route_id",
"max": 0,
"min": 0,
"name": "route_id",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text_cerr_group_hash",
"max": 0,
"min": 0,
"name": "group_hash",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": true,
"system": false,
"type": "text"
},
{
"hidden": false,
"id": "bool_cerr_resolved",
"name": "resolved",
"presentable": false,
"required": false,
"system": false,
"type": "bool"
},
{
"cascadeDelete": false,
"collectionId": "pbc_3072146508",
"hidden": false,
"id": "relation_cerr_player",
"maxSelect": 1,
"minSelect": 0,
"name": "player",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text_cerr_session_id",
"max": 0,
"min": 0,
"name": "session_id",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text_cerr_user_agent",
"max": 0,
"min": 0,
"name": "user_agent",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"hidden": false,
"id": "json_cerr_props",
"maxSize": 0,
"name": "props",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"hidden": false,
"id": "autodate2990389176",
"name": "created",
"onCreate": true,
"onUpdate": false,
"presentable": false,
"system": false,
"type": "autodate"
},
{
"hidden": false,
"id": "autodate3332085495",
"name": "updated",
"onCreate": true,
"onUpdate": true,
"presentable": false,
"system": false,
"type": "autodate"
}
],
"id": "pbc_client_errors",
"indexes": [
"CREATE INDEX `idx_client_errors_group_created` ON `client_errors` (`group_hash`, `created`)",
"CREATE INDEX `idx_client_errors_created` ON `client_errors` (`created`)",
"CREATE INDEX `idx_client_errors_resolved_created` ON `client_errors` (`resolved`, `created`)"
],
"listRule": null,
"name": "client_errors",
"system": false,
"type": "base",
"updateRule": null,
"viewRule": null
});
return app.save(collection);
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_client_errors");
return app.delete(collection);
})
@@ -1,240 +0,0 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const rollups = new Collection({
"createRule": null,
"deleteRule": null,
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"hidden": false,
"id": "text3208210256",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text_tr_date",
"max": 0,
"min": 0,
"name": "date",
"pattern": "^\\d{4}-\\d{2}-\\d{2}$",
"presentable": false,
"primaryKey": false,
"required": true,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text_tr_metric",
"max": 0,
"min": 0,
"name": "metric",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": true,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text_tr_dim",
"max": 0,
"min": 0,
"name": "dim",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"hidden": false,
"id": "number_tr_value",
"max": null,
"min": null,
"name": "value",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "json_tr_meta",
"maxSize": 0,
"name": "meta",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"hidden": false,
"id": "autodate2990389176",
"name": "created",
"onCreate": true,
"onUpdate": false,
"presentable": false,
"system": false,
"type": "autodate"
},
{
"hidden": false,
"id": "autodate3332085495",
"name": "updated",
"onCreate": true,
"onUpdate": true,
"presentable": false,
"system": false,
"type": "autodate"
}
],
"id": "pbc_telemetry_rollups",
"indexes": [
"CREATE UNIQUE INDEX `idx_rollups_date_metric_dim` ON `telemetry_rollups` (`date`, `metric`, `dim`)",
"CREATE INDEX `idx_rollups_metric_date` ON `telemetry_rollups` (`metric`, `date`)"
],
"listRule": null,
"name": "telemetry_rollups",
"system": false,
"type": "base",
"updateRule": null,
"viewRule": null
});
app.save(rollups);
const alerts = new Collection({
"createRule": null,
"deleteRule": null,
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"hidden": false,
"id": "text3208210256",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text_ta_kind",
"max": 0,
"min": 0,
"name": "kind",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": true,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text_ta_dim",
"max": 0,
"min": 0,
"name": "dim",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text_ta_message",
"max": 0,
"min": 0,
"name": "message",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"hidden": false,
"id": "number_ta_value",
"max": null,
"min": null,
"name": "value",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "json_ta_meta",
"maxSize": 0,
"name": "meta",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"hidden": false,
"id": "autodate2990389176",
"name": "created",
"onCreate": true,
"onUpdate": false,
"presentable": false,
"system": false,
"type": "autodate"
},
{
"hidden": false,
"id": "autodate3332085495",
"name": "updated",
"onCreate": true,
"onUpdate": true,
"presentable": false,
"system": false,
"type": "autodate"
}
],
"id": "pbc_telemetry_alerts",
"indexes": [
"CREATE INDEX `idx_alerts_kind_dim_created` ON `telemetry_alerts` (`kind`, `dim`, `created`)"
],
"listRule": null,
"name": "telemetry_alerts",
"system": false,
"type": "base",
"updateRule": null,
"viewRule": null
});
return app.save(alerts);
}, (app) => {
app.delete(app.findCollectionByNameOrId("pbc_telemetry_alerts"));
return app.delete(app.findCollectionByNameOrId("pbc_telemetry_rollups"));
})
-26
View File
@@ -2,26 +2,6 @@
overscroll-behavior: none; overscroll-behavior: none;
} }
/* Keep app chrome non-selectable so iOS long-press doesn't open the
edit menu (AutoFill/Copy/Siri). Inputs and opted-in regions stay
selectable. */
body {
-webkit-touch-callout: none;
-webkit-user-select: none;
user-select: none;
}
input,
textarea,
[contenteditable="true"],
[contenteditable=""],
[data-selectable],
[data-selectable] * {
-webkit-touch-callout: default;
-webkit-user-select: text;
user-select: text;
}
@keyframes spin { @keyframes spin {
from { transform: rotate(0deg); } from { transform: rotate(0deg); }
to { transform: rotate(360deg); } to { transform: rotate(360deg); }
@@ -50,12 +30,6 @@ textarea,
transform: scale(0.90) translateY(-4px); transform: scale(0.90) translateY(-4px);
} }
/* themes paint their full-page texture through this element */
.flxn-theme-overlay {
position: fixed;
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.app, .app,
[data-drawer-level] { [data-drawer-level] {
+1
View File
@@ -13,6 +13,7 @@ const { count, size, warnings } = await generateSW({
'icon-192x192.png', 'icon-192x192.png',
'icon-512x512.png', 'icon-512x512.png',
'site.webmanifest', 'site.webmanifest',
'styles.css',
'push-handlers.js', 'push-handlers.js',
], ],
// Hand-written Web Push handlers (public/push-handlers.js) are pulled into // Hand-written Web Push handlers (public/push-handlers.js) are pulled into
+8 -17
View File
@@ -1,5 +1,5 @@
// Grant or revoke a role for a user by phone number (US +1 assumed). // Grant or revoke the "Admin" role for a user by phone number (US +1 assumed).
// Usage: bun run scripts/make-admin.ts <phone> [--role Admin|Developer] [--revoke] // Usage: bun run scripts/make-admin.ts <phone> [--revoke]
import "dotenv/config"; import "dotenv/config";
import SuperTokens from "supertokens-node"; import SuperTokens from "supertokens-node";
import Session from "supertokens-node/recipe/session"; import Session from "supertokens-node/recipe/session";
@@ -26,19 +26,10 @@ SuperTokens.init({
], ],
}); });
const VALID_ROLES = ["Admin", "Developer"] as const;
const raw = process.argv[2]; const raw = process.argv[2];
const revoke = process.argv.includes("--revoke"); const revoke = process.argv.includes("--revoke");
const roleFlagIndex = process.argv.indexOf("--role");
const role = roleFlagIndex === -1 ? "Admin" : process.argv[roleFlagIndex + 1];
if (!raw) { if (!raw) {
console.error("Usage: bun run scripts/make-admin.ts <phone> [--role Admin|Developer] [--revoke]"); console.error("Usage: bun run scripts/make-admin.ts <phone> [--revoke]");
process.exit(1);
}
if (!VALID_ROLES.includes(role as (typeof VALID_ROLES)[number])) {
console.error(`Invalid role "${role}". Valid roles: ${VALID_ROLES.join(", ")}`);
process.exit(1); process.exit(1);
} }
@@ -69,15 +60,15 @@ if (!user) {
process.exit(1); process.exit(1);
} }
await UserRoles.createNewRoleOrAddPermissions(role, []); await UserRoles.createNewRoleOrAddPermissions("Admin", []);
if (revoke) { if (revoke) {
const res = await UserRoles.removeUserRole("public", user.id, role); const res = await UserRoles.removeUserRole("public", user.id, "Admin");
console.log(`Removed ${role} from ${matched} (user ${user.id}):`, res.status); console.log(`Removed Admin from ${matched} (user ${user.id}):`, res.status);
} else { } else {
const res = await UserRoles.addRoleToUser("public", user.id, role); const res = await UserRoles.addRoleToUser("public", user.id, "Admin");
console.log( console.log(
`Granted ${role} to ${matched} (user ${user.id}):`, `Granted Admin to ${matched} (user ${user.id}):`,
res.status === "OK" res.status === "OK"
? res.didUserAlreadyHaveRole ? res.didUserAlreadyHaveRole
? "already had it" ? "already had it"
+1 -206
View File
@@ -22,7 +22,6 @@ import { Route as AuthedAdminRouteImport } from './routes/_authed/admin'
import { Route as AuthedTournamentsIndexRouteImport } from './routes/_authed/tournaments/index' import { Route as AuthedTournamentsIndexRouteImport } from './routes/_authed/tournaments/index'
import { Route as AuthedAdminIndexRouteImport } from './routes/_authed/admin/index' import { Route as AuthedAdminIndexRouteImport } from './routes/_authed/admin/index'
import { Route as ApiTournamentsUploadLogoRouteImport } from './routes/api/tournaments/upload-logo' import { Route as ApiTournamentsUploadLogoRouteImport } from './routes/api/tournaments/upload-logo'
import { Route as ApiTelemetryBeaconRouteImport } from './routes/api/telemetry/beacon'
import { Route as ApiTeamsUploadLogoRouteImport } from './routes/api/teams/upload-logo' import { Route as ApiTeamsUploadLogoRouteImport } from './routes/api/teams/upload-logo'
import { Route as ApiSpotifyTokenRouteImport } from './routes/api/spotify/token' import { Route as ApiSpotifyTokenRouteImport } from './routes/api/spotify/token'
import { Route as ApiSpotifySearchRouteImport } from './routes/api/spotify/search' import { Route as ApiSpotifySearchRouteImport } from './routes/api/spotify/search'
@@ -38,27 +37,19 @@ import { Route as ApiAuthSplatRouteImport } from './routes/api/auth.$'
import { Route as AuthedTournamentsTournamentIdRouteImport } from './routes/_authed/tournaments/$tournamentId' import { Route as AuthedTournamentsTournamentIdRouteImport } from './routes/_authed/tournaments/$tournamentId'
import { Route as AuthedTeamsTeamIdRouteImport } from './routes/_authed/teams.$teamId' import { Route as AuthedTeamsTeamIdRouteImport } from './routes/_authed/teams.$teamId'
import { Route as AuthedProfilePlayerIdRouteImport } from './routes/_authed/profile.$playerId' import { Route as AuthedProfilePlayerIdRouteImport } from './routes/_authed/profile.$playerId'
import { Route as AuthedAdminTelemetryRouteImport } from './routes/_authed/admin/telemetry'
import { Route as AuthedAdminPreviewRouteImport } from './routes/_authed/admin/preview' import { Route as AuthedAdminPreviewRouteImport } from './routes/_authed/admin/preview'
import { Route as AuthedAdminBadgesRouteImport } from './routes/_authed/admin/badges' import { Route as AuthedAdminBadgesRouteImport } from './routes/_authed/admin/badges'
import { Route as AuthedAdminActivitiesRouteImport } from './routes/_authed/admin/activities' import { Route as AuthedAdminActivitiesRouteImport } from './routes/_authed/admin/activities'
import { Route as AuthedAdminTournamentsIndexRouteImport } from './routes/_authed/admin/tournaments/index' import { Route as AuthedAdminTournamentsIndexRouteImport } from './routes/_authed/admin/tournaments/index'
import { Route as AuthedAdminTelemetryIndexRouteImport } from './routes/_authed/admin/telemetry/index'
import { Route as AuthedTournamentsIdPredictionsRouteImport } from './routes/_authed/tournaments/$id.predictions' import { Route as AuthedTournamentsIdPredictionsRouteImport } from './routes/_authed/tournaments/$id.predictions'
import { Route as AuthedTournamentsIdGroupsRouteImport } from './routes/_authed/tournaments/$id.groups' import { Route as AuthedTournamentsIdGroupsRouteImport } from './routes/_authed/tournaments/$id.groups'
import { Route as AuthedTournamentsIdBracketRouteImport } from './routes/_authed/tournaments/$id.bracket' import { Route as AuthedTournamentsIdBracketRouteImport } from './routes/_authed/tournaments/$id.bracket'
import { Route as AuthedAdminTelemetryUsageRouteImport } from './routes/_authed/admin/telemetry/usage'
import { Route as AuthedAdminTelemetrySessionsRouteImport } from './routes/_authed/admin/telemetry/sessions'
import { Route as AuthedAdminTelemetryPlayersRouteImport } from './routes/_authed/admin/telemetry/players'
import { Route as AuthedAdminTelemetryFunctionsRouteImport } from './routes/_authed/admin/telemetry/functions'
import { Route as AuthedAdminTelemetryErrorsRouteImport } from './routes/_authed/admin/telemetry/errors'
import { Route as AuthedAdminTournamentsIdIndexRouteImport } from './routes/_authed/admin/tournaments/$id/index' import { Route as AuthedAdminTournamentsIdIndexRouteImport } from './routes/_authed/admin/tournaments/$id/index'
import { Route as ApiFilesCollectionRecordIdFileRouteImport } from './routes/api/files/$collection/$recordId/$file' import { Route as ApiFilesCollectionRecordIdFileRouteImport } from './routes/api/files/$collection/$recordId/$file'
import { Route as AuthedTournamentsIdPredictionsMakeRouteImport } from './routes/_authed/tournaments/$id.predictions_.make' import { Route as AuthedTournamentsIdPredictionsMakeRouteImport } from './routes/_authed/tournaments/$id.predictions_.make'
import { Route as AuthedTournamentsIdPredictionsPlayerIdRouteImport } from './routes/_authed/tournaments/$id.predictions_.$playerId' import { Route as AuthedTournamentsIdPredictionsPlayerIdRouteImport } from './routes/_authed/tournaments/$id.predictions_.$playerId'
import { Route as AuthedAdminTournamentsRunIdRouteImport } from './routes/_authed/admin/tournaments/run.$id' import { Route as AuthedAdminTournamentsRunIdRouteImport } from './routes/_authed/admin/tournaments/run.$id'
import { Route as AuthedAdminTournamentsIdTeamsRouteImport } from './routes/_authed/admin/tournaments/$id/teams' import { Route as AuthedAdminTournamentsIdTeamsRouteImport } from './routes/_authed/admin/tournaments/$id/teams'
import { Route as AuthedAdminTournamentsIdEnrollmentRouteImport } from './routes/_authed/admin/tournaments/$id/enrollment'
import { Route as AuthedAdminTournamentsIdAssignPartnersRouteImport } from './routes/_authed/admin/tournaments/$id/assign-partners' import { Route as AuthedAdminTournamentsIdAssignPartnersRouteImport } from './routes/_authed/admin/tournaments/$id/assign-partners'
const RefreshSessionRoute = RefreshSessionRouteImport.update({ const RefreshSessionRoute = RefreshSessionRouteImport.update({
@@ -126,11 +117,6 @@ const ApiTournamentsUploadLogoRoute =
path: '/api/tournaments/upload-logo', path: '/api/tournaments/upload-logo',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const ApiTelemetryBeaconRoute = ApiTelemetryBeaconRouteImport.update({
id: '/api/telemetry/beacon',
path: '/api/telemetry/beacon',
getParentRoute: () => rootRouteImport,
} as any)
const ApiTeamsUploadLogoRoute = ApiTeamsUploadLogoRouteImport.update({ const ApiTeamsUploadLogoRoute = ApiTeamsUploadLogoRouteImport.update({
id: '/api/teams/upload-logo', id: '/api/teams/upload-logo',
path: '/api/teams/upload-logo', path: '/api/teams/upload-logo',
@@ -207,11 +193,6 @@ const AuthedProfilePlayerIdRoute = AuthedProfilePlayerIdRouteImport.update({
path: '/profile/$playerId', path: '/profile/$playerId',
getParentRoute: () => AuthedRoute, getParentRoute: () => AuthedRoute,
} as any) } as any)
const AuthedAdminTelemetryRoute = AuthedAdminTelemetryRouteImport.update({
id: '/telemetry',
path: '/telemetry',
getParentRoute: () => AuthedAdminRoute,
} as any)
const AuthedAdminPreviewRoute = AuthedAdminPreviewRouteImport.update({ const AuthedAdminPreviewRoute = AuthedAdminPreviewRouteImport.update({
id: '/preview', id: '/preview',
path: '/preview', path: '/preview',
@@ -233,12 +214,6 @@ const AuthedAdminTournamentsIndexRoute =
path: '/tournaments/', path: '/tournaments/',
getParentRoute: () => AuthedAdminRoute, getParentRoute: () => AuthedAdminRoute,
} as any) } as any)
const AuthedAdminTelemetryIndexRoute =
AuthedAdminTelemetryIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => AuthedAdminTelemetryRoute,
} as any)
const AuthedTournamentsIdPredictionsRoute = const AuthedTournamentsIdPredictionsRoute =
AuthedTournamentsIdPredictionsRouteImport.update({ AuthedTournamentsIdPredictionsRouteImport.update({
id: '/tournaments/$id/predictions', id: '/tournaments/$id/predictions',
@@ -257,36 +232,6 @@ const AuthedTournamentsIdBracketRoute =
path: '/tournaments/$id/bracket', path: '/tournaments/$id/bracket',
getParentRoute: () => AuthedRoute, getParentRoute: () => AuthedRoute,
} as any) } as any)
const AuthedAdminTelemetryUsageRoute =
AuthedAdminTelemetryUsageRouteImport.update({
id: '/usage',
path: '/usage',
getParentRoute: () => AuthedAdminTelemetryRoute,
} as any)
const AuthedAdminTelemetrySessionsRoute =
AuthedAdminTelemetrySessionsRouteImport.update({
id: '/sessions',
path: '/sessions',
getParentRoute: () => AuthedAdminTelemetryRoute,
} as any)
const AuthedAdminTelemetryPlayersRoute =
AuthedAdminTelemetryPlayersRouteImport.update({
id: '/players',
path: '/players',
getParentRoute: () => AuthedAdminTelemetryRoute,
} as any)
const AuthedAdminTelemetryFunctionsRoute =
AuthedAdminTelemetryFunctionsRouteImport.update({
id: '/functions',
path: '/functions',
getParentRoute: () => AuthedAdminTelemetryRoute,
} as any)
const AuthedAdminTelemetryErrorsRoute =
AuthedAdminTelemetryErrorsRouteImport.update({
id: '/errors',
path: '/errors',
getParentRoute: () => AuthedAdminTelemetryRoute,
} as any)
const AuthedAdminTournamentsIdIndexRoute = const AuthedAdminTournamentsIdIndexRoute =
AuthedAdminTournamentsIdIndexRouteImport.update({ AuthedAdminTournamentsIdIndexRouteImport.update({
id: '/tournaments/$id/', id: '/tournaments/$id/',
@@ -323,12 +268,6 @@ const AuthedAdminTournamentsIdTeamsRoute =
path: '/tournaments/$id/teams', path: '/tournaments/$id/teams',
getParentRoute: () => AuthedAdminRoute, getParentRoute: () => AuthedAdminRoute,
} as any) } as any)
const AuthedAdminTournamentsIdEnrollmentRoute =
AuthedAdminTournamentsIdEnrollmentRouteImport.update({
id: '/tournaments/$id/enrollment',
path: '/tournaments/$id/enrollment',
getParentRoute: () => AuthedAdminRoute,
} as any)
const AuthedAdminTournamentsIdAssignPartnersRoute = const AuthedAdminTournamentsIdAssignPartnersRoute =
AuthedAdminTournamentsIdAssignPartnersRouteImport.update({ AuthedAdminTournamentsIdAssignPartnersRouteImport.update({
id: '/tournaments/$id/assign-partners', id: '/tournaments/$id/assign-partners',
@@ -349,7 +288,6 @@ export interface FileRoutesByFullPath {
'/admin/activities': typeof AuthedAdminActivitiesRoute '/admin/activities': typeof AuthedAdminActivitiesRoute
'/admin/badges': typeof AuthedAdminBadgesRoute '/admin/badges': typeof AuthedAdminBadgesRoute
'/admin/preview': typeof AuthedAdminPreviewRoute '/admin/preview': typeof AuthedAdminPreviewRoute
'/admin/telemetry': typeof AuthedAdminTelemetryRouteWithChildren
'/profile/$playerId': typeof AuthedProfilePlayerIdRoute '/profile/$playerId': typeof AuthedProfilePlayerIdRoute
'/teams/$teamId': typeof AuthedTeamsTeamIdRoute '/teams/$teamId': typeof AuthedTeamsTeamIdRoute
'/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute '/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
@@ -365,22 +303,14 @@ export interface FileRoutesByFullPath {
'/api/spotify/search': typeof ApiSpotifySearchRoute '/api/spotify/search': typeof ApiSpotifySearchRoute
'/api/spotify/token': typeof ApiSpotifyTokenRoute '/api/spotify/token': typeof ApiSpotifyTokenRoute
'/api/teams/upload-logo': typeof ApiTeamsUploadLogoRoute '/api/teams/upload-logo': typeof ApiTeamsUploadLogoRoute
'/api/telemetry/beacon': typeof ApiTelemetryBeaconRoute
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoRoute '/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoRoute
'/admin/': typeof AuthedAdminIndexRoute '/admin/': typeof AuthedAdminIndexRoute
'/tournaments/': typeof AuthedTournamentsIndexRoute '/tournaments/': typeof AuthedTournamentsIndexRoute
'/admin/telemetry/errors': typeof AuthedAdminTelemetryErrorsRoute
'/admin/telemetry/functions': typeof AuthedAdminTelemetryFunctionsRoute
'/admin/telemetry/players': typeof AuthedAdminTelemetryPlayersRoute
'/admin/telemetry/sessions': typeof AuthedAdminTelemetrySessionsRoute
'/admin/telemetry/usage': typeof AuthedAdminTelemetryUsageRoute
'/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute '/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
'/tournaments/$id/groups': typeof AuthedTournamentsIdGroupsRoute '/tournaments/$id/groups': typeof AuthedTournamentsIdGroupsRoute
'/tournaments/$id/predictions': typeof AuthedTournamentsIdPredictionsRoute '/tournaments/$id/predictions': typeof AuthedTournamentsIdPredictionsRoute
'/admin/telemetry/': typeof AuthedAdminTelemetryIndexRoute
'/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute '/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute
'/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute '/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute
'/admin/tournaments/$id/enrollment': typeof AuthedAdminTournamentsIdEnrollmentRoute
'/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute '/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
'/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute '/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
'/tournaments/$id/predictions/$playerId': typeof AuthedTournamentsIdPredictionsPlayerIdRoute '/tournaments/$id/predictions/$playerId': typeof AuthedTournamentsIdPredictionsPlayerIdRoute
@@ -415,22 +345,14 @@ export interface FileRoutesByTo {
'/api/spotify/search': typeof ApiSpotifySearchRoute '/api/spotify/search': typeof ApiSpotifySearchRoute
'/api/spotify/token': typeof ApiSpotifyTokenRoute '/api/spotify/token': typeof ApiSpotifyTokenRoute
'/api/teams/upload-logo': typeof ApiTeamsUploadLogoRoute '/api/teams/upload-logo': typeof ApiTeamsUploadLogoRoute
'/api/telemetry/beacon': typeof ApiTelemetryBeaconRoute
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoRoute '/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoRoute
'/admin': typeof AuthedAdminIndexRoute '/admin': typeof AuthedAdminIndexRoute
'/tournaments': typeof AuthedTournamentsIndexRoute '/tournaments': typeof AuthedTournamentsIndexRoute
'/admin/telemetry/errors': typeof AuthedAdminTelemetryErrorsRoute
'/admin/telemetry/functions': typeof AuthedAdminTelemetryFunctionsRoute
'/admin/telemetry/players': typeof AuthedAdminTelemetryPlayersRoute
'/admin/telemetry/sessions': typeof AuthedAdminTelemetrySessionsRoute
'/admin/telemetry/usage': typeof AuthedAdminTelemetryUsageRoute
'/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute '/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
'/tournaments/$id/groups': typeof AuthedTournamentsIdGroupsRoute '/tournaments/$id/groups': typeof AuthedTournamentsIdGroupsRoute
'/tournaments/$id/predictions': typeof AuthedTournamentsIdPredictionsRoute '/tournaments/$id/predictions': typeof AuthedTournamentsIdPredictionsRoute
'/admin/telemetry': typeof AuthedAdminTelemetryIndexRoute
'/admin/tournaments': typeof AuthedAdminTournamentsIndexRoute '/admin/tournaments': typeof AuthedAdminTournamentsIndexRoute
'/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute '/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute
'/admin/tournaments/$id/enrollment': typeof AuthedAdminTournamentsIdEnrollmentRoute
'/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute '/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
'/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute '/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
'/tournaments/$id/predictions/$playerId': typeof AuthedTournamentsIdPredictionsPlayerIdRoute '/tournaments/$id/predictions/$playerId': typeof AuthedTournamentsIdPredictionsPlayerIdRoute
@@ -453,7 +375,6 @@ export interface FileRoutesById {
'/_authed/admin/activities': typeof AuthedAdminActivitiesRoute '/_authed/admin/activities': typeof AuthedAdminActivitiesRoute
'/_authed/admin/badges': typeof AuthedAdminBadgesRoute '/_authed/admin/badges': typeof AuthedAdminBadgesRoute
'/_authed/admin/preview': typeof AuthedAdminPreviewRoute '/_authed/admin/preview': typeof AuthedAdminPreviewRoute
'/_authed/admin/telemetry': typeof AuthedAdminTelemetryRouteWithChildren
'/_authed/profile/$playerId': typeof AuthedProfilePlayerIdRoute '/_authed/profile/$playerId': typeof AuthedProfilePlayerIdRoute
'/_authed/teams/$teamId': typeof AuthedTeamsTeamIdRoute '/_authed/teams/$teamId': typeof AuthedTeamsTeamIdRoute
'/_authed/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute '/_authed/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
@@ -469,22 +390,14 @@ export interface FileRoutesById {
'/api/spotify/search': typeof ApiSpotifySearchRoute '/api/spotify/search': typeof ApiSpotifySearchRoute
'/api/spotify/token': typeof ApiSpotifyTokenRoute '/api/spotify/token': typeof ApiSpotifyTokenRoute
'/api/teams/upload-logo': typeof ApiTeamsUploadLogoRoute '/api/teams/upload-logo': typeof ApiTeamsUploadLogoRoute
'/api/telemetry/beacon': typeof ApiTelemetryBeaconRoute
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoRoute '/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoRoute
'/_authed/admin/': typeof AuthedAdminIndexRoute '/_authed/admin/': typeof AuthedAdminIndexRoute
'/_authed/tournaments/': typeof AuthedTournamentsIndexRoute '/_authed/tournaments/': typeof AuthedTournamentsIndexRoute
'/_authed/admin/telemetry/errors': typeof AuthedAdminTelemetryErrorsRoute
'/_authed/admin/telemetry/functions': typeof AuthedAdminTelemetryFunctionsRoute
'/_authed/admin/telemetry/players': typeof AuthedAdminTelemetryPlayersRoute
'/_authed/admin/telemetry/sessions': typeof AuthedAdminTelemetrySessionsRoute
'/_authed/admin/telemetry/usage': typeof AuthedAdminTelemetryUsageRoute
'/_authed/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute '/_authed/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
'/_authed/tournaments/$id/groups': typeof AuthedTournamentsIdGroupsRoute '/_authed/tournaments/$id/groups': typeof AuthedTournamentsIdGroupsRoute
'/_authed/tournaments/$id/predictions': typeof AuthedTournamentsIdPredictionsRoute '/_authed/tournaments/$id/predictions': typeof AuthedTournamentsIdPredictionsRoute
'/_authed/admin/telemetry/': typeof AuthedAdminTelemetryIndexRoute
'/_authed/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute '/_authed/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute
'/_authed/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute '/_authed/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute
'/_authed/admin/tournaments/$id/enrollment': typeof AuthedAdminTournamentsIdEnrollmentRoute
'/_authed/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute '/_authed/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
'/_authed/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute '/_authed/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
'/_authed/tournaments/$id/predictions_/$playerId': typeof AuthedTournamentsIdPredictionsPlayerIdRoute '/_authed/tournaments/$id/predictions_/$playerId': typeof AuthedTournamentsIdPredictionsPlayerIdRoute
@@ -507,7 +420,6 @@ export interface FileRouteTypes {
| '/admin/activities' | '/admin/activities'
| '/admin/badges' | '/admin/badges'
| '/admin/preview' | '/admin/preview'
| '/admin/telemetry'
| '/profile/$playerId' | '/profile/$playerId'
| '/teams/$teamId' | '/teams/$teamId'
| '/tournaments/$tournamentId' | '/tournaments/$tournamentId'
@@ -523,22 +435,14 @@ export interface FileRouteTypes {
| '/api/spotify/search' | '/api/spotify/search'
| '/api/spotify/token' | '/api/spotify/token'
| '/api/teams/upload-logo' | '/api/teams/upload-logo'
| '/api/telemetry/beacon'
| '/api/tournaments/upload-logo' | '/api/tournaments/upload-logo'
| '/admin/' | '/admin/'
| '/tournaments/' | '/tournaments/'
| '/admin/telemetry/errors'
| '/admin/telemetry/functions'
| '/admin/telemetry/players'
| '/admin/telemetry/sessions'
| '/admin/telemetry/usage'
| '/tournaments/$id/bracket' | '/tournaments/$id/bracket'
| '/tournaments/$id/groups' | '/tournaments/$id/groups'
| '/tournaments/$id/predictions' | '/tournaments/$id/predictions'
| '/admin/telemetry/'
| '/admin/tournaments/' | '/admin/tournaments/'
| '/admin/tournaments/$id/assign-partners' | '/admin/tournaments/$id/assign-partners'
| '/admin/tournaments/$id/enrollment'
| '/admin/tournaments/$id/teams' | '/admin/tournaments/$id/teams'
| '/admin/tournaments/run/$id' | '/admin/tournaments/run/$id'
| '/tournaments/$id/predictions/$playerId' | '/tournaments/$id/predictions/$playerId'
@@ -573,22 +477,14 @@ export interface FileRouteTypes {
| '/api/spotify/search' | '/api/spotify/search'
| '/api/spotify/token' | '/api/spotify/token'
| '/api/teams/upload-logo' | '/api/teams/upload-logo'
| '/api/telemetry/beacon'
| '/api/tournaments/upload-logo' | '/api/tournaments/upload-logo'
| '/admin' | '/admin'
| '/tournaments' | '/tournaments'
| '/admin/telemetry/errors'
| '/admin/telemetry/functions'
| '/admin/telemetry/players'
| '/admin/telemetry/sessions'
| '/admin/telemetry/usage'
| '/tournaments/$id/bracket' | '/tournaments/$id/bracket'
| '/tournaments/$id/groups' | '/tournaments/$id/groups'
| '/tournaments/$id/predictions' | '/tournaments/$id/predictions'
| '/admin/telemetry'
| '/admin/tournaments' | '/admin/tournaments'
| '/admin/tournaments/$id/assign-partners' | '/admin/tournaments/$id/assign-partners'
| '/admin/tournaments/$id/enrollment'
| '/admin/tournaments/$id/teams' | '/admin/tournaments/$id/teams'
| '/admin/tournaments/run/$id' | '/admin/tournaments/run/$id'
| '/tournaments/$id/predictions/$playerId' | '/tournaments/$id/predictions/$playerId'
@@ -610,7 +506,6 @@ export interface FileRouteTypes {
| '/_authed/admin/activities' | '/_authed/admin/activities'
| '/_authed/admin/badges' | '/_authed/admin/badges'
| '/_authed/admin/preview' | '/_authed/admin/preview'
| '/_authed/admin/telemetry'
| '/_authed/profile/$playerId' | '/_authed/profile/$playerId'
| '/_authed/teams/$teamId' | '/_authed/teams/$teamId'
| '/_authed/tournaments/$tournamentId' | '/_authed/tournaments/$tournamentId'
@@ -626,22 +521,14 @@ export interface FileRouteTypes {
| '/api/spotify/search' | '/api/spotify/search'
| '/api/spotify/token' | '/api/spotify/token'
| '/api/teams/upload-logo' | '/api/teams/upload-logo'
| '/api/telemetry/beacon'
| '/api/tournaments/upload-logo' | '/api/tournaments/upload-logo'
| '/_authed/admin/' | '/_authed/admin/'
| '/_authed/tournaments/' | '/_authed/tournaments/'
| '/_authed/admin/telemetry/errors'
| '/_authed/admin/telemetry/functions'
| '/_authed/admin/telemetry/players'
| '/_authed/admin/telemetry/sessions'
| '/_authed/admin/telemetry/usage'
| '/_authed/tournaments/$id/bracket' | '/_authed/tournaments/$id/bracket'
| '/_authed/tournaments/$id/groups' | '/_authed/tournaments/$id/groups'
| '/_authed/tournaments/$id/predictions' | '/_authed/tournaments/$id/predictions'
| '/_authed/admin/telemetry/'
| '/_authed/admin/tournaments/' | '/_authed/admin/tournaments/'
| '/_authed/admin/tournaments/$id/assign-partners' | '/_authed/admin/tournaments/$id/assign-partners'
| '/_authed/admin/tournaments/$id/enrollment'
| '/_authed/admin/tournaments/$id/teams' | '/_authed/admin/tournaments/$id/teams'
| '/_authed/admin/tournaments/run/$id' | '/_authed/admin/tournaments/run/$id'
| '/_authed/tournaments/$id/predictions_/$playerId' | '/_authed/tournaments/$id/predictions_/$playerId'
@@ -668,7 +555,6 @@ export interface RootRouteChildren {
ApiSpotifySearchRoute: typeof ApiSpotifySearchRoute ApiSpotifySearchRoute: typeof ApiSpotifySearchRoute
ApiSpotifyTokenRoute: typeof ApiSpotifyTokenRoute ApiSpotifyTokenRoute: typeof ApiSpotifyTokenRoute
ApiTeamsUploadLogoRoute: typeof ApiTeamsUploadLogoRoute ApiTeamsUploadLogoRoute: typeof ApiTeamsUploadLogoRoute
ApiTelemetryBeaconRoute: typeof ApiTelemetryBeaconRoute
ApiTournamentsUploadLogoRoute: typeof ApiTournamentsUploadLogoRoute ApiTournamentsUploadLogoRoute: typeof ApiTournamentsUploadLogoRoute
ApiFilesCollectionRecordIdFileRoute: typeof ApiFilesCollectionRecordIdFileRoute ApiFilesCollectionRecordIdFileRoute: typeof ApiFilesCollectionRecordIdFileRoute
} }
@@ -766,13 +652,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiTournamentsUploadLogoRouteImport preLoaderRoute: typeof ApiTournamentsUploadLogoRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/api/telemetry/beacon': {
id: '/api/telemetry/beacon'
path: '/api/telemetry/beacon'
fullPath: '/api/telemetry/beacon'
preLoaderRoute: typeof ApiTelemetryBeaconRouteImport
parentRoute: typeof rootRouteImport
}
'/api/teams/upload-logo': { '/api/teams/upload-logo': {
id: '/api/teams/upload-logo' id: '/api/teams/upload-logo'
path: '/api/teams/upload-logo' path: '/api/teams/upload-logo'
@@ -878,13 +757,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthedProfilePlayerIdRouteImport preLoaderRoute: typeof AuthedProfilePlayerIdRouteImport
parentRoute: typeof AuthedRoute parentRoute: typeof AuthedRoute
} }
'/_authed/admin/telemetry': {
id: '/_authed/admin/telemetry'
path: '/telemetry'
fullPath: '/admin/telemetry'
preLoaderRoute: typeof AuthedAdminTelemetryRouteImport
parentRoute: typeof AuthedAdminRoute
}
'/_authed/admin/preview': { '/_authed/admin/preview': {
id: '/_authed/admin/preview' id: '/_authed/admin/preview'
path: '/preview' path: '/preview'
@@ -913,13 +785,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthedAdminTournamentsIndexRouteImport preLoaderRoute: typeof AuthedAdminTournamentsIndexRouteImport
parentRoute: typeof AuthedAdminRoute parentRoute: typeof AuthedAdminRoute
} }
'/_authed/admin/telemetry/': {
id: '/_authed/admin/telemetry/'
path: '/'
fullPath: '/admin/telemetry/'
preLoaderRoute: typeof AuthedAdminTelemetryIndexRouteImport
parentRoute: typeof AuthedAdminTelemetryRoute
}
'/_authed/tournaments/$id/predictions': { '/_authed/tournaments/$id/predictions': {
id: '/_authed/tournaments/$id/predictions' id: '/_authed/tournaments/$id/predictions'
path: '/tournaments/$id/predictions' path: '/tournaments/$id/predictions'
@@ -941,41 +806,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthedTournamentsIdBracketRouteImport preLoaderRoute: typeof AuthedTournamentsIdBracketRouteImport
parentRoute: typeof AuthedRoute parentRoute: typeof AuthedRoute
} }
'/_authed/admin/telemetry/usage': {
id: '/_authed/admin/telemetry/usage'
path: '/usage'
fullPath: '/admin/telemetry/usage'
preLoaderRoute: typeof AuthedAdminTelemetryUsageRouteImport
parentRoute: typeof AuthedAdminTelemetryRoute
}
'/_authed/admin/telemetry/sessions': {
id: '/_authed/admin/telemetry/sessions'
path: '/sessions'
fullPath: '/admin/telemetry/sessions'
preLoaderRoute: typeof AuthedAdminTelemetrySessionsRouteImport
parentRoute: typeof AuthedAdminTelemetryRoute
}
'/_authed/admin/telemetry/players': {
id: '/_authed/admin/telemetry/players'
path: '/players'
fullPath: '/admin/telemetry/players'
preLoaderRoute: typeof AuthedAdminTelemetryPlayersRouteImport
parentRoute: typeof AuthedAdminTelemetryRoute
}
'/_authed/admin/telemetry/functions': {
id: '/_authed/admin/telemetry/functions'
path: '/functions'
fullPath: '/admin/telemetry/functions'
preLoaderRoute: typeof AuthedAdminTelemetryFunctionsRouteImport
parentRoute: typeof AuthedAdminTelemetryRoute
}
'/_authed/admin/telemetry/errors': {
id: '/_authed/admin/telemetry/errors'
path: '/errors'
fullPath: '/admin/telemetry/errors'
preLoaderRoute: typeof AuthedAdminTelemetryErrorsRouteImport
parentRoute: typeof AuthedAdminTelemetryRoute
}
'/_authed/admin/tournaments/$id/': { '/_authed/admin/tournaments/$id/': {
id: '/_authed/admin/tournaments/$id/' id: '/_authed/admin/tournaments/$id/'
path: '/tournaments/$id' path: '/tournaments/$id'
@@ -1018,13 +848,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthedAdminTournamentsIdTeamsRouteImport preLoaderRoute: typeof AuthedAdminTournamentsIdTeamsRouteImport
parentRoute: typeof AuthedAdminRoute parentRoute: typeof AuthedAdminRoute
} }
'/_authed/admin/tournaments/$id/enrollment': {
id: '/_authed/admin/tournaments/$id/enrollment'
path: '/tournaments/$id/enrollment'
fullPath: '/admin/tournaments/$id/enrollment'
preLoaderRoute: typeof AuthedAdminTournamentsIdEnrollmentRouteImport
parentRoute: typeof AuthedAdminRoute
}
'/_authed/admin/tournaments/$id/assign-partners': { '/_authed/admin/tournaments/$id/assign-partners': {
id: '/_authed/admin/tournaments/$id/assign-partners' id: '/_authed/admin/tournaments/$id/assign-partners'
path: '/tournaments/$id/assign-partners' path: '/tournaments/$id/assign-partners'
@@ -1035,36 +858,13 @@ declare module '@tanstack/react-router' {
} }
} }
interface AuthedAdminTelemetryRouteChildren {
AuthedAdminTelemetryErrorsRoute: typeof AuthedAdminTelemetryErrorsRoute
AuthedAdminTelemetryFunctionsRoute: typeof AuthedAdminTelemetryFunctionsRoute
AuthedAdminTelemetryPlayersRoute: typeof AuthedAdminTelemetryPlayersRoute
AuthedAdminTelemetrySessionsRoute: typeof AuthedAdminTelemetrySessionsRoute
AuthedAdminTelemetryUsageRoute: typeof AuthedAdminTelemetryUsageRoute
AuthedAdminTelemetryIndexRoute: typeof AuthedAdminTelemetryIndexRoute
}
const AuthedAdminTelemetryRouteChildren: AuthedAdminTelemetryRouteChildren = {
AuthedAdminTelemetryErrorsRoute: AuthedAdminTelemetryErrorsRoute,
AuthedAdminTelemetryFunctionsRoute: AuthedAdminTelemetryFunctionsRoute,
AuthedAdminTelemetryPlayersRoute: AuthedAdminTelemetryPlayersRoute,
AuthedAdminTelemetrySessionsRoute: AuthedAdminTelemetrySessionsRoute,
AuthedAdminTelemetryUsageRoute: AuthedAdminTelemetryUsageRoute,
AuthedAdminTelemetryIndexRoute: AuthedAdminTelemetryIndexRoute,
}
const AuthedAdminTelemetryRouteWithChildren =
AuthedAdminTelemetryRoute._addFileChildren(AuthedAdminTelemetryRouteChildren)
interface AuthedAdminRouteChildren { interface AuthedAdminRouteChildren {
AuthedAdminActivitiesRoute: typeof AuthedAdminActivitiesRoute AuthedAdminActivitiesRoute: typeof AuthedAdminActivitiesRoute
AuthedAdminBadgesRoute: typeof AuthedAdminBadgesRoute AuthedAdminBadgesRoute: typeof AuthedAdminBadgesRoute
AuthedAdminPreviewRoute: typeof AuthedAdminPreviewRoute AuthedAdminPreviewRoute: typeof AuthedAdminPreviewRoute
AuthedAdminTelemetryRoute: typeof AuthedAdminTelemetryRouteWithChildren
AuthedAdminIndexRoute: typeof AuthedAdminIndexRoute AuthedAdminIndexRoute: typeof AuthedAdminIndexRoute
AuthedAdminTournamentsIndexRoute: typeof AuthedAdminTournamentsIndexRoute AuthedAdminTournamentsIndexRoute: typeof AuthedAdminTournamentsIndexRoute
AuthedAdminTournamentsIdAssignPartnersRoute: typeof AuthedAdminTournamentsIdAssignPartnersRoute AuthedAdminTournamentsIdAssignPartnersRoute: typeof AuthedAdminTournamentsIdAssignPartnersRoute
AuthedAdminTournamentsIdEnrollmentRoute: typeof AuthedAdminTournamentsIdEnrollmentRoute
AuthedAdminTournamentsIdTeamsRoute: typeof AuthedAdminTournamentsIdTeamsRoute AuthedAdminTournamentsIdTeamsRoute: typeof AuthedAdminTournamentsIdTeamsRoute
AuthedAdminTournamentsRunIdRoute: typeof AuthedAdminTournamentsRunIdRoute AuthedAdminTournamentsRunIdRoute: typeof AuthedAdminTournamentsRunIdRoute
AuthedAdminTournamentsIdIndexRoute: typeof AuthedAdminTournamentsIdIndexRoute AuthedAdminTournamentsIdIndexRoute: typeof AuthedAdminTournamentsIdIndexRoute
@@ -1074,13 +874,10 @@ const AuthedAdminRouteChildren: AuthedAdminRouteChildren = {
AuthedAdminActivitiesRoute: AuthedAdminActivitiesRoute, AuthedAdminActivitiesRoute: AuthedAdminActivitiesRoute,
AuthedAdminBadgesRoute: AuthedAdminBadgesRoute, AuthedAdminBadgesRoute: AuthedAdminBadgesRoute,
AuthedAdminPreviewRoute: AuthedAdminPreviewRoute, AuthedAdminPreviewRoute: AuthedAdminPreviewRoute,
AuthedAdminTelemetryRoute: AuthedAdminTelemetryRouteWithChildren,
AuthedAdminIndexRoute: AuthedAdminIndexRoute, AuthedAdminIndexRoute: AuthedAdminIndexRoute,
AuthedAdminTournamentsIndexRoute: AuthedAdminTournamentsIndexRoute, AuthedAdminTournamentsIndexRoute: AuthedAdminTournamentsIndexRoute,
AuthedAdminTournamentsIdAssignPartnersRoute: AuthedAdminTournamentsIdAssignPartnersRoute:
AuthedAdminTournamentsIdAssignPartnersRoute, AuthedAdminTournamentsIdAssignPartnersRoute,
AuthedAdminTournamentsIdEnrollmentRoute:
AuthedAdminTournamentsIdEnrollmentRoute,
AuthedAdminTournamentsIdTeamsRoute: AuthedAdminTournamentsIdTeamsRoute, AuthedAdminTournamentsIdTeamsRoute: AuthedAdminTournamentsIdTeamsRoute,
AuthedAdminTournamentsRunIdRoute: AuthedAdminTournamentsRunIdRoute, AuthedAdminTournamentsRunIdRoute: AuthedAdminTournamentsRunIdRoute,
AuthedAdminTournamentsIdIndexRoute: AuthedAdminTournamentsIdIndexRoute, AuthedAdminTournamentsIdIndexRoute: AuthedAdminTournamentsIdIndexRoute,
@@ -1147,7 +944,6 @@ const rootRouteChildren: RootRouteChildren = {
ApiSpotifySearchRoute: ApiSpotifySearchRoute, ApiSpotifySearchRoute: ApiSpotifySearchRoute,
ApiSpotifyTokenRoute: ApiSpotifyTokenRoute, ApiSpotifyTokenRoute: ApiSpotifyTokenRoute,
ApiTeamsUploadLogoRoute: ApiTeamsUploadLogoRoute, ApiTeamsUploadLogoRoute: ApiTeamsUploadLogoRoute,
ApiTelemetryBeaconRoute: ApiTelemetryBeaconRoute,
ApiTournamentsUploadLogoRoute: ApiTournamentsUploadLogoRoute, ApiTournamentsUploadLogoRoute: ApiTournamentsUploadLogoRoute,
ApiFilesCollectionRecordIdFileRoute: ApiFilesCollectionRecordIdFileRoute, ApiFilesCollectionRecordIdFileRoute: ApiFilesCollectionRecordIdFileRoute,
} }
@@ -1156,11 +952,10 @@ export const routeTree = rootRouteImport
._addFileTypes<FileRouteTypes>() ._addFileTypes<FileRouteTypes>()
import type { getRouter } from './router.tsx' import type { getRouter } from './router.tsx'
import type { startInstance } from './start.ts' import type { createStart } from '@tanstack/react-start'
declare module '@tanstack/react-start' { declare module '@tanstack/react-start' {
interface Register { interface Register {
ssr: true ssr: true
router: Awaited<ReturnType<typeof getRouter>> router: Awaited<ReturnType<typeof getRouter>>
config: Awaited<ReturnType<typeof startInstance.getOptions>>
} }
} }
-4
View File
@@ -3,7 +3,6 @@ import { createRouter as createTanStackRouter } from "@tanstack/react-router";
import { setupRouterSsrQueryIntegration } from "@tanstack/react-router-ssr-query"; import { setupRouterSsrQueryIntegration } from "@tanstack/react-router-ssr-query";
import { routeTree } from "./routeTree.gen"; import { routeTree } from "./routeTree.gen";
import { DefaultCatchBoundary } from "../components/DefaultCatchBoundary"; import { DefaultCatchBoundary } from "../components/DefaultCatchBoundary";
import RoutePending from "../components/route-pending";
import { defaultHeaderConfig } from "@/features/core/hooks/use-router-config"; import { defaultHeaderConfig } from "@/features/core/hooks/use-router-config";
export function getRouter() { export function getRouter() {
@@ -31,9 +30,6 @@ export function getRouter() {
}, },
defaultPreload: "intent", defaultPreload: "intent",
defaultPreloadStaleTime: 60_000, defaultPreloadStaleTime: 60_000,
defaultPendingMs: 100,
defaultPendingMinMs: 0,
defaultPendingComponent: RoutePending,
defaultErrorComponent: DefaultCatchBoundary, defaultErrorComponent: DefaultCatchBoundary,
}); });
+4 -22
View File
@@ -5,13 +5,11 @@ import {
Scripts, Scripts,
createRootRouteWithContext, createRootRouteWithContext,
isRedirect, isRedirect,
useRouter,
} from "@tanstack/react-router"; } from "@tanstack/react-router";
import * as React from "react"; import * as React from "react";
import { DefaultCatchBoundary } from "@/components/DefaultCatchBoundary"; import { DefaultCatchBoundary } from "@/components/DefaultCatchBoundary";
import { type QueryClient } from "@tanstack/react-query"; import { type QueryClient } from "@tanstack/react-query";
import { ensureSuperTokensFrontend } from "@/lib/supertokens/client"; import { ensureSuperTokensFrontend } from "@/lib/supertokens/client";
import { initTelemetry, track } from "@/lib/telemetry/client";
import { AuthContextType } from "@/contexts/auth-context"; import { AuthContextType } from "@/contexts/auth-context";
import Providers from "@/features/core/components/providers"; import Providers from "@/features/core/components/providers";
import { SessionMonitor } from "@/components/session-monitor"; import { SessionMonitor } from "@/components/session-monitor";
@@ -31,7 +29,6 @@ import { getRootMeta } from "@/lib/i18n/meta";
import FullScreenLoader from "@/components/full-screen-loader"; import FullScreenLoader from "@/components/full-screen-loader";
import { CHROME_COLORS } from "@/lib/mantine/theme-colors"; import { CHROME_COLORS } from "@/lib/mantine/theme-colors";
import mantineCssUrl from '@mantine/core/styles.css?url' import mantineCssUrl from '@mantine/core/styles.css?url'
import appStylesUrl from '../styles.css?url'
import mantineDatesCssUrl from '@mantine/dates/styles.css?url' import mantineDatesCssUrl from '@mantine/dates/styles.css?url'
import mantineCarouselCssUrl from '@mantine/carousel/styles.css?url' import mantineCarouselCssUrl from '@mantine/carousel/styles.css?url'
import mantineTiptapCssUrl from '@mantine/tiptap/styles.css?url' import mantineTiptapCssUrl from '@mantine/tiptap/styles.css?url'
@@ -102,7 +99,6 @@ export const Route = createRootRouteWithContext<{
}, },
{ rel: "manifest", href: "/site.webmanifest" }, { rel: "manifest", href: "/site.webmanifest" },
{ rel: "icon", href: "/favicon.ico" }, { rel: "icon", href: "/favicon.ico" },
{ rel: 'stylesheet', href: appStylesUrl },
{ rel: 'stylesheet', href: mantineCssUrl }, { rel: 'stylesheet', href: mantineCssUrl },
{ rel: 'stylesheet', href: mantineCarouselCssUrl }, { rel: 'stylesheet', href: mantineCarouselCssUrl },
{ rel: 'stylesheet', href: mantineDatesCssUrl }, { rel: 'stylesheet', href: mantineDatesCssUrl },
@@ -179,27 +175,12 @@ export const Route = createRootRouteWithContext<{
}); });
function RootComponent() { function RootComponent() {
const router = useRouter();
React.useEffect(() => { React.useEffect(() => {
ensureSuperTokensFrontend(); ensureSuperTokensFrontend();
initTelemetry(router);
if (import.meta.env.PROD && 'serviceWorker' in navigator) { if (import.meta.env.PROD && 'serviceWorker' in navigator) {
// first install claims the page too; only reload on real updates navigator.serviceWorker.register('/sw.js').catch(() => {});
const hadController = !!navigator.serviceWorker.controller;
let reloading = false;
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (!hadController || reloading) return;
reloading = true;
window.location.reload();
});
navigator.serviceWorker.register('/sw.js').then((registration) => {
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') registration.update().catch(() => {});
});
}).catch(() => track('sw_register_failed'));
} }
}, [router]); }, []);
return ( return (
<RootDocument> <RootDocument>
@@ -212,6 +193,7 @@ function RootComponent() {
); );
} }
// todo: analytics -> process.env data-website-id
function RootDocument({ children }: { children: React.ReactNode }) { function RootDocument({ children }: { children: React.ReactNode }) {
return ( return (
<html <html
@@ -245,6 +227,7 @@ function RootDocument({ children }: { children: React.ReactNode }) {
'try{var d=document.documentElement,t=localStorage.getItem("flxn-theme");if(t&&t!=="default"){d.setAttribute("data-theme",t);}var s=localStorage.getItem("flxn-theme-scheme");if(s){d.setAttribute("data-mantine-color-scheme",s);}var f=JSON.parse(localStorage.getItem("flxn-theme-fonts")||"[]");for(var i=0;i<f.length;i++){var l=document.createElement("link");l.rel="stylesheet";l.href=f[i];l.setAttribute("data-flxn-theme-font",t||"default");document.head.appendChild(l);}}catch(e){}', 'try{var d=document.documentElement,t=localStorage.getItem("flxn-theme");if(t&&t!=="default"){d.setAttribute("data-theme",t);}var s=localStorage.getItem("flxn-theme-scheme");if(s){d.setAttribute("data-mantine-color-scheme",s);}var f=JSON.parse(localStorage.getItem("flxn-theme-fonts")||"[]");for(var i=0;i<f.length;i++){var l=document.createElement("link");l.rel="stylesheet";l.href=f[i];l.setAttribute("data-flxn-theme-font",t||"default");document.head.appendChild(l);}}catch(e){}',
}} }}
/> />
<link rel="stylesheet" href="/styles.css" />
</head> </head>
<body <body
style={{ style={{
@@ -256,7 +239,6 @@ function RootDocument({ children }: { children: React.ReactNode }) {
}} }}
> >
<div className="app">{children}</div> <div className="app">{children}</div>
<div className="flxn-theme-overlay" aria-hidden="true" />
<Scripts /> <Scripts />
</body> </body>
</html> </html>
+1 -2
View File
@@ -1,12 +1,11 @@
import { Outlet, redirect, createFileRoute } from "@tanstack/react-router"; import { Outlet, redirect, createFileRoute } from "@tanstack/react-router";
import { msg } from "@lingui/core/macro"; import { msg } from "@lingui/core/macro";
import type { HeaderConfig } from "@/features/core/types/header-config"; import type { HeaderConfig } from "@/features/core/types/header-config";
import { isAdminRole } from "@/features/core/utils/roles";
export const Route = createFileRoute("/_authed/admin")({ export const Route = createFileRoute("/_authed/admin")({
component: Outlet, component: Outlet,
beforeLoad: ({ context }) => { beforeLoad: ({ context }) => {
if (!isAdminRole(context.auth?.roles)) { if (!context.auth?.roles?.includes("Admin")) {
throw redirect({ to: "/" }); throw redirect({ to: "/" });
} }
+71 -3
View File
@@ -1,7 +1,75 @@
import { createFileRoute, redirect } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
import { ActivitiesTable, activityQueries } from "@/features/activities";
import { PlayersActivityTable, playerQueries } from "@/features/players";
import { Box, Divider, Group, Skeleton, Stack, Tabs } from "@mantine/core";
import { Suspense, useState } from "react";
import { Trans } from "@lingui/react/macro";
import { msg } from "@lingui/core/macro";
export const Route = createFileRoute("/_authed/admin/activities")({ export const Route = createFileRoute("/_authed/admin/activities")({
beforeLoad: () => { component: Stats,
throw redirect({ to: "/admin/telemetry/functions" }); beforeLoad: ({ context }) => {
const queryClient = context.queryClient;
prefetchServerQuery(queryClient, activityQueries.search());
prefetchServerQuery(queryClient, playerQueries.activity());
}, },
loader: () => ({
withPadding: false,
fullWidth: true,
header: {
title: msg`Activities`,
withBackButton: true,
},
refresh: [activityQueries.search().queryKey, playerQueries.activity().queryKey],
}),
}); });
function ActivityRowsSkeleton({ withSearch = false }: { withSearch?: boolean }) {
return (
<Stack gap={0}>
{withSearch && (
<Box px="md" pb="sm">
<Skeleton height={42} radius="sm" />
</Box>
)}
{Array.from({ length: 8 }).map((_, index) => (
<div key={`activity-skeleton-${index}`} style={{ opacity: Math.max(1 - index * 0.1, 0.35) }}>
<Group p="md" wrap="nowrap" w="100%" justify="space-between">
<Stack gap={6}>
<Skeleton height={14} width={160} radius="sm" />
<Skeleton height={10} width={100} radius="sm" />
</Stack>
<Skeleton height={10} width={60} radius="sm" />
</Group>
<Divider />
</div>
))}
</Stack>
);
}
function Stats() {
const [activeTab, setActiveTab] = useState<string | null>("server-functions");
return (
<Tabs value={activeTab} onChange={setActiveTab}>
<Tabs.List mb='md'>
<Tabs.Tab value="server-functions"><Trans>Server Functions</Trans></Tabs.Tab>
<Tabs.Tab value="player-activity"><Trans>Player Activity</Trans></Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="server-functions">
<Suspense fallback={<ActivityRowsSkeleton withSearch />}>
<ActivitiesTable />
</Suspense>
</Tabs.Panel>
<Tabs.Panel value="player-activity">
<Suspense fallback={<ActivityRowsSkeleton />}>
<PlayersActivityTable />
</Suspense>
</Tabs.Panel>
</Tabs>
);
}
+2 -7
View File
@@ -1,13 +1,8 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { AdminPage } from "@/features/admin";
import { msg } from "@lingui/core/macro"; import { msg } from "@lingui/core/macro";
import { AdminDashboard } from "@/features/admin";
import { tournamentQueries } from "@/features/tournaments/queries";
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
export const Route = createFileRoute("/_authed/admin/")({ export const Route = createFileRoute("/_authed/admin/")({
beforeLoad: ({ context }) => {
prefetchServerQuery(context.queryClient, tournamentQueries.current());
},
loader: () => ({ loader: () => ({
header: { header: {
withBackButton: true, withBackButton: true,
@@ -15,5 +10,5 @@ export const Route = createFileRoute("/_authed/admin/")({
}, },
withPadding: false, withPadding: false,
}), }),
component: () => <AdminDashboard />, component: () => <AdminPage />,
}); });
@@ -1,70 +0,0 @@
import { Outlet, createFileRoute, redirect, useNavigate, useRouterState } from "@tanstack/react-router";
import { Tabs } from "@mantine/core";
import { Trans } from "@lingui/react/macro";
import { msg } from "@lingui/core/macro";
import { isDeveloperRole } from "@/features/core/utils/roles";
const TAB_ROUTES = {
usage: "/admin/telemetry/usage",
players: "/admin/telemetry/players",
functions: "/admin/telemetry/functions",
sessions: "/admin/telemetry/sessions",
errors: "/admin/telemetry/errors",
} as const;
type TabValue = keyof typeof TAB_ROUTES;
export const Route = createFileRoute("/_authed/admin/telemetry")({
component: TelemetryLayout,
beforeLoad: ({ context }) => {
if (!isDeveloperRole(context.auth?.roles)) {
throw redirect({ to: "/admin" });
}
},
loader: () => ({
header: {
title: msg`Telemetry`,
withBackButton: true,
},
}),
});
function TelemetryLayout() {
const navigate = useNavigate();
const pathname = useRouterState({ select: (s) => s.location.pathname });
const activeTab =
(Object.keys(TAB_ROUTES) as TabValue[]).find((tab) =>
pathname.endsWith(`/${tab}`)
) ?? "usage";
return (
<Tabs
value={activeTab}
onChange={(value) => {
if (value && value in TAB_ROUTES) {
navigate({ to: TAB_ROUTES[value as TabValue] });
}
}}
>
<Tabs.List mb="md" style={{ flexWrap: "nowrap", overflowX: "auto", overflowY: "hidden" }}>
<Tabs.Tab value="usage">
<Trans>Usage</Trans>
</Tabs.Tab>
<Tabs.Tab value="players">
<Trans>Players</Trans>
</Tabs.Tab>
<Tabs.Tab value="functions">
<Trans>Functions</Trans>
</Tabs.Tab>
<Tabs.Tab value="sessions">
<Trans>Sessions</Trans>
</Tabs.Tab>
<Tabs.Tab value="errors">
<Trans>Errors</Trans>
</Tabs.Tab>
</Tabs.List>
<Outlet />
</Tabs>
);
}
@@ -1,27 +0,0 @@
import { createFileRoute } from "@tanstack/react-router";
import { Suspense } from "react";
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
import { telemetryQueries, lastNDays, ErrorBrowser } from "@/features/telemetry";
import { ListSkeleton } from "@/components/skeletons";
const errorGroupParams = () => ({ ...lastNDays(30), includeResolved: false });
export const Route = createFileRoute("/_authed/admin/telemetry/errors")({
component: ErrorsTab,
beforeLoad: ({ context }) => {
prefetchServerQuery(context.queryClient, telemetryQueries.errorGroups(errorGroupParams()));
},
loader: () => ({
withPadding: false,
fullWidth: true,
refresh: [telemetryQueries.errorGroups(errorGroupParams()).queryKey],
}),
});
function ErrorsTab() {
return (
<Suspense fallback={<ListSkeleton rows={6} withSubtitle />}>
<ErrorBrowser />
</Suspense>
);
}
@@ -1,28 +0,0 @@
import { createFileRoute } from "@tanstack/react-router";
import { Suspense } from "react";
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
import { ActivitiesTable, activityQueries } from "@/features/activities";
import { playerQueries } from "@/features/players";
import { ListSkeleton } from "@/components/skeletons";
export const Route = createFileRoute("/_authed/admin/telemetry/functions")({
component: FunctionsTab,
beforeLoad: ({ context }) => {
const queryClient = context.queryClient;
prefetchServerQuery(queryClient, activityQueries.search());
prefetchServerQuery(queryClient, playerQueries.list());
},
loader: () => ({
withPadding: false,
fullWidth: true,
refresh: [activityQueries.search().queryKey],
}),
});
function FunctionsTab() {
return (
<Suspense fallback={<ListSkeleton rows={8} withSearch withSubtitle withTrailing="text" />}>
<ActivitiesTable />
</Suspense>
);
}
@@ -1,7 +0,0 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
export const Route = createFileRoute("/_authed/admin/telemetry/")({
beforeLoad: () => {
throw redirect({ to: "/admin/telemetry/usage" });
},
});
@@ -1,25 +0,0 @@
import { createFileRoute } from "@tanstack/react-router";
import { Suspense } from "react";
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
import { PlayersActivityTable, playerQueries } from "@/features/players";
import { ListSkeleton } from "@/components/skeletons";
export const Route = createFileRoute("/_authed/admin/telemetry/players")({
component: PlayersTab,
beforeLoad: ({ context }) => {
prefetchServerQuery(context.queryClient, playerQueries.activity());
},
loader: () => ({
withPadding: false,
fullWidth: true,
refresh: [playerQueries.activity().queryKey],
}),
});
function PlayersTab() {
return (
<Suspense fallback={<ListSkeleton rows={8} withSubtitle withTrailing="text" />}>
<PlayersActivityTable />
</Suspense>
);
}
@@ -1,25 +0,0 @@
import { createFileRoute } from "@tanstack/react-router";
import { Suspense } from "react";
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
import { telemetryQueries, SessionsList, defaultSessionParams } from "@/features/telemetry";
import { ListSkeleton } from "@/components/skeletons";
export const Route = createFileRoute("/_authed/admin/telemetry/sessions")({
component: SessionsTab,
beforeLoad: ({ context }) => {
prefetchServerQuery(context.queryClient, telemetryQueries.sessions(defaultSessionParams));
},
loader: () => ({
withPadding: false,
fullWidth: true,
refresh: [telemetryQueries.sessions(defaultSessionParams).queryKey],
}),
});
function SessionsTab() {
return (
<Suspense fallback={<ListSkeleton rows={8} withSubtitle withTrailing="text" />}>
<SessionsList />
</Suspense>
);
}
@@ -1,21 +0,0 @@
import { createFileRoute } from "@tanstack/react-router";
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
import { telemetryQueries, lastNDays, USAGE_METRICS, UsageScreen } from "@/features/telemetry";
const defaultUsageParams = () => ({ metrics: USAGE_METRICS, ...lastNDays(7) });
export const Route = createFileRoute("/_authed/admin/telemetry/usage")({
component: UsageTab,
beforeLoad: ({ context }) => {
prefetchServerQuery(context.queryClient, telemetryQueries.rollups(defaultUsageParams()));
},
loader: () => ({
withPadding: false,
fullWidth: true,
refresh: [telemetryQueries.rollups(defaultUsageParams()).queryKey],
}),
});
function UsageTab() {
return <UsageScreen />;
}
@@ -1,7 +1,6 @@
import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router"; import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router";
import { tournamentQueries, useFreeAgents, useTournament } from "@/features/tournaments/queries"; import { tournamentQueries, useFreeAgents, useTournament } from "@/features/tournaments/queries";
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure"; import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
import { StatTileSkeleton } from "@/components/skeletons";
import { Stack, Text, Button, Alert, LoadingOverlay, Group, Skeleton } from "@mantine/core"; import { Stack, Text, Button, Alert, LoadingOverlay, Group, Skeleton } from "@mantine/core";
import { useState } from "react"; import { useState } from "react";
import useGenerateRandomTeams from "@/features/tournaments/hooks/use-generate-random-teams"; import useGenerateRandomTeams from "@/features/tournaments/hooks/use-generate-random-teams";
@@ -38,7 +37,10 @@ function AssignPartnersPending() {
return ( return (
<Stack gap="lg"> <Stack gap="lg">
<Stack gap="xs"> <Stack gap="xs">
<StatTileSkeleton align="row" /> <Group gap="xs" align="baseline">
<Skeleton height={28} width={36} radius="sm" />
<Skeleton height={14} width={110} radius="sm" />
</Group>
<Skeleton height={36} w="100%" radius="sm" style={{ opacity: 0.7 }} /> <Skeleton height={36} w="100%" radius="sm" style={{ opacity: 0.7 }} />
</Stack> </Stack>
</Stack> </Stack>
@@ -1,56 +0,0 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
import { tournamentQueries } from "@/features/tournaments/queries";
import Enrollment from "@/features/tournaments/components/enrollment";
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
import { ListSkeleton, StatTileSkeleton } from "@/components/skeletons";
import { Skeleton, Stack } from "@mantine/core";
import { msg } from "@lingui/core/macro";
export const Route = createFileRoute("/_authed/admin/tournaments/$id/enrollment")({
beforeLoad: async ({ context, params }) => {
const { queryClient } = context;
const tournament = await ensureServerQueryData(
queryClient,
tournamentQueries.details(params.id)
);
if (!tournament) throw redirect({ to: "/admin/tournaments" });
if (tournament.regional) {
await ensureServerQueryData(
queryClient,
tournamentQueries.free_agents(params.id)
);
} else {
await ensureServerQueryData(
queryClient,
tournamentQueries.unenrolled(params.id)
);
}
return { tournament };
},
loader: () => ({
header: {
withBackButton: true,
title: msg`Enrollment`,
},
}),
component: RouteComponent,
pendingComponent: EnrollmentPending,
});
function EnrollmentPending() {
return (
<Stack gap="lg">
<StatTileSkeleton align="row" />
<Stack gap="xs">
<Skeleton height={14} width={80} radius="sm" />
<Skeleton height={42} radius="sm" />
</Stack>
<ListSkeleton rows={6} withAvatar avatarSize={32} withTrailing padding="xs" />
</Stack>
);
}
function RouteComponent() {
const { tournament } = Route.useRouteContext();
return <Enrollment tournament={tournament} />;
}
@@ -2,7 +2,7 @@ import { createFileRoute, redirect } from "@tanstack/react-router";
import { tournamentQueries } from "@/features/tournaments/queries"; import { tournamentQueries } from "@/features/tournaments/queries";
import ManageTournament from "@/features/tournaments/components/manage-tournament"; import ManageTournament from "@/features/tournaments/components/manage-tournament";
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure"; import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
import { ListSkeleton } from "@/components/skeletons"; import { Divider, Group, Skeleton, Stack } from "@mantine/core";
import { msg } from "@lingui/core/macro"; import { msg } from "@lingui/core/macro";
export const Route = createFileRoute("/_authed/admin/tournaments/$id/")({ export const Route = createFileRoute("/_authed/admin/tournaments/$id/")({
@@ -31,14 +31,18 @@ export const Route = createFileRoute("/_authed/admin/tournaments/$id/")({
function ManageTournamentPending() { function ManageTournamentPending() {
return ( return (
<ListSkeleton <Stack gap={0}>
rows={5} {Array.from({ length: 5 }).map((_, index) => (
withAvatar <div key={`manage-tournament-skeleton-${index}`} style={{ opacity: Math.max(1 - index * 0.14, 0.4) }}>
avatarSize={20} <Group p="md" wrap="nowrap" w="100%">
withTrailing <Skeleton height={20} width={20} radius="sm" />
fadeStep={0.14} <Skeleton height={16} width="45%" radius="sm" />
fadeFloor={0.4} <Skeleton ml="auto" height={20} width={20} radius="sm" />
/> </Group>
<Divider />
</div>
))}
</Stack>
); );
} }
@@ -2,8 +2,7 @@ import { createFileRoute, redirect } from "@tanstack/react-router";
import { tournamentQueries } from "@/features/tournaments/queries"; import { tournamentQueries } from "@/features/tournaments/queries";
import ManageTeams from "@/features/teams/components/manage-teams"; import ManageTeams from "@/features/teams/components/manage-teams";
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure"; import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
import { ListSkeleton } from "@/components/skeletons"; import { Box, Divider, Group, Skeleton, Stack } from "@mantine/core";
import { Box, Skeleton, Stack } from "@mantine/core";
import { msg } from "@lingui/core/macro"; import { msg } from "@lingui/core/macro";
export const Route = createFileRoute("/_authed/admin/tournaments/$id/teams")({ export const Route = createFileRoute("/_authed/admin/tournaments/$id/teams")({
@@ -39,7 +38,21 @@ function ManageTeamsPending() {
<Box px="md"> <Box px="md">
<Skeleton height={12} width={90} radius="sm" /> <Skeleton height={12} width={90} radius="sm" />
</Box> </Box>
<ListSkeleton rows={8} withAvatar avatarSize={40} withTrailing="double" padding="xs" /> <Stack gap={0}>
{Array.from({ length: 8 }).map((_, index) => (
<div key={`manage-teams-skeleton-${index}`} style={{ opacity: Math.max(1 - index * 0.1, 0.35) }}>
<Group p="xs" wrap="nowrap" w="100%">
<Skeleton height={40} width={40} radius="sm" />
<Skeleton height={16} width="45%" radius="sm" />
<Stack ml="auto" gap={6} align="flex-end">
<Skeleton height={10} width={90} radius="sm" />
<Skeleton height={10} width={70} radius="sm" />
</Stack>
</Group>
<Divider />
</div>
))}
</Stack>
</Stack> </Stack>
); );
} }
@@ -1,7 +1,7 @@
import ManageTournaments from "@/features/admin/components/manage-tournaments"; import ManageTournaments from "@/features/admin/components/manage-tournaments";
import { tournamentQueries } from "@/features/tournaments/queries"; import { tournamentQueries } from "@/features/tournaments/queries";
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch"; import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
import { ListSkeleton } from "@/components/skeletons"; import { Divider, Group, Skeleton, Stack } from "@mantine/core";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { Suspense } from "react"; import { Suspense } from "react";
import { msg } from "@lingui/core/macro"; import { msg } from "@lingui/core/macro";
@@ -22,13 +22,25 @@ export const Route = createFileRoute("/_authed/admin/tournaments/")({
component: RouteComponent, component: RouteComponent,
}); });
function TournamentListSkeleton() {
return (
<Stack gap={0}>
{Array.from({ length: 6 }).map((_, index) => (
<div key={`manage-tournaments-skeleton-${index}`} style={{ opacity: Math.max(1 - index * 0.12, 0.4) }}>
<Group p="md" wrap="nowrap" w="100%">
<Skeleton height={16} width="55%" radius="sm" />
<Skeleton ml="auto" height={20} width={20} radius="sm" />
</Group>
<Divider />
</div>
))}
</Stack>
);
}
function RouteComponent() { function RouteComponent() {
return ( return (
<Suspense <Suspense fallback={<TournamentListSkeleton />}>
fallback={
<ListSkeleton rows={6} titleWidth="55%" withTrailing fadeStep={0.12} fadeFloor={0.4} />
}
>
<ManageTournaments /> <ManageTournaments />
</Suspense> </Suspense>
); );
@@ -8,10 +8,12 @@ import SeedTournament from "@/features/tournaments/components/seed-tournament";
import SetupGroupStage from "@/features/tournaments/components/setup-group-stage"; import SetupGroupStage from "@/features/tournaments/components/setup-group-stage";
import GroupStageView from "@/features/tournaments/components/group-stage-view"; import GroupStageView from "@/features/tournaments/components/group-stage-view";
import { Container, Stack, Divider, Title, Box, Card, Group, Skeleton, SimpleGrid } from "@mantine/core"; import { Container, Stack, Divider, Title, Box, Card, Group, Skeleton, SimpleGrid } from "@mantine/core";
import { useMemo } from "react";
import { BracketData } from "@/features/bracket/types";
import { Match } from "@/features/matches/types";
import BracketView from "@/features/bracket/components/bracket-view"; import BracketView from "@/features/bracket/components/bracket-view";
import { SpotifyControlsBar } from "@/features/spotify/components"; import { SpotifyControlsBar } from "@/features/spotify/components";
import { useIsAdmin } from "@/features/core/hooks/use-is-admin"; import { useAuth } from "@/contexts/auth-context";
import { useTournamentRunState } from "@/features/tournaments/hooks/use-tournament-run-state";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { msg } from "@lingui/core/macro"; import { msg } from "@lingui/core/macro";
@@ -81,10 +83,67 @@ function RunTournamentPending() {
function RouteComponent() { function RouteComponent() {
const { id } = Route.useParams(); const { id } = Route.useParams();
const { data: tournament } = useTournament(id); const { data: tournament } = useTournament(id);
const isAdmin = useIsAdmin(); const { roles } = useAuth();
const isAdmin = roles?.includes('Admin') || false;
const { hasGroupStage, hasKnockout, knockoutBracketPopulated, nextUpMatchId, bracket } = const hasGroupStage = useMemo(() => {
useTournamentRunState(tournament.matches); return tournament.matches?.some((match) => match.round === -1) || false;
}, [tournament.matches]);
const hasKnockout = useMemo(() => {
return tournament.matches?.some((match) => match.round !== -1) || false;
}, [tournament.matches]);
const knockoutBracketPopulated = useMemo(() => {
return tournament.matches?.some((match) =>
match.round === 0 && match.lid >= 0 && (match.home || match.away)
) || false;
}, [tournament.matches]);
const nextUpMatchId = useMemo(() => {
const ready = (tournament.matches ?? [])
.filter(
(match) =>
match.status === "ready" && match.home && match.away && !match.bye
)
.sort((a, b) => a.order - b.order);
return ready[0]?.id;
}, [tournament.matches]);
const bracket: BracketData = useMemo(() => {
if (!tournament.matches || tournament.matches.length === 0) {
return { winners: [], losers: [] };
}
const winnersMap = new Map<number, Match[]>();
const losersMap = new Map<number, Match[]>();
tournament.matches
.filter((match) => match.round !== -1)
.sort((a, b) => a.lid - b.lid)
.forEach((match) => {
if (!match.is_losers_bracket) {
if (!winnersMap.has(match.round)) {
winnersMap.set(match.round, []);
}
winnersMap.get(match.round)!.push(match);
} else {
if (!losersMap.has(match.round)) {
losersMap.set(match.round, []);
}
losersMap.get(match.round)!.push(match);
}
});
const winners = Array.from(winnersMap.entries())
.sort(([a], [b]) => a - b)
.map(([, matches]) => matches);
const losers = Array.from(losersMap.entries())
.sort(([a], [b]) => a - b)
.map(([, matches]) => matches);
return { winners, losers };
}, [tournament.matches]);
return ( return (
<Container size="md" px={0}> <Container size="md" px={0}>
+7 -5
View File
@@ -1,8 +1,7 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { tournamentQueries, useCurrentTournament } from "@/features/tournaments/queries"; import { tournamentQueries, useCurrentTournament } from "@/features/tournaments/queries";
import UpcomingTournament from "@/features/tournaments/components/upcoming-tournament"; import UpcomingTournament from "@/features/tournaments/components/upcoming-tournament";
import { getServerQueryData } from "@/lib/tanstack-query/utils/ensure"; import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
import StartedTournament from "@/features/tournaments/components/started-tournament"; import StartedTournament from "@/features/tournaments/components/started-tournament";
import { Suspense } from "react"; import { Suspense } from "react";
import UpcomingTournamentSkeleton from "@/features/tournaments/components/upcoming-tournament/skeleton"; import UpcomingTournamentSkeleton from "@/features/tournaments/components/upcoming-tournament/skeleton";
@@ -11,13 +10,16 @@ export const Route = createFileRoute("/_authed/")({
component: () => <Suspense fallback={<UpcomingTournamentSkeleton />}> component: () => <Suspense fallback={<UpcomingTournamentSkeleton />}>
<Home /> <Home />
</Suspense>, </Suspense>,
beforeLoad: ({ context }) => { beforeLoad: async ({ context }) => {
prefetchServerQuery(context.queryClient, tournamentQueries.current()); const queryClient = context.queryClient;
const tournament = await ensureServerQueryData(queryClient, tournamentQueries.current())
return { tournament }
}, },
loader: ({ context }) => ({ loader: ({ context }) => ({
withPadding: false, withPadding: false,
header: { header: {
title: getServerQueryData(context.queryClient, tournamentQueries.current())?.name || "FLXN" title: context.tournament.name || "FLXN"
} }
}), }),
pendingComponent: () => <UpcomingTournamentSkeleton /> pendingComponent: () => <UpcomingTournamentSkeleton />
+19 -5
View File
@@ -14,12 +14,26 @@ const searchSchema = z.object({
export const Route = createFileRoute("/_authed/profile/$playerId")({ export const Route = createFileRoute("/_authed/profile/$playerId")({
validateSearch: searchSchema, validateSearch: searchSchema,
beforeLoad: ({ params, context }) => { beforeLoad: async ({ params, context }) => {
const { queryClient } = context; const { queryClient } = context;
prefetchServerQuery(queryClient, playerQueries.details(params.playerId)); await Promise.all([
prefetchServerQuery(queryClient, playerQueries.matches(params.playerId)); prefetchServerQuery(
prefetchServerQuery(queryClient, playerQueries.stats(params.playerId)); queryClient,
prefetchServerQuery(queryClient, badgeQueries.playerBadges(params.playerId)); playerQueries.details(params.playerId)
),
prefetchServerQuery(
queryClient,
playerQueries.matches(params.playerId)
),
prefetchServerQuery(
queryClient,
playerQueries.stats(params.playerId)
),
prefetchServerQuery(
queryClient,
badgeQueries.playerBadges(params.playerId)
),
]);
}, },
loader: ({ params, context }) => ({ loader: ({ params, context }) => ({
header: { header: {
+4 -13
View File
@@ -1,7 +1,7 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { playerQueries } from "@/features/players/queries"; import { playerQueries } from "@/features/players/queries";
import PlayerStatsTable from "@/features/players/components/player-stats-table"; import PlayerStatsTable from "@/features/players/components/player-stats-table";
import { Suspense, useState, useDeferredValue, useEffect, useRef } from "react"; import { Suspense, useState, useDeferredValue, useEffect } from "react";
import PlayerStatsTableSkeleton from "@/features/players/components/player-stats-table-skeleton"; import PlayerStatsTableSkeleton from "@/features/players/components/player-stats-table-skeleton";
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch"; import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
import LeagueHeadToHead from "@/features/players/components/league-head-to-head"; import LeagueHeadToHead from "@/features/players/components/league-head-to-head";
@@ -9,7 +9,6 @@ import { Box, Loader, Tabs, Button, Group, Container, Stack } from "@mantine/cor
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { msg } from "@lingui/core/macro"; import { msg } from "@lingui/core/macro";
import { track } from "@/lib/telemetry/client";
export const Route = createFileRoute("/_authed/stats")({ export const Route = createFileRoute("/_authed/stats")({
component: Stats, component: Stats,
@@ -33,14 +32,6 @@ function Stats() {
const isStale = viewType !== deferredViewType; const isStale = viewType !== deferredViewType;
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const viewTypeRef = useRef(viewType);
const changeViewType = (next: typeof viewType) => {
if (viewTypeRef.current === next) return;
viewTypeRef.current = next;
track("stats_filtered", { value: next });
setViewType(next);
};
useEffect(() => { useEffect(() => {
const timeout = window.setTimeout(() => { const timeout = window.setTimeout(() => {
prefetchServerQuery(queryClient, playerQueries.allStats('mainline')); prefetchServerQuery(queryClient, playerQueries.allStats('mainline'));
@@ -63,21 +54,21 @@ function Stats() {
<Button <Button
variant={viewType === 'all' ? 'filled' : 'light'} variant={viewType === 'all' ? 'filled' : 'light'}
size="compact-xs" size="compact-xs"
onClick={() => changeViewType('all')} onClick={() => setViewType('all')}
> >
<Trans>All</Trans> <Trans>All</Trans>
</Button> </Button>
<Button <Button
variant={viewType === 'mainline' ? 'filled' : 'light'} variant={viewType === 'mainline' ? 'filled' : 'light'}
size="compact-xs" size="compact-xs"
onClick={() => changeViewType('mainline')} onClick={() => setViewType('mainline')}
> >
<Trans>Mainline</Trans> <Trans>Mainline</Trans>
</Button> </Button>
<Button <Button
variant={viewType === 'regional' ? 'filled' : 'light'} variant={viewType === 'regional' ? 'filled' : 'light'}
size="compact-xs" size="compact-xs"
onClick={() => changeViewType('regional')} onClick={() => setViewType('regional')}
> >
<Trans>Regional</Trans> <Trans>Regional</Trans>
</Button> </Button>
+2 -2
View File
@@ -12,9 +12,9 @@ const searchSchema = z.object({
export const Route = createFileRoute("/_authed/teams/$teamId")({ export const Route = createFileRoute("/_authed/teams/$teamId")({
validateSearch: searchSchema, validateSearch: searchSchema,
beforeLoad: ({ params, context }) => { beforeLoad: async ({ params, context }) => {
const { queryClient } = context; const { queryClient } = context;
prefetchServerQuery(queryClient, teamQueries.details(params.teamId)); await prefetchServerQuery(queryClient, teamQueries.details(params.teamId));
}, },
loader: ({ params }) => ({ loader: ({ params }) => ({
header: { header: {
@@ -1,7 +1,6 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router'
import { tournamentQueries } from '@/features/tournaments/queries'; import { tournamentQueries } from '@/features/tournaments/queries';
import Profile from '@/features/tournaments/components/profile'; import Profile from '@/features/tournaments/components/profile';
import { isAdminRole } from '@/features/core/utils/roles';
import { z } from "zod"; import { z } from "zod";
import { prefetchServerQuery } from '@/lib/tanstack-query/utils/prefetch'; import { prefetchServerQuery } from '@/lib/tanstack-query/utils/prefetch';
import { Suspense } from 'react'; import { Suspense } from 'react';
@@ -21,7 +20,7 @@ export const Route = createFileRoute('/_authed/tournaments/$tournamentId')({
header: { header: {
collapsed: true, collapsed: true,
withBackButton: true, withBackButton: true,
settingsLink: isAdminRole(context.auth.roles) ? `/admin/tournaments/${params.tournamentId}` : undefined settingsLink: context.auth.roles.includes("Admin") ? `/admin/tournaments/${params.tournamentId}` : undefined
}, },
refresh: [tournamentQueries.details(params.tournamentId).queryKey], refresh: [tournamentQueries.details(params.tournamentId).queryKey],
withPadding: false withPadding: false
+2
View File
@@ -8,6 +8,8 @@ ensureSuperTokensBackend();
// forwards all supertokens api methods to our API // forwards all supertokens api methods to our API
const superTokensHandler = handleAuthAPIRequest(); const superTokensHandler = handleAuthAPIRequest();
const handleRequest = async ({ request }: {request: Request}) => { const handleRequest = async ({ request }: {request: Request}) => {
console.log("=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=");
console.log("Handling auth request:", request.method, request.url);
return superTokensHandler(request); return superTokensHandler(request);
}; };
export const Route = createFileRoute('/api/auth/$')({ export const Route = createFileRoute('/api/auth/$')({
+7 -13
View File
@@ -1,14 +1,9 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { import { serverEvents, EVENT_TYPES, type ServerEvent } from "@/lib/events/emitter";
serverEvents,
EVENT_TYPES,
incrementConnections,
decrementConnections,
type ServerEvent,
} from "@/lib/events/emitter";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
import { superTokensRequestMiddleware } from "@/utils/supertokens"; import { superTokensRequestMiddleware } from "@/utils/supertokens";
let activeConnections = 0;
const encoder = new TextEncoder(); const encoder = new TextEncoder();
export const Route = createFileRoute("/api/events/$")({ export const Route = createFileRoute("/api/events/$")({
@@ -16,9 +11,9 @@ export const Route = createFileRoute("/api/events/$")({
middleware: [superTokensRequestMiddleware], middleware: [superTokensRequestMiddleware],
handlers: { handlers: {
GET: ({ request }) => { GET: ({ request }) => {
const active = incrementConnections(); activeConnections++;
const connectionId = `conn_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`; const connectionId = `conn_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
logger.info(`ServerEvents | New connection ${connectionId}. Active: ${active}`); logger.info(`ServerEvents | New connection ${connectionId}. Active: ${activeConnections}`);
let cleanedUp = false; let cleanedUp = false;
let cleanup = () => {}; let cleanup = () => {};
@@ -39,20 +34,19 @@ export const Route = createFileRoute("/api/events/$")({
serverEvents.on(type, handleEvent); serverEvents.on(type, handleEvent);
} }
// Under the shortest idle timeout observed on proxies/PWA paths.
const pingInterval = setInterval(() => { const pingInterval = setInterval(() => {
send({ type: "ping", timestamp: Date.now() }); send({ type: "ping", timestamp: Date.now() });
}, 8000); }, 15000);
cleanup = () => { cleanup = () => {
if (cleanedUp) return; if (cleanedUp) return;
cleanedUp = true; cleanedUp = true;
const remaining = decrementConnections(); activeConnections--;
for (const type of EVENT_TYPES) { for (const type of EVENT_TYPES) {
serverEvents.off(type, handleEvent); serverEvents.off(type, handleEvent);
} }
clearInterval(pingInterval); clearInterval(pingInterval);
logger.info(`ServerEvents | Connection ${connectionId} cleanup completed. Active: ${remaining}`); logger.info(`ServerEvents | Connection ${connectionId} cleanup completed. Active: ${activeConnections}`);
}; };
request.signal?.addEventListener("abort", cleanup); request.signal?.addEventListener("abort", cleanup);
+5 -12
View File
@@ -1,26 +1,19 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { ensureTelemetryScheduler } from "@/lib/telemetry/scheduler.server";
import { getHealthSnapshot } from "@/lib/telemetry/health.server";
import { ensureSuperTokensBackend } from "@/lib/supertokens/server";
export const Route = createFileRoute("/api/health")({ export const Route = createFileRoute("/api/health")({
server: { server: {
handlers: { handlers: {
GET: async () => { GET: () => {
ensureSuperTokensBackend();
ensureTelemetryScheduler();
const snapshot = await getHealthSnapshot();
// Always 200: a dependency outage must not fail the k8s probes.
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
status: snapshot.status, status: "ok",
checks: snapshot.checks,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}), }),
{ {
status: 200, status: 200,
headers: { "Content-Type": "application/json" }, headers: {
"Content-Type": "application/json",
},
} }
); );
}, },
+1 -2
View File
@@ -1,6 +1,5 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { superTokensRequestMiddleware } from "@/utils/supertokens"; import { superTokensRequestMiddleware } from "@/utils/supertokens";
import { apiLoggingMiddleware } from "@/lib/telemetry/api-logging.server";
import { pbAdmin } from "@/lib/pocketbase/client"; import { pbAdmin } from "@/lib/pocketbase/client";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
@@ -12,7 +11,7 @@ const json = (body: unknown, status = 200) =>
export const Route = createFileRoute("/api/push/subscribe")({ export const Route = createFileRoute("/api/push/subscribe")({
server: { server: {
middleware: [apiLoggingMiddleware, superTokensRequestMiddleware], middleware: [superTokensRequestMiddleware],
handlers: { handlers: {
POST: async ({ request, context }) => { POST: async ({ request, context }) => {
const player = (context as any).player; const player = (context as any).player;
+1 -2
View File
@@ -1,7 +1,6 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { msg } from "@lingui/core/macro"; import { msg } from "@lingui/core/macro";
import { superTokensRequestMiddleware } from "@/utils/supertokens"; import { superTokensRequestMiddleware } from "@/utils/supertokens";
import { apiLoggingMiddleware } from "@/lib/telemetry/api-logging.server";
import { sendPushToPlayer } from "@/lib/push"; import { sendPushToPlayer } from "@/lib/push";
import { isPushConfigured } from "@/lib/config"; import { isPushConfigured } from "@/lib/config";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
@@ -14,7 +13,7 @@ const json = (body: unknown, status = 200) =>
export const Route = createFileRoute("/api/push/test")({ export const Route = createFileRoute("/api/push/test")({
server: { server: {
middleware: [apiLoggingMiddleware, superTokensRequestMiddleware], middleware: [superTokensRequestMiddleware],
handlers: { handlers: {
POST: async ({ context }) => { POST: async ({ context }) => {
const player = (context as any).player; const player = (context as any).player;
+1 -2
View File
@@ -1,6 +1,5 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { superTokensRequestMiddleware } from "@/utils/supertokens"; import { superTokensRequestMiddleware } from "@/utils/supertokens";
import { apiLoggingMiddleware } from "@/lib/telemetry/api-logging.server";
import { pbAdmin } from "@/lib/pocketbase/client"; import { pbAdmin } from "@/lib/pocketbase/client";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
@@ -12,7 +11,7 @@ const json = (body: unknown, status = 200) =>
export const Route = createFileRoute("/api/push/unsubscribe")({ export const Route = createFileRoute("/api/push/unsubscribe")({
server: { server: {
middleware: [apiLoggingMiddleware, superTokensRequestMiddleware], middleware: [superTokensRequestMiddleware],
handlers: { handlers: {
POST: async ({ request, context }) => { POST: async ({ request, context }) => {
const player = (context as any).player; const player = (context as any).player;
+2 -4
View File
@@ -1,8 +1,6 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { msg } from "@lingui/core/macro"; import { msg } from "@lingui/core/macro";
import { superTokensRequestMiddleware } from "@/utils/supertokens"; import { superTokensRequestMiddleware } from "@/utils/supertokens";
import { isAdminRole } from "@/features/core/utils/roles";
import { apiLoggingMiddleware } from "@/lib/telemetry/api-logging.server";
import { pbAdmin } from "@/lib/pocketbase/client"; import { pbAdmin } from "@/lib/pocketbase/client";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
import { localizedFor } from "@/lib/i18n/server-messages"; import { localizedFor } from "@/lib/i18n/server-messages";
@@ -14,13 +12,13 @@ const uploadSchema = z.object({
export const Route = createFileRoute("/api/teams/upload-logo")({ export const Route = createFileRoute("/api/teams/upload-logo")({
server: { server: {
middleware: [apiLoggingMiddleware, superTokensRequestMiddleware], middleware: [superTokensRequestMiddleware],
handlers: { handlers: {
POST: async ({ request, context }) => { POST: async ({ request, context }) => {
const i18n = localizedFor(context.metadata); const i18n = localizedFor(context.metadata);
try { try {
const userId = context.userAuthId; const userId = context.userAuthId;
const isAdmin = isAdminRole(context.roles); const isAdmin = context.roles.includes("Admin");
if (!userId) return new Response("Unauthenticated", { status: 401 }); if (!userId) return new Response("Unauthenticated", { status: 401 });
-132
View File
@@ -1,132 +0,0 @@
import { createFileRoute } from "@tanstack/react-router";
import { superTokensOptionalRequestMiddleware } from "@/utils/supertokens";
import { beaconPayloadSchema, MAX_BEACON_BYTES, type BeaconEvent } from "@/lib/telemetry/beacon-schema";
import { errorGroupHash } from "@/lib/telemetry/error-hash";
import { redactArguments } from "@/lib/telemetry/redact-args";
import { Logger } from "@/lib/logger";
import type { Player } from "@/features/players/types";
const logger = new Logger("Telemetry");
const BUCKET_CAPACITY = 120;
const REFILL_PER_MS = BUCKET_CAPACITY / 60_000;
const buckets = new Map<string, { tokens: number; updatedAt: number }>();
const takeTokens = (key: string, count: number): boolean => {
const now = Date.now();
const bucket = buckets.get(key) ?? { tokens: BUCKET_CAPACITY, updatedAt: now };
bucket.tokens = Math.min(BUCKET_CAPACITY, bucket.tokens + (now - bucket.updatedAt) * REFILL_PER_MS);
bucket.updatedAt = now;
if (bucket.tokens < count) {
buckets.set(key, bucket);
return false;
}
bucket.tokens -= count;
buckets.set(key, bucket);
if (buckets.size > 2000) {
for (const [k, v] of buckets) {
if (now - v.updatedAt > 5 * 60_000) buckets.delete(k);
}
}
return true;
};
const clientIp = (request: Request): string =>
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
request.headers.get("x-real-ip") ||
"unknown";
const writeEvents = async (
events: BeaconEvent[],
base: { sessionId: string; playerId?: string; userAgent?: string }
) => {
const { isExcludedPlayerId } = await import("@/lib/telemetry/exclusions.server");
if (base.playerId && (await isExcludedPlayerId(base.playerId))) {
// Excluded players still report errors; their usage stays out.
events = events.filter((event) => event.kind === "error");
if (events.length === 0) return;
}
const { pbAdmin } = await import("@/lib/pocketbase/client");
await pbAdmin.authPromise;
for (const event of events) {
try {
if (event.kind === "error") {
await pbAdmin.createClientError({
message: event.message,
stack: event.stack,
source: event.source,
path: event.path,
route_id: event.routeId,
group_hash: errorGroupHash(event),
resolved: false,
player: base.playerId,
session_id: base.sessionId,
user_agent: base.userAgent,
props: redactArguments(event.props),
});
} else {
await pbAdmin.createClientEvent({
name: event.kind === "vital" ? `vital.${event.metric}` : event.name,
player: base.playerId,
session_id: base.sessionId,
path: event.path,
route_id: event.routeId,
value: event.value,
props:
event.kind === "vital"
? event.rating
? { rating: event.rating }
: undefined
: redactArguments(event.props),
user_agent: base.userAgent,
});
}
} catch (error) {
logger.error("Failed to persist beacon event", error);
}
}
};
export const Route = createFileRoute("/api/telemetry/beacon")({
server: {
middleware: [superTokensOptionalRequestMiddleware],
handlers: {
POST: async ({ request, context }) => {
const player = (context as unknown as { player?: Player | null }).player ?? undefined;
const raw = await request.text();
if (raw.length > MAX_BEACON_BYTES) {
return new Response(null, { status: 413 });
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return new Response(null, { status: 400 });
}
const result = beaconPayloadSchema.safeParse(parsed);
if (!result.success) {
return new Response(null, { status: 400 });
}
const key = player ? `p:${player.id}` : `ip:${clientIp(request)}`;
if (!takeTokens(key, result.data.events.length)) {
return new Response(null, { status: 429 });
}
void writeEvents(result.data.events, {
sessionId: result.data.sessionId,
playerId: player?.id,
userAgent: request.headers.get("user-agent") ?? undefined,
}).catch((error) => logger.error("Beacon write batch failed", error));
return new Response(null, { status: 204 });
},
},
},
});
@@ -1,8 +1,6 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { msg } from "@lingui/core/macro"; import { msg } from "@lingui/core/macro";
import { superTokensRequestMiddleware } from "@/utils/supertokens"; import { superTokensRequestMiddleware } from "@/utils/supertokens";
import { isAdminRole } from "@/features/core/utils/roles";
import { apiLoggingMiddleware } from "@/lib/telemetry/api-logging.server";
import { pbAdmin } from "@/lib/pocketbase/client"; import { pbAdmin } from "@/lib/pocketbase/client";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
import { localizedFor } from "@/lib/i18n/server-messages"; import { localizedFor } from "@/lib/i18n/server-messages";
@@ -14,13 +12,13 @@ const uploadSchema = z.object({
export const Route = createFileRoute("/api/tournaments/upload-logo")({ export const Route = createFileRoute("/api/tournaments/upload-logo")({
server: { server: {
middleware: [apiLoggingMiddleware, superTokensRequestMiddleware], middleware: [superTokensRequestMiddleware],
handlers: { handlers: {
POST: async ({ request, context }) => { POST: async ({ request, context }) => {
const i18n = localizedFor(context.metadata); const i18n = localizedFor(context.metadata);
try { try {
const userId = context.userAuthId; const userId = context.userAuthId;
const isAdmin = isAdminRole(context.roles); const isAdmin = context.roles.includes("Admin");
if (!userId) return new Response("Unauthenticated", { status: 401 }); if (!userId) return new Response("Unauthenticated", { status: 401 });
if (!isAdmin) return new Response("Unauthorized", { status: 403 }); if (!isAdmin) return new Response("Unauthorized", { status: 403 });
-11
View File
@@ -1,11 +0,0 @@
import { createCsrfMiddleware, createStart } from "@tanstack/react-start";
import { serverFnLoggingMiddleware } from "@/utils/activities";
const csrfMiddleware = createCsrfMiddleware({
filter: (ctx) => ctx.handlerType === "serverFn",
});
export const startInstance = createStart(() => ({
requestMiddleware: [csrfMiddleware],
functionMiddleware: [serverFnLoggingMiddleware],
}));
-2
View File
@@ -22,7 +22,6 @@ import { useDisclosure } from '@mantine/hooks'
import { useEffect } from 'react' import { useEffect } from 'react'
import toast from '@/lib/sonner' import toast from '@/lib/sonner'
import { logger } from '@/lib/logger' import { logger } from '@/lib/logger'
import { captureBoundaryError } from '@/lib/telemetry/client'
import { XCircleIcon, WarningIcon } from '@phosphor-icons/react' import { XCircleIcon, WarningIcon } from '@phosphor-icons/react'
import { Trans, useLingui } from '@lingui/react/macro' import { Trans, useLingui } from '@lingui/react/macro'
import Button from './button' import Button from './button'
@@ -42,7 +41,6 @@ export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
useEffect(() => { useEffect(() => {
logger.error('DefaultCatchBoundary | ', error) logger.error('DefaultCatchBoundary | ', error)
captureBoundaryError(error)
if (errorMessage.toLowerCase().includes('unauthenticated')) { if (errorMessage.toLowerCase().includes('unauthenticated')) {
toast.error(t`You've been logged out`) toast.error(t`You've been logged out`)
-3
View File
@@ -53,14 +53,12 @@ export function Countdown({ date, label, color }: CountdownProps) {
return ( return (
<Group gap="xs"> <Group gap="xs">
{label && <Text size='sm' fw={500}>{label}:</Text>} {label && <Text size='sm' fw={500}>{label}:</Text>}
{/* Server and client render different seconds; the first client tick corrects it. */}
<Text <Text
size='sm' size='sm'
fw={600} fw={600}
c={isFinalStretch ? 'red' : color} c={isFinalStretch ? 'red' : color}
ff="monospace" ff="monospace"
className={classes.time} className={classes.time}
suppressHydrationWarning
> >
{prefix} {prefix}
<span <span
@@ -68,7 +66,6 @@ export function Countdown({ date, label, color }: CountdownProps) {
className={`${classes.seconds} ${ className={`${classes.seconds} ${
isFinalStretch ? classes.finalTick : classes.tick isFinalStretch ? classes.finalTick : classes.tick
}`} }`}
suppressHydrationWarning
> >
{pad(timeLeft.seconds)} {pad(timeLeft.seconds)}
</span> </span>
-35
View File
@@ -1,35 +0,0 @@
import { Box } from "@mantine/core";
import { FootballHelmetIcon } from "@phosphor-icons/react";
const HeadToHeadIcon = ({ size = 14 }: { size?: number }) => (
<Box
component="span"
style={{
position: "relative",
display: "inline-block",
width: size * (27.5 / 14),
height: size * (16 / 14),
}}
>
<FootballHelmetIcon
size={size}
style={{
position: "absolute",
left: 0,
top: 0,
transform: "rotate(25deg)",
}}
/>
<FootballHelmetIcon
size={size}
style={{
position: "absolute",
right: 0,
top: 0,
transform: "scaleX(-1) rotate(25deg)",
}}
/>
</Box>
);
export default HeadToHeadIcon;
+1 -6
View File
@@ -1,5 +1,4 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { track } from '@/lib/telemetry/client'
import { Box, Paper, Group, Text, ActionIcon } from '@mantine/core' import { Box, Paper, Group, Text, ActionIcon } from '@mantine/core'
import { DownloadIcon, XIcon } from '@phosphor-icons/react' import { DownloadIcon, XIcon } from '@phosphor-icons/react'
import { Trans, useLingui } from '@lingui/react/macro' import { Trans, useLingui } from '@lingui/react/macro'
@@ -27,17 +26,13 @@ export function IOSInstallPrompt() {
if ((isIOS || isAndroid) && !isInStandaloneMode && !hasBeenDismissed) { if ((isIOS || isAndroid) && !isInStandaloneMode && !hasBeenDismissed) {
setPlatform(isIOS ? 'ios' : 'android') setPlatform(isIOS ? 'ios' : 'android')
const timer = setTimeout(() => { const timer = setTimeout(() => setShow(true), 3000)
setShow(true)
track('pwa_ios_prompt_shown')
}, 3000)
return () => clearTimeout(timer) return () => clearTimeout(timer)
} }
}, []) }, [])
const handleDismiss = () => { const handleDismiss = () => {
localStorage.setItem('pwa-install-prompt-dismissed', 'true') localStorage.setItem('pwa-install-prompt-dismissed', 'true')
track('pwa_ios_prompt_dismissed')
setShow(false) setShow(false)
} }
-28
View File
@@ -1,28 +0,0 @@
import { Text, UnstyledButton } from "@mantine/core";
import { Icon } from "@phosphor-icons/react";
import classes from "./nav-tile.module.css";
interface NavTileProps {
label: string;
Icon: Icon;
onClick?: () => void;
disabled?: boolean;
}
/** A single square-ish nav tile with icon-over-label and press feedback. */
export const NavTile = ({ label, Icon, onClick, disabled }: NavTileProps) => (
<UnstyledButton
className={`${classes.tile} flxn-tile`}
onClick={onClick}
disabled={disabled}
>
<span className={`${classes.icon} flxn-tile-icon`}>
<Icon size={22} weight="bold" />
</span>
<Text size="xs" fw={600} lineClamp={2} lh={1.2}>
{label}
</Text>
</UnstyledButton>
);
export default NavTile;
@@ -1,43 +0,0 @@
.tile {
-webkit-tap-highlight-color: transparent;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 14px 8px;
border-radius: var(--mantine-radius-md);
border: 1px solid var(--mantine-color-default-border);
background-color: var(--mantine-color-body);
text-align: center;
}
.icon {
color: var(--mantine-primary-color-filled);
display: flex;
}
@media (hover: hover) {
.tile:hover:not(:disabled) {
background-color: var(--mantine-color-default-hover);
}
}
.tile:active:not(:disabled) {
background-color: var(--mantine-color-default-hover);
}
.tile:disabled {
opacity: 0.5;
cursor: default;
}
@media (prefers-reduced-motion: no-preference) {
.tile {
transition: background-color 120ms ease-out, transform 120ms cubic-bezier(0.32, 0.72, 0, 1);
}
.tile:active:not(:disabled) {
transform: scale(0.97);
}
}
-17
View File
@@ -1,17 +0,0 @@
import { Skeleton, Stack } from "@mantine/core";
const RoutePending = () => (
<Stack gap="md" p="md" w="100%">
{Array.from({ length: 5 }).map((_, index) => (
<Skeleton
key={`route-pending-${index}`}
height={96}
w="100%"
radius="md"
style={{ opacity: Math.max(1 - index * 0.15, 0.3) }}
/>
))}
</Stack>
);
export default RoutePending;
+7 -12
View File
@@ -9,7 +9,7 @@ import {
import { useIsMobile } from "@/hooks/use-is-mobile"; import { useIsMobile } from "@/hooks/use-is-mobile";
import Drawer from "./drawer"; import Drawer from "./drawer";
import Modal from "./modal"; import Modal from "./modal";
import { Box, Flex, Loader } from "@mantine/core"; import { ScrollArea, Flex, Loader } from "@mantine/core";
interface SheetProps extends PropsWithChildren { interface SheetProps extends PropsWithChildren {
title?: string; title?: string;
@@ -60,19 +60,14 @@ const Sheet: React.FC<SheetProps> = ({ title, children, opened, onChange }) => {
<Loader size='lg' /> <Loader size='lg' />
</Flex> </Flex>
}> }>
{/* Native overflow, not ScrollArea: vaul's drag-vs-scroll detection <ScrollArea.Autosize
only recognizes natively scrollable elements. */} style={{ flex: 1, maxHeight: '75dvh' }}
<Box scrollbarSize={8}
style={{ scrollbars="y"
flex: 1, type="scroll"
maxHeight: "75dvh",
overflowY: "auto",
overscrollBehavior: "contain",
WebkitOverflowScrolling: "touch",
}}
> >
{children} {children}
</Box> </ScrollArea.Autosize>
</Suspense> </Suspense>
</SheetComponent> </SheetComponent>
); );
@@ -1,35 +0,0 @@
import { Box, Group, Paper, Skeleton } from "@mantine/core";
import type { MantineRadius } from "@mantine/core";
export interface CardSkeletonProps {
withAvatar?: boolean;
avatarSize?: number;
withBadge?: boolean;
radius?: MantineRadius;
opacity?: number;
}
export const CardSkeleton = ({
withAvatar = true,
avatarSize = 32,
withBadge = true,
radius = "md",
opacity = 1,
}: CardSkeletonProps) => {
return (
<Paper p="sm" withBorder radius={radius} style={{ opacity }}>
<Group gap="sm" align="center" wrap="nowrap">
{withAvatar && (
<Skeleton height={avatarSize} width={avatarSize} radius="sm" />
)}
<Box style={{ flex: 1 }}>
<Skeleton height={16} width="70%" mb={4} />
<Skeleton height={12} width="40%" />
</Box>
{withBadge && <Skeleton height={20} width={60} radius="xl" />}
</Group>
</Paper>
);
};
export default CardSkeleton;
-3
View File
@@ -1,3 +0,0 @@
export * from "./list-skeleton";
export * from "./card-skeleton";
export * from "./stat-tile-skeleton";
@@ -1,76 +0,0 @@
import { Box, Divider, Group, Skeleton, Stack } from "@mantine/core";
import type { MantineSpacing } from "@mantine/core";
export interface ListSkeletonProps {
rows?: number;
withSearch?: boolean;
withAvatar?: boolean;
avatarSize?: number;
withSubtitle?: boolean;
withTrailing?: boolean | "double" | "text";
titleWidth?: string | number;
padding?: MantineSpacing;
fadeStep?: number;
fadeFloor?: number;
}
export const ListSkeleton = ({
rows = 6,
withSearch = false,
withAvatar = false,
avatarSize = 40,
withSubtitle = false,
withTrailing = false,
titleWidth = "45%",
padding = "md",
fadeStep = 0.1,
fadeFloor = 0.35,
}: ListSkeletonProps) => {
return (
<Stack gap={0}>
{withSearch && (
<Box px={padding} pb="sm">
<Skeleton height={42} radius="sm" />
</Box>
)}
{Array.from({ length: rows }).map((_, index) => (
<div
key={index}
style={{ opacity: Math.max(1 - index * fadeStep, fadeFloor) }}
>
<Group
p={padding}
wrap="nowrap"
w="100%"
justify={withTrailing === "text" ? "space-between" : undefined}
>
{withAvatar && (
<Skeleton height={avatarSize} width={avatarSize} radius="sm" />
)}
{withSubtitle ? (
<Stack gap={6}>
<Skeleton height={14} width={160} radius="sm" />
<Skeleton height={10} width={100} radius="sm" />
</Stack>
) : (
<Skeleton height={16} width={titleWidth} radius="sm" />
)}
{withTrailing === "double" ? (
<Stack ml="auto" gap={6} align="flex-end">
<Skeleton height={10} width={90} radius="sm" />
<Skeleton height={10} width={70} radius="sm" />
</Stack>
) : withTrailing === "text" ? (
<Skeleton height={10} width={60} radius="sm" />
) : withTrailing ? (
<Skeleton ml="auto" height={20} width={20} radius="sm" />
) : null}
</Group>
<Divider />
</div>
))}
</Stack>
);
};
export default ListSkeleton;
@@ -1,42 +0,0 @@
import { Card, Group, Skeleton, Stack } from "@mantine/core";
export interface StatTileSkeletonProps {
withCard?: boolean;
align?: "row" | "column";
numberWidth?: number;
numberHeight?: number;
labelWidth?: number;
labelHeight?: number;
}
export const StatTileSkeleton = ({
withCard = false,
align = "column",
numberWidth = align === "row" ? 36 : 48,
numberHeight = 28,
labelWidth = align === "row" ? 110 : 80,
labelHeight = align === "row" ? 14 : 12,
}: StatTileSkeletonProps) => {
const content =
align === "row" ? (
<Group gap="xs" align="baseline">
<Skeleton height={numberHeight} width={numberWidth} radius="sm" />
<Skeleton height={labelHeight} width={labelWidth} radius="sm" />
</Group>
) : (
<Stack gap={4} align="center">
<Skeleton height={numberHeight} width={numberWidth} radius="sm" />
<Skeleton height={labelHeight} width={labelWidth} radius="sm" />
</Stack>
);
if (!withCard) return content;
return (
<Card withBorder radius="md" p="md">
{content}
</Card>
);
};
export default StatTileSkeleton;
+2 -8
View File
@@ -6,7 +6,7 @@ import {
ReactNode, ReactNode,
useRef, useRef,
useCallback, useCallback,
startTransition, useMemo,
} from "react"; } from "react";
import { useRouter } from "@tanstack/react-router"; import { useRouter } from "@tanstack/react-router";
@@ -48,9 +48,6 @@ function SwipeableTabs({
}, [search?.tab, tabs, defaultTab]); }, [search?.tab, tabs, defaultTab]);
const [activeTab, setActiveTab] = useState(getActiveTabFromUrl); const [activeTab, setActiveTab] = useState(getActiveTabFromUrl);
const [visitedTabs, setVisitedTabs] = useState<ReadonlySet<number>>(
() => new Set([getActiveTabFromUrl()])
);
const [stuck, setStuck] = useState(false); const [stuck, setStuck] = useState(false);
const sentinelRef = useRef<HTMLDivElement>(null); const sentinelRef = useRef<HTMLDivElement>(null);
const [rootRef, setRootRef] = useState<HTMLDivElement | null>(null); const [rootRef, setRootRef] = useState<HTMLDivElement | null>(null);
@@ -63,9 +60,6 @@ function SwipeableTabs({
if (index === activeTab || index < 0 || index >= tabs.length) return; if (index === activeTab || index < 0 || index >= tabs.length) return;
setActiveTab(index); setActiveTab(index);
startTransition(() => {
setVisitedTabs((prev) => (prev.has(index) ? prev : new Set(prev).add(index)));
});
embla?.scrollTo(index); embla?.scrollTo(index);
onTabChange?.(index, tabs[index]); onTabChange?.(index, tabs[index]);
@@ -231,7 +225,7 @@ function SwipeableTabs({
{tabs.map((tab, index) => ( {tabs.map((tab, index) => (
<Carousel.Slide key={`${tab.label}-content-${index}`}> <Carousel.Slide key={`${tab.label}-content-${index}`}>
<Box ref={setSlideRef(index)} style={{ height: "auto", width: "100%", maxWidth: "100vw", overflow: "hidden" }}> <Box ref={setSlideRef(index)} style={{ height: "auto", width: "100%", maxWidth: "100vw", overflow: "hidden" }}>
{(visitedTabs.has(index) || index === activeTab) ? tab.content : null} {tab.content}
</Box> </Box>
</Carousel.Slide> </Carousel.Slide>
))} ))}
-1
View File
@@ -18,7 +18,6 @@ interface AuthData {
theme?: string; theme?: string;
locale?: string; locale?: string;
seenThemesHint?: boolean; seenThemesHint?: boolean;
telemetryAlerts?: boolean;
}; };
roles: string[]; roles: string[];
phone: string; phone: string;
+3 -2
View File
@@ -1,7 +1,7 @@
import { createContext, useCallback, useEffect, useMemo, useState, PropsWithChildren } from 'react'; import { createContext, useCallback, useEffect, useMemo, useState, PropsWithChildren } from 'react';
import { useLingui } from '@lingui/react/macro'; import { useLingui } from '@lingui/react/macro';
import { SpotifyAuth } from '@/lib/spotify/auth'; import { SpotifyAuth } from '@/lib/spotify/auth';
import { useIsAdmin } from '@/features/core/hooks/use-is-admin'; import { useAuth } from './auth-context';
import { useConfig } from '@/hooks/use-config'; import { useConfig } from '@/hooks/use-config';
import type { import type {
SpotifyContextType, SpotifyContextType,
@@ -40,7 +40,8 @@ const deepEqual = (a: unknown, b: unknown): boolean => {
}; };
export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => { export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
const isAdmin = useIsAdmin(); const { roles } = useAuth();
const isAdmin = roles?.includes('Admin') || false;
const config = useConfig(); const config = useConfig();
const { t } = useLingui(); const { t } = useLingui();
@@ -1,32 +1,268 @@
import { useState, useMemo } from "react"; import { useState, useMemo, memo } from "react";
import { Trans, Plural, useLingui } from "@lingui/react/macro"; import { Trans, Plural, useLingui } from "@lingui/react/macro";
import { Alert, Code, Container, Group, Stack, Text } from "@mantine/core"; import {
import { CheckIcon, XIcon } from "@phosphor-icons/react"; Text,
TextInput,
Stack,
Group,
Box,
Container,
Divider,
UnstyledButton,
Select,
Pagination,
Code,
Alert,
ThemeIcon,
Title,
} from "@mantine/core";
import {
MagnifyingGlassIcon,
CaretUpIcon,
CaretDownIcon,
CheckIcon,
XIcon,
ChecksIcon,
PulseIcon,
} from "@phosphor-icons/react";
import { Activity, ActivitySearchParams } from "../types"; import { Activity, ActivitySearchParams } from "../types";
import { useActivities } from "../queries"; import { useActivities } from "../queries";
import { usePlayers } from "@/features/players/queries"; import Sheet from "@/components/sheet/sheet";
import { useSheet } from "@/hooks/use-sheet"; import { useSheet } from "@/hooks/use-sheet";
import {
DetailSheet,
PaginatedList,
SearchFilterBar,
TelemetryListItem,
} from "@/features/telemetry/components/shared";
const SLOW_THRESHOLD_MS = 1000; interface ActivityListItemProps {
activity: Activity;
onClick: () => void;
}
const ActivityListItem = memo(({ activity, onClick }: ActivityListItemProps) => {
const { t, i18n } = useLingui();
const playerName = typeof activity.player === "object" && activity.player
? `${activity.player.first_name} ${activity.player.last_name}`
: t`System`;
const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
return date.toLocaleString(i18n.locale);
};
return (
<UnstyledButton
w="100%"
p="md"
onClick={onClick}
style={{
borderRadius: 0,
transition: "background-color 0.15s ease",
}}
styles={{
root: {
'&:hover': {
backgroundColor: 'var(--mantine-color-gray-0)',
},
},
}}
>
<Group justify="space-between" align="flex-start" w="100%">
<Stack gap={4} flex={1}>
<Group gap="xs">
<Text size="sm" fw={600}>
{activity.name}
</Text>
{activity.success ? (
<CheckIcon size={16} color="var(--mantine-color-green-6)" />
) : (
<XIcon size={16} color="var(--mantine-color-red-6)" />
)}
</Group>
<Group gap="md">
<Text size="xs" c="dimmed">
{playerName}
</Text>
<Text size="xs" c="dimmed">
<Trans>{activity.duration}ms</Trans>
</Text>
<Text size="xs" c="dimmed">
{formatDate(activity.created)}
</Text>
</Group>
{activity.error && (
<Text size="xs" c="red" lineClamp={1}>
{activity.error}
</Text>
)}
</Stack>
</Group>
</UnstyledButton>
);
});
interface ActivityDetailsSheetProps {
activity: Activity | null;
isOpen: boolean;
onClose: () => void;
}
const ActivityDetailsSheet = memo(({ activity, isOpen, onClose }: ActivityDetailsSheetProps) => {
const { t, i18n } = useLingui();
if (!activity) return null;
const playerName = typeof activity.player === "object" && activity.player
? `${activity.player.first_name} ${activity.player.last_name}`
: t`System`;
const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
return date.toLocaleString(i18n.locale);
};
return (
<Sheet title={t`Activity Details`} opened={isOpen} onChange={onClose}>
<Stack gap="md" p="md">
<Stack gap="xs">
<Text size="xs" fw={700} c="dimmed">
<Trans>Function Name</Trans>
</Text>
<Text size="sm">{activity.name}</Text>
</Stack>
<Stack gap="xs">
<Text size="xs" fw={700} c="dimmed">
<Trans>Status</Trans>
</Text>
<Group gap="xs">
{activity.success ? (
<>
<CheckIcon size={16} color="var(--mantine-color-green-6)" />
<Text size="sm" c="green">
<Trans>Success</Trans>
</Text>
</>
) : (
<>
<XIcon size={16} color="var(--mantine-color-red-6)" />
<Text size="sm" c="red">
<Trans>Failed</Trans>
</Text>
</>
)}
</Group>
</Stack>
<Stack gap="xs">
<Text size="xs" fw={700} c="dimmed">
<Trans>Player</Trans>
</Text>
<Text size="sm">{playerName}</Text>
</Stack>
<Stack gap="xs">
<Text size="xs" fw={700} c="dimmed">
<Trans>Duration</Trans>
</Text>
<Text size="sm"><Trans>{activity.duration}ms</Trans></Text>
</Stack>
<Stack gap="xs">
<Text size="xs" fw={700} c="dimmed">
<Trans>Created</Trans>
</Text>
<Text size="sm">{formatDate(activity.created)}</Text>
</Stack>
{activity.user_agent && (
<Stack gap="xs">
<Text size="xs" fw={700} c="dimmed">
<Trans>User Agent</Trans>
</Text>
<Text size="xs" style={{ wordBreak: "break-word" }}>
{activity.user_agent}
</Text>
</Stack>
)}
{activity.error && (
<Stack gap="xs">
<Text size="xs" fw={700} c="dimmed">
<Trans>Error Message</Trans>
</Text>
<Alert color="red" variant="light">
<Text size="sm" style={{ wordBreak: "break-word" }}>
{activity.error}
</Text>
</Alert>
</Stack>
)}
{activity.arguments && (
<Stack gap="xs">
<Text size="xs" fw={700} c="dimmed">
<Trans>Arguments</Trans>
</Text>
<Code block style={{ fontSize: "11px" }}>
{JSON.stringify(activity.arguments, null, 2)}
</Code>
</Stack>
)}
</Stack>
</Sheet>
);
});
const ActivitiesResults = ({ searchParams, page, setPage, onActivityClick }: any) => {
const { data: result } = useActivities(searchParams);
return (
<>
<Stack gap={0}>
{result.items.map((activity: Activity, index: number) => (
<Box key={activity.id}>
<ActivityListItem
activity={activity}
onClick={() => onActivityClick(activity)}
/>
{index < result.items.length - 1 && <Divider />}
</Box>
))}
</Stack>
{result.items.length === 0 && (
<Stack align="center" gap="md" py="xl">
<ThemeIcon size="xl" variant="light" radius="md">
<PulseIcon size={32} />
</ThemeIcon>
<Title order={3} c="dimmed">
<Trans>No Activities Found</Trans>
</Title>
</Stack>
)}
{result.totalPages > 1 && (
<Group justify="center" py="md">
<Pagination
total={result.totalPages}
value={page}
onChange={setPage}
size="sm"
/>
</Group>
)}
</>
);
};
export const ActivitiesTable = () => { export const ActivitiesTable = () => {
const { t, i18n } = useLingui(); const { t } = useLingui();
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [successFilter, setSuccessFilter] = useState<string | null>(null); const [successFilter, setSuccessFilter] = useState<string | null>(null);
const [playerFilter, setPlayerFilter] = useState<string | null>(null);
const [slowOnly, setSlowOnly] = useState(false);
const [sortBy, setSortBy] = useState("-created"); const [sortBy, setSortBy] = useState("-created");
const [selectedActivity, setSelectedActivity] = useState<Activity | null>(null); const [selectedActivity, setSelectedActivity] = useState<Activity | null>(null);
const { isOpen: detailsOpened, open: openDetails, close: closeDetails } = useSheet();
const { data: players } = usePlayers(); const {
isOpen: detailsOpened,
open: openDetails,
close: closeDetails,
} = useSheet();
const searchParams: ActivitySearchParams = useMemo( const searchParams: ActivitySearchParams = useMemo(
() => ({ () => ({
@@ -34,24 +270,13 @@ export const ActivitiesTable = () => {
perPage: 100, perPage: 100,
name: search || undefined, name: search || undefined,
success: successFilter === "success" ? true : successFilter === "failure" ? false : undefined, success: successFilter === "success" ? true : successFilter === "failure" ? false : undefined,
player: playerFilter || undefined,
sortBy, sortBy,
}), }),
[page, search, successFilter, playerFilter, sortBy] [page, search, successFilter, sortBy]
); );
const { data: result } = useActivities(searchParams); const { data: result } = useActivities(searchParams);
const items = useMemo(
() => (slowOnly ? result.items.filter((a) => a.duration > SLOW_THRESHOLD_MS) : result.items),
[result.items, slowOnly]
);
const playerName = (activity: Activity) =>
typeof activity.player === "object" && activity.player
? `${activity.player.first_name} ${activity.player.last_name}`
: t`System`;
const handleSort = (field: string) => { const handleSort = (field: string) => {
setSortBy((prev) => { setSortBy((prev) => {
if (prev === field) return `-${field}`; if (prev === field) return `-${field}`;
@@ -60,6 +285,12 @@ export const ActivitiesTable = () => {
}); });
}; };
const getSortIcon = (field: string) => {
if (sortBy === field) return <CaretUpIcon size={14} />;
if (sortBy === `-${field}`) return <CaretDownIcon size={14} />;
return null;
};
const handleActivityClick = (activity: Activity) => { const handleActivityClick = (activity: Activity) => {
setSelectedActivity(activity); setSelectedActivity(activity);
openDetails(); openDetails();
@@ -73,163 +304,90 @@ export const ActivitiesTable = () => {
return ( return (
<Container size="100%" px={0}> <Container size="100%" px={0}>
<Stack gap="xs"> <Stack gap="xs">
<SearchFilterBar <Stack gap="xs" px="md">
searchValue={search} <TextInput
onSearchChange={(value) => { placeholder={t`serverFn name`}
setSearch(value); value={search}
setPage(1); onChange={(e) => {
}} setSearch(e.currentTarget.value);
searchPlaceholder={t`serverFn name`} setPage(1);
filters={[ }}
{ leftSection={<MagnifyingGlassIcon size={16} />}
placeholder: t`Status`, size="md"
value: successFilter, />
onChange: (value) => {
<Group>
<Select
placeholder={t`Status`}
value={successFilter}
onChange={(value) => {
setSuccessFilter(value); setSuccessFilter(value);
setPage(1); setPage(1);
}, }}
data: [ data={[
{ value: "all", label: t`All` }, { value: "all", label: t`All` },
{ value: "success", label: t`Success` }, { value: "success", label: t`Success` },
{ value: "failure", label: t`Failure` }, { value: "failure", label: t`Failure` },
],
},
{
placeholder: t`Player`,
value: playerFilter,
onChange: (value) => {
setPlayerFilter(value);
setPage(1);
},
data: players.map((player) => ({
value: player.id,
label: `${player.first_name} ${player.last_name}`,
})),
searchable: true,
},
]}
chips={[
{
label: t`Slow >1s (this page)`,
checked: slowOnly,
onChange: setSlowOnly,
},
]}
sortFields={[
{ field: "created", label: <Trans>Date</Trans> },
{ field: "duration", label: <Trans>Duration</Trans> },
]}
sortBy={sortBy}
onSort={handleSort}
summary={
<Plural value={result.totalItems} one="# total activity" other="# total activities" />
}
/>
<PaginatedList
items={items}
keyOf={(activity) => activity.id}
empty={t`No Activities Found`}
page={page}
totalPages={result.totalPages}
onPageChange={setPage}
renderItem={(activity) => (
<TelemetryListItem
title={activity.name}
icon={
activity.success ? (
<CheckIcon size={16} color="var(--mantine-color-green-6)" />
) : (
<XIcon size={16} color="var(--mantine-color-red-6)" />
)
}
meta={[
playerName(activity),
<Trans>{activity.duration}ms</Trans>,
new Date(activity.created).toLocaleString(i18n.locale),
]} ]}
error={activity.error} clearable
onClick={() => handleActivityClick(activity)} size="sm"
style={{ flex: 1 }}
/> />
)} </Group>
</Stack>
<Group px="md" justify="space-between" align="center">
<Text size="10px" lh={0} c="dimmed">
<Plural value={result.totalItems} one="# total activity" other="# total activities" />
</Text>
<Group gap="xs">
<Text size="xs" c="dimmed">
<Trans>Sort:</Trans>
</Text>
<UnstyledButton
onClick={() => handleSort("created")}
style={{ display: "flex", alignItems: "center", gap: 4 }}
>
<Text
size="xs"
fw={sortBy.includes("created") ? 600 : 400}
c={sortBy.includes("created") ? "var(--mantine-color-text)" : "dimmed"}
>
<Trans>Date</Trans>
</Text>
{getSortIcon("created")}
</UnstyledButton>
<Text size="xs" c="dimmed">
•
</Text>
<UnstyledButton
onClick={() => handleSort("duration")}
style={{ display: "flex", alignItems: "center", gap: 4 }}
>
<Text
size="xs"
fw={sortBy.includes("duration") ? 600 : 400}
c={sortBy.includes("duration") ? "var(--mantine-color-text)" : "dimmed"}
>
<Trans>Duration</Trans>
</Text>
{getSortIcon("duration")}
</UnstyledButton>
</Group>
</Group>
<ActivitiesResults
searchParams={searchParams}
page={page}
setPage={setPage}
onActivityClick={handleActivityClick}
/> />
</Stack> </Stack>
<DetailSheet <ActivityDetailsSheet
title={t`Activity Details`} activity={selectedActivity}
opened={detailsOpened} isOpen={detailsOpened}
onClose={handleCloseDetails} onClose={handleCloseDetails}
fields={
selectedActivity
? [
{ label: <Trans>Function Name</Trans>, value: selectedActivity.name },
{
label: <Trans>Status</Trans>,
value: (
<Group gap="xs">
{selectedActivity.success ? (
<>
<CheckIcon size={16} color="var(--mantine-color-green-6)" />
<Text size="sm" c="green">
<Trans>Success</Trans>
</Text>
</>
) : (
<>
<XIcon size={16} color="var(--mantine-color-red-6)" />
<Text size="sm" c="red">
<Trans>Failed</Trans>
</Text>
</>
)}
</Group>
),
},
{ label: <Trans>Player</Trans>, value: playerName(selectedActivity) },
{
label: <Trans>Duration</Trans>,
value: (
<Text size="sm">
<Trans>{selectedActivity.duration}ms</Trans>
</Text>
),
},
{
label: <Trans>Created</Trans>,
value: new Date(selectedActivity.created).toLocaleString(i18n.locale),
},
...(selectedActivity.user_agent
? [{ label: <Trans>User Agent</Trans>, value: selectedActivity.user_agent }]
: []),
...(selectedActivity.error
? [
{
label: <Trans>Error Message</Trans>,
value: (
<Alert color="red" variant="light">
<Text size="sm" style={{ wordBreak: "break-word" }}>
{selectedActivity.error}
</Text>
</Alert>
),
},
]
: []),
...(selectedActivity.arguments
? [
{
label: <Trans>Arguments</Trans>,
value: (
<Code block style={{ fontSize: "11px" }}>
{JSON.stringify(selectedActivity.arguments, null, 2)}
</Code>
),
},
]
: []),
]
: []
}
/> />
</Container> </Container>
); );
+2 -2
View File
@@ -1,4 +1,4 @@
import { superTokensDeveloperFunctionMiddleware } from "@/utils/supertokens"; import { superTokensAdminFunctionMiddleware } from "@/utils/supertokens";
import { createServerFn } from "@tanstack/react-start"; import { createServerFn } from "@tanstack/react-start";
import { pbAdmin } from "@/lib/pocketbase/client"; import { pbAdmin } from "@/lib/pocketbase/client";
import { z } from "zod"; import { z } from "zod";
@@ -17,7 +17,7 @@ const activitySearchParamsSchema = z.object({
export const searchActivities = createServerFn() export const searchActivities = createServerFn()
.validator(activitySearchParamsSchema) .validator(activitySearchParamsSchema)
.middleware([superTokensDeveloperFunctionMiddleware]) .middleware([superTokensAdminFunctionMiddleware])
.handler(async ({ data }) => .handler(async ({ data }) =>
toServerResult<ActivityListResult>(async () => { toServerResult<ActivityListResult>(async () => {
const result = await pbAdmin.searchActivities(data); const result = await pbAdmin.searchActivities(data);
@@ -1,22 +1,21 @@
import { List, Text } from "@mantine/core"; import { List, Text } from "@mantine/core";
import { Plural, Trans, useLingui } from "@lingui/react/macro"; import { Plural, Trans, useLingui } from "@lingui/react/macro";
import ListLink from "@/components/list-link";
import { import {
DatabaseIcon,
TreeStructureIcon, TreeStructureIcon,
TrophyIcon, TrophyIcon,
MedalIcon, MedalIcon,
CrownIcon, CrownIcon,
PulseIcon, ListIcon,
} from "@phosphor-icons/react"; } from "@phosphor-icons/react";
import ListLink from "@/components/list-link";
import ListButton from "@/components/list-button"; import ListButton from "@/components/list-button";
import { useIsDeveloper } from "@/features/core/hooks/use-is-developer";
import { getBadgeMigrationStatus, migrateBadgeProgress } from "@/features/badges/server"; import { getBadgeMigrationStatus, migrateBadgeProgress } from "@/features/badges/server";
import { useServerQuery } from "@/lib/tanstack-query/hooks"; import { useServerQuery } from "@/lib/tanstack-query/hooks";
import toast from "@/lib/sonner"; import toast from "@/lib/sonner";
export const ToolsList = () => { const AdminPage = () => {
const { t } = useLingui(); const { t } = useLingui();
const isDeveloper = useIsDeveloper();
const { data: migrationStatus, refetch: refetchMigrationStatus } = useServerQuery({ const { data: migrationStatus, refetch: refetchMigrationStatus } = useServerQuery({
queryKey: ["admin", "badge-migration-status"], queryKey: ["admin", "badge-migration-status"],
@@ -52,13 +51,6 @@ export const ToolsList = () => {
Icon={TrophyIcon} Icon={TrophyIcon}
to="/admin/tournaments" to="/admin/tournaments"
/> />
{isDeveloper && (
<ListLink
label={t`Telemetry`}
Icon={PulseIcon}
to="/admin/telemetry/usage"
/>
)}
<ListLink <ListLink
label={t`Award Badges`} label={t`Award Badges`}
Icon={CrownIcon} Icon={CrownIcon}
@@ -94,6 +86,18 @@ export const ToolsList = () => {
<Trans>Badge recalculation failed. Check the server logs.</Trans> <Trans>Badge recalculation failed. Check the server logs.</Trans>
</Text> </Text>
)} )}
<ListLink
label={t`Activities`}
Icon={ListIcon}
to="/admin/activities"
/>
<ListButton
label={t`Open Pocketbase`}
Icon={DatabaseIcon}
onClick={() =>
window.location.replace(process.env.POCKETBASE_URL! + "/_/")
}
/>
<ListLink <ListLink
label={t`Bracket Preview`} label={t`Bracket Preview`}
Icon={TreeStructureIcon} Icon={TreeStructureIcon}
@@ -103,4 +107,4 @@ export const ToolsList = () => {
); );
}; };
export default ToolsList; export default AdminPage;
+25 -19
View File
@@ -1,7 +1,7 @@
import { useState } from "react"; import { useState } from "react";
import { Box, Card, Text, Select, Button, Group, Stack, Badge, Divider } from "@mantine/core"; import { Box, Card, Text, Select, Button, Group, Stack, Badge, Divider } from "@mantine/core";
import { Trans, useLingui } from "@lingui/react/macro"; import { Trans, useLingui } from "@lingui/react/macro";
import useAwardManualBadge from "@/features/badges/hooks/use-award-manual-badge"; import { awardManualBadge } from "@/features/badges/server";
import { useAllBadges } from "@/features/badges/queries"; import { useAllBadges } from "@/features/badges/queries";
import toast from "@/lib/sonner"; import toast from "@/lib/sonner";
import { usePlayers } from "@/features/players/queries"; import { usePlayers } from "@/features/players/queries";
@@ -13,29 +13,35 @@ const AwardBadges = () => {
const [selectedPlayerId, setSelectedPlayerId] = useState<string | null>(null); const [selectedPlayerId, setSelectedPlayerId] = useState<string | null>(null);
const [selectedBadgeId, setSelectedBadgeId] = useState<string | null>(null); const [selectedBadgeId, setSelectedBadgeId] = useState<string | null>(null);
const [isAwarding, setIsAwarding] = useState(false);
const awardBadge = useAwardManualBadge();
const manualBadges = allBadges.filter((badge) => badge.type === "manual"); const manualBadges = allBadges.filter((badge) => badge.type === "manual");
const handleAwardBadge = () => { const handleAwardBadge = async () => {
if (!selectedPlayerId || !selectedBadgeId) return; if (!selectedPlayerId || !selectedBadgeId) return;
awardBadge.mutate( setIsAwarding(true);
{ playerId: selectedPlayerId, badgeId: selectedBadgeId }, try {
{ await awardManualBadge({
onSuccess: () => { data: {
const selectedPlayer = players.find((p) => p.id === selectedPlayerId); playerId: selectedPlayerId,
const playerName = selectedPlayer badgeId: selectedBadgeId,
? `${selectedPlayer.first_name} ${selectedPlayer.last_name}`
: t`Player`;
toast.success(t`Badge awarded to ${playerName}`);
setSelectedPlayerId(null);
}, },
} });
);
const selectedPlayer = players.find((p) => p.id === selectedPlayerId);
const playerName = selectedPlayer
? `${selectedPlayer.first_name} ${selectedPlayer.last_name}`
: t`Player`;
toast.success(t`Badge awarded to ${playerName}`);
setSelectedPlayerId(null);
} catch (error) {
toast.error(t`Failed to award badge`);
} finally {
setIsAwarding(false);
}
}; };
const playerOptions = players.map((player) => ({ const playerOptions = players.map((player) => ({
@@ -92,7 +98,7 @@ const AwardBadges = () => {
<Button <Button
onClick={handleAwardBadge} onClick={handleAwardBadge}
disabled={!selectedPlayerId} disabled={!selectedPlayerId}
loading={awardBadge.isPending} loading={isAwarding}
size="md" size="md"
> >
<Trans>Award Badge</Trans> <Trans>Award Badge</Trans>
@@ -1,30 +0,0 @@
import { Suspense } from "react";
import { SimpleGrid, Skeleton } from "@mantine/core";
import QuietBoundary from "./quiet-boundary";
import SectionBox from "./section-box";
import TournamentSection from "./tournament-section";
import ToolsList from "./tools-list";
const TournamentSkeleton = () => (
<SectionBox>
<Skeleton height={24} width={180} radius="sm" />
<SimpleGrid cols={3} spacing="sm" mt="sm">
<Skeleton height={72} radius="md" />
<Skeleton height={72} radius="md" />
<Skeleton height={72} radius="md" />
</SimpleGrid>
</SectionBox>
);
export const AdminDashboard = () => (
<>
<QuietBoundary>
<Suspense fallback={<TournamentSkeleton />}>
<TournamentSection />
</Suspense>
</QuietBoundary>
<ToolsList />
</>
);
export default AdminDashboard;
@@ -1,32 +0,0 @@
import { Component, ReactNode } from "react";
import { Text } from "@mantine/core";
import { Trans } from "@lingui/react/macro";
interface QuietBoundaryProps {
children: ReactNode;
}
interface QuietBoundaryState {
hasError: boolean;
}
export class QuietBoundary extends Component<QuietBoundaryProps, QuietBoundaryState> {
state: QuietBoundaryState = { hasError: false };
static getDerivedStateFromError(): QuietBoundaryState {
return { hasError: true };
}
render() {
if (this.state.hasError) {
return (
<Text size="sm" c="dimmed">
<Trans>Unavailable right now</Trans>
</Text>
);
}
return this.props.children;
}
}
export default QuietBoundary;
@@ -1,14 +0,0 @@
import { ReactNode } from "react";
import { Box } from "@mantine/core";
export const SectionBox = ({ children }: { children: ReactNode }) => (
<Box
px="md"
py="sm"
style={{ borderBottom: "1px solid var(--mantine-color-default-border)" }}
>
{children}
</Box>
);
export default SectionBox;
@@ -1,48 +0,0 @@
import { SimpleGrid, Text, Title } from "@mantine/core";
import { useLingui } from "@lingui/react/macro";
import { useNavigate } from "@tanstack/react-router";
import { GearIcon, PlayIcon, UsersThreeIcon } from "@phosphor-icons/react";
import { useCurrentTournament } from "@/features/tournaments/queries";
import { NavTile } from "@/components/nav-tile";
import { Countdown } from "@/components/countdown";
import SectionBox from "./section-box";
export const TournamentSection = () => {
const { t } = useLingui();
const navigate = useNavigate();
const { data: tournament } = useCurrentTournament();
if (!tournament) return null;
const upcoming = !!tournament.start_time && Date.parse(tournament.start_time) > Date.now();
return (
<SectionBox>
<Title order={3}>{tournament.name}</Title>
{upcoming && (
<Text size="sm" c="dimmed" component="div">
<Countdown date={new Date(tournament.start_time)} label={t`Starts in`} />
</Text>
)}
<SimpleGrid cols={3} spacing="sm" mt="sm">
<NavTile
label={t`Run`}
Icon={PlayIcon}
onClick={() => navigate({ to: `/admin/tournaments/run/${tournament.id}` })}
/>
<NavTile
label={t`Enrollment`}
Icon={UsersThreeIcon}
onClick={() => navigate({ to: `/admin/tournaments/${tournament.id}/enrollment` })}
/>
<NavTile
label={t`Manage`}
Icon={GearIcon}
onClick={() => navigate({ to: `/admin/tournaments/${tournament.id}` })}
/>
</SimpleGrid>
</SectionBox>
);
};
export default TournamentSection;
+1 -1
View File
@@ -2,4 +2,4 @@ import { Logger } from "@/lib/logger";
export const logger = new Logger("Admin"); export const logger = new Logger("Admin");
export { default as AdminDashboard } from "./components/dashboard"; export { default as AdminPage } from "./components/admin-page";
@@ -49,7 +49,6 @@ export const BadgeIcon = ({ badge, filled, size = 48 }: BadgeIconProps & { size?
width={size} width={size}
height={size} height={size}
loading="lazy" loading="lazy"
decoding="async"
onError={() => setImageError(true)} onError={() => setImageError(true)}
style={{ style={{
objectFit: 'contain', objectFit: 'contain',
@@ -19,7 +19,6 @@ import PlayerList from '@/features/players/components/player-list';
import { useAuth } from '@/contexts/auth-context'; import { useAuth } from '@/contexts/auth-context';
import { Player } from '@/features/players/types'; import { Player } from '@/features/players/types';
import { Trans, useLingui } from '@lingui/react/macro'; import { Trans, useLingui } from '@lingui/react/macro';
import { track } from '@/lib/telemetry/client';
const BadgeStatsTable = () => { const BadgeStatsTable = () => {
const { data: allBadges } = useAllBadges(); const { data: allBadges } = useAllBadges();
@@ -142,13 +141,7 @@ const BadgeStatRow: React.FC<BadgeStatRowProps> = ({
return ( return (
<Box key={badge.id}> <Box key={badge.id}>
<UnstyledButton <UnstyledButton onClick={badgeSheet.open} w='100%'>
onClick={() => {
track("badge_viewed", { value: badge.key });
badgeSheet.open();
}}
w='100%'
>
<Grid p={'xs'} align='center'> <Grid p={'xs'} align='center'>
<Grid.Col span={2} style={{ display: 'flex', alignItems: 'center' }}> <Grid.Col span={2} style={{ display: 'flex', alignItems: 'center' }}>
<BadgeIcon badge={badge} filled={true} /> <BadgeIcon badge={badge} filled={true} />
@@ -1,21 +0,0 @@
import { useQueryClient } from "@tanstack/react-query";
import { useServerMutation } from "@/lib/tanstack-query/hooks/use-server-mutation";
import { awardManualBadge } from "@/features/badges/server";
import { badgeKeys } from "@/features/badges/queries";
interface AwardManualBadgeInput {
playerId: string;
badgeId: string;
}
export default function useAwardManualBadge() {
const queryClient = useQueryClient();
return useServerMutation({
mutationFn: (data: AwardManualBadgeInput) => awardManualBadge({ data }),
onSuccess: (_data, { playerId }) => {
queryClient.invalidateQueries({ queryKey: badgeKeys.playerBadges(playerId) });
queryClient.invalidateQueries({ queryKey: badgeKeys.allEarnedBadges() });
},
});
}
+3 -2
View File
@@ -1,5 +1,6 @@
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result"; import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
import { superTokensAdminFunctionMiddleware, superTokensFunctionMiddleware } from "@/utils/supertokens"; import { superTokensAdminFunctionMiddleware, superTokensFunctionMiddleware } from "@/utils/supertokens";
import { serverFnLoggingMiddleware } from "@/utils/activities";
import { createServerFn } from "@tanstack/react-start"; import { createServerFn } from "@tanstack/react-start";
import { pbAdmin } from "@/lib/pocketbase/client"; import { pbAdmin } from "@/lib/pocketbase/client";
import { z } from "zod"; import { z } from "zod";
@@ -14,7 +15,7 @@ export const getPlayerBadges = createServerFn()
); );
export const migrateBadgeProgress = createServerFn() export const migrateBadgeProgress = createServerFn()
.middleware([superTokensAdminFunctionMiddleware]) .middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
.handler(async () => .handler(async () =>
toServerResult(async () => startBadgeMigration()) toServerResult(async () => startBadgeMigration())
); );
@@ -40,7 +41,7 @@ export const awardManualBadge = createServerFn()
playerId: z.string(), playerId: z.string(),
badgeId: z.string(), badgeId: z.string(),
})) }))
.middleware([superTokensAdminFunctionMiddleware]) .middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
.handler(async ({ data }) => .handler(async ({ data }) =>
toServerResult(async () => { toServerResult(async () => {
const result = await pbAdmin.awardManualBadge(data.playerId, data.badgeId); const result = await pbAdmin.awardManualBadge(data.playerId, data.badgeId);
@@ -399,7 +399,7 @@ export const MatchCard: React.FC<MatchCardProps> = ({
</Sheet> </Sheet>
</Flex> </Flex>
{!showControls && <MatchReport match={match} compact source="match_card" />} {!showControls && <MatchReport match={match} compact />}
</Stack> </Stack>
); );
}; };
+26 -14
View File
@@ -13,7 +13,7 @@ import {
Tooltip, Tooltip,
} from "@mantine/core"; } from "@mantine/core";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion"; import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import HeadToHeadIcon from "@/components/head-to-head-icon"; import { FootballHelmetIcon } from "@phosphor-icons/react";
import { useNavigate } from "@tanstack/react-router"; import { useNavigate } from "@tanstack/react-router";
import { Match } from "@/features/matches/types"; import { Match } from "@/features/matches/types";
import TeamAvatar from "@/components/team-avatar"; import TeamAvatar from "@/components/team-avatar";
@@ -24,7 +24,6 @@ import { MatchReport } from "./match-report";
import Sheet from "@/components/sheet/sheet"; import Sheet from "@/components/sheet/sheet";
import { useSheet } from "@/hooks/use-sheet"; import { useSheet } from "@/hooks/use-sheet";
import { Trans, useLingui } from "@lingui/react/macro"; import { Trans, useLingui } from "@lingui/react/macro";
import { track } from "@/lib/telemetry/client";
const EASE: [number, number, number, number] = [0.32, 0.72, 0, 1]; const EASE: [number, number, number, number] = [0.32, 0.72, 0, 1];
@@ -156,7 +155,7 @@ const MatchDock = ({ match, onClose }: MatchDockProps) => {
ended={ended} ended={ended}
/> />
<MatchReport match={match} source="match_dock" /> <MatchReport match={match} />
<Group justify="space-between" wrap="nowrap" gap="sm"> <Group justify="space-between" wrap="nowrap" gap="sm">
<Box style={{ flex: 1, minWidth: 0 }}> <Box style={{ flex: 1, minWidth: 0 }}>
@@ -167,7 +166,7 @@ const MatchDock = ({ match, onClose }: MatchDockProps) => {
</Center> </Center>
} }
> >
<EmojiBar matchId={match.id} source="match_dock" /> <EmojiBar matchId={match.id} />
</Suspense> </Suspense>
</Box> </Box>
{!hasPrivate && ( {!hasPrivate && (
@@ -175,20 +174,33 @@ const MatchDock = ({ match, onClose }: MatchDockProps) => {
<ActionIcon <ActionIcon
variant="subtle" variant="subtle"
size="sm" size="sm"
onClick={() => { onClick={h2hSheet.open}
track("h2h_viewed", {
source: "match_dock",
context: [match?.home?.name, match?.away?.name]
.filter(Boolean)
.join(" v "),
});
h2hSheet.open();
}}
aria-label={t`View head-to-head`} aria-label={t`View head-to-head`}
w={40} w={40}
style={{ flexShrink: 0 }} style={{ flexShrink: 0 }}
> >
<HeadToHeadIcon /> <Group
style={{ position: "relative", width: 27.5, height: 16 }}
>
<FootballHelmetIcon
size={14}
style={{
position: "absolute",
left: 0,
top: 0,
transform: "rotate(25deg)",
}}
/>
<FootballHelmetIcon
size={14}
style={{
position: "absolute",
right: 0,
top: 0,
transform: "scaleX(-1) rotate(25deg)",
}}
/>
</Group>
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
)} )}
@@ -13,7 +13,6 @@ import Sheet from "@/components/sheet/sheet";
import { MatchForm } from "./match-form"; import { MatchForm } from "./match-form";
import toast from "@/lib/sonner"; import toast from "@/lib/sonner";
import { Trans, useLingui } from "@lingui/react/macro"; import { Trans, useLingui } from "@lingui/react/macro";
import { track } from "@/lib/telemetry/client";
const teamId = (t: Match["home"]): string | undefined => const teamId = (t: Match["home"]): string | undefined =>
!t ? undefined : typeof t === "string" ? t : t.id; !t ? undefined : typeof t === "string" ? t : t.id;
@@ -27,10 +26,9 @@ const teamName = (t: Match["home"]): string | undefined =>
interface MatchReportProps { interface MatchReportProps {
match: Match; match: Match;
compact?: boolean; compact?: boolean;
source?: string;
} }
export const MatchReport: React.FC<MatchReportProps> = ({ match, compact, source }) => { export const MatchReport: React.FC<MatchReportProps> = ({ match, compact }) => {
const { user } = useAuth(); const { user } = useAuth();
const formSheet = useSheet(); const formSheet = useSheet();
const { t } = useLingui(); const { t } = useLingui();
@@ -131,9 +129,8 @@ export const MatchReport: React.FC<MatchReportProps> = ({ match, compact, source
const openForm = useCallback(() => { const openForm = useCallback(() => {
setAcknowledgedReport(pendingSignature); setAcknowledgedReport(pendingSignature);
track("score_report_opened", source ? { source } : undefined);
formSheet.open(); formSheet.open();
}, [pendingSignature, formSheet.open, source]); }, [pendingSignature, formSheet.open]);
const dismissTakeover = useCallback(() => { const dismissTakeover = useCallback(() => {
setAcknowledgedReport(pendingSignature); setAcknowledgedReport(pendingSignature);
+1 -1
View File
@@ -14,7 +14,7 @@ const BackButton = ({ top=20, left=20 }: { top?: number, left?: number }) => {
onClick={() => router.history.back()} onClick={() => router.history.back()}
pos='absolute' pos='absolute'
left={left} left={left}
top={`calc(${top}px + env(safe-area-inset-top, 0px))`} top={top}
> >
<ArrowLeftIcon weight='bold' size={20} /> <ArrowLeftIcon weight='bold' size={20} />
</UnstyledButton> </UnstyledButton>
-1
View File
@@ -59,7 +59,6 @@ const Header = ({ collapsed, title, titleValues, withBackButton }: HeaderConfig)
style={{ style={{
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
paddingTop: 'env(safe-area-inset-top, 0px)',
}} }}
> >
{ withBackButton && <BackButton /> } { withBackButton && <BackButton /> }
+9 -11
View File
@@ -1,28 +1,24 @@
import { AppShell } from '@mantine/core'; import { AppShell, Box } from '@mantine/core';
import { PropsWithChildren } from 'react'; import { PropsWithChildren } from 'react';
import { useLocation } from '@tanstack/react-router';
import Header from './header'; import Header from './header';
import Navbar from './navbar'; import Navbar from './navbar';
import Pullable from './pullable'; import Pullable from './pullable';
import useVisualViewportSize from '../hooks/use-visual-viewport-size'; import useVisualViewportSize from '../hooks/use-visual-viewport-size';
import useRouterConfig from '../hooks/use-router-config'; import useRouterConfig from '../hooks/use-router-config';
import Page from '@/components/page'; import Page from '@/components/page';
import './route-transition.css';
const Layout: React.FC<PropsWithChildren> = ({ children }) => { const Layout: React.FC<PropsWithChildren> = ({ children }) => {
const { header, withPadding, fullWidth } = useRouterConfig(); const { header, withPadding, fullWidth } = useRouterConfig();
const viewport = useVisualViewportSize(); const viewport = useVisualViewportSize();
const location = useLocation();
return ( return (
<AppShell <AppShell
id='app-shell' id='app-shell'
layout='alt' layout='alt'
// 0 = header collapse is instant; Mantine's default animates main's padding header={{ height: 60, collapsed: header.collapsed }}
transitionDuration={0}
header={{
// Old installs froze black-translucent at add-to-home-screen time, so
// the webview can extend under the status bar; inset is 0 elsewhere.
height: 'calc(60px + env(safe-area-inset-top, 0px))',
collapsed: header.collapsed,
}}
navbar={{ navbar={{
width: { base: 0, sm: 100, md: 200, lg: 300 }, width: { base: 0, sm: 100, md: 200, lg: 300 },
breakpoint: 'sm', breakpoint: 'sm',
@@ -47,14 +43,16 @@ const Layout: React.FC<PropsWithChildren> = ({ children }) => {
pos='relative' pos='relative'
h='100%' h='100%'
mah='100%' mah='100%'
pb={{ base: 'calc(73px + env(safe-area-inset-bottom, 0px))', sm: 0 }} pb={{ base: 'calc(65px + env(safe-area-inset-bottom, 0px))', sm: 0 }}
px={{ base: 0.01, sm: 100, md: 200, lg: 300 }} px={{ base: 0.01, sm: 100, md: 200, lg: 300 }}
maw='100dvw' maw='100dvw'
style={{ overflow: 'hidden' }} style={{ overflow: 'hidden' }}
> >
<Pullable> <Pullable>
<Page noPadding={!withPadding} fullWidth={fullWidth}> <Page noPadding={!withPadding} fullWidth={fullWidth}>
{children} <Box key={location.pathname} className="flxn-route-enter">
{children}
</Box>
</Page> </Page>
</Pullable> </Pullable>
</AppShell.Main> </AppShell.Main>
@@ -1,68 +0,0 @@
import { useLayoutEffect, useRef } from "react";
import { useRouterState } from "@tanstack/react-router";
const TRAVEL_MS = 333;
export const useNavIndicator = (isMobile: boolean) => {
const containerRef = useRef<HTMLDivElement>(null);
const indicatorRef = useRef<HTMLSpanElement>(null);
const shapeRef = useRef<HTMLSpanElement>(null);
const pathname = useRouterState({ select: (s) => s.location.pathname });
useLayoutEffect(() => {
const container = containerRef.current;
const indicator = indicatorRef.current;
const shape = shapeRef.current;
if (!container || !indicator || !shape) return;
let last: number | null = null;
const place = () => {
const active = container.querySelector<HTMLElement>("[data-active]");
if (!active) {
delete indicator.dataset.placed;
last = null;
return;
}
const x = active.offsetLeft;
const y = active.offsetTop;
const travelled = isMobile ? x : y;
const first = indicator.dataset.placed !== "1";
if (first) indicator.style.transition = "none";
indicator.style.setProperty("--nav-x", `${x}px`);
indicator.style.setProperty("--nav-y", `${y}px`);
indicator.style.setProperty("--nav-w", `${active.offsetWidth}px`);
indicator.style.setProperty("--nav-h", `${active.offsetHeight}px`);
indicator.dataset.placed = "1";
if (first) {
indicator.getBoundingClientRect();
indicator.style.transition = "";
} else if (
last !== null &&
Math.abs(travelled - last) > 1 &&
!window.matchMedia("(prefers-reduced-motion: reduce)").matches
) {
const axis = isMobile ? "scaleX" : "scaleY";
shape.animate(
[
{ transform: `${axis}(1)`, offset: 0 },
{ transform: `${axis}(0.88)`, offset: 0.4 },
{ transform: `${axis}(1)`, offset: 1 },
],
{ duration: TRAVEL_MS, easing: "ease-in-out" }
);
}
last = travelled;
};
place();
const observer = new ResizeObserver(place);
observer.observe(container);
return () => observer.disconnect();
}, [pathname, isMobile]);
return { containerRef, indicatorRef, shapeRef };
};
@@ -32,19 +32,30 @@ export const NavLink = ({
<Box <Box
component={Link} component={Link}
to={href} to={href}
className={`flxn-nav-link ${styles.navLinkBox}`} className={styles.navLinkBox}
p={{ base: 0, sm: 8 }} p={{ base: 0, sm: 8 }}
aria-label={label} aria-label={label}
aria-current={isActive ? "page" : undefined} aria-current={isActive ? "page" : undefined}
data-active={isActive || undefined}
> >
<Flex <Flex
direction={{ base: "column", md: "row" }} direction={{ base: "column", md: "row" }}
align="center" align="center"
gap={{ base: 0, md: "xs" }} gap={{ base: 0, md: "xs" }}
> >
<Icon weight={isActive ? "fill" : "regular"} size={28} /> <Icon
<Text visibleFrom="md" ta="center" size="md" fw={isActive ? 800 : 500}> weight={isActive ? "fill" : "regular"}
size={28}
style={{
color: isActive ? "var(--mantine-primary-color-filled)" : undefined,
}}
/>
<Text
visibleFrom="md"
ta="center"
size="md"
fw={isActive ? 800 : 500}
c={isActive ? "var(--mantine-primary-color-filled)" : undefined}
>
{label} {label}
</Text> </Text>
</Flex> </Flex>
@@ -11,6 +11,11 @@
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
} }
.navLinkBox svg,
.navLinkBox p {
transition: color 150ms ease-out;
}
@media (prefers-reduced-motion: no-preference) { @media (prefers-reduced-motion: no-preference) {
.navLinkBox { .navLinkBox {
transition: transform 150ms cubic-bezier(0.32, 0.72, 0, 1); transition: transform 150ms cubic-bezier(0.32, 0.72, 0, 1);
+4 -14
View File
@@ -5,13 +5,11 @@ import { useAuth } from "@/contexts/auth-context";
import { useLinks } from "../hooks/use-links"; import { useLinks } from "../hooks/use-links";
import { memo } from "react"; import { memo } from "react";
import { useIsPWA } from "@/hooks/use-is-pwa"; import { useIsPWA } from "@/hooks/use-is-pwa";
import { useNavIndicator } from "./nav-indicator/use-nav-indicator";
const Navbar = () => { const Navbar = () => {
const { user, roles } = useAuth() const { user, roles } = useAuth()
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const isPWA = useIsPWA(); const isPWA = useIsPWA();
const { containerRef, indicatorRef, shapeRef } = useNavIndicator(isMobile);
const links = useLinks(user?.id, roles); const links = useLinks(user?.id, roles);
@@ -19,16 +17,9 @@ const Navbar = () => {
? 'max(env(safe-area-inset-bottom, 0px), 0.5rem)' ? 'max(env(safe-area-inset-bottom, 0px), 0.5rem)'
: 'env(safe-area-inset-bottom, 0px)'; : 'env(safe-area-inset-bottom, 0px)';
const indicator = (
<span ref={indicatorRef} className="flxn-nav-indicator" aria-hidden="true">
<span ref={shapeRef} className="flxn-nav-indicator-shape" />
</span>
);
if (isMobile) return ( if (isMobile) return (
<Paper className="flxn-nav" component='nav' role='navigation' withBorder shadow="sm" radius='lg' h='4rem' w='calc(100% - 1rem)' pos='fixed' m='0.5rem' bottom={bottomOffset} style={{ zIndex: 10 }}> <Paper component='nav' role='navigation' withBorder shadow="sm" radius='lg' h='4rem' w='calc(100% - 1rem)' pos='fixed' m='0.5rem' bottom={bottomOffset} style={{ zIndex: 10 }}>
<Group ref={containerRef} pos='relative' gap='xs' justify='space-around' h='100%' w='100%' px={{ base: 12, sm: 0 }}> <Group gap='xs' justify='space-around' h='100%' w='100%' px={{ base: 12, sm: 0 }}>
{indicator}
{links.map((link) => ( {links.map((link) => (
<NavLink key={link.href} {...link} /> <NavLink key={link.href} {...link} />
))} ))}
@@ -36,10 +27,9 @@ const Navbar = () => {
</Paper> </Paper>
) )
return <AppShell.Navbar className="flxn-nav" p="xs" role='navigation'> return <AppShell.Navbar p="xs" role='navigation'>
<AppShell.Section grow component={ScrollArea}> <AppShell.Section grow component={ScrollArea}>
<Stack ref={containerRef} pos='relative' gap='xs' mx='auto' w='fit-content' justify='end' mt='md'> <Stack gap='xs' mx='auto' w='fit-content' justify='end' mt='md'>
{indicator}
{links.map((link) => ( {links.map((link) => (
<NavLink key={link.href} {...link} /> <NavLink key={link.href} {...link} />
))} ))}
@@ -0,0 +1,11 @@
@media (prefers-reduced-motion: no-preference) {
.flxn-route-enter {
animation: flxn-route-fade-in 180ms ease-out;
}
}
@keyframes flxn-route-fade-in {
from {
opacity: 0;
}
}
-7
View File
@@ -1,7 +0,0 @@
import { useAuth } from "@/contexts/auth-context";
import { isAdminRole } from "../utils/roles";
export const useIsAdmin = (): boolean => {
const { roles } = useAuth();
return isAdminRole(roles);
};
@@ -1,7 +0,0 @@
import { useAuth } from "@/contexts/auth-context";
import { isDeveloperRole } from "../utils/roles";
export const useIsDeveloper = (): boolean => {
const { roles } = useAuth();
return isDeveloperRole(roles);
};
+1 -2
View File
@@ -1,7 +1,6 @@
import { HouseIcon, RankingIcon, ShieldIcon, TrophyIcon, UserCircleIcon } from "@phosphor-icons/react"; import { HouseIcon, RankingIcon, ShieldIcon, TrophyIcon, UserCircleIcon } from "@phosphor-icons/react";
import { useMemo } from "react"; import { useMemo } from "react";
import { useLingui } from "@lingui/react/macro"; import { useLingui } from "@lingui/react/macro";
import { isAdminRole } from "../utils/roles";
export const useLinks = (userId: string | undefined, roles: string[]) => { export const useLinks = (userId: string | undefined, roles: string[]) => {
const { t } = useLingui(); const { t } = useLingui();
@@ -32,7 +31,7 @@ export const useLinks = (userId: string | undefined, roles: string[]) => {
} }
] ]
if (isAdminRole(roles)) { if (roles.includes('Admin')) {
links.push({ links.push({
label: t`Admin`, label: t`Admin`,
href: '/admin', href: '/admin',
-8
View File
@@ -1,8 +0,0 @@
export const ADMIN_ROLE = "Admin";
export const DEVELOPER_ROLE = "Developer";
export const isDeveloperRole = (roles?: string[] | null): boolean =>
!!roles?.includes(DEVELOPER_ROLE);
export const isAdminRole = (roles?: string[] | null): boolean =>
!!roles?.includes(ADMIN_ROLE) || isDeveloperRole(roles);
+1 -1
View File
@@ -21,7 +21,7 @@ const useConsumeCode = (onWrongCode: () => void) => {
} else { } else {
queryClient.setQueryData(playerKeys.auth, response.data); queryClient.setQueryData(playerKeys.auth, response.data);
toast.success(t`Successfully logged in. Welcome back!`); toast.success(t`Successfully logged in. Welcome back!`);
window.location.href = '/'; navigate({ to: '/' })
} }
} else if (data.status === 'INCORRECT_USER_INPUT_CODE_ERROR') { } else if (data.status === 'INCORRECT_USER_INPUT_CODE_ERROR') {
onWrongCode(); onWrongCode();
+1 -1
View File
@@ -21,7 +21,7 @@ const useCreateUser = () => {
...old, ...old,
user: data user: data
})); }));
window.location.href = '/'; navigate({ to: '/' });
} }
}); });
}; };
+22 -8
View File
@@ -1,5 +1,5 @@
import { Text, Group, Stack, Paper, Indicator, Box, Tooltip, ActionIcon } from "@mantine/core"; import { Text, Group, Stack, Paper, Indicator, Box, Tooltip, ActionIcon } from "@mantine/core";
import HeadToHeadIcon from "@/components/head-to-head-icon"; import { FootballHelmetIcon } from "@phosphor-icons/react";
import { useNavigate } from "@tanstack/react-router"; import { useNavigate } from "@tanstack/react-router";
import { Match } from "../types"; import { Match } from "../types";
import TeamAvatar from "@/components/team-avatar"; import TeamAvatar from "@/components/team-avatar";
@@ -10,7 +10,6 @@ import Sheet from "@/components/sheet/sheet";
import TeamHeadToHeadSheet from "./team-head-to-head-sheet"; import TeamHeadToHeadSheet from "./team-head-to-head-sheet";
import AnimatedScore from "./animated-score"; import AnimatedScore from "./animated-score";
import { Trans, useLingui } from "@lingui/react/macro"; import { Trans, useLingui } from "@lingui/react/macro";
import { track } from "@/lib/telemetry/client";
interface MatchCardProps { interface MatchCardProps {
match: Match; match: Match;
@@ -42,10 +41,6 @@ const MatchCard = ({ match, hideH2H = false }: MatchCardProps) => {
const handleH2HClick = (e: React.MouseEvent) => { const handleH2HClick = (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
track("h2h_viewed", {
source: "match_card",
context: [match.home?.name, match.away?.name].filter(Boolean).join(" v "),
});
h2hSheet.open(); h2hSheet.open();
}; };
@@ -94,7 +89,26 @@ const MatchCard = ({ match, hideH2H = false }: MatchCardProps) => {
aria-label={t`View head-to-head`} aria-label={t`View head-to-head`}
w={40} w={40}
> >
<HeadToHeadIcon /> <Group style={{ position: 'relative', width: 27.5, height: 16 }}>
<FootballHelmetIcon
size={14}
style={{
position: 'absolute',
left: 0,
top: 0,
transform: 'rotate(25deg)',
}}
/>
<FootballHelmetIcon
size={14}
style={{
position: 'absolute',
right: 0,
top: 0,
transform: 'scaleX(-1) rotate(25deg)',
}}
/>
</Group>
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
)} )}
@@ -204,7 +218,7 @@ const MatchCard = ({ match, hideH2H = false }: MatchCardProps) => {
}} }}
> >
<Suspense> <Suspense>
<EmojiBar matchId={match.id} source="match_card" /> <EmojiBar matchId={match.id} />
</Suspense> </Suspense>
</Paper> </Paper>
</Box> </Box>
@@ -1,4 +1,4 @@
import { MantineSpacing, Stack, Text } from "@mantine/core"; import { Stack, Text } from "@mantine/core";
import { useMemo } from "react"; import { useMemo } from "react";
import { Match } from "../types"; import { Match } from "../types";
import MatchCard from "./match-card"; import MatchCard from "./match-card";
@@ -7,10 +7,9 @@ import { Trans } from "@lingui/react/macro";
interface MatchListProps { interface MatchListProps {
matches: Match[]; matches: Match[];
hideH2H?: boolean; hideH2H?: boolean;
p?: MantineSpacing;
} }
const MatchList = ({ matches, hideH2H = false, p = "md" }: MatchListProps) => { const MatchList = ({ matches, hideH2H = false }: MatchListProps) => {
const filteredMatches = useMemo( const filteredMatches = useMemo(
() => () =>
[...(matches ?? [])] [...(matches ?? [])]
@@ -28,7 +27,7 @@ const MatchList = ({ matches, hideH2H = false, p = "md" }: MatchListProps) => {
const isRegional = filteredMatches[0]?.tournament?.regional; const isRegional = filteredMatches[0]?.tournament?.regional;
return ( return (
<Stack p={p} gap="sm"> <Stack p="md" gap="sm">
{isRegional && ( {isRegional && (
<Text size="xs" c="dimmed" ta="center" px="md"> <Text size="xs" c="dimmed" ta="center" px="md">
<Trans>Matches for regionals are unordered</Trans> <Trans>Matches for regionals are unordered</Trans>
@@ -200,10 +200,10 @@ const TeamHeadToHeadContent = ({ team1, team2, isOpen = true }: TeamHeadToHeadSh
</Stack> </Stack>
<Stack gap="xs"> <Stack gap="xs">
<Text size="sm" fw={600}> <Text size="sm" fw={600} px="md">
<Trans>Match History (<Plural value={totalMatches} one="# match" other="# matches" />)</Trans> <Trans>Match History (<Plural value={totalMatches} one="# match" other="# matches" />)</Trans>
</Text> </Text>
<MatchList matches={matches} hideH2H p={0} /> <MatchList matches={matches} hideH2H />
</Stack> </Stack>
</Stack> </Stack>
); );
+8 -7
View File
@@ -9,6 +9,7 @@ import { Match, MatchInput } from "@/features/matches/types";
import { emitServerEvent } from "@/lib/events/emitter"; import { emitServerEvent } from "@/lib/events/emitter";
import { superTokensFunctionMiddleware } from "@/utils/supertokens"; import { superTokensFunctionMiddleware } from "@/utils/supertokens";
import { PlayerInfo } from "../players/types"; import { PlayerInfo } from "../players/types";
import { serverFnLoggingMiddleware } from "@/utils/activities";
const orderedTeamsSchema = z.object({ const orderedTeamsSchema = z.object({
tournamentId: z.string(), tournamentId: z.string(),
@@ -17,7 +18,7 @@ const orderedTeamsSchema = z.object({
export const generateTournamentBracket = createServerFn() export const generateTournamentBracket = createServerFn()
.validator(orderedTeamsSchema) .validator(orderedTeamsSchema)
.middleware([superTokensAdminFunctionMiddleware]) .middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
.handler(async ({ data: { tournamentId, orderedTeamIds } }) => .handler(async ({ data: { tournamentId, orderedTeamIds } }) =>
toServerResult(async () => { toServerResult(async () => {
logger.info("Generating tournament bracket", { logger.info("Generating tournament bracket", {
@@ -140,7 +141,7 @@ export const generateTournamentBracket = createServerFn()
export const startMatch = createServerFn() export const startMatch = createServerFn()
.validator(z.string()) .validator(z.string())
.middleware([superTokensAdminFunctionMiddleware]) .middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
.handler(async ({ data }) => .handler(async ({ data }) =>
toServerResult(async () => { toServerResult(async () => {
logger.info("Starting match", data); logger.info("Starting match", data);
@@ -169,7 +170,7 @@ export const populateKnockoutBracket = createServerFn()
.validator(z.object({ .validator(z.object({
tournamentId: z.string(), tournamentId: z.string(),
})) }))
.middleware([superTokensAdminFunctionMiddleware]) .middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
.handler(async ({ data: { tournamentId } }) => .handler(async ({ data: { tournamentId } }) =>
toServerResult(async () => { toServerResult(async () => {
const tournament = await pbAdmin.getTournament(tournamentId); const tournament = await pbAdmin.getTournament(tournamentId);
@@ -800,7 +801,7 @@ const endMatchSchema = z.object({
}); });
export const endMatch = createServerFn() export const endMatch = createServerFn()
.validator(endMatchSchema) .validator(endMatchSchema)
.middleware([superTokensAdminFunctionMiddleware]) .middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
.handler(async ({ data: { matchId, home_cups, away_cups, ot_count } }) => .handler(async ({ data: { matchId, home_cups, away_cups, ot_count } }) =>
toServerResult(async () => { toServerResult(async () => {
logger.info("Ending match", matchId); logger.info("Ending match", matchId);
@@ -826,7 +827,7 @@ const reportScoreSchema = z.object({
export const reportMatchScore = createServerFn() export const reportMatchScore = createServerFn()
.validator(reportScoreSchema) .validator(reportScoreSchema)
.middleware([superTokensFunctionMiddleware]) .middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
.handler(async ({ data: { matchId, home_cups, away_cups, ot_count }, context }) => .handler(async ({ data: { matchId, home_cups, away_cups, ot_count }, context }) =>
toServerResult(async () => { toServerResult(async () => {
const player = await pbAdmin.getPlayerByAuthId(context.userAuthId); const player = await pbAdmin.getPlayerByAuthId(context.userAuthId);
@@ -889,7 +890,7 @@ const matchIdSchema = z.object({ matchId: z.string() });
export const confirmMatchScore = createServerFn() export const confirmMatchScore = createServerFn()
.validator(matchIdSchema) .validator(matchIdSchema)
.middleware([superTokensFunctionMiddleware]) .middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
.handler(async ({ data: { matchId }, context }) => .handler(async ({ data: { matchId }, context }) =>
toServerResult(async () => { toServerResult(async () => {
const player = await pbAdmin.getPlayerByAuthId(context.userAuthId); const player = await pbAdmin.getPlayerByAuthId(context.userAuthId);
@@ -928,7 +929,7 @@ export const confirmMatchScore = createServerFn()
export const clearMatchReport = createServerFn() export const clearMatchReport = createServerFn()
.validator(matchIdSchema) .validator(matchIdSchema)
.middleware([superTokensFunctionMiddleware]) .middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
.handler(async ({ data: { matchId }, context }) => .handler(async ({ data: { matchId }, context }) =>
toServerResult(async () => { toServerResult(async () => {
const player = await pbAdmin.getPlayerByAuthId(context.userAuthId); const player = await pbAdmin.getPlayerByAuthId(context.userAuthId);
-9
View File
@@ -1,9 +0,0 @@
import { describe, expect, it } from "vitest";
import { matchInputSchema } from "./types";
describe("matchInputSchema.partial()", () => {
it("does not inject keys the caller did not send", () => {
const parsed = matchInputSchema.partial().parse({ home_cups: 7 });
expect(parsed).toEqual({ home_cups: 7 });
});
});
+9 -9
View File
@@ -42,20 +42,20 @@ export interface Match {
export const matchInputSchema = z.object({ export const matchInputSchema = z.object({
order: z.number().int().min(1).optional(), order: z.number().int().min(1).optional(),
lid: z.number().int().min(1), lid: z.number().int().min(1),
reset: z.boolean().optional(), reset: z.boolean().optional().default(false),
round: z.number().int().min(1), round: z.number().int().min(1),
home_cups: z.number().int().min(0).optional(), home_cups: z.number().int().min(0).optional().default(0),
away_cups: z.number().int().min(0).optional(), away_cups: z.number().int().min(0).optional().default(0),
ot_count: z.number().int().min(0).optional(), ot_count: z.number().int().min(0).optional().default(0),
start_time: z.iso.datetime().optional(), start_time: z.iso.datetime().optional(),
end_time: z.iso.datetime().optional(), end_time: z.iso.datetime().optional(),
bye: z.boolean().optional(), bye: z.boolean().optional().default(false),
home_from_lid: z.number().int().min(1).optional(), home_from_lid: z.number().int().min(1).optional(),
away_from_lid: z.number().int().min(1).optional(), away_from_lid: z.number().int().min(1).optional(),
home_from_loser: z.boolean().optional(), home_from_loser: z.boolean().optional().default(false),
away_from_loser: z.boolean().optional(), away_from_loser: z.boolean().optional().default(false),
is_losers_bracket: z.boolean().optional(), is_losers_bracket: z.boolean().optional().default(false),
status: z.enum(["tbd", "ready", "started", "ended"]).optional(), status: z.enum(["tbd", "ready", "started", "ended"]).optional().default("tbd"),
tournament: z.string().min(1), tournament: z.string().min(1),
home: z.string().min(1).nullable().optional(), home: z.string().min(1).nullable().optional(),
away: z.string().min(1).nullable().optional(), away: z.string().min(1).nullable().optional(),
@@ -1,5 +1,5 @@
import { Stack, Text, TextInput, Box, Paper, Group, Divider, ActionIcon, Button } from "@mantine/core"; import { Stack, Text, TextInput, Box, Paper, Group, Divider, ActionIcon, Button } from "@mantine/core";
import { useState, useMemo, useRef } from "react"; import { useState, useMemo } from "react";
import { MagnifyingGlassIcon, XIcon, ArrowRightIcon, PlusIcon } from "@phosphor-icons/react"; import { MagnifyingGlassIcon, XIcon, ArrowRightIcon, PlusIcon } from "@phosphor-icons/react";
import { Trans, useLingui } from "@lingui/react/macro"; import { Trans, useLingui } from "@lingui/react/macro";
import { useAllPlayerStats } from "../queries"; import { useAllPlayerStats } from "../queries";
@@ -8,7 +8,6 @@ import { useSheet } from "@/hooks/use-sheet";
import Sheet from "@/components/sheet/sheet"; import Sheet from "@/components/sheet/sheet";
import PlayerHeadToHeadSheet from "./player-head-to-head-sheet"; import PlayerHeadToHeadSheet from "./player-head-to-head-sheet";
import PlayerAvatar from "@/components/player-avatar"; import PlayerAvatar from "@/components/player-avatar";
import { track } from "@/lib/telemetry/client";
const LeagueHeadToHead = () => { const LeagueHeadToHead = () => {
const { t } = useLingui(); const { t } = useLingui();
@@ -16,7 +15,6 @@ const LeagueHeadToHead = () => {
const [player1Id, setPlayer1Id] = useState<string | null>(null); const [player1Id, setPlayer1Id] = useState<string | null>(null);
const [player2Id, setPlayer2Id] = useState<string | null>(null); const [player2Id, setPlayer2Id] = useState<string | null>(null);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const searchedRef = useRef(false);
const { data: allPlayerStats } = useAllPlayerStats(); const { data: allPlayerStats } = useAllPlayerStats();
const h2hSheet = useSheet(); const h2hSheet = useSheet();
@@ -53,12 +51,6 @@ const LeagueHeadToHead = () => {
setPlayer1Id(playerId); setPlayer1Id(playerId);
} else { } else {
setPlayer2Id(playerId); setPlayer2Id(playerId);
const opponent =
allPlayerStats?.find((p) => p.player_id === playerId)?.player_name || "";
track("h2h_viewed", {
source: "stats",
context: [player1Name, opponent].filter(Boolean).join(" v "),
});
h2hSheet.open(); h2hSheet.open();
} }
}; };
@@ -136,14 +128,7 @@ const LeagueHeadToHead = () => {
<TextInput <TextInput
placeholder={t`Search players`} placeholder={t`Search players`}
value={search} value={search}
onChange={(e) => { onChange={(e) => setSearch(e.currentTarget.value)}
const value = e.currentTarget.value;
if (value && !searchedRef.current) {
searchedRef.current = true;
track("player_searched");
}
setSearch(value);
}}
leftSection={<MagnifyingGlassIcon size={16} />} leftSection={<MagnifyingGlassIcon size={16} />}
size="md" size="md"
px="md" px="md"
@@ -260,10 +260,10 @@ const PlayerHeadToHeadContent = ({
</Stack> </Stack>
<Stack gap="xs"> <Stack gap="xs">
<Text size="sm" fw={600}> <Text size="sm" fw={600} px="md">
<Trans>Match History ({totalMatches})</Trans> <Trans>Match History ({totalMatches})</Trans>
</Text> </Text>
<MatchList matches={matches} hideH2H p={0} /> <MatchList matches={matches} hideH2H />
</Stack> </Stack>
</Stack> </Stack>
); );
@@ -28,7 +28,6 @@ import { useNavigate } from "@tanstack/react-router";
import { useAllPlayerStats } from "../queries"; import { useAllPlayerStats } from "../queries";
import { PlayerListItemSkeleton } from "./player-stats-table-skeleton"; import { PlayerListItemSkeleton } from "./player-stats-table-skeleton";
import { Trans, useLingui } from "@lingui/react/macro"; import { Trans, useLingui } from "@lingui/react/macro";
import { track } from "@/lib/telemetry/client";
type SortKey = keyof PlayerStats | "mmr"; type SortKey = keyof PlayerStats | "mmr";
type SortDirection = "asc" | "desc"; type SortDirection = "asc" | "desc";
@@ -178,7 +177,6 @@ const PlayerStatsTable = ({ viewType = 'all' }: PlayerStatsTableProps) => {
const navigate = useNavigate(); const navigate = useNavigate();
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const deferredSearch = useDeferredValue(search); const deferredSearch = useDeferredValue(search);
const searchedRef = useRef(false);
const [sortConfig, setSortConfig] = useState<SortConfig>({ const [sortConfig, setSortConfig] = useState<SortConfig>({
key: "mmr" as SortKey, key: "mmr" as SortKey,
direction: "desc", direction: "desc",
@@ -293,10 +291,10 @@ const PlayerStatsTable = ({ viewType = 'all' }: PlayerStatsTableProps) => {
}, [navigate]); }, [navigate]);
const handleSort = (key: SortKey) => { const handleSort = (key: SortKey) => {
const direction = setSortConfig((prev) => ({
sortConfig.key === key && sortConfig.direction === "desc" ? "asc" : "desc"; key,
track("stats_sorted", { value: key, context: direction }); direction: prev.key === key && prev.direction === "desc" ? "asc" : "desc",
setSortConfig({ key, direction }); }));
}; };
const getSortIcon = (key: SortKey) => { const getSortIcon = (key: SortKey) => {
@@ -330,14 +328,7 @@ const PlayerStatsTable = ({ viewType = 'all' }: PlayerStatsTableProps) => {
<TextInput <TextInput
placeholder={t`Search players`} placeholder={t`Search players`}
value={search} value={search}
onChange={(e) => { onChange={(e) => setSearch(e.currentTarget.value)}
const value = e.currentTarget.value;
if (value && !searchedRef.current) {
searchedRef.current = true;
track("player_searched");
}
setSearch(value);
}}
leftSection={<MagnifyingGlassIcon size={16} />} leftSection={<MagnifyingGlassIcon size={16} />}
size="md" size="md"
px="md" px="md"
@@ -1,8 +1,7 @@
import Sheet from "@/components/sheet/sheet"; import Sheet from "@/components/sheet/sheet";
import { useAuth } from "@/contexts/auth-context"; import { useAuth } from "@/contexts/auth-context";
import { Flex, Title, ActionIcon, Stack, Button } from "@mantine/core"; import { Flex, Title, ActionIcon, Stack, Button, Box } from "@mantine/core";
import { PencilIcon } from "@phosphor-icons/react"; import { PencilIcon, FootballHelmetIcon } from "@phosphor-icons/react";
import HeadToHeadIcon from "@/components/head-to-head-icon";
import { useMemo } from "react"; import { useMemo } from "react";
import { useLingui } from "@lingui/react/macro"; import { useLingui } from "@lingui/react/macro";
import NameUpdateForm from "./name-form"; import NameUpdateForm from "./name-form";
@@ -10,7 +9,6 @@ import PlayerAvatar from "@/components/player-avatar";
import { useSheet } from "@/hooks/use-sheet"; import { useSheet } from "@/hooks/use-sheet";
import { Player } from "../../types"; import { Player } from "../../types";
import PlayerHeadToHeadSheet from "../player-head-to-head-sheet"; import PlayerHeadToHeadSheet from "../player-head-to-head-sheet";
import { track } from "@/lib/telemetry/client";
interface HeaderProps { interface HeaderProps {
player: Player; player: Player;
@@ -55,17 +53,30 @@ const Header = ({ player }: HeaderProps) => {
variant="subtle" variant="subtle"
size="sm" size="sm"
radius="xl" radius="xl"
onClick={() => { onClick={h2hSheet.open}
track("h2h_viewed", {
source: "profile",
context: [player.first_name, player.last_name].filter(Boolean).join(" "),
});
h2hSheet.open();
}}
w={40} w={40}
display={!owner ? 'block' : 'none'} display={!owner ? 'block' : 'none'}
> >
<HeadToHeadIcon /> <Box style={{ position: 'relative', width: 27.5, height: 16 }}>
<FootballHelmetIcon
size={14}
style={{
position: 'absolute',
left: 0,
top: 0,
transform: 'rotate(25deg)'
}}
/>
<FootballHelmetIcon
size={14}
style={{
position: 'absolute',
right: 0,
top: 0,
transform: 'scaleX(-1) rotate(25deg)'
}}
/>
</Box>
</ActionIcon> </ActionIcon>
</Flex> </Flex>
</Flex> </Flex>
@@ -1,5 +1,5 @@
import { Box, Stack, Text, Divider, Group, Button } from "@mantine/core"; import { Box, Stack, Text, Divider, Group, Button } from "@mantine/core";
import { Suspense, useState, useDeferredValue, useRef } from "react"; import { Suspense, useState, useDeferredValue } from "react";
import { Trans, useLingui } from "@lingui/react/macro"; import { Trans, useLingui } from "@lingui/react/macro";
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
import Header from "./header"; import Header from "./header";
@@ -10,7 +10,6 @@ import StatsOverview, { StatsSkeleton } from "@/components/stats-overview";
import MatchList from "@/features/matches/components/match-list"; import MatchList from "@/features/matches/components/match-list";
import BadgeShowcase from "@/features/badges/components/badge-showcase"; import BadgeShowcase from "@/features/badges/components/badge-showcase";
import BadgeShowcaseSkeleton from "@/features/badges/components/badge-showcase-skeleton"; import BadgeShowcaseSkeleton from "@/features/badges/components/badge-showcase-skeleton";
import { track } from "@/lib/telemetry/client";
interface ProfileProps { interface ProfileProps {
id: string; id: string;
@@ -21,22 +20,14 @@ const StatsWithFilter = ({ id }: { id: string }) => {
const deferredViewType = useDeferredValue(viewType); const deferredViewType = useDeferredValue(viewType);
const isStale = viewType !== deferredViewType; const isStale = viewType !== deferredViewType;
const viewTypeRef = useRef(viewType);
const changeViewType = (next: typeof viewType) => {
if (viewTypeRef.current === next) return;
viewTypeRef.current = next;
track("stats_filtered", { value: next, source: "profile" });
setViewType(next);
};
return ( return (
<Stack> <Stack>
<Group gap="xs" px="md" justify="space-between" align="center"> <Group gap="xs" px="md" justify="space-between" align="center">
<Text size="md" fw={700}><Trans>Statistics</Trans></Text> <Text size="md" fw={700}><Trans>Statistics</Trans></Text>
<Group gap="xs"> <Group gap="xs">
<Button variant={viewType === 'all' ? 'filled' : 'light'} size="compact-xs" onClick={() => changeViewType('all')}><Trans>All</Trans></Button> <Button variant={viewType === 'all' ? 'filled' : 'light'} size="compact-xs" onClick={() => setViewType('all')}><Trans>All</Trans></Button>
<Button variant={viewType === 'mainline' ? 'filled' : 'light'} size="compact-xs" onClick={() => changeViewType('mainline')}><Trans>Mainline</Trans></Button> <Button variant={viewType === 'mainline' ? 'filled' : 'light'} size="compact-xs" onClick={() => setViewType('mainline')}><Trans>Mainline</Trans></Button>
<Button variant={viewType === 'regional' ? 'filled' : 'light'} size="compact-xs" onClick={() => changeViewType('regional')}><Trans>Regional</Trans></Button> <Button variant={viewType === 'regional' ? 'filled' : 'light'} size="compact-xs" onClick={() => setViewType('regional')}><Trans>Regional</Trans></Button>
</Group> </Group>
</Group> </Group>
<Box style={{ opacity: isStale ? 0.6 : 1, transition: 'opacity 150ms' }}> <Box style={{ opacity: isStale ? 0.6 : 1, transition: 'opacity 150ms' }}>
@@ -99,12 +90,7 @@ const Profile = ({ id }: ProfileProps) => {
<> <>
<Header player={player} /> <Header player={player} />
<Box mt="lg"> <Box mt="lg">
<SwipeableTabs <SwipeableTabs tabs={tabs} />
tabs={tabs}
onTabChange={(_, tab) => {
if (tab.value === "matches") track("match_history_viewed", { source: "profile" });
}}
/>
</Box> </Box>
</> </>
); );

Some files were not shown because too many files have changed in this diff Show More