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",
"framer-motion": "^12.23.12",
"pocketbase": "^0.26.2",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-imask": "^7.6.1",
"redaxios": "^0.5.1",
"sonner": "^2.0.7",
@@ -41,7 +41,6 @@
"twilio": "^5.8.0",
"vaul": "^1.1.2",
"web-push": "^3.6.7",
"web-vitals": "^6.2.0",
"zod": "^4.0.15",
},
"devDependencies": {
@@ -49,8 +48,8 @@
"@lingui/cli": "^6.5.0",
"@lingui/vite-plugin": "^6.5.0",
"@types/node": "^22.5.4",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@types/web-push": "^3.6.4",
"@vitejs/plugin-react": "^5.0.0",
"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/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=="],
@@ -1329,9 +1328,9 @@
"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=="],
@@ -1397,7 +1396,7 @@
"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=="],
@@ -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-vitals": ["web-vitals@6.2.0", "", {}, "sha512-i2jhLkq6eB38jgJX5OIjEkyzQKgsG7FR56v7GphJNjzKAySgbRbwAfsFC0dDfHROWmCycf912SJpU2QoYIcMVg=="],
"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=="],
-6
View File
@@ -55,12 +55,6 @@ spec:
value: "3000"
- name: NODE_ENV
value: "production"
- name: TELEMETRY_EXCLUDE_PHONES
valueFrom:
secretKeyRef:
name: flxn-secrets
key: telemetry_exclude_phones
optional: true
- name: TWILIO_ACCOUNT_SID
valueFrom:
+4 -5
View File
@@ -43,8 +43,8 @@
"facehash": "^0.0.7",
"framer-motion": "^12.23.12",
"pocketbase": "^0.26.2",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-imask": "^7.6.1",
"redaxios": "^0.5.1",
"sonner": "^2.0.7",
@@ -53,7 +53,6 @@
"twilio": "^5.8.0",
"vaul": "^1.1.2",
"web-push": "^3.6.7",
"web-vitals": "^6.2.0",
"zod": "^4.0.15"
},
"devDependencies": {
@@ -61,8 +60,8 @@
"@lingui/cli": "^6.5.0",
"@lingui/vite-plugin": "^6.5.0",
"@types/node": "^22.5.4",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@types/web-push": "^3.6.4",
"@vitejs/plugin-react": "^5.0.0",
"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;
}
/* 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 {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
@@ -50,12 +30,6 @@ textarea,
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) {
.app,
[data-drawer-level] {
+1
View File
@@ -13,6 +13,7 @@ const { count, size, warnings } = await generateSW({
'icon-192x192.png',
'icon-512x512.png',
'site.webmanifest',
'styles.css',
'push-handlers.js',
],
// 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).
// Usage: bun run scripts/make-admin.ts <phone> [--role Admin|Developer] [--revoke]
// Grant or revoke the "Admin" role for a user by phone number (US +1 assumed).
// Usage: bun run scripts/make-admin.ts <phone> [--revoke]
import "dotenv/config";
import SuperTokens from "supertokens-node";
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 revoke = process.argv.includes("--revoke");
const roleFlagIndex = process.argv.indexOf("--role");
const role = roleFlagIndex === -1 ? "Admin" : process.argv[roleFlagIndex + 1];
if (!raw) {
console.error("Usage: bun run scripts/make-admin.ts <phone> [--role Admin|Developer] [--revoke]");
process.exit(1);
}
if (!VALID_ROLES.includes(role as (typeof VALID_ROLES)[number])) {
console.error(`Invalid role "${role}". Valid roles: ${VALID_ROLES.join(", ")}`);
console.error("Usage: bun run scripts/make-admin.ts <phone> [--revoke]");
process.exit(1);
}
@@ -69,15 +60,15 @@ if (!user) {
process.exit(1);
}
await UserRoles.createNewRoleOrAddPermissions(role, []);
await UserRoles.createNewRoleOrAddPermissions("Admin", []);
if (revoke) {
const res = await UserRoles.removeUserRole("public", user.id, role);
console.log(`Removed ${role} from ${matched} (user ${user.id}):`, res.status);
const res = await UserRoles.removeUserRole("public", user.id, "Admin");
console.log(`Removed Admin from ${matched} (user ${user.id}):`, res.status);
} else {
const res = await UserRoles.addRoleToUser("public", user.id, role);
const res = await UserRoles.addRoleToUser("public", user.id, "Admin");
console.log(
`Granted ${role} to ${matched} (user ${user.id}):`,
`Granted Admin to ${matched} (user ${user.id}):`,
res.status === "OK"
? res.didUserAlreadyHaveRole
? "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 AuthedAdminIndexRouteImport } from './routes/_authed/admin/index'
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 ApiSpotifyTokenRouteImport } from './routes/api/spotify/token'
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 AuthedTeamsTeamIdRouteImport } from './routes/_authed/teams.$teamId'
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 AuthedAdminBadgesRouteImport } from './routes/_authed/admin/badges'
import { Route as AuthedAdminActivitiesRouteImport } from './routes/_authed/admin/activities'
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 AuthedTournamentsIdGroupsRouteImport } from './routes/_authed/tournaments/$id.groups'
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 ApiFilesCollectionRecordIdFileRouteImport } from './routes/api/files/$collection/$recordId/$file'
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 AuthedAdminTournamentsRunIdRouteImport } from './routes/_authed/admin/tournaments/run.$id'
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'
const RefreshSessionRoute = RefreshSessionRouteImport.update({
@@ -126,11 +117,6 @@ const ApiTournamentsUploadLogoRoute =
path: '/api/tournaments/upload-logo',
getParentRoute: () => rootRouteImport,
} as any)
const ApiTelemetryBeaconRoute = ApiTelemetryBeaconRouteImport.update({
id: '/api/telemetry/beacon',
path: '/api/telemetry/beacon',
getParentRoute: () => rootRouteImport,
} as any)
const ApiTeamsUploadLogoRoute = ApiTeamsUploadLogoRouteImport.update({
id: '/api/teams/upload-logo',
path: '/api/teams/upload-logo',
@@ -207,11 +193,6 @@ const AuthedProfilePlayerIdRoute = AuthedProfilePlayerIdRouteImport.update({
path: '/profile/$playerId',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedAdminTelemetryRoute = AuthedAdminTelemetryRouteImport.update({
id: '/telemetry',
path: '/telemetry',
getParentRoute: () => AuthedAdminRoute,
} as any)
const AuthedAdminPreviewRoute = AuthedAdminPreviewRouteImport.update({
id: '/preview',
path: '/preview',
@@ -233,12 +214,6 @@ const AuthedAdminTournamentsIndexRoute =
path: '/tournaments/',
getParentRoute: () => AuthedAdminRoute,
} as any)
const AuthedAdminTelemetryIndexRoute =
AuthedAdminTelemetryIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => AuthedAdminTelemetryRoute,
} as any)
const AuthedTournamentsIdPredictionsRoute =
AuthedTournamentsIdPredictionsRouteImport.update({
id: '/tournaments/$id/predictions',
@@ -257,36 +232,6 @@ const AuthedTournamentsIdBracketRoute =
path: '/tournaments/$id/bracket',
getParentRoute: () => AuthedRoute,
} 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 =
AuthedAdminTournamentsIdIndexRouteImport.update({
id: '/tournaments/$id/',
@@ -323,12 +268,6 @@ const AuthedAdminTournamentsIdTeamsRoute =
path: '/tournaments/$id/teams',
getParentRoute: () => AuthedAdminRoute,
} as any)
const AuthedAdminTournamentsIdEnrollmentRoute =
AuthedAdminTournamentsIdEnrollmentRouteImport.update({
id: '/tournaments/$id/enrollment',
path: '/tournaments/$id/enrollment',
getParentRoute: () => AuthedAdminRoute,
} as any)
const AuthedAdminTournamentsIdAssignPartnersRoute =
AuthedAdminTournamentsIdAssignPartnersRouteImport.update({
id: '/tournaments/$id/assign-partners',
@@ -349,7 +288,6 @@ export interface FileRoutesByFullPath {
'/admin/activities': typeof AuthedAdminActivitiesRoute
'/admin/badges': typeof AuthedAdminBadgesRoute
'/admin/preview': typeof AuthedAdminPreviewRoute
'/admin/telemetry': typeof AuthedAdminTelemetryRouteWithChildren
'/profile/$playerId': typeof AuthedProfilePlayerIdRoute
'/teams/$teamId': typeof AuthedTeamsTeamIdRoute
'/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
@@ -365,22 +303,14 @@ export interface FileRoutesByFullPath {
'/api/spotify/search': typeof ApiSpotifySearchRoute
'/api/spotify/token': typeof ApiSpotifyTokenRoute
'/api/teams/upload-logo': typeof ApiTeamsUploadLogoRoute
'/api/telemetry/beacon': typeof ApiTelemetryBeaconRoute
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoRoute
'/admin/': typeof AuthedAdminIndexRoute
'/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/groups': typeof AuthedTournamentsIdGroupsRoute
'/tournaments/$id/predictions': typeof AuthedTournamentsIdPredictionsRoute
'/admin/telemetry/': typeof AuthedAdminTelemetryIndexRoute
'/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute
'/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute
'/admin/tournaments/$id/enrollment': typeof AuthedAdminTournamentsIdEnrollmentRoute
'/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
'/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
'/tournaments/$id/predictions/$playerId': typeof AuthedTournamentsIdPredictionsPlayerIdRoute
@@ -415,22 +345,14 @@ export interface FileRoutesByTo {
'/api/spotify/search': typeof ApiSpotifySearchRoute
'/api/spotify/token': typeof ApiSpotifyTokenRoute
'/api/teams/upload-logo': typeof ApiTeamsUploadLogoRoute
'/api/telemetry/beacon': typeof ApiTelemetryBeaconRoute
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoRoute
'/admin': typeof AuthedAdminIndexRoute
'/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/groups': typeof AuthedTournamentsIdGroupsRoute
'/tournaments/$id/predictions': typeof AuthedTournamentsIdPredictionsRoute
'/admin/telemetry': typeof AuthedAdminTelemetryIndexRoute
'/admin/tournaments': typeof AuthedAdminTournamentsIndexRoute
'/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute
'/admin/tournaments/$id/enrollment': typeof AuthedAdminTournamentsIdEnrollmentRoute
'/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
'/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
'/tournaments/$id/predictions/$playerId': typeof AuthedTournamentsIdPredictionsPlayerIdRoute
@@ -453,7 +375,6 @@ export interface FileRoutesById {
'/_authed/admin/activities': typeof AuthedAdminActivitiesRoute
'/_authed/admin/badges': typeof AuthedAdminBadgesRoute
'/_authed/admin/preview': typeof AuthedAdminPreviewRoute
'/_authed/admin/telemetry': typeof AuthedAdminTelemetryRouteWithChildren
'/_authed/profile/$playerId': typeof AuthedProfilePlayerIdRoute
'/_authed/teams/$teamId': typeof AuthedTeamsTeamIdRoute
'/_authed/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
@@ -469,22 +390,14 @@ export interface FileRoutesById {
'/api/spotify/search': typeof ApiSpotifySearchRoute
'/api/spotify/token': typeof ApiSpotifyTokenRoute
'/api/teams/upload-logo': typeof ApiTeamsUploadLogoRoute
'/api/telemetry/beacon': typeof ApiTelemetryBeaconRoute
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoRoute
'/_authed/admin/': typeof AuthedAdminIndexRoute
'/_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/groups': typeof AuthedTournamentsIdGroupsRoute
'/_authed/tournaments/$id/predictions': typeof AuthedTournamentsIdPredictionsRoute
'/_authed/admin/telemetry/': typeof AuthedAdminTelemetryIndexRoute
'/_authed/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute
'/_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/run/$id': typeof AuthedAdminTournamentsRunIdRoute
'/_authed/tournaments/$id/predictions_/$playerId': typeof AuthedTournamentsIdPredictionsPlayerIdRoute
@@ -507,7 +420,6 @@ export interface FileRouteTypes {
| '/admin/activities'
| '/admin/badges'
| '/admin/preview'
| '/admin/telemetry'
| '/profile/$playerId'
| '/teams/$teamId'
| '/tournaments/$tournamentId'
@@ -523,22 +435,14 @@ export interface FileRouteTypes {
| '/api/spotify/search'
| '/api/spotify/token'
| '/api/teams/upload-logo'
| '/api/telemetry/beacon'
| '/api/tournaments/upload-logo'
| '/admin/'
| '/tournaments/'
| '/admin/telemetry/errors'
| '/admin/telemetry/functions'
| '/admin/telemetry/players'
| '/admin/telemetry/sessions'
| '/admin/telemetry/usage'
| '/tournaments/$id/bracket'
| '/tournaments/$id/groups'
| '/tournaments/$id/predictions'
| '/admin/telemetry/'
| '/admin/tournaments/'
| '/admin/tournaments/$id/assign-partners'
| '/admin/tournaments/$id/enrollment'
| '/admin/tournaments/$id/teams'
| '/admin/tournaments/run/$id'
| '/tournaments/$id/predictions/$playerId'
@@ -573,22 +477,14 @@ export interface FileRouteTypes {
| '/api/spotify/search'
| '/api/spotify/token'
| '/api/teams/upload-logo'
| '/api/telemetry/beacon'
| '/api/tournaments/upload-logo'
| '/admin'
| '/tournaments'
| '/admin/telemetry/errors'
| '/admin/telemetry/functions'
| '/admin/telemetry/players'
| '/admin/telemetry/sessions'
| '/admin/telemetry/usage'
| '/tournaments/$id/bracket'
| '/tournaments/$id/groups'
| '/tournaments/$id/predictions'
| '/admin/telemetry'
| '/admin/tournaments'
| '/admin/tournaments/$id/assign-partners'
| '/admin/tournaments/$id/enrollment'
| '/admin/tournaments/$id/teams'
| '/admin/tournaments/run/$id'
| '/tournaments/$id/predictions/$playerId'
@@ -610,7 +506,6 @@ export interface FileRouteTypes {
| '/_authed/admin/activities'
| '/_authed/admin/badges'
| '/_authed/admin/preview'
| '/_authed/admin/telemetry'
| '/_authed/profile/$playerId'
| '/_authed/teams/$teamId'
| '/_authed/tournaments/$tournamentId'
@@ -626,22 +521,14 @@ export interface FileRouteTypes {
| '/api/spotify/search'
| '/api/spotify/token'
| '/api/teams/upload-logo'
| '/api/telemetry/beacon'
| '/api/tournaments/upload-logo'
| '/_authed/admin/'
| '/_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/groups'
| '/_authed/tournaments/$id/predictions'
| '/_authed/admin/telemetry/'
| '/_authed/admin/tournaments/'
| '/_authed/admin/tournaments/$id/assign-partners'
| '/_authed/admin/tournaments/$id/enrollment'
| '/_authed/admin/tournaments/$id/teams'
| '/_authed/admin/tournaments/run/$id'
| '/_authed/tournaments/$id/predictions_/$playerId'
@@ -668,7 +555,6 @@ export interface RootRouteChildren {
ApiSpotifySearchRoute: typeof ApiSpotifySearchRoute
ApiSpotifyTokenRoute: typeof ApiSpotifyTokenRoute
ApiTeamsUploadLogoRoute: typeof ApiTeamsUploadLogoRoute
ApiTelemetryBeaconRoute: typeof ApiTelemetryBeaconRoute
ApiTournamentsUploadLogoRoute: typeof ApiTournamentsUploadLogoRoute
ApiFilesCollectionRecordIdFileRoute: typeof ApiFilesCollectionRecordIdFileRoute
}
@@ -766,13 +652,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiTournamentsUploadLogoRouteImport
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': {
id: '/api/teams/upload-logo'
path: '/api/teams/upload-logo'
@@ -878,13 +757,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthedProfilePlayerIdRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/admin/telemetry': {
id: '/_authed/admin/telemetry'
path: '/telemetry'
fullPath: '/admin/telemetry'
preLoaderRoute: typeof AuthedAdminTelemetryRouteImport
parentRoute: typeof AuthedAdminRoute
}
'/_authed/admin/preview': {
id: '/_authed/admin/preview'
path: '/preview'
@@ -913,13 +785,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthedAdminTournamentsIndexRouteImport
parentRoute: typeof AuthedAdminRoute
}
'/_authed/admin/telemetry/': {
id: '/_authed/admin/telemetry/'
path: '/'
fullPath: '/admin/telemetry/'
preLoaderRoute: typeof AuthedAdminTelemetryIndexRouteImport
parentRoute: typeof AuthedAdminTelemetryRoute
}
'/_authed/tournaments/$id/predictions': {
id: '/_authed/tournaments/$id/predictions'
path: '/tournaments/$id/predictions'
@@ -941,41 +806,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthedTournamentsIdBracketRouteImport
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/': {
id: '/_authed/admin/tournaments/$id/'
path: '/tournaments/$id'
@@ -1018,13 +848,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthedAdminTournamentsIdTeamsRouteImport
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': {
id: '/_authed/admin/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 {
AuthedAdminActivitiesRoute: typeof AuthedAdminActivitiesRoute
AuthedAdminBadgesRoute: typeof AuthedAdminBadgesRoute
AuthedAdminPreviewRoute: typeof AuthedAdminPreviewRoute
AuthedAdminTelemetryRoute: typeof AuthedAdminTelemetryRouteWithChildren
AuthedAdminIndexRoute: typeof AuthedAdminIndexRoute
AuthedAdminTournamentsIndexRoute: typeof AuthedAdminTournamentsIndexRoute
AuthedAdminTournamentsIdAssignPartnersRoute: typeof AuthedAdminTournamentsIdAssignPartnersRoute
AuthedAdminTournamentsIdEnrollmentRoute: typeof AuthedAdminTournamentsIdEnrollmentRoute
AuthedAdminTournamentsIdTeamsRoute: typeof AuthedAdminTournamentsIdTeamsRoute
AuthedAdminTournamentsRunIdRoute: typeof AuthedAdminTournamentsRunIdRoute
AuthedAdminTournamentsIdIndexRoute: typeof AuthedAdminTournamentsIdIndexRoute
@@ -1074,13 +874,10 @@ const AuthedAdminRouteChildren: AuthedAdminRouteChildren = {
AuthedAdminActivitiesRoute: AuthedAdminActivitiesRoute,
AuthedAdminBadgesRoute: AuthedAdminBadgesRoute,
AuthedAdminPreviewRoute: AuthedAdminPreviewRoute,
AuthedAdminTelemetryRoute: AuthedAdminTelemetryRouteWithChildren,
AuthedAdminIndexRoute: AuthedAdminIndexRoute,
AuthedAdminTournamentsIndexRoute: AuthedAdminTournamentsIndexRoute,
AuthedAdminTournamentsIdAssignPartnersRoute:
AuthedAdminTournamentsIdAssignPartnersRoute,
AuthedAdminTournamentsIdEnrollmentRoute:
AuthedAdminTournamentsIdEnrollmentRoute,
AuthedAdminTournamentsIdTeamsRoute: AuthedAdminTournamentsIdTeamsRoute,
AuthedAdminTournamentsRunIdRoute: AuthedAdminTournamentsRunIdRoute,
AuthedAdminTournamentsIdIndexRoute: AuthedAdminTournamentsIdIndexRoute,
@@ -1147,7 +944,6 @@ const rootRouteChildren: RootRouteChildren = {
ApiSpotifySearchRoute: ApiSpotifySearchRoute,
ApiSpotifyTokenRoute: ApiSpotifyTokenRoute,
ApiTeamsUploadLogoRoute: ApiTeamsUploadLogoRoute,
ApiTelemetryBeaconRoute: ApiTelemetryBeaconRoute,
ApiTournamentsUploadLogoRoute: ApiTournamentsUploadLogoRoute,
ApiFilesCollectionRecordIdFileRoute: ApiFilesCollectionRecordIdFileRoute,
}
@@ -1156,11 +952,10 @@ export const routeTree = rootRouteImport
._addFileTypes<FileRouteTypes>()
import type { getRouter } from './router.tsx'
import type { startInstance } from './start.ts'
import type { createStart } from '@tanstack/react-start'
declare module '@tanstack/react-start' {
interface Register {
ssr: true
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 { routeTree } from "./routeTree.gen";
import { DefaultCatchBoundary } from "../components/DefaultCatchBoundary";
import RoutePending from "../components/route-pending";
import { defaultHeaderConfig } from "@/features/core/hooks/use-router-config";
export function getRouter() {
@@ -31,9 +30,6 @@ export function getRouter() {
},
defaultPreload: "intent",
defaultPreloadStaleTime: 60_000,
defaultPendingMs: 100,
defaultPendingMinMs: 0,
defaultPendingComponent: RoutePending,
defaultErrorComponent: DefaultCatchBoundary,
});
+4 -22
View File
@@ -5,13 +5,11 @@ import {
Scripts,
createRootRouteWithContext,
isRedirect,
useRouter,
} from "@tanstack/react-router";
import * as React from "react";
import { DefaultCatchBoundary } from "@/components/DefaultCatchBoundary";
import { type QueryClient } from "@tanstack/react-query";
import { ensureSuperTokensFrontend } from "@/lib/supertokens/client";
import { initTelemetry, track } from "@/lib/telemetry/client";
import { AuthContextType } from "@/contexts/auth-context";
import Providers from "@/features/core/components/providers";
import { SessionMonitor } from "@/components/session-monitor";
@@ -31,7 +29,6 @@ import { getRootMeta } from "@/lib/i18n/meta";
import FullScreenLoader from "@/components/full-screen-loader";
import { CHROME_COLORS } from "@/lib/mantine/theme-colors";
import mantineCssUrl from '@mantine/core/styles.css?url'
import appStylesUrl from '../styles.css?url'
import mantineDatesCssUrl from '@mantine/dates/styles.css?url'
import mantineCarouselCssUrl from '@mantine/carousel/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: "icon", href: "/favicon.ico" },
{ rel: 'stylesheet', href: appStylesUrl },
{ rel: 'stylesheet', href: mantineCssUrl },
{ rel: 'stylesheet', href: mantineCarouselCssUrl },
{ rel: 'stylesheet', href: mantineDatesCssUrl },
@@ -179,27 +175,12 @@ export const Route = createRootRouteWithContext<{
});
function RootComponent() {
const router = useRouter();
React.useEffect(() => {
ensureSuperTokensFrontend();
initTelemetry(router);
if (import.meta.env.PROD && 'serviceWorker' in navigator) {
// first install claims the page too; only reload on real updates
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'));
navigator.serviceWorker.register('/sw.js').catch(() => {});
}
}, [router]);
}, []);
return (
<RootDocument>
@@ -212,6 +193,7 @@ function RootComponent() {
);
}
// todo: analytics -> process.env data-website-id
function RootDocument({ children }: { children: React.ReactNode }) {
return (
<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){}',
}}
/>
<link rel="stylesheet" href="/styles.css" />
</head>
<body
style={{
@@ -256,7 +239,6 @@ function RootDocument({ children }: { children: React.ReactNode }) {
}}
>
<div className="app">{children}</div>
<div className="flxn-theme-overlay" aria-hidden="true" />
<Scripts />
</body>
</html>
+1 -2
View File
@@ -1,12 +1,11 @@
import { Outlet, redirect, createFileRoute } from "@tanstack/react-router";
import { msg } from "@lingui/core/macro";
import type { HeaderConfig } from "@/features/core/types/header-config";
import { isAdminRole } from "@/features/core/utils/roles";
export const Route = createFileRoute("/_authed/admin")({
component: Outlet,
beforeLoad: ({ context }) => {
if (!isAdminRole(context.auth?.roles)) {
if (!context.auth?.roles?.includes("Admin")) {
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")({
beforeLoad: () => {
throw redirect({ to: "/admin/telemetry/functions" });
component: Stats,
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 { AdminPage } from "@/features/admin";
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/")({
beforeLoad: ({ context }) => {
prefetchServerQuery(context.queryClient, tournamentQueries.current());
},
loader: () => ({
header: {
withBackButton: true,
@@ -15,5 +10,5 @@ export const Route = createFileRoute("/_authed/admin/")({
},
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 { tournamentQueries, useFreeAgents, useTournament } from "@/features/tournaments/queries";
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 { useState } from "react";
import useGenerateRandomTeams from "@/features/tournaments/hooks/use-generate-random-teams";
@@ -38,7 +37,10 @@ function AssignPartnersPending() {
return (
<Stack gap="lg">
<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 }} />
</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 ManageTournament from "@/features/tournaments/components/manage-tournament";
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";
export const Route = createFileRoute("/_authed/admin/tournaments/$id/")({
@@ -31,14 +31,18 @@ export const Route = createFileRoute("/_authed/admin/tournaments/$id/")({
function ManageTournamentPending() {
return (
<ListSkeleton
rows={5}
withAvatar
avatarSize={20}
withTrailing
fadeStep={0.14}
fadeFloor={0.4}
/>
<Stack gap={0}>
{Array.from({ length: 5 }).map((_, index) => (
<div key={`manage-tournament-skeleton-${index}`} style={{ opacity: Math.max(1 - index * 0.14, 0.4) }}>
<Group p="md" wrap="nowrap" w="100%">
<Skeleton height={20} width={20} radius="sm" />
<Skeleton height={16} width="45%" radius="sm" />
<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 ManageTeams from "@/features/teams/components/manage-teams";
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
import { ListSkeleton } from "@/components/skeletons";
import { Box, Skeleton, Stack } from "@mantine/core";
import { Box, Divider, Group, Skeleton, Stack } from "@mantine/core";
import { msg } from "@lingui/core/macro";
export const Route = createFileRoute("/_authed/admin/tournaments/$id/teams")({
@@ -39,7 +38,21 @@ function ManageTeamsPending() {
<Box px="md">
<Skeleton height={12} width={90} radius="sm" />
</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>
);
}
@@ -1,7 +1,7 @@
import ManageTournaments from "@/features/admin/components/manage-tournaments";
import { tournamentQueries } from "@/features/tournaments/queries";
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 { Suspense } from "react";
import { msg } from "@lingui/core/macro";
@@ -22,13 +22,25 @@ export const Route = createFileRoute("/_authed/admin/tournaments/")({
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() {
return (
<Suspense
fallback={
<ListSkeleton rows={6} titleWidth="55%" withTrailing fadeStep={0.12} fadeFloor={0.4} />
}
>
<Suspense fallback={<TournamentListSkeleton />}>
<ManageTournaments />
</Suspense>
);
@@ -8,10 +8,12 @@ import SeedTournament from "@/features/tournaments/components/seed-tournament";
import SetupGroupStage from "@/features/tournaments/components/setup-group-stage";
import GroupStageView from "@/features/tournaments/components/group-stage-view";
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 { SpotifyControlsBar } from "@/features/spotify/components";
import { useIsAdmin } from "@/features/core/hooks/use-is-admin";
import { useTournamentRunState } from "@/features/tournaments/hooks/use-tournament-run-state";
import { useAuth } from "@/contexts/auth-context";
import { Trans } from "@lingui/react/macro";
import { msg } from "@lingui/core/macro";
@@ -81,10 +83,67 @@ function RunTournamentPending() {
function RouteComponent() {
const { id } = Route.useParams();
const { data: tournament } = useTournament(id);
const isAdmin = useIsAdmin();
const { roles } = useAuth();
const isAdmin = roles?.includes('Admin') || false;
const { hasGroupStage, hasKnockout, knockoutBracketPopulated, nextUpMatchId, bracket } =
useTournamentRunState(tournament.matches);
const hasGroupStage = useMemo(() => {
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 (
<Container size="md" px={0}>
+7 -5
View File
@@ -1,8 +1,7 @@
import { createFileRoute } from "@tanstack/react-router";
import { tournamentQueries, useCurrentTournament } from "@/features/tournaments/queries";
import UpcomingTournament from "@/features/tournaments/components/upcoming-tournament";
import { getServerQueryData } from "@/lib/tanstack-query/utils/ensure";
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
import StartedTournament from "@/features/tournaments/components/started-tournament";
import { Suspense } from "react";
import UpcomingTournamentSkeleton from "@/features/tournaments/components/upcoming-tournament/skeleton";
@@ -11,13 +10,16 @@ export const Route = createFileRoute("/_authed/")({
component: () => <Suspense fallback={<UpcomingTournamentSkeleton />}>
<Home />
</Suspense>,
beforeLoad: ({ context }) => {
prefetchServerQuery(context.queryClient, tournamentQueries.current());
beforeLoad: async ({ context }) => {
const queryClient = context.queryClient;
const tournament = await ensureServerQueryData(queryClient, tournamentQueries.current())
return { tournament }
},
loader: ({ context }) => ({
withPadding: false,
header: {
title: getServerQueryData(context.queryClient, tournamentQueries.current())?.name || "FLXN"
title: context.tournament.name || "FLXN"
}
}),
pendingComponent: () => <UpcomingTournamentSkeleton />
+19 -5
View File
@@ -14,12 +14,26 @@ const searchSchema = z.object({
export const Route = createFileRoute("/_authed/profile/$playerId")({
validateSearch: searchSchema,
beforeLoad: ({ params, context }) => {
beforeLoad: async ({ params, context }) => {
const { queryClient } = context;
prefetchServerQuery(queryClient, playerQueries.details(params.playerId));
prefetchServerQuery(queryClient, playerQueries.matches(params.playerId));
prefetchServerQuery(queryClient, playerQueries.stats(params.playerId));
prefetchServerQuery(queryClient, badgeQueries.playerBadges(params.playerId));
await Promise.all([
prefetchServerQuery(
queryClient,
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 }) => ({
header: {
+4 -13
View File
@@ -1,7 +1,7 @@
import { createFileRoute } from "@tanstack/react-router";
import { playerQueries } from "@/features/players/queries";
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 { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
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 { Trans } from "@lingui/react/macro";
import { msg } from "@lingui/core/macro";
import { track } from "@/lib/telemetry/client";
export const Route = createFileRoute("/_authed/stats")({
component: Stats,
@@ -33,14 +32,6 @@ function Stats() {
const isStale = viewType !== deferredViewType;
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(() => {
const timeout = window.setTimeout(() => {
prefetchServerQuery(queryClient, playerQueries.allStats('mainline'));
@@ -63,21 +54,21 @@ function Stats() {
<Button
variant={viewType === 'all' ? 'filled' : 'light'}
size="compact-xs"
onClick={() => changeViewType('all')}
onClick={() => setViewType('all')}
>
<Trans>All</Trans>
</Button>
<Button
variant={viewType === 'mainline' ? 'filled' : 'light'}
size="compact-xs"
onClick={() => changeViewType('mainline')}
onClick={() => setViewType('mainline')}
>
<Trans>Mainline</Trans>
</Button>
<Button
variant={viewType === 'regional' ? 'filled' : 'light'}
size="compact-xs"
onClick={() => changeViewType('regional')}
onClick={() => setViewType('regional')}
>
<Trans>Regional</Trans>
</Button>
+2 -2
View File
@@ -12,9 +12,9 @@ const searchSchema = z.object({
export const Route = createFileRoute("/_authed/teams/$teamId")({
validateSearch: searchSchema,
beforeLoad: ({ params, context }) => {
beforeLoad: async ({ params, context }) => {
const { queryClient } = context;
prefetchServerQuery(queryClient, teamQueries.details(params.teamId));
await prefetchServerQuery(queryClient, teamQueries.details(params.teamId));
},
loader: ({ params }) => ({
header: {
@@ -1,7 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'
import { tournamentQueries } from '@/features/tournaments/queries';
import Profile from '@/features/tournaments/components/profile';
import { isAdminRole } from '@/features/core/utils/roles';
import { z } from "zod";
import { prefetchServerQuery } from '@/lib/tanstack-query/utils/prefetch';
import { Suspense } from 'react';
@@ -21,7 +20,7 @@ export const Route = createFileRoute('/_authed/tournaments/$tournamentId')({
header: {
collapsed: 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],
withPadding: false
+2
View File
@@ -8,6 +8,8 @@ ensureSuperTokensBackend();
// forwards all supertokens api methods to our API
const superTokensHandler = handleAuthAPIRequest();
const handleRequest = async ({ request }: {request: Request}) => {
console.log("=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=");
console.log("Handling auth request:", request.method, request.url);
return superTokensHandler(request);
};
export const Route = createFileRoute('/api/auth/$')({
+7 -13
View File
@@ -1,14 +1,9 @@
import { createFileRoute } from "@tanstack/react-router";
import {
serverEvents,
EVENT_TYPES,
incrementConnections,
decrementConnections,
type ServerEvent,
} from "@/lib/events/emitter";
import { serverEvents, EVENT_TYPES, type ServerEvent } from "@/lib/events/emitter";
import { logger } from "@/lib/logger";
import { superTokensRequestMiddleware } from "@/utils/supertokens";
let activeConnections = 0;
const encoder = new TextEncoder();
export const Route = createFileRoute("/api/events/$")({
@@ -16,9 +11,9 @@ export const Route = createFileRoute("/api/events/$")({
middleware: [superTokensRequestMiddleware],
handlers: {
GET: ({ request }) => {
const active = incrementConnections();
activeConnections++;
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 cleanup = () => {};
@@ -39,20 +34,19 @@ export const Route = createFileRoute("/api/events/$")({
serverEvents.on(type, handleEvent);
}
// Under the shortest idle timeout observed on proxies/PWA paths.
const pingInterval = setInterval(() => {
send({ type: "ping", timestamp: Date.now() });
}, 8000);
}, 15000);
cleanup = () => {
if (cleanedUp) return;
cleanedUp = true;
const remaining = decrementConnections();
activeConnections--;
for (const type of EVENT_TYPES) {
serverEvents.off(type, handleEvent);
}
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);
+5 -12
View File
@@ -1,26 +1,19 @@
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")({
server: {
handlers: {
GET: async () => {
ensureSuperTokensBackend();
ensureTelemetryScheduler();
const snapshot = await getHealthSnapshot();
// Always 200: a dependency outage must not fail the k8s probes.
GET: () => {
return new Response(
JSON.stringify({
status: snapshot.status,
checks: snapshot.checks,
status: "ok",
timestamp: new Date().toISOString(),
}),
{
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 { superTokensRequestMiddleware } from "@/utils/supertokens";
import { apiLoggingMiddleware } from "@/lib/telemetry/api-logging.server";
import { pbAdmin } from "@/lib/pocketbase/client";
import { logger } from "@/lib/logger";
@@ -12,7 +11,7 @@ const json = (body: unknown, status = 200) =>
export const Route = createFileRoute("/api/push/subscribe")({
server: {
middleware: [apiLoggingMiddleware, superTokensRequestMiddleware],
middleware: [superTokensRequestMiddleware],
handlers: {
POST: async ({ request, context }) => {
const player = (context as any).player;
+1 -2
View File
@@ -1,7 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import { msg } from "@lingui/core/macro";
import { superTokensRequestMiddleware } from "@/utils/supertokens";
import { apiLoggingMiddleware } from "@/lib/telemetry/api-logging.server";
import { sendPushToPlayer } from "@/lib/push";
import { isPushConfigured } from "@/lib/config";
import { logger } from "@/lib/logger";
@@ -14,7 +13,7 @@ const json = (body: unknown, status = 200) =>
export const Route = createFileRoute("/api/push/test")({
server: {
middleware: [apiLoggingMiddleware, superTokensRequestMiddleware],
middleware: [superTokensRequestMiddleware],
handlers: {
POST: async ({ context }) => {
const player = (context as any).player;
+1 -2
View File
@@ -1,6 +1,5 @@
import { createFileRoute } from "@tanstack/react-router";
import { superTokensRequestMiddleware } from "@/utils/supertokens";
import { apiLoggingMiddleware } from "@/lib/telemetry/api-logging.server";
import { pbAdmin } from "@/lib/pocketbase/client";
import { logger } from "@/lib/logger";
@@ -12,7 +11,7 @@ const json = (body: unknown, status = 200) =>
export const Route = createFileRoute("/api/push/unsubscribe")({
server: {
middleware: [apiLoggingMiddleware, superTokensRequestMiddleware],
middleware: [superTokensRequestMiddleware],
handlers: {
POST: async ({ request, context }) => {
const player = (context as any).player;
+2 -4
View File
@@ -1,8 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import { msg } from "@lingui/core/macro";
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 { logger } from "@/lib/logger";
import { localizedFor } from "@/lib/i18n/server-messages";
@@ -14,13 +12,13 @@ const uploadSchema = z.object({
export const Route = createFileRoute("/api/teams/upload-logo")({
server: {
middleware: [apiLoggingMiddleware, superTokensRequestMiddleware],
middleware: [superTokensRequestMiddleware],
handlers: {
POST: async ({ request, context }) => {
const i18n = localizedFor(context.metadata);
try {
const userId = context.userAuthId;
const isAdmin = isAdminRole(context.roles);
const isAdmin = context.roles.includes("Admin");
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 { msg } from "@lingui/core/macro";
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 { logger } from "@/lib/logger";
import { localizedFor } from "@/lib/i18n/server-messages";
@@ -14,13 +12,13 @@ const uploadSchema = z.object({
export const Route = createFileRoute("/api/tournaments/upload-logo")({
server: {
middleware: [apiLoggingMiddleware, superTokensRequestMiddleware],
middleware: [superTokensRequestMiddleware],
handlers: {
POST: async ({ request, context }) => {
const i18n = localizedFor(context.metadata);
try {
const userId = context.userAuthId;
const isAdmin = isAdminRole(context.roles);
const isAdmin = context.roles.includes("Admin");
if (!userId) return new Response("Unauthenticated", { status: 401 });
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 toast from '@/lib/sonner'
import { logger } from '@/lib/logger'
import { captureBoundaryError } from '@/lib/telemetry/client'
import { XCircleIcon, WarningIcon } from '@phosphor-icons/react'
import { Trans, useLingui } from '@lingui/react/macro'
import Button from './button'
@@ -42,7 +41,6 @@ export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
useEffect(() => {
logger.error('DefaultCatchBoundary | ', error)
captureBoundaryError(error)
if (errorMessage.toLowerCase().includes('unauthenticated')) {
toast.error(t`You've been logged out`)
-3
View File
@@ -53,14 +53,12 @@ export function Countdown({ date, label, color }: CountdownProps) {
return (
<Group gap="xs">
{label && <Text size='sm' fw={500}>{label}:</Text>}
{/* Server and client render different seconds; the first client tick corrects it. */}
<Text
size='sm'
fw={600}
c={isFinalStretch ? 'red' : color}
ff="monospace"
className={classes.time}
suppressHydrationWarning
>
{prefix}
<span
@@ -68,7 +66,6 @@ export function Countdown({ date, label, color }: CountdownProps) {
className={`${classes.seconds} ${
isFinalStretch ? classes.finalTick : classes.tick
}`}
suppressHydrationWarning
>
{pad(timeLeft.seconds)}
</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 { track } from '@/lib/telemetry/client'
import { Box, Paper, Group, Text, ActionIcon } from '@mantine/core'
import { DownloadIcon, XIcon } from '@phosphor-icons/react'
import { Trans, useLingui } from '@lingui/react/macro'
@@ -27,17 +26,13 @@ export function IOSInstallPrompt() {
if ((isIOS || isAndroid) && !isInStandaloneMode && !hasBeenDismissed) {
setPlatform(isIOS ? 'ios' : 'android')
const timer = setTimeout(() => {
setShow(true)
track('pwa_ios_prompt_shown')
}, 3000)
const timer = setTimeout(() => setShow(true), 3000)
return () => clearTimeout(timer)
}
}, [])
const handleDismiss = () => {
localStorage.setItem('pwa-install-prompt-dismissed', 'true')
track('pwa_ios_prompt_dismissed')
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 Drawer from "./drawer";
import Modal from "./modal";
import { Box, Flex, Loader } from "@mantine/core";
import { ScrollArea, Flex, Loader } from "@mantine/core";
interface SheetProps extends PropsWithChildren {
title?: string;
@@ -60,19 +60,14 @@ const Sheet: React.FC<SheetProps> = ({ title, children, opened, onChange }) => {
<Loader size='lg' />
</Flex>
}>
{/* Native overflow, not ScrollArea: vaul's drag-vs-scroll detection
only recognizes natively scrollable elements. */}
<Box
style={{
flex: 1,
maxHeight: "75dvh",
overflowY: "auto",
overscrollBehavior: "contain",
WebkitOverflowScrolling: "touch",
}}
<ScrollArea.Autosize
style={{ flex: 1, maxHeight: '75dvh' }}
scrollbarSize={8}
scrollbars="y"
type="scroll"
>
{children}
</Box>
</ScrollArea.Autosize>
</Suspense>
</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,
useRef,
useCallback,
startTransition,
useMemo,
} from "react";
import { useRouter } from "@tanstack/react-router";
@@ -48,9 +48,6 @@ function SwipeableTabs({
}, [search?.tab, tabs, defaultTab]);
const [activeTab, setActiveTab] = useState(getActiveTabFromUrl);
const [visitedTabs, setVisitedTabs] = useState<ReadonlySet<number>>(
() => new Set([getActiveTabFromUrl()])
);
const [stuck, setStuck] = useState(false);
const sentinelRef = useRef<HTMLDivElement>(null);
const [rootRef, setRootRef] = useState<HTMLDivElement | null>(null);
@@ -63,9 +60,6 @@ function SwipeableTabs({
if (index === activeTab || index < 0 || index >= tabs.length) return;
setActiveTab(index);
startTransition(() => {
setVisitedTabs((prev) => (prev.has(index) ? prev : new Set(prev).add(index)));
});
embla?.scrollTo(index);
onTabChange?.(index, tabs[index]);
@@ -231,7 +225,7 @@ function SwipeableTabs({
{tabs.map((tab, index) => (
<Carousel.Slide key={`${tab.label}-content-${index}`}>
<Box ref={setSlideRef(index)} style={{ height: "auto", width: "100%", maxWidth: "100vw", overflow: "hidden" }}>
{(visitedTabs.has(index) || index === activeTab) ? tab.content : null}
{tab.content}
</Box>
</Carousel.Slide>
))}
-1
View File
@@ -18,7 +18,6 @@ interface AuthData {
theme?: string;
locale?: string;
seenThemesHint?: boolean;
telemetryAlerts?: boolean;
};
roles: string[];
phone: string;
+3 -2
View File
@@ -1,7 +1,7 @@
import { createContext, useCallback, useEffect, useMemo, useState, PropsWithChildren } from 'react';
import { useLingui } from '@lingui/react/macro';
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 type {
SpotifyContextType,
@@ -40,7 +40,8 @@ const deepEqual = (a: unknown, b: unknown): boolean => {
};
export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
const isAdmin = useIsAdmin();
const { roles } = useAuth();
const isAdmin = roles?.includes('Admin') || false;
const config = useConfig();
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 { Alert, Code, Container, Group, Stack, Text } from "@mantine/core";
import { CheckIcon, XIcon } from "@phosphor-icons/react";
import {
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 { useActivities } from "../queries";
import { usePlayers } from "@/features/players/queries";
import Sheet from "@/components/sheet/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 = () => {
const { t, i18n } = useLingui();
const { t } = useLingui();
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
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 [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(
() => ({
@@ -34,24 +270,13 @@ export const ActivitiesTable = () => {
perPage: 100,
name: search || undefined,
success: successFilter === "success" ? true : successFilter === "failure" ? false : undefined,
player: playerFilter || undefined,
sortBy,
}),
[page, search, successFilter, playerFilter, sortBy]
[page, search, successFilter, sortBy]
);
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) => {
setSortBy((prev) => {
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) => {
setSelectedActivity(activity);
openDetails();
@@ -73,163 +304,90 @@ export const ActivitiesTable = () => {
return (
<Container size="100%" px={0}>
<Stack gap="xs">
<SearchFilterBar
searchValue={search}
onSearchChange={(value) => {
setSearch(value);
setPage(1);
}}
searchPlaceholder={t`serverFn name`}
filters={[
{
placeholder: t`Status`,
value: successFilter,
onChange: (value) => {
<Stack gap="xs" px="md">
<TextInput
placeholder={t`serverFn name`}
value={search}
onChange={(e) => {
setSearch(e.currentTarget.value);
setPage(1);
}}
leftSection={<MagnifyingGlassIcon size={16} />}
size="md"
/>
<Group>
<Select
placeholder={t`Status`}
value={successFilter}
onChange={(value) => {
setSuccessFilter(value);
setPage(1);
},
data: [
}}
data={[
{ value: "all", label: t`All` },
{ value: "success", label: t`Success` },
{ 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}
onClick={() => handleActivityClick(activity)}
clearable
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>
<DetailSheet
title={t`Activity Details`}
opened={detailsOpened}
<ActivityDetailsSheet
activity={selectedActivity}
isOpen={detailsOpened}
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>
);
+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 { pbAdmin } from "@/lib/pocketbase/client";
import { z } from "zod";
@@ -17,7 +17,7 @@ const activitySearchParamsSchema = z.object({
export const searchActivities = createServerFn()
.validator(activitySearchParamsSchema)
.middleware([superTokensDeveloperFunctionMiddleware])
.middleware([superTokensAdminFunctionMiddleware])
.handler(async ({ data }) =>
toServerResult<ActivityListResult>(async () => {
const result = await pbAdmin.searchActivities(data);
@@ -1,22 +1,21 @@
import { List, Text } from "@mantine/core";
import { Plural, Trans, useLingui } from "@lingui/react/macro";
import ListLink from "@/components/list-link";
import {
DatabaseIcon,
TreeStructureIcon,
TrophyIcon,
MedalIcon,
CrownIcon,
PulseIcon,
ListIcon,
} from "@phosphor-icons/react";
import ListLink from "@/components/list-link";
import ListButton from "@/components/list-button";
import { useIsDeveloper } from "@/features/core/hooks/use-is-developer";
import { getBadgeMigrationStatus, migrateBadgeProgress } from "@/features/badges/server";
import { useServerQuery } from "@/lib/tanstack-query/hooks";
import toast from "@/lib/sonner";
export const ToolsList = () => {
const AdminPage = () => {
const { t } = useLingui();
const isDeveloper = useIsDeveloper();
const { data: migrationStatus, refetch: refetchMigrationStatus } = useServerQuery({
queryKey: ["admin", "badge-migration-status"],
@@ -52,13 +51,6 @@ export const ToolsList = () => {
Icon={TrophyIcon}
to="/admin/tournaments"
/>
{isDeveloper && (
<ListLink
label={t`Telemetry`}
Icon={PulseIcon}
to="/admin/telemetry/usage"
/>
)}
<ListLink
label={t`Award Badges`}
Icon={CrownIcon}
@@ -94,6 +86,18 @@ export const ToolsList = () => {
<Trans>Badge recalculation failed. Check the server logs.</Trans>
</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
label={t`Bracket Preview`}
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 { Box, Card, Text, Select, Button, Group, Stack, Badge, Divider } from "@mantine/core";
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 toast from "@/lib/sonner";
import { usePlayers } from "@/features/players/queries";
@@ -13,29 +13,35 @@ const AwardBadges = () => {
const [selectedPlayerId, setSelectedPlayerId] = useState<string | null>(null);
const [selectedBadgeId, setSelectedBadgeId] = useState<string | null>(null);
const awardBadge = useAwardManualBadge();
const [isAwarding, setIsAwarding] = useState(false);
const manualBadges = allBadges.filter((badge) => badge.type === "manual");
const handleAwardBadge = () => {
const handleAwardBadge = async () => {
if (!selectedPlayerId || !selectedBadgeId) return;
awardBadge.mutate(
{ playerId: selectedPlayerId, badgeId: selectedBadgeId },
{
onSuccess: () => {
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);
setIsAwarding(true);
try {
await awardManualBadge({
data: {
playerId: selectedPlayerId,
badgeId: selectedBadgeId,
},
}
);
});
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) => ({
@@ -92,7 +98,7 @@ const AwardBadges = () => {
<Button
onClick={handleAwardBadge}
disabled={!selectedPlayerId}
loading={awardBadge.isPending}
loading={isAwarding}
size="md"
>
<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 { 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}
height={size}
loading="lazy"
decoding="async"
onError={() => setImageError(true)}
style={{
objectFit: 'contain',
@@ -19,7 +19,6 @@ import PlayerList from '@/features/players/components/player-list';
import { useAuth } from '@/contexts/auth-context';
import { Player } from '@/features/players/types';
import { Trans, useLingui } from '@lingui/react/macro';
import { track } from '@/lib/telemetry/client';
const BadgeStatsTable = () => {
const { data: allBadges } = useAllBadges();
@@ -142,13 +141,7 @@ const BadgeStatRow: React.FC<BadgeStatRowProps> = ({
return (
<Box key={badge.id}>
<UnstyledButton
onClick={() => {
track("badge_viewed", { value: badge.key });
badgeSheet.open();
}}
w='100%'
>
<UnstyledButton onClick={badgeSheet.open} w='100%'>
<Grid p={'xs'} align='center'>
<Grid.Col span={2} style={{ display: 'flex', alignItems: 'center' }}>
<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 { superTokensAdminFunctionMiddleware, superTokensFunctionMiddleware } from "@/utils/supertokens";
import { serverFnLoggingMiddleware } from "@/utils/activities";
import { createServerFn } from "@tanstack/react-start";
import { pbAdmin } from "@/lib/pocketbase/client";
import { z } from "zod";
@@ -14,7 +15,7 @@ export const getPlayerBadges = createServerFn()
);
export const migrateBadgeProgress = createServerFn()
.middleware([superTokensAdminFunctionMiddleware])
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
.handler(async () =>
toServerResult(async () => startBadgeMigration())
);
@@ -40,7 +41,7 @@ export const awardManualBadge = createServerFn()
playerId: z.string(),
badgeId: z.string(),
}))
.middleware([superTokensAdminFunctionMiddleware])
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
.handler(async ({ data }) =>
toServerResult(async () => {
const result = await pbAdmin.awardManualBadge(data.playerId, data.badgeId);
@@ -399,7 +399,7 @@ export const MatchCard: React.FC<MatchCardProps> = ({
</Sheet>
</Flex>
{!showControls && <MatchReport match={match} compact source="match_card" />}
{!showControls && <MatchReport match={match} compact />}
</Stack>
);
};
+26 -14
View File
@@ -13,7 +13,7 @@ import {
Tooltip,
} from "@mantine/core";
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 { Match } from "@/features/matches/types";
import TeamAvatar from "@/components/team-avatar";
@@ -24,7 +24,6 @@ import { MatchReport } from "./match-report";
import Sheet from "@/components/sheet/sheet";
import { useSheet } from "@/hooks/use-sheet";
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];
@@ -156,7 +155,7 @@ const MatchDock = ({ match, onClose }: MatchDockProps) => {
ended={ended}
/>
<MatchReport match={match} source="match_dock" />
<MatchReport match={match} />
<Group justify="space-between" wrap="nowrap" gap="sm">
<Box style={{ flex: 1, minWidth: 0 }}>
@@ -167,7 +166,7 @@ const MatchDock = ({ match, onClose }: MatchDockProps) => {
</Center>
}
>
<EmojiBar matchId={match.id} source="match_dock" />
<EmojiBar matchId={match.id} />
</Suspense>
</Box>
{!hasPrivate && (
@@ -175,20 +174,33 @@ const MatchDock = ({ match, onClose }: MatchDockProps) => {
<ActionIcon
variant="subtle"
size="sm"
onClick={() => {
track("h2h_viewed", {
source: "match_dock",
context: [match?.home?.name, match?.away?.name]
.filter(Boolean)
.join(" v "),
});
h2hSheet.open();
}}
onClick={h2hSheet.open}
aria-label={t`View head-to-head`}
w={40}
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>
</Tooltip>
)}
@@ -13,7 +13,6 @@ import Sheet from "@/components/sheet/sheet";
import { MatchForm } from "./match-form";
import toast from "@/lib/sonner";
import { Trans, useLingui } from "@lingui/react/macro";
import { track } from "@/lib/telemetry/client";
const teamId = (t: Match["home"]): string | undefined =>
!t ? undefined : typeof t === "string" ? t : t.id;
@@ -27,10 +26,9 @@ const teamName = (t: Match["home"]): string | undefined =>
interface MatchReportProps {
match: Match;
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 formSheet = useSheet();
const { t } = useLingui();
@@ -131,9 +129,8 @@ export const MatchReport: React.FC<MatchReportProps> = ({ match, compact, source
const openForm = useCallback(() => {
setAcknowledgedReport(pendingSignature);
track("score_report_opened", source ? { source } : undefined);
formSheet.open();
}, [pendingSignature, formSheet.open, source]);
}, [pendingSignature, formSheet.open]);
const dismissTakeover = useCallback(() => {
setAcknowledgedReport(pendingSignature);
+1 -1
View File
@@ -14,7 +14,7 @@ const BackButton = ({ top=20, left=20 }: { top?: number, left?: number }) => {
onClick={() => router.history.back()}
pos='absolute'
left={left}
top={`calc(${top}px + env(safe-area-inset-top, 0px))`}
top={top}
>
<ArrowLeftIcon weight='bold' size={20} />
</UnstyledButton>
-1
View File
@@ -59,7 +59,6 @@ const Header = ({ collapsed, title, titleValues, withBackButton }: HeaderConfig)
style={{
alignItems: 'center',
justifyContent: 'center',
paddingTop: 'env(safe-area-inset-top, 0px)',
}}
>
{ 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 { useLocation } from '@tanstack/react-router';
import Header from './header';
import Navbar from './navbar';
import Pullable from './pullable';
import useVisualViewportSize from '../hooks/use-visual-viewport-size';
import useRouterConfig from '../hooks/use-router-config';
import Page from '@/components/page';
import './route-transition.css';
const Layout: React.FC<PropsWithChildren> = ({ children }) => {
const { header, withPadding, fullWidth } = useRouterConfig();
const viewport = useVisualViewportSize();
const location = useLocation();
return (
<AppShell
id='app-shell'
layout='alt'
// 0 = header collapse is instant; Mantine's default animates main's padding
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,
}}
header={{ height: 60, collapsed: header.collapsed }}
navbar={{
width: { base: 0, sm: 100, md: 200, lg: 300 },
breakpoint: 'sm',
@@ -47,14 +43,16 @@ const Layout: React.FC<PropsWithChildren> = ({ children }) => {
pos='relative'
h='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 }}
maw='100dvw'
style={{ overflow: 'hidden' }}
>
<Pullable>
<Page noPadding={!withPadding} fullWidth={fullWidth}>
{children}
<Box key={location.pathname} className="flxn-route-enter">
{children}
</Box>
</Page>
</Pullable>
</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
component={Link}
to={href}
className={`flxn-nav-link ${styles.navLinkBox}`}
className={styles.navLinkBox}
p={{ base: 0, sm: 8 }}
aria-label={label}
aria-current={isActive ? "page" : undefined}
data-active={isActive || undefined}
>
<Flex
direction={{ base: "column", md: "row" }}
align="center"
gap={{ base: 0, md: "xs" }}
>
<Icon weight={isActive ? "fill" : "regular"} size={28} />
<Text visibleFrom="md" ta="center" size="md" fw={isActive ? 800 : 500}>
<Icon
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}
</Text>
</Flex>
@@ -11,6 +11,11 @@
-webkit-tap-highlight-color: transparent;
}
.navLinkBox svg,
.navLinkBox p {
transition: color 150ms ease-out;
}
@media (prefers-reduced-motion: no-preference) {
.navLinkBox {
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 { memo } from "react";
import { useIsPWA } from "@/hooks/use-is-pwa";
import { useNavIndicator } from "./nav-indicator/use-nav-indicator";
const Navbar = () => {
const { user, roles } = useAuth()
const isMobile = useIsMobile();
const isPWA = useIsPWA();
const { containerRef, indicatorRef, shapeRef } = useNavIndicator(isMobile);
const links = useLinks(user?.id, roles);
@@ -19,16 +17,9 @@ const Navbar = () => {
? 'max(env(safe-area-inset-bottom, 0px), 0.5rem)'
: '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 (
<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 }}>
<Group ref={containerRef} pos='relative' gap='xs' justify='space-around' h='100%' w='100%' px={{ base: 12, sm: 0 }}>
{indicator}
<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 gap='xs' justify='space-around' h='100%' w='100%' px={{ base: 12, sm: 0 }}>
{links.map((link) => (
<NavLink key={link.href} {...link} />
))}
@@ -36,10 +27,9 @@ const Navbar = () => {
</Paper>
)
return <AppShell.Navbar className="flxn-nav" p="xs" role='navigation'>
return <AppShell.Navbar p="xs" role='navigation'>
<AppShell.Section grow component={ScrollArea}>
<Stack ref={containerRef} pos='relative' gap='xs' mx='auto' w='fit-content' justify='end' mt='md'>
{indicator}
<Stack gap='xs' mx='auto' w='fit-content' justify='end' mt='md'>
{links.map((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 { useMemo } from "react";
import { useLingui } from "@lingui/react/macro";
import { isAdminRole } from "../utils/roles";
export const useLinks = (userId: string | undefined, roles: string[]) => {
const { t } = useLingui();
@@ -32,7 +31,7 @@ export const useLinks = (userId: string | undefined, roles: string[]) => {
}
]
if (isAdminRole(roles)) {
if (roles.includes('Admin')) {
links.push({
label: t`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 {
queryClient.setQueryData(playerKeys.auth, response.data);
toast.success(t`Successfully logged in. Welcome back!`);
window.location.href = '/';
navigate({ to: '/' })
}
} else if (data.status === 'INCORRECT_USER_INPUT_CODE_ERROR') {
onWrongCode();
+1 -1
View File
@@ -21,7 +21,7 @@ const useCreateUser = () => {
...old,
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 HeadToHeadIcon from "@/components/head-to-head-icon";
import { FootballHelmetIcon } from "@phosphor-icons/react";
import { useNavigate } from "@tanstack/react-router";
import { Match } from "../types";
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 AnimatedScore from "./animated-score";
import { Trans, useLingui } from "@lingui/react/macro";
import { track } from "@/lib/telemetry/client";
interface MatchCardProps {
match: Match;
@@ -42,10 +41,6 @@ const MatchCard = ({ match, hideH2H = false }: MatchCardProps) => {
const handleH2HClick = (e: React.MouseEvent) => {
e.stopPropagation();
track("h2h_viewed", {
source: "match_card",
context: [match.home?.name, match.away?.name].filter(Boolean).join(" v "),
});
h2hSheet.open();
};
@@ -94,7 +89,26 @@ const MatchCard = ({ match, hideH2H = false }: MatchCardProps) => {
aria-label={t`View head-to-head`}
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>
</Tooltip>
)}
@@ -204,7 +218,7 @@ const MatchCard = ({ match, hideH2H = false }: MatchCardProps) => {
}}
>
<Suspense>
<EmojiBar matchId={match.id} source="match_card" />
<EmojiBar matchId={match.id} />
</Suspense>
</Paper>
</Box>
@@ -1,4 +1,4 @@
import { MantineSpacing, Stack, Text } from "@mantine/core";
import { Stack, Text } from "@mantine/core";
import { useMemo } from "react";
import { Match } from "../types";
import MatchCard from "./match-card";
@@ -7,10 +7,9 @@ import { Trans } from "@lingui/react/macro";
interface MatchListProps {
matches: Match[];
hideH2H?: boolean;
p?: MantineSpacing;
}
const MatchList = ({ matches, hideH2H = false, p = "md" }: MatchListProps) => {
const MatchList = ({ matches, hideH2H = false }: MatchListProps) => {
const filteredMatches = useMemo(
() =>
[...(matches ?? [])]
@@ -28,7 +27,7 @@ const MatchList = ({ matches, hideH2H = false, p = "md" }: MatchListProps) => {
const isRegional = filteredMatches[0]?.tournament?.regional;
return (
<Stack p={p} gap="sm">
<Stack p="md" gap="sm">
{isRegional && (
<Text size="xs" c="dimmed" ta="center" px="md">
<Trans>Matches for regionals are unordered</Trans>
@@ -200,10 +200,10 @@ const TeamHeadToHeadContent = ({ team1, team2, isOpen = true }: TeamHeadToHeadSh
</Stack>
<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>
</Text>
<MatchList matches={matches} hideH2H p={0} />
<MatchList matches={matches} hideH2H />
</Stack>
</Stack>
);
+8 -7
View File
@@ -9,6 +9,7 @@ import { Match, MatchInput } from "@/features/matches/types";
import { emitServerEvent } from "@/lib/events/emitter";
import { superTokensFunctionMiddleware } from "@/utils/supertokens";
import { PlayerInfo } from "../players/types";
import { serverFnLoggingMiddleware } from "@/utils/activities";
const orderedTeamsSchema = z.object({
tournamentId: z.string(),
@@ -17,7 +18,7 @@ const orderedTeamsSchema = z.object({
export const generateTournamentBracket = createServerFn()
.validator(orderedTeamsSchema)
.middleware([superTokensAdminFunctionMiddleware])
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
.handler(async ({ data: { tournamentId, orderedTeamIds } }) =>
toServerResult(async () => {
logger.info("Generating tournament bracket", {
@@ -140,7 +141,7 @@ export const generateTournamentBracket = createServerFn()
export const startMatch = createServerFn()
.validator(z.string())
.middleware([superTokensAdminFunctionMiddleware])
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
.handler(async ({ data }) =>
toServerResult(async () => {
logger.info("Starting match", data);
@@ -169,7 +170,7 @@ export const populateKnockoutBracket = createServerFn()
.validator(z.object({
tournamentId: z.string(),
}))
.middleware([superTokensAdminFunctionMiddleware])
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
.handler(async ({ data: { tournamentId } }) =>
toServerResult(async () => {
const tournament = await pbAdmin.getTournament(tournamentId);
@@ -800,7 +801,7 @@ const endMatchSchema = z.object({
});
export const endMatch = createServerFn()
.validator(endMatchSchema)
.middleware([superTokensAdminFunctionMiddleware])
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
.handler(async ({ data: { matchId, home_cups, away_cups, ot_count } }) =>
toServerResult(async () => {
logger.info("Ending match", matchId);
@@ -826,7 +827,7 @@ const reportScoreSchema = z.object({
export const reportMatchScore = createServerFn()
.validator(reportScoreSchema)
.middleware([superTokensFunctionMiddleware])
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
.handler(async ({ data: { matchId, home_cups, away_cups, ot_count }, context }) =>
toServerResult(async () => {
const player = await pbAdmin.getPlayerByAuthId(context.userAuthId);
@@ -889,7 +890,7 @@ const matchIdSchema = z.object({ matchId: z.string() });
export const confirmMatchScore = createServerFn()
.validator(matchIdSchema)
.middleware([superTokensFunctionMiddleware])
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
.handler(async ({ data: { matchId }, context }) =>
toServerResult(async () => {
const player = await pbAdmin.getPlayerByAuthId(context.userAuthId);
@@ -928,7 +929,7 @@ export const confirmMatchScore = createServerFn()
export const clearMatchReport = createServerFn()
.validator(matchIdSchema)
.middleware([superTokensFunctionMiddleware])
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
.handler(async ({ data: { matchId }, context }) =>
toServerResult(async () => {
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({
order: z.number().int().min(1).optional(),
lid: z.number().int().min(1),
reset: z.boolean().optional(),
reset: z.boolean().optional().default(false),
round: z.number().int().min(1),
home_cups: z.number().int().min(0).optional(),
away_cups: z.number().int().min(0).optional(),
ot_count: z.number().int().min(0).optional(),
home_cups: z.number().int().min(0).optional().default(0),
away_cups: z.number().int().min(0).optional().default(0),
ot_count: z.number().int().min(0).optional().default(0),
start_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(),
away_from_lid: z.number().int().min(1).optional(),
home_from_loser: z.boolean().optional(),
away_from_loser: z.boolean().optional(),
is_losers_bracket: z.boolean().optional(),
status: z.enum(["tbd", "ready", "started", "ended"]).optional(),
home_from_loser: z.boolean().optional().default(false),
away_from_loser: z.boolean().optional().default(false),
is_losers_bracket: z.boolean().optional().default(false),
status: z.enum(["tbd", "ready", "started", "ended"]).optional().default("tbd"),
tournament: z.string().min(1),
home: 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 { useState, useMemo, useRef } from "react";
import { useState, useMemo } from "react";
import { MagnifyingGlassIcon, XIcon, ArrowRightIcon, PlusIcon } from "@phosphor-icons/react";
import { Trans, useLingui } from "@lingui/react/macro";
import { useAllPlayerStats } from "../queries";
@@ -8,7 +8,6 @@ import { useSheet } from "@/hooks/use-sheet";
import Sheet from "@/components/sheet/sheet";
import PlayerHeadToHeadSheet from "./player-head-to-head-sheet";
import PlayerAvatar from "@/components/player-avatar";
import { track } from "@/lib/telemetry/client";
const LeagueHeadToHead = () => {
const { t } = useLingui();
@@ -16,7 +15,6 @@ const LeagueHeadToHead = () => {
const [player1Id, setPlayer1Id] = useState<string | null>(null);
const [player2Id, setPlayer2Id] = useState<string | null>(null);
const [search, setSearch] = useState("");
const searchedRef = useRef(false);
const { data: allPlayerStats } = useAllPlayerStats();
const h2hSheet = useSheet();
@@ -53,12 +51,6 @@ const LeagueHeadToHead = () => {
setPlayer1Id(playerId);
} else {
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();
}
};
@@ -136,14 +128,7 @@ const LeagueHeadToHead = () => {
<TextInput
placeholder={t`Search players`}
value={search}
onChange={(e) => {
const value = e.currentTarget.value;
if (value && !searchedRef.current) {
searchedRef.current = true;
track("player_searched");
}
setSearch(value);
}}
onChange={(e) => setSearch(e.currentTarget.value)}
leftSection={<MagnifyingGlassIcon size={16} />}
size="md"
px="md"
@@ -260,10 +260,10 @@ const PlayerHeadToHeadContent = ({
</Stack>
<Stack gap="xs">
<Text size="sm" fw={600}>
<Text size="sm" fw={600} px="md">
<Trans>Match History ({totalMatches})</Trans>
</Text>
<MatchList matches={matches} hideH2H p={0} />
<MatchList matches={matches} hideH2H />
</Stack>
</Stack>
);
@@ -28,7 +28,6 @@ import { useNavigate } from "@tanstack/react-router";
import { useAllPlayerStats } from "../queries";
import { PlayerListItemSkeleton } from "./player-stats-table-skeleton";
import { Trans, useLingui } from "@lingui/react/macro";
import { track } from "@/lib/telemetry/client";
type SortKey = keyof PlayerStats | "mmr";
type SortDirection = "asc" | "desc";
@@ -178,7 +177,6 @@ const PlayerStatsTable = ({ viewType = 'all' }: PlayerStatsTableProps) => {
const navigate = useNavigate();
const [search, setSearch] = useState("");
const deferredSearch = useDeferredValue(search);
const searchedRef = useRef(false);
const [sortConfig, setSortConfig] = useState<SortConfig>({
key: "mmr" as SortKey,
direction: "desc",
@@ -293,10 +291,10 @@ const PlayerStatsTable = ({ viewType = 'all' }: PlayerStatsTableProps) => {
}, [navigate]);
const handleSort = (key: SortKey) => {
const direction =
sortConfig.key === key && sortConfig.direction === "desc" ? "asc" : "desc";
track("stats_sorted", { value: key, context: direction });
setSortConfig({ key, direction });
setSortConfig((prev) => ({
key,
direction: prev.key === key && prev.direction === "desc" ? "asc" : "desc",
}));
};
const getSortIcon = (key: SortKey) => {
@@ -330,14 +328,7 @@ const PlayerStatsTable = ({ viewType = 'all' }: PlayerStatsTableProps) => {
<TextInput
placeholder={t`Search players`}
value={search}
onChange={(e) => {
const value = e.currentTarget.value;
if (value && !searchedRef.current) {
searchedRef.current = true;
track("player_searched");
}
setSearch(value);
}}
onChange={(e) => setSearch(e.currentTarget.value)}
leftSection={<MagnifyingGlassIcon size={16} />}
size="md"
px="md"
@@ -1,8 +1,7 @@
import Sheet from "@/components/sheet/sheet";
import { useAuth } from "@/contexts/auth-context";
import { Flex, Title, ActionIcon, Stack, Button } from "@mantine/core";
import { PencilIcon } from "@phosphor-icons/react";
import HeadToHeadIcon from "@/components/head-to-head-icon";
import { Flex, Title, ActionIcon, Stack, Button, Box } from "@mantine/core";
import { PencilIcon, FootballHelmetIcon } from "@phosphor-icons/react";
import { useMemo } from "react";
import { useLingui } from "@lingui/react/macro";
import NameUpdateForm from "./name-form";
@@ -10,7 +9,6 @@ import PlayerAvatar from "@/components/player-avatar";
import { useSheet } from "@/hooks/use-sheet";
import { Player } from "../../types";
import PlayerHeadToHeadSheet from "../player-head-to-head-sheet";
import { track } from "@/lib/telemetry/client";
interface HeaderProps {
player: Player;
@@ -55,17 +53,30 @@ const Header = ({ player }: HeaderProps) => {
variant="subtle"
size="sm"
radius="xl"
onClick={() => {
track("h2h_viewed", {
source: "profile",
context: [player.first_name, player.last_name].filter(Boolean).join(" "),
});
h2hSheet.open();
}}
onClick={h2hSheet.open}
w={40}
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>
</Flex>
</Flex>
@@ -1,5 +1,5 @@
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 { Link } from "@tanstack/react-router";
import Header from "./header";
@@ -10,7 +10,6 @@ import StatsOverview, { StatsSkeleton } from "@/components/stats-overview";
import MatchList from "@/features/matches/components/match-list";
import BadgeShowcase from "@/features/badges/components/badge-showcase";
import BadgeShowcaseSkeleton from "@/features/badges/components/badge-showcase-skeleton";
import { track } from "@/lib/telemetry/client";
interface ProfileProps {
id: string;
@@ -21,22 +20,14 @@ const StatsWithFilter = ({ id }: { id: string }) => {
const deferredViewType = useDeferredValue(viewType);
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 (
<Stack>
<Group gap="xs" px="md" justify="space-between" align="center">
<Text size="md" fw={700}><Trans>Statistics</Trans></Text>
<Group gap="xs">
<Button variant={viewType === 'all' ? 'filled' : 'light'} size="compact-xs" onClick={() => changeViewType('all')}><Trans>All</Trans></Button>
<Button variant={viewType === 'mainline' ? 'filled' : 'light'} size="compact-xs" onClick={() => changeViewType('mainline')}><Trans>Mainline</Trans></Button>
<Button variant={viewType === 'regional' ? 'filled' : 'light'} size="compact-xs" onClick={() => changeViewType('regional')}><Trans>Regional</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={() => setViewType('mainline')}><Trans>Mainline</Trans></Button>
<Button variant={viewType === 'regional' ? 'filled' : 'light'} size="compact-xs" onClick={() => setViewType('regional')}><Trans>Regional</Trans></Button>
</Group>
</Group>
<Box style={{ opacity: isStale ? 0.6 : 1, transition: 'opacity 150ms' }}>
@@ -99,12 +90,7 @@ const Profile = ({ id }: ProfileProps) => {
<>
<Header player={player} />
<Box mt="lg">
<SwipeableTabs
tabs={tabs}
onTabChange={(_, tab) => {
if (tab.value === "matches") track("match_history_viewed", { source: "profile" });
}}
/>
<SwipeableTabs tabs={tabs} />
</Box>
</>
);

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