59 Commits
Author SHA1 Message Date
kyle dbfda83e14 Update src/features/login/hooks/use-create-user.ts
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 9s
CI/CD Pipeline / i18n Catalog Check (push) Successful in 37s
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 2m44s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 8m30s
2026-09-21 12:15:47 -05:00
kyle 8fde1c9362 Update src/features/login/hooks/use-consume-code.ts
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 9s
CI/CD Pipeline / i18n Catalog Check (push) Successful in 43s
CI/CD Pipeline / Deploy to Kubernetes (push) Canceled after 0s
CI/CD Pipeline / Build and Push App Docker Image (push) Canceled after 12s
2026-09-21 12:15:00 -05:00
kyle 9febb8bb60 remove slide animation, use pill instead
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 9s
CI/CD Pipeline / i18n Catalog Check (push) Successful in 38s
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 2m11s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 8m31s
2026-09-14 00:53:24 -07:00
kyle a754fc0282 match cards h2h margins
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 11s
CI/CD Pipeline / i18n Catalog Check (push) Successful in 37s
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 2m11s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 8m30s
2026-09-11 11:53:27 -07:00
kyle 6f17a5d4a8 slide animation
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 7s
CI/CD Pipeline / i18n Catalog Check (push) Successful in 44s
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 2m10s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 8m31s
2026-09-11 10:28:45 -07:00
kyle 9ed3f7169e slide animation
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 13s
CI/CD Pipeline / i18n Catalog Check (push) Successful in 31s
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 3m25s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 8m29s
2026-09-11 08:32:42 -07:00
kyle c4a9d47180 slide animation
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 14s
CI/CD Pipeline / i18n Catalog Check (push) Successful in 33s
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 3m27s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 8m28s
2026-09-10 22:21:16 -07:00
kyle 8971748d5c i18n: refresh catalog line references
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 9s
CI/CD Pipeline / i18n Catalog Check (push) Successful in 36s
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 2m7s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 8m27s
2026-08-30 14:33:50 -05:00
kyle 0ac6636d08 h2h icon
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 16s
CI/CD Pipeline / i18n Catalog Check (push) Failing after 35s
CI/CD Pipeline / Build and Push App Docker Image (push) Skipped
CI/CD Pipeline / Deploy to Kubernetes (push) Skipped
2026-08-30 13:05:47 -05:00
kyle f6faf97cdf develpoer role
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 10s
CI/CD Pipeline / i18n Catalog Check (push) Successful in 43s
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 2m14s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 8m29s
2026-08-30 12:36:28 -05:00
kyle 6bb3ee4bc7 fix: render local-timezone dates client-side only
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 14s
CI/CD Pipeline / i18n Catalog Check (push) Successful in 37s
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 2m10s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 8m28s
- started-tournament header and tournament list formatted times in the
  server timezone during ssr, mismatching hydration on every load
2026-08-27 14:14:03 -07:00
kyle 7bda138d69 feat(telemetry): matchup and direction context in timelines; live error groups
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 9s
CI/CD Pipeline / i18n Catalog Check (push) Successful in 47s
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 2m18s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 8m29s
- h2h_viewed carries who-v-who, stats_sorted carries asc/desc via a
  timeline-only context prop
- error browser aggregates raw rows so new groups appear immediately
- catalogs re-extracted for shifted line references
2026-08-27 10:47:57 -07:00
kyle 9b51d61519 i18n: refresh catalog line references
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 8s
CI/CD Pipeline / i18n Catalog Check (push) Successful in 42s
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 2m8s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 8m28s
2026-08-27 10:32:14 -07:00
kyle 8e42862d20 fix(telemetry): review nits
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 8s
CI/CD Pipeline / i18n Catalog Check (push) Failing after 49s
CI/CD Pipeline / Build and Push App Docker Image (push) Skipped
CI/CD Pipeline / Deploy to Kubernetes (push) Skipped
- dock h2h source corrected to match_dock; badge_viewed breakdown via value
- stats filter guards unified, ref-based, double-tap safe
- emoji picker reuses handleReactionClick; comment and import tidy
2026-08-26 23:19:45 -07:00
kyle f593b2c7b8 feat(telemetry): product interaction events
- h2h_viewed from match card, dock, profile, stats, predictions
- reaction_sent with emoji and surface; score_report_opened from all
  three report entry points
- stats_filtered and stats_sorted on stats route and profile
- rules/teams/players/bracket/tournament view events with sources
- match_history_viewed, badge_viewed, player_searched (count only)
2026-08-26 23:04:36 -07:00
kyle fc65fdb451 feat(telemetry): resolve profile and team names in session timelines 2026-08-26 22:56:11 -07:00
kyle 8171354e2a feat(telemetry): generic event aggregates and usage range presets
- rollups emit event.count (dim name) and event.detail.count (dim name|detail
  from props.source/emoji/tournament)
- usage screen: events section with tap-through breakdown; 7d/30d/180d/custom
  date-range presets
2026-08-26 22:53:58 -07:00
kyle cb16d9757e fix(telemetry): idle-based session rotation; faster sse keepalive
- session id rotates after 30 idle minutes so installed pwas produce
  visit-sized sessions instead of one eternal id
- sse ping every 8s, under the idle timeout observed killing streams
2026-08-26 22:43:25 -07:00
kyle 96d5535293 fix: countdown hydration mismatch; rate-limit sse-error session refreshes
- suppressHydrationWarning on countdown time text (server second differs
  from client second; first tick corrects)
- sse reconnects attempt a session refresh at most once per minute so a
  flaky stream cannot hammer the refresh endpoint
2026-08-26 22:41:05 -07:00
kyle f5b49d49ee code fix
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 13s
CI/CD Pipeline / i18n Catalog Check (push) Successful in 42s
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 2m8s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 8m28s
2026-08-26 20:33:13 -07:00
kyle fd45ba1112 Merge branch 'admin-telemetry-redesign' into development
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 10s
CI/CD Pipeline / i18n Catalog Check (push) Successful in 44s
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 3m0s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 8m27s
2026-08-26 19:14:00 -07:00
kyle f13e5b752a refactor(enrollment): drop count header, keep player-count warnings 2026-08-26 19:12:33 -07:00
kyle 4106c3c846 fix(build): keep supertokens server init out of the client module graph
- ensureSuperTokensBackend moved to the health route handler; the dynamic
  imports in exclusions and alerts dragged recipes into the client build
2026-08-26 18:58:31 -07:00
kyle 044e573933 Merge branch 'admin-telemetry-redesign' into development
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 19s
CI/CD Pipeline / i18n Catalog Check (push) Successful in 41s
CI/CD Pipeline / Build and Push App Docker Image (push) Failing after 51s
CI/CD Pipeline / Deploy to Kubernetes (push) Skipped
2026-08-25 23:36:10 -07:00
kyle 3d8ee52a15 feat(telemetry): opt-in admin alert pushes
- telemetryAlerts metadata flag, admin-gated server fn
- alerts sent only to admins with the flag enabled
- settings toggle visible to admins only, under device notifications
2026-08-25 23:33:01 -07:00
kyle 1ddcffc511 fix(telemetry): review findings
- wau/mau trailing scans run hourly and on day finalization, not every tick
- exclusion lookup failures cached 30s so an st outage cannot amplify
- unverified relation filter dropped; player emptiness checked in js
- anonymous unauthenticated read failures no longer logged
- alerts and exclusions ensure supertokens init on cold pods
2026-08-25 23:26:34 -07:00
kyle 31f6237446 refactor(admin): drop status strip from dashboard; full-bleed usage tab
- admin home is tournament section + tools list only
- usage route matches sibling tabs (fullWidth, no shell padding); screen
  pads its own content so the tab bar stays flush
2026-08-25 23:22:21 -07:00
kyle 6dff5f19c1 chore(k8s): optional telemetry_exclude_phones secret env 2026-08-25 23:04:31 -07:00
kyle 9f5fac2753 feat(telemetry): exclude configured phones from usage telemetry
- TELEMETRY_EXCLUDE_PHONES resolves to player ids at runtime, cached 10m
- excluded players write no activities, client events, or vitals
- their client errors are still captured
2026-08-25 23:04:14 -07:00
kyle c05bdec252 fix(sheet): native scroll container so vaul drag detection works 2026-08-25 22:59:00 -07:00
kyle e4d1ba720a feat(telemetry): session journey view replaces raw events table
- sessions listed from client_events grouped by session id (7d window)
- timeline sheet merges page views, client events, errors, and server-fn
  activities joined by player within the session window
- vitals excluded from timelines; gap markers over 5 minutes
2026-08-25 22:56:09 -07:00
kyle 5d7747839c refactor(telemetry): text-only list empty states, distinct p50/p95 series
- paginated-list empty prop is a plain string, no icon block
- second sparkline series drawn in dimmed color; area disabled on dual charts
- duration legend shows latest p50/p95 values
2026-08-25 22:38:45 -07:00
kyle fd0489a22f refactor(telemetry): chart scale labels, calmer vitals and error styling, usage-first tabs
- sparklines get start/end date and max-value footers
- vitals rating shown by value color instead of badges
- error rows use plain counts, subtle resolve button, terse empty state
- telemetry tab order: usage, players, functions, events, errors; usage is default
2026-08-25 22:35:16 -07:00
kyle 7ea189aa61 fix(telemetry): stop logging beacon requests as activities 2026-08-25 22:26:54 -07:00
kyle 1cc75591fa refactor(admin): align admin ui with app design language
- dashboard: card grid replaced by status strip + nav-tile section + list
- nav-tile promoted to shared component
- stat tiles and usage screen de-carded, sections divided like settings
- hub run tournament back to a list row
- telemetry link restored in tools list
2026-08-25 22:24:36 -07:00
kyle 895737febc style: trim comments to load-bearing invariants only 2026-08-25 22:13:41 -07:00
kyle 5e9d030297 fix(telemetry): enable client tracking in dev 2026-08-25 22:11:09 -07:00
kyle 2b02de2193 fix(telemetry): start scheduler from admin dashboard too, not only health probes 2026-08-25 22:09:08 -07:00
kyle 13481936f0 feat(start): csrf middleware for server functions 2026-08-25 22:05:25 -07:00
kyle 603d5a6f7f i18n: translate telemetry and admin strings for de/es/ja 2026-08-25 21:58:59 -07:00
kyle eb3dbe048b i18n: extract catalogs for telemetry and admin redesign strings 2026-08-25 21:54:51 -07:00
kyle 4af4a65527 bug
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 8s
CI/CD Pipeline / i18n Catalog Check (push) Successful in 30s
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 2m3s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 8m27s
2026-08-25 21:54:50 -07:00
kyle a52872bea8 feat(admin): dashboard home, telemetry section, enrollment ops screen
- mission-control dashboard: now strip, tournament quick actions, usage
  sparklines, error feed, live activity, tools list
- /admin/telemetry with functions/events/errors/players/usage child routes,
  hand-rolled svg chart primitives, decomposed activities table
- error browser with grouping and mark-resolved
- consolidated per-tournament enrollment screen; hub simplified with
  run tournament promoted
- /admin/activities redirects to /admin/telemetry/functions
2026-08-25 21:54:21 -07:00
kyle 06fe1f4ed7 feat(telemetry): rollups, alerts, scheduler, health checks, admin server fns
- pure rollup accumulators with tests; daily upserts keyed date/metric/dim
- alert checks with db-backed cooldown and push to admin players
- in-process scheduler started from health probes
- health endpoint checks pb + supertokens, stale-while-revalidate, always 200
- sse connection counter moved to emitter
- telemetry feature server fns: events/errors/groups/rollups/alerts/status
2026-08-25 21:38:08 -07:00
kyle e2863c1f6c feat(telemetry): collections, universal server-fn coverage, beacon, client tracker
- activities indexes + client_events/client_errors/rollups/alerts collections
- global function middleware via createStart; reads skipped on success
- actor stash, denial dedup, control-flow error guards
- api route logging, auth event overrides, beacon endpoint, web vitals
- centralized admin role checks, shared skeletons, award-badges fix
- extracted deriveRunState with tests
2026-08-25 21:33:25 -07:00
kyle 15aeb64b19 pwa fixes
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 8s
CI/CD Pipeline / i18n Catalog Check (push) Successful in 40s
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 2m6s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 8m27s
2026-08-23 21:51:38 -07:00
kyle 8a894e8365 Merge branch 'deep-ketch' into development
CI/CD Pipeline / i18n Catalog Check (push) Successful in 38s
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 3m11s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 14s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 8m31s
2026-08-23 20:31:49 -07:00
kyle e3fea18ab2 badges improvements 2026-08-23 20:31:44 -07:00
kyle 58702da156 style: trim narration comments from recent changes 2026-08-23 20:06:05 -07:00
kyle c342b91029 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.
2026-08-23 20:06:05 -07:00
kyle 792425a83e 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.
2026-08-23 19:47:31 -07:00
kyle ac2e61731c Merge branch 'theme-discovery-presets' into development 2026-08-23 19:26:37 -07:00
kyle 11bb70316a fix(pwa): simplify the install prompt copy 2026-08-23 19:03:57 -07:00
kyle 9fc79dfc07 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.
2026-08-23 18:27:53 -07:00
kyle 60e91d1371 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.
2026-08-23 18:27:45 -07:00
kyle a45c2aeafa 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.
2026-08-23 18:27:40 -07:00
kyle f63dfde85d 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.
2026-08-23 18:27:35 -07:00
kyle 5ac39ac7a2 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
kyle 733615b9e0 redesign h2h player picker: single slot + Me quick-select 2026-08-23 17:59:24 -07:00
203 changed files with 9919 additions and 2679 deletions
+12 -9
View File
@@ -31,8 +31,8 @@
"facehash": "^0.0.7", "facehash": "^0.0.7",
"framer-motion": "^12.23.12", "framer-motion": "^12.23.12",
"pocketbase": "^0.26.2", "pocketbase": "^0.26.2",
"react": "^19.2.7", "react": "^19.3.0",
"react-dom": "^19.2.7", "react-dom": "^19.3.0",
"react-imask": "^7.6.1", "react-imask": "^7.6.1",
"redaxios": "^0.5.1", "redaxios": "^0.5.1",
"sonner": "^2.0.7", "sonner": "^2.0.7",
@@ -41,6 +41,7 @@
"twilio": "^5.8.0", "twilio": "^5.8.0",
"vaul": "^1.1.2", "vaul": "^1.1.2",
"web-push": "^3.6.7", "web-push": "^3.6.7",
"web-vitals": "^6.2.0",
"zod": "^4.0.15", "zod": "^4.0.15",
}, },
"devDependencies": { "devDependencies": {
@@ -48,8 +49,8 @@
"@lingui/cli": "^6.5.0", "@lingui/cli": "^6.5.0",
"@lingui/vite-plugin": "^6.5.0", "@lingui/vite-plugin": "^6.5.0",
"@types/node": "^22.5.4", "@types/node": "^22.5.4",
"@types/react": "^19.2.17", "@types/react": "^19.3.0",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.3.0",
"@types/web-push": "^3.6.4", "@types/web-push": "^3.6.4",
"@vitejs/plugin-react": "^5.0.0", "@vitejs/plugin-react": "^5.0.0",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
@@ -654,9 +655,9 @@
"@types/node": ["@types/node@22.19.10", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-tF5VOugLS/EuDlTBijk0MqABfP8UxgYazTLo3uIn3b4yJgg26QRbVYJYsDtHrjdDUIRfP70+VfhTTc+CE1yskw=="], "@types/node": ["@types/node@22.19.10", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-tF5VOugLS/EuDlTBijk0MqABfP8UxgYazTLo3uIn3b4yJgg26QRbVYJYsDtHrjdDUIRfP70+VfhTTc+CE1yskw=="],
"@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="], "@types/react": ["@types/react@19.3.0", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg=="],
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], "@types/react-dom": ["@types/react-dom@19.3.0", "", { "peerDependencies": { "@types/react": "^19.3.0" } }, "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q=="],
"@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="], "@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="],
@@ -1328,9 +1329,9 @@
"raf-schd": ["raf-schd@4.0.3", "", {}, "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ=="], "raf-schd": ["raf-schd@4.0.3", "", {}, "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ=="],
"react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="], "react": ["react@19.3.0", "", {}, "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog=="],
"react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="], "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-imask": ["react-imask@7.6.1", "", { "dependencies": { "imask": "^7.6.1", "prop-types": "^15.8.1" }, "peerDependencies": { "react": ">=0.14.0" } }, "sha512-vLNfzcCz62Yzx/GRGh5tiCph9Gbh2cZu+Tz8OiO5it2eNuuhpA0DWhhSlOtVtSJ80+Bx+vFK5De8eQ9AmbkXzA=="], "react-imask": ["react-imask@7.6.1", "", { "dependencies": { "imask": "^7.6.1", "prop-types": "^15.8.1" }, "peerDependencies": { "react": ">=0.14.0" } }, "sha512-vLNfzcCz62Yzx/GRGh5tiCph9Gbh2cZu+Tz8OiO5it2eNuuhpA0DWhhSlOtVtSJ80+Bx+vFK5De8eQ9AmbkXzA=="],
@@ -1396,7 +1397,7 @@
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "scheduler": ["scheduler@0.28.0", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="],
"scmp": ["scmp@2.1.0", "", {}, "sha512-o/mRQGk9Rcer/jEEw/yw4mwo3EU/NvYvp577/Btqrym9Qy5/MdWGBqipbALgd2lrdWTJ5/gqDusxfnQBxOxT2Q=="], "scmp": ["scmp@2.1.0", "", {}, "sha512-o/mRQGk9Rcer/jEEw/yw4mwo3EU/NvYvp577/Btqrym9Qy5/MdWGBqipbALgd2lrdWTJ5/gqDusxfnQBxOxT2Q=="],
@@ -1590,6 +1591,8 @@
"web-push": ["web-push@3.6.7", "", { "dependencies": { "asn1.js": "^5.3.0", "http_ece": "1.2.0", "https-proxy-agent": "^7.0.0", "jws": "^4.0.0", "minimist": "^1.2.5" }, "bin": { "web-push": "src/cli.js" } }, "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A=="], "web-push": ["web-push@3.6.7", "", { "dependencies": { "asn1.js": "^5.3.0", "http_ece": "1.2.0", "https-proxy-agent": "^7.0.0", "jws": "^4.0.0", "minimist": "^1.2.5" }, "bin": { "web-push": "src/cli.js" } }, "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A=="],
"web-vitals": ["web-vitals@6.2.0", "", {}, "sha512-i2jhLkq6eB38jgJX5OIjEkyzQKgsG7FR56v7GphJNjzKAySgbRbwAfsFC0dDfHROWmCycf912SJpU2QoYIcMVg=="],
"webidl-conversions": ["webidl-conversions@4.0.2", "", {}, "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg=="], "webidl-conversions": ["webidl-conversions@4.0.2", "", {}, "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg=="],
"webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
+6
View File
@@ -55,6 +55,12 @@ spec:
value: "3000" value: "3000"
- name: NODE_ENV - name: NODE_ENV
value: "production" value: "production"
- name: TELEMETRY_EXCLUDE_PHONES
valueFrom:
secretKeyRef:
name: flxn-secrets
key: telemetry_exclude_phones
optional: true
- name: TWILIO_ACCOUNT_SID - name: TWILIO_ACCOUNT_SID
valueFrom: valueFrom:
+5 -4
View File
@@ -43,8 +43,8 @@
"facehash": "^0.0.7", "facehash": "^0.0.7",
"framer-motion": "^12.23.12", "framer-motion": "^12.23.12",
"pocketbase": "^0.26.2", "pocketbase": "^0.26.2",
"react": "^19.2.7", "react": "^19.3.0",
"react-dom": "^19.2.7", "react-dom": "^19.3.0",
"react-imask": "^7.6.1", "react-imask": "^7.6.1",
"redaxios": "^0.5.1", "redaxios": "^0.5.1",
"sonner": "^2.0.7", "sonner": "^2.0.7",
@@ -53,6 +53,7 @@
"twilio": "^5.8.0", "twilio": "^5.8.0",
"vaul": "^1.1.2", "vaul": "^1.1.2",
"web-push": "^3.6.7", "web-push": "^3.6.7",
"web-vitals": "^6.2.0",
"zod": "^4.0.15" "zod": "^4.0.15"
}, },
"devDependencies": { "devDependencies": {
@@ -60,8 +61,8 @@
"@lingui/cli": "^6.5.0", "@lingui/cli": "^6.5.0",
"@lingui/vite-plugin": "^6.5.0", "@lingui/vite-plugin": "^6.5.0",
"@types/node": "^22.5.4", "@types/node": "^22.5.4",
"@types/react": "^19.2.17", "@types/react": "^19.3.0",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.3.0",
"@types/web-push": "^3.6.4", "@types/web-push": "^3.6.4",
"@vitejs/plugin-react": "^5.0.0", "@vitejs/plugin-react": "^5.0.0",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
@@ -0,0 +1,23 @@
/// <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)
})
@@ -0,0 +1,167 @@
/// <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);
})
@@ -0,0 +1,207 @@
/// <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);
})
@@ -0,0 +1,240 @@
/// <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"));
})
-1
View File
@@ -13,7 +13,6 @@ const { count, size, warnings } = await generateSW({
'icon-192x192.png', 'icon-192x192.png',
'icon-512x512.png', 'icon-512x512.png',
'site.webmanifest', 'site.webmanifest',
'styles.css',
'push-handlers.js', 'push-handlers.js',
], ],
// Hand-written Web Push handlers (public/push-handlers.js) are pulled into // Hand-written Web Push handlers (public/push-handlers.js) are pulled into
+17 -8
View File
@@ -1,5 +1,5 @@
// Grant or revoke the "Admin" role for a user by phone number (US +1 assumed). // Grant or revoke a role for a user by phone number (US +1 assumed).
// Usage: bun run scripts/make-admin.ts <phone> [--revoke] // Usage: bun run scripts/make-admin.ts <phone> [--role Admin|Developer] [--revoke]
import "dotenv/config"; import "dotenv/config";
import SuperTokens from "supertokens-node"; import SuperTokens from "supertokens-node";
import Session from "supertokens-node/recipe/session"; import Session from "supertokens-node/recipe/session";
@@ -26,10 +26,19 @@ SuperTokens.init({
], ],
}); });
const VALID_ROLES = ["Admin", "Developer"] as const;
const raw = process.argv[2]; const raw = process.argv[2];
const revoke = process.argv.includes("--revoke"); const revoke = process.argv.includes("--revoke");
const roleFlagIndex = process.argv.indexOf("--role");
const role = roleFlagIndex === -1 ? "Admin" : process.argv[roleFlagIndex + 1];
if (!raw) { if (!raw) {
console.error("Usage: bun run scripts/make-admin.ts <phone> [--revoke]"); 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(", ")}`);
process.exit(1); process.exit(1);
} }
@@ -60,15 +69,15 @@ if (!user) {
process.exit(1); process.exit(1);
} }
await UserRoles.createNewRoleOrAddPermissions("Admin", []); await UserRoles.createNewRoleOrAddPermissions(role, []);
if (revoke) { if (revoke) {
const res = await UserRoles.removeUserRole("public", user.id, "Admin"); const res = await UserRoles.removeUserRole("public", user.id, role);
console.log(`Removed Admin from ${matched} (user ${user.id}):`, res.status); console.log(`Removed ${role} from ${matched} (user ${user.id}):`, res.status);
} else { } else {
const res = await UserRoles.addRoleToUser("public", user.id, "Admin"); const res = await UserRoles.addRoleToUser("public", user.id, role);
console.log( console.log(
`Granted Admin to ${matched} (user ${user.id}):`, `Granted ${role} to ${matched} (user ${user.id}):`,
res.status === "OK" res.status === "OK"
? res.didUserAlreadyHaveRole ? res.didUserAlreadyHaveRole
? "already had it" ? "already had it"
+206 -1
View File
@@ -22,6 +22,7 @@ import { Route as AuthedAdminRouteImport } from './routes/_authed/admin'
import { Route as AuthedTournamentsIndexRouteImport } from './routes/_authed/tournaments/index' import { Route as AuthedTournamentsIndexRouteImport } from './routes/_authed/tournaments/index'
import { Route as AuthedAdminIndexRouteImport } from './routes/_authed/admin/index' import { Route as AuthedAdminIndexRouteImport } from './routes/_authed/admin/index'
import { Route as ApiTournamentsUploadLogoRouteImport } from './routes/api/tournaments/upload-logo' import { Route as ApiTournamentsUploadLogoRouteImport } from './routes/api/tournaments/upload-logo'
import { Route as ApiTelemetryBeaconRouteImport } from './routes/api/telemetry/beacon'
import { Route as ApiTeamsUploadLogoRouteImport } from './routes/api/teams/upload-logo' import { Route as ApiTeamsUploadLogoRouteImport } from './routes/api/teams/upload-logo'
import { Route as ApiSpotifyTokenRouteImport } from './routes/api/spotify/token' import { Route as ApiSpotifyTokenRouteImport } from './routes/api/spotify/token'
import { Route as ApiSpotifySearchRouteImport } from './routes/api/spotify/search' import { Route as ApiSpotifySearchRouteImport } from './routes/api/spotify/search'
@@ -37,19 +38,27 @@ import { Route as ApiAuthSplatRouteImport } from './routes/api/auth.$'
import { Route as AuthedTournamentsTournamentIdRouteImport } from './routes/_authed/tournaments/$tournamentId' import { Route as AuthedTournamentsTournamentIdRouteImport } from './routes/_authed/tournaments/$tournamentId'
import { Route as AuthedTeamsTeamIdRouteImport } from './routes/_authed/teams.$teamId' import { Route as AuthedTeamsTeamIdRouteImport } from './routes/_authed/teams.$teamId'
import { Route as AuthedProfilePlayerIdRouteImport } from './routes/_authed/profile.$playerId' import { Route as AuthedProfilePlayerIdRouteImport } from './routes/_authed/profile.$playerId'
import { Route as AuthedAdminTelemetryRouteImport } from './routes/_authed/admin/telemetry'
import { Route as AuthedAdminPreviewRouteImport } from './routes/_authed/admin/preview' import { Route as AuthedAdminPreviewRouteImport } from './routes/_authed/admin/preview'
import { Route as AuthedAdminBadgesRouteImport } from './routes/_authed/admin/badges' import { Route as AuthedAdminBadgesRouteImport } from './routes/_authed/admin/badges'
import { Route as AuthedAdminActivitiesRouteImport } from './routes/_authed/admin/activities' import { Route as AuthedAdminActivitiesRouteImport } from './routes/_authed/admin/activities'
import { Route as AuthedAdminTournamentsIndexRouteImport } from './routes/_authed/admin/tournaments/index' import { Route as AuthedAdminTournamentsIndexRouteImport } from './routes/_authed/admin/tournaments/index'
import { Route as AuthedAdminTelemetryIndexRouteImport } from './routes/_authed/admin/telemetry/index'
import { Route as AuthedTournamentsIdPredictionsRouteImport } from './routes/_authed/tournaments/$id.predictions' import { Route as AuthedTournamentsIdPredictionsRouteImport } from './routes/_authed/tournaments/$id.predictions'
import { Route as AuthedTournamentsIdGroupsRouteImport } from './routes/_authed/tournaments/$id.groups' import { Route as AuthedTournamentsIdGroupsRouteImport } from './routes/_authed/tournaments/$id.groups'
import { Route as AuthedTournamentsIdBracketRouteImport } from './routes/_authed/tournaments/$id.bracket' import { Route as AuthedTournamentsIdBracketRouteImport } from './routes/_authed/tournaments/$id.bracket'
import { Route as AuthedAdminTelemetryUsageRouteImport } from './routes/_authed/admin/telemetry/usage'
import { Route as AuthedAdminTelemetrySessionsRouteImport } from './routes/_authed/admin/telemetry/sessions'
import { Route as AuthedAdminTelemetryPlayersRouteImport } from './routes/_authed/admin/telemetry/players'
import { Route as AuthedAdminTelemetryFunctionsRouteImport } from './routes/_authed/admin/telemetry/functions'
import { Route as AuthedAdminTelemetryErrorsRouteImport } from './routes/_authed/admin/telemetry/errors'
import { Route as AuthedAdminTournamentsIdIndexRouteImport } from './routes/_authed/admin/tournaments/$id/index' import { Route as AuthedAdminTournamentsIdIndexRouteImport } from './routes/_authed/admin/tournaments/$id/index'
import { Route as ApiFilesCollectionRecordIdFileRouteImport } from './routes/api/files/$collection/$recordId/$file' import { Route as ApiFilesCollectionRecordIdFileRouteImport } from './routes/api/files/$collection/$recordId/$file'
import { Route as AuthedTournamentsIdPredictionsMakeRouteImport } from './routes/_authed/tournaments/$id.predictions_.make' import { Route as AuthedTournamentsIdPredictionsMakeRouteImport } from './routes/_authed/tournaments/$id.predictions_.make'
import { Route as AuthedTournamentsIdPredictionsPlayerIdRouteImport } from './routes/_authed/tournaments/$id.predictions_.$playerId' import { Route as AuthedTournamentsIdPredictionsPlayerIdRouteImport } from './routes/_authed/tournaments/$id.predictions_.$playerId'
import { Route as AuthedAdminTournamentsRunIdRouteImport } from './routes/_authed/admin/tournaments/run.$id' import { Route as AuthedAdminTournamentsRunIdRouteImport } from './routes/_authed/admin/tournaments/run.$id'
import { Route as AuthedAdminTournamentsIdTeamsRouteImport } from './routes/_authed/admin/tournaments/$id/teams' import { Route as AuthedAdminTournamentsIdTeamsRouteImport } from './routes/_authed/admin/tournaments/$id/teams'
import { Route as AuthedAdminTournamentsIdEnrollmentRouteImport } from './routes/_authed/admin/tournaments/$id/enrollment'
import { Route as AuthedAdminTournamentsIdAssignPartnersRouteImport } from './routes/_authed/admin/tournaments/$id/assign-partners' import { Route as AuthedAdminTournamentsIdAssignPartnersRouteImport } from './routes/_authed/admin/tournaments/$id/assign-partners'
const RefreshSessionRoute = RefreshSessionRouteImport.update({ const RefreshSessionRoute = RefreshSessionRouteImport.update({
@@ -117,6 +126,11 @@ const ApiTournamentsUploadLogoRoute =
path: '/api/tournaments/upload-logo', path: '/api/tournaments/upload-logo',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const ApiTelemetryBeaconRoute = ApiTelemetryBeaconRouteImport.update({
id: '/api/telemetry/beacon',
path: '/api/telemetry/beacon',
getParentRoute: () => rootRouteImport,
} as any)
const ApiTeamsUploadLogoRoute = ApiTeamsUploadLogoRouteImport.update({ const ApiTeamsUploadLogoRoute = ApiTeamsUploadLogoRouteImport.update({
id: '/api/teams/upload-logo', id: '/api/teams/upload-logo',
path: '/api/teams/upload-logo', path: '/api/teams/upload-logo',
@@ -193,6 +207,11 @@ const AuthedProfilePlayerIdRoute = AuthedProfilePlayerIdRouteImport.update({
path: '/profile/$playerId', path: '/profile/$playerId',
getParentRoute: () => AuthedRoute, getParentRoute: () => AuthedRoute,
} as any) } as any)
const AuthedAdminTelemetryRoute = AuthedAdminTelemetryRouteImport.update({
id: '/telemetry',
path: '/telemetry',
getParentRoute: () => AuthedAdminRoute,
} as any)
const AuthedAdminPreviewRoute = AuthedAdminPreviewRouteImport.update({ const AuthedAdminPreviewRoute = AuthedAdminPreviewRouteImport.update({
id: '/preview', id: '/preview',
path: '/preview', path: '/preview',
@@ -214,6 +233,12 @@ const AuthedAdminTournamentsIndexRoute =
path: '/tournaments/', path: '/tournaments/',
getParentRoute: () => AuthedAdminRoute, getParentRoute: () => AuthedAdminRoute,
} as any) } as any)
const AuthedAdminTelemetryIndexRoute =
AuthedAdminTelemetryIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => AuthedAdminTelemetryRoute,
} as any)
const AuthedTournamentsIdPredictionsRoute = const AuthedTournamentsIdPredictionsRoute =
AuthedTournamentsIdPredictionsRouteImport.update({ AuthedTournamentsIdPredictionsRouteImport.update({
id: '/tournaments/$id/predictions', id: '/tournaments/$id/predictions',
@@ -232,6 +257,36 @@ const AuthedTournamentsIdBracketRoute =
path: '/tournaments/$id/bracket', path: '/tournaments/$id/bracket',
getParentRoute: () => AuthedRoute, getParentRoute: () => AuthedRoute,
} as any) } as any)
const AuthedAdminTelemetryUsageRoute =
AuthedAdminTelemetryUsageRouteImport.update({
id: '/usage',
path: '/usage',
getParentRoute: () => AuthedAdminTelemetryRoute,
} as any)
const AuthedAdminTelemetrySessionsRoute =
AuthedAdminTelemetrySessionsRouteImport.update({
id: '/sessions',
path: '/sessions',
getParentRoute: () => AuthedAdminTelemetryRoute,
} as any)
const AuthedAdminTelemetryPlayersRoute =
AuthedAdminTelemetryPlayersRouteImport.update({
id: '/players',
path: '/players',
getParentRoute: () => AuthedAdminTelemetryRoute,
} as any)
const AuthedAdminTelemetryFunctionsRoute =
AuthedAdminTelemetryFunctionsRouteImport.update({
id: '/functions',
path: '/functions',
getParentRoute: () => AuthedAdminTelemetryRoute,
} as any)
const AuthedAdminTelemetryErrorsRoute =
AuthedAdminTelemetryErrorsRouteImport.update({
id: '/errors',
path: '/errors',
getParentRoute: () => AuthedAdminTelemetryRoute,
} as any)
const AuthedAdminTournamentsIdIndexRoute = const AuthedAdminTournamentsIdIndexRoute =
AuthedAdminTournamentsIdIndexRouteImport.update({ AuthedAdminTournamentsIdIndexRouteImport.update({
id: '/tournaments/$id/', id: '/tournaments/$id/',
@@ -268,6 +323,12 @@ const AuthedAdminTournamentsIdTeamsRoute =
path: '/tournaments/$id/teams', path: '/tournaments/$id/teams',
getParentRoute: () => AuthedAdminRoute, getParentRoute: () => AuthedAdminRoute,
} as any) } as any)
const AuthedAdminTournamentsIdEnrollmentRoute =
AuthedAdminTournamentsIdEnrollmentRouteImport.update({
id: '/tournaments/$id/enrollment',
path: '/tournaments/$id/enrollment',
getParentRoute: () => AuthedAdminRoute,
} as any)
const AuthedAdminTournamentsIdAssignPartnersRoute = const AuthedAdminTournamentsIdAssignPartnersRoute =
AuthedAdminTournamentsIdAssignPartnersRouteImport.update({ AuthedAdminTournamentsIdAssignPartnersRouteImport.update({
id: '/tournaments/$id/assign-partners', id: '/tournaments/$id/assign-partners',
@@ -288,6 +349,7 @@ export interface FileRoutesByFullPath {
'/admin/activities': typeof AuthedAdminActivitiesRoute '/admin/activities': typeof AuthedAdminActivitiesRoute
'/admin/badges': typeof AuthedAdminBadgesRoute '/admin/badges': typeof AuthedAdminBadgesRoute
'/admin/preview': typeof AuthedAdminPreviewRoute '/admin/preview': typeof AuthedAdminPreviewRoute
'/admin/telemetry': typeof AuthedAdminTelemetryRouteWithChildren
'/profile/$playerId': typeof AuthedProfilePlayerIdRoute '/profile/$playerId': typeof AuthedProfilePlayerIdRoute
'/teams/$teamId': typeof AuthedTeamsTeamIdRoute '/teams/$teamId': typeof AuthedTeamsTeamIdRoute
'/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute '/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
@@ -303,14 +365,22 @@ export interface FileRoutesByFullPath {
'/api/spotify/search': typeof ApiSpotifySearchRoute '/api/spotify/search': typeof ApiSpotifySearchRoute
'/api/spotify/token': typeof ApiSpotifyTokenRoute '/api/spotify/token': typeof ApiSpotifyTokenRoute
'/api/teams/upload-logo': typeof ApiTeamsUploadLogoRoute '/api/teams/upload-logo': typeof ApiTeamsUploadLogoRoute
'/api/telemetry/beacon': typeof ApiTelemetryBeaconRoute
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoRoute '/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoRoute
'/admin/': typeof AuthedAdminIndexRoute '/admin/': typeof AuthedAdminIndexRoute
'/tournaments/': typeof AuthedTournamentsIndexRoute '/tournaments/': typeof AuthedTournamentsIndexRoute
'/admin/telemetry/errors': typeof AuthedAdminTelemetryErrorsRoute
'/admin/telemetry/functions': typeof AuthedAdminTelemetryFunctionsRoute
'/admin/telemetry/players': typeof AuthedAdminTelemetryPlayersRoute
'/admin/telemetry/sessions': typeof AuthedAdminTelemetrySessionsRoute
'/admin/telemetry/usage': typeof AuthedAdminTelemetryUsageRoute
'/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute '/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
'/tournaments/$id/groups': typeof AuthedTournamentsIdGroupsRoute '/tournaments/$id/groups': typeof AuthedTournamentsIdGroupsRoute
'/tournaments/$id/predictions': typeof AuthedTournamentsIdPredictionsRoute '/tournaments/$id/predictions': typeof AuthedTournamentsIdPredictionsRoute
'/admin/telemetry/': typeof AuthedAdminTelemetryIndexRoute
'/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute '/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute
'/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute '/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute
'/admin/tournaments/$id/enrollment': typeof AuthedAdminTournamentsIdEnrollmentRoute
'/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute '/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
'/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute '/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
'/tournaments/$id/predictions/$playerId': typeof AuthedTournamentsIdPredictionsPlayerIdRoute '/tournaments/$id/predictions/$playerId': typeof AuthedTournamentsIdPredictionsPlayerIdRoute
@@ -345,14 +415,22 @@ export interface FileRoutesByTo {
'/api/spotify/search': typeof ApiSpotifySearchRoute '/api/spotify/search': typeof ApiSpotifySearchRoute
'/api/spotify/token': typeof ApiSpotifyTokenRoute '/api/spotify/token': typeof ApiSpotifyTokenRoute
'/api/teams/upload-logo': typeof ApiTeamsUploadLogoRoute '/api/teams/upload-logo': typeof ApiTeamsUploadLogoRoute
'/api/telemetry/beacon': typeof ApiTelemetryBeaconRoute
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoRoute '/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoRoute
'/admin': typeof AuthedAdminIndexRoute '/admin': typeof AuthedAdminIndexRoute
'/tournaments': typeof AuthedTournamentsIndexRoute '/tournaments': typeof AuthedTournamentsIndexRoute
'/admin/telemetry/errors': typeof AuthedAdminTelemetryErrorsRoute
'/admin/telemetry/functions': typeof AuthedAdminTelemetryFunctionsRoute
'/admin/telemetry/players': typeof AuthedAdminTelemetryPlayersRoute
'/admin/telemetry/sessions': typeof AuthedAdminTelemetrySessionsRoute
'/admin/telemetry/usage': typeof AuthedAdminTelemetryUsageRoute
'/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute '/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
'/tournaments/$id/groups': typeof AuthedTournamentsIdGroupsRoute '/tournaments/$id/groups': typeof AuthedTournamentsIdGroupsRoute
'/tournaments/$id/predictions': typeof AuthedTournamentsIdPredictionsRoute '/tournaments/$id/predictions': typeof AuthedTournamentsIdPredictionsRoute
'/admin/telemetry': typeof AuthedAdminTelemetryIndexRoute
'/admin/tournaments': typeof AuthedAdminTournamentsIndexRoute '/admin/tournaments': typeof AuthedAdminTournamentsIndexRoute
'/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute '/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute
'/admin/tournaments/$id/enrollment': typeof AuthedAdminTournamentsIdEnrollmentRoute
'/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute '/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
'/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute '/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
'/tournaments/$id/predictions/$playerId': typeof AuthedTournamentsIdPredictionsPlayerIdRoute '/tournaments/$id/predictions/$playerId': typeof AuthedTournamentsIdPredictionsPlayerIdRoute
@@ -375,6 +453,7 @@ export interface FileRoutesById {
'/_authed/admin/activities': typeof AuthedAdminActivitiesRoute '/_authed/admin/activities': typeof AuthedAdminActivitiesRoute
'/_authed/admin/badges': typeof AuthedAdminBadgesRoute '/_authed/admin/badges': typeof AuthedAdminBadgesRoute
'/_authed/admin/preview': typeof AuthedAdminPreviewRoute '/_authed/admin/preview': typeof AuthedAdminPreviewRoute
'/_authed/admin/telemetry': typeof AuthedAdminTelemetryRouteWithChildren
'/_authed/profile/$playerId': typeof AuthedProfilePlayerIdRoute '/_authed/profile/$playerId': typeof AuthedProfilePlayerIdRoute
'/_authed/teams/$teamId': typeof AuthedTeamsTeamIdRoute '/_authed/teams/$teamId': typeof AuthedTeamsTeamIdRoute
'/_authed/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute '/_authed/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
@@ -390,14 +469,22 @@ export interface FileRoutesById {
'/api/spotify/search': typeof ApiSpotifySearchRoute '/api/spotify/search': typeof ApiSpotifySearchRoute
'/api/spotify/token': typeof ApiSpotifyTokenRoute '/api/spotify/token': typeof ApiSpotifyTokenRoute
'/api/teams/upload-logo': typeof ApiTeamsUploadLogoRoute '/api/teams/upload-logo': typeof ApiTeamsUploadLogoRoute
'/api/telemetry/beacon': typeof ApiTelemetryBeaconRoute
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoRoute '/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoRoute
'/_authed/admin/': typeof AuthedAdminIndexRoute '/_authed/admin/': typeof AuthedAdminIndexRoute
'/_authed/tournaments/': typeof AuthedTournamentsIndexRoute '/_authed/tournaments/': typeof AuthedTournamentsIndexRoute
'/_authed/admin/telemetry/errors': typeof AuthedAdminTelemetryErrorsRoute
'/_authed/admin/telemetry/functions': typeof AuthedAdminTelemetryFunctionsRoute
'/_authed/admin/telemetry/players': typeof AuthedAdminTelemetryPlayersRoute
'/_authed/admin/telemetry/sessions': typeof AuthedAdminTelemetrySessionsRoute
'/_authed/admin/telemetry/usage': typeof AuthedAdminTelemetryUsageRoute
'/_authed/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute '/_authed/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
'/_authed/tournaments/$id/groups': typeof AuthedTournamentsIdGroupsRoute '/_authed/tournaments/$id/groups': typeof AuthedTournamentsIdGroupsRoute
'/_authed/tournaments/$id/predictions': typeof AuthedTournamentsIdPredictionsRoute '/_authed/tournaments/$id/predictions': typeof AuthedTournamentsIdPredictionsRoute
'/_authed/admin/telemetry/': typeof AuthedAdminTelemetryIndexRoute
'/_authed/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute '/_authed/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute
'/_authed/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute '/_authed/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute
'/_authed/admin/tournaments/$id/enrollment': typeof AuthedAdminTournamentsIdEnrollmentRoute
'/_authed/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute '/_authed/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
'/_authed/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute '/_authed/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
'/_authed/tournaments/$id/predictions_/$playerId': typeof AuthedTournamentsIdPredictionsPlayerIdRoute '/_authed/tournaments/$id/predictions_/$playerId': typeof AuthedTournamentsIdPredictionsPlayerIdRoute
@@ -420,6 +507,7 @@ export interface FileRouteTypes {
| '/admin/activities' | '/admin/activities'
| '/admin/badges' | '/admin/badges'
| '/admin/preview' | '/admin/preview'
| '/admin/telemetry'
| '/profile/$playerId' | '/profile/$playerId'
| '/teams/$teamId' | '/teams/$teamId'
| '/tournaments/$tournamentId' | '/tournaments/$tournamentId'
@@ -435,14 +523,22 @@ export interface FileRouteTypes {
| '/api/spotify/search' | '/api/spotify/search'
| '/api/spotify/token' | '/api/spotify/token'
| '/api/teams/upload-logo' | '/api/teams/upload-logo'
| '/api/telemetry/beacon'
| '/api/tournaments/upload-logo' | '/api/tournaments/upload-logo'
| '/admin/' | '/admin/'
| '/tournaments/' | '/tournaments/'
| '/admin/telemetry/errors'
| '/admin/telemetry/functions'
| '/admin/telemetry/players'
| '/admin/telemetry/sessions'
| '/admin/telemetry/usage'
| '/tournaments/$id/bracket' | '/tournaments/$id/bracket'
| '/tournaments/$id/groups' | '/tournaments/$id/groups'
| '/tournaments/$id/predictions' | '/tournaments/$id/predictions'
| '/admin/telemetry/'
| '/admin/tournaments/' | '/admin/tournaments/'
| '/admin/tournaments/$id/assign-partners' | '/admin/tournaments/$id/assign-partners'
| '/admin/tournaments/$id/enrollment'
| '/admin/tournaments/$id/teams' | '/admin/tournaments/$id/teams'
| '/admin/tournaments/run/$id' | '/admin/tournaments/run/$id'
| '/tournaments/$id/predictions/$playerId' | '/tournaments/$id/predictions/$playerId'
@@ -477,14 +573,22 @@ export interface FileRouteTypes {
| '/api/spotify/search' | '/api/spotify/search'
| '/api/spotify/token' | '/api/spotify/token'
| '/api/teams/upload-logo' | '/api/teams/upload-logo'
| '/api/telemetry/beacon'
| '/api/tournaments/upload-logo' | '/api/tournaments/upload-logo'
| '/admin' | '/admin'
| '/tournaments' | '/tournaments'
| '/admin/telemetry/errors'
| '/admin/telemetry/functions'
| '/admin/telemetry/players'
| '/admin/telemetry/sessions'
| '/admin/telemetry/usage'
| '/tournaments/$id/bracket' | '/tournaments/$id/bracket'
| '/tournaments/$id/groups' | '/tournaments/$id/groups'
| '/tournaments/$id/predictions' | '/tournaments/$id/predictions'
| '/admin/telemetry'
| '/admin/tournaments' | '/admin/tournaments'
| '/admin/tournaments/$id/assign-partners' | '/admin/tournaments/$id/assign-partners'
| '/admin/tournaments/$id/enrollment'
| '/admin/tournaments/$id/teams' | '/admin/tournaments/$id/teams'
| '/admin/tournaments/run/$id' | '/admin/tournaments/run/$id'
| '/tournaments/$id/predictions/$playerId' | '/tournaments/$id/predictions/$playerId'
@@ -506,6 +610,7 @@ export interface FileRouteTypes {
| '/_authed/admin/activities' | '/_authed/admin/activities'
| '/_authed/admin/badges' | '/_authed/admin/badges'
| '/_authed/admin/preview' | '/_authed/admin/preview'
| '/_authed/admin/telemetry'
| '/_authed/profile/$playerId' | '/_authed/profile/$playerId'
| '/_authed/teams/$teamId' | '/_authed/teams/$teamId'
| '/_authed/tournaments/$tournamentId' | '/_authed/tournaments/$tournamentId'
@@ -521,14 +626,22 @@ export interface FileRouteTypes {
| '/api/spotify/search' | '/api/spotify/search'
| '/api/spotify/token' | '/api/spotify/token'
| '/api/teams/upload-logo' | '/api/teams/upload-logo'
| '/api/telemetry/beacon'
| '/api/tournaments/upload-logo' | '/api/tournaments/upload-logo'
| '/_authed/admin/' | '/_authed/admin/'
| '/_authed/tournaments/' | '/_authed/tournaments/'
| '/_authed/admin/telemetry/errors'
| '/_authed/admin/telemetry/functions'
| '/_authed/admin/telemetry/players'
| '/_authed/admin/telemetry/sessions'
| '/_authed/admin/telemetry/usage'
| '/_authed/tournaments/$id/bracket' | '/_authed/tournaments/$id/bracket'
| '/_authed/tournaments/$id/groups' | '/_authed/tournaments/$id/groups'
| '/_authed/tournaments/$id/predictions' | '/_authed/tournaments/$id/predictions'
| '/_authed/admin/telemetry/'
| '/_authed/admin/tournaments/' | '/_authed/admin/tournaments/'
| '/_authed/admin/tournaments/$id/assign-partners' | '/_authed/admin/tournaments/$id/assign-partners'
| '/_authed/admin/tournaments/$id/enrollment'
| '/_authed/admin/tournaments/$id/teams' | '/_authed/admin/tournaments/$id/teams'
| '/_authed/admin/tournaments/run/$id' | '/_authed/admin/tournaments/run/$id'
| '/_authed/tournaments/$id/predictions_/$playerId' | '/_authed/tournaments/$id/predictions_/$playerId'
@@ -555,6 +668,7 @@ export interface RootRouteChildren {
ApiSpotifySearchRoute: typeof ApiSpotifySearchRoute ApiSpotifySearchRoute: typeof ApiSpotifySearchRoute
ApiSpotifyTokenRoute: typeof ApiSpotifyTokenRoute ApiSpotifyTokenRoute: typeof ApiSpotifyTokenRoute
ApiTeamsUploadLogoRoute: typeof ApiTeamsUploadLogoRoute ApiTeamsUploadLogoRoute: typeof ApiTeamsUploadLogoRoute
ApiTelemetryBeaconRoute: typeof ApiTelemetryBeaconRoute
ApiTournamentsUploadLogoRoute: typeof ApiTournamentsUploadLogoRoute ApiTournamentsUploadLogoRoute: typeof ApiTournamentsUploadLogoRoute
ApiFilesCollectionRecordIdFileRoute: typeof ApiFilesCollectionRecordIdFileRoute ApiFilesCollectionRecordIdFileRoute: typeof ApiFilesCollectionRecordIdFileRoute
} }
@@ -652,6 +766,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiTournamentsUploadLogoRouteImport preLoaderRoute: typeof ApiTournamentsUploadLogoRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/api/telemetry/beacon': {
id: '/api/telemetry/beacon'
path: '/api/telemetry/beacon'
fullPath: '/api/telemetry/beacon'
preLoaderRoute: typeof ApiTelemetryBeaconRouteImport
parentRoute: typeof rootRouteImport
}
'/api/teams/upload-logo': { '/api/teams/upload-logo': {
id: '/api/teams/upload-logo' id: '/api/teams/upload-logo'
path: '/api/teams/upload-logo' path: '/api/teams/upload-logo'
@@ -757,6 +878,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthedProfilePlayerIdRouteImport preLoaderRoute: typeof AuthedProfilePlayerIdRouteImport
parentRoute: typeof AuthedRoute parentRoute: typeof AuthedRoute
} }
'/_authed/admin/telemetry': {
id: '/_authed/admin/telemetry'
path: '/telemetry'
fullPath: '/admin/telemetry'
preLoaderRoute: typeof AuthedAdminTelemetryRouteImport
parentRoute: typeof AuthedAdminRoute
}
'/_authed/admin/preview': { '/_authed/admin/preview': {
id: '/_authed/admin/preview' id: '/_authed/admin/preview'
path: '/preview' path: '/preview'
@@ -785,6 +913,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthedAdminTournamentsIndexRouteImport preLoaderRoute: typeof AuthedAdminTournamentsIndexRouteImport
parentRoute: typeof AuthedAdminRoute parentRoute: typeof AuthedAdminRoute
} }
'/_authed/admin/telemetry/': {
id: '/_authed/admin/telemetry/'
path: '/'
fullPath: '/admin/telemetry/'
preLoaderRoute: typeof AuthedAdminTelemetryIndexRouteImport
parentRoute: typeof AuthedAdminTelemetryRoute
}
'/_authed/tournaments/$id/predictions': { '/_authed/tournaments/$id/predictions': {
id: '/_authed/tournaments/$id/predictions' id: '/_authed/tournaments/$id/predictions'
path: '/tournaments/$id/predictions' path: '/tournaments/$id/predictions'
@@ -806,6 +941,41 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthedTournamentsIdBracketRouteImport preLoaderRoute: typeof AuthedTournamentsIdBracketRouteImport
parentRoute: typeof AuthedRoute parentRoute: typeof AuthedRoute
} }
'/_authed/admin/telemetry/usage': {
id: '/_authed/admin/telemetry/usage'
path: '/usage'
fullPath: '/admin/telemetry/usage'
preLoaderRoute: typeof AuthedAdminTelemetryUsageRouteImport
parentRoute: typeof AuthedAdminTelemetryRoute
}
'/_authed/admin/telemetry/sessions': {
id: '/_authed/admin/telemetry/sessions'
path: '/sessions'
fullPath: '/admin/telemetry/sessions'
preLoaderRoute: typeof AuthedAdminTelemetrySessionsRouteImport
parentRoute: typeof AuthedAdminTelemetryRoute
}
'/_authed/admin/telemetry/players': {
id: '/_authed/admin/telemetry/players'
path: '/players'
fullPath: '/admin/telemetry/players'
preLoaderRoute: typeof AuthedAdminTelemetryPlayersRouteImport
parentRoute: typeof AuthedAdminTelemetryRoute
}
'/_authed/admin/telemetry/functions': {
id: '/_authed/admin/telemetry/functions'
path: '/functions'
fullPath: '/admin/telemetry/functions'
preLoaderRoute: typeof AuthedAdminTelemetryFunctionsRouteImport
parentRoute: typeof AuthedAdminTelemetryRoute
}
'/_authed/admin/telemetry/errors': {
id: '/_authed/admin/telemetry/errors'
path: '/errors'
fullPath: '/admin/telemetry/errors'
preLoaderRoute: typeof AuthedAdminTelemetryErrorsRouteImport
parentRoute: typeof AuthedAdminTelemetryRoute
}
'/_authed/admin/tournaments/$id/': { '/_authed/admin/tournaments/$id/': {
id: '/_authed/admin/tournaments/$id/' id: '/_authed/admin/tournaments/$id/'
path: '/tournaments/$id' path: '/tournaments/$id'
@@ -848,6 +1018,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthedAdminTournamentsIdTeamsRouteImport preLoaderRoute: typeof AuthedAdminTournamentsIdTeamsRouteImport
parentRoute: typeof AuthedAdminRoute parentRoute: typeof AuthedAdminRoute
} }
'/_authed/admin/tournaments/$id/enrollment': {
id: '/_authed/admin/tournaments/$id/enrollment'
path: '/tournaments/$id/enrollment'
fullPath: '/admin/tournaments/$id/enrollment'
preLoaderRoute: typeof AuthedAdminTournamentsIdEnrollmentRouteImport
parentRoute: typeof AuthedAdminRoute
}
'/_authed/admin/tournaments/$id/assign-partners': { '/_authed/admin/tournaments/$id/assign-partners': {
id: '/_authed/admin/tournaments/$id/assign-partners' id: '/_authed/admin/tournaments/$id/assign-partners'
path: '/tournaments/$id/assign-partners' path: '/tournaments/$id/assign-partners'
@@ -858,13 +1035,36 @@ declare module '@tanstack/react-router' {
} }
} }
interface AuthedAdminTelemetryRouteChildren {
AuthedAdminTelemetryErrorsRoute: typeof AuthedAdminTelemetryErrorsRoute
AuthedAdminTelemetryFunctionsRoute: typeof AuthedAdminTelemetryFunctionsRoute
AuthedAdminTelemetryPlayersRoute: typeof AuthedAdminTelemetryPlayersRoute
AuthedAdminTelemetrySessionsRoute: typeof AuthedAdminTelemetrySessionsRoute
AuthedAdminTelemetryUsageRoute: typeof AuthedAdminTelemetryUsageRoute
AuthedAdminTelemetryIndexRoute: typeof AuthedAdminTelemetryIndexRoute
}
const AuthedAdminTelemetryRouteChildren: AuthedAdminTelemetryRouteChildren = {
AuthedAdminTelemetryErrorsRoute: AuthedAdminTelemetryErrorsRoute,
AuthedAdminTelemetryFunctionsRoute: AuthedAdminTelemetryFunctionsRoute,
AuthedAdminTelemetryPlayersRoute: AuthedAdminTelemetryPlayersRoute,
AuthedAdminTelemetrySessionsRoute: AuthedAdminTelemetrySessionsRoute,
AuthedAdminTelemetryUsageRoute: AuthedAdminTelemetryUsageRoute,
AuthedAdminTelemetryIndexRoute: AuthedAdminTelemetryIndexRoute,
}
const AuthedAdminTelemetryRouteWithChildren =
AuthedAdminTelemetryRoute._addFileChildren(AuthedAdminTelemetryRouteChildren)
interface AuthedAdminRouteChildren { interface AuthedAdminRouteChildren {
AuthedAdminActivitiesRoute: typeof AuthedAdminActivitiesRoute AuthedAdminActivitiesRoute: typeof AuthedAdminActivitiesRoute
AuthedAdminBadgesRoute: typeof AuthedAdminBadgesRoute AuthedAdminBadgesRoute: typeof AuthedAdminBadgesRoute
AuthedAdminPreviewRoute: typeof AuthedAdminPreviewRoute AuthedAdminPreviewRoute: typeof AuthedAdminPreviewRoute
AuthedAdminTelemetryRoute: typeof AuthedAdminTelemetryRouteWithChildren
AuthedAdminIndexRoute: typeof AuthedAdminIndexRoute AuthedAdminIndexRoute: typeof AuthedAdminIndexRoute
AuthedAdminTournamentsIndexRoute: typeof AuthedAdminTournamentsIndexRoute AuthedAdminTournamentsIndexRoute: typeof AuthedAdminTournamentsIndexRoute
AuthedAdminTournamentsIdAssignPartnersRoute: typeof AuthedAdminTournamentsIdAssignPartnersRoute AuthedAdminTournamentsIdAssignPartnersRoute: typeof AuthedAdminTournamentsIdAssignPartnersRoute
AuthedAdminTournamentsIdEnrollmentRoute: typeof AuthedAdminTournamentsIdEnrollmentRoute
AuthedAdminTournamentsIdTeamsRoute: typeof AuthedAdminTournamentsIdTeamsRoute AuthedAdminTournamentsIdTeamsRoute: typeof AuthedAdminTournamentsIdTeamsRoute
AuthedAdminTournamentsRunIdRoute: typeof AuthedAdminTournamentsRunIdRoute AuthedAdminTournamentsRunIdRoute: typeof AuthedAdminTournamentsRunIdRoute
AuthedAdminTournamentsIdIndexRoute: typeof AuthedAdminTournamentsIdIndexRoute AuthedAdminTournamentsIdIndexRoute: typeof AuthedAdminTournamentsIdIndexRoute
@@ -874,10 +1074,13 @@ const AuthedAdminRouteChildren: AuthedAdminRouteChildren = {
AuthedAdminActivitiesRoute: AuthedAdminActivitiesRoute, AuthedAdminActivitiesRoute: AuthedAdminActivitiesRoute,
AuthedAdminBadgesRoute: AuthedAdminBadgesRoute, AuthedAdminBadgesRoute: AuthedAdminBadgesRoute,
AuthedAdminPreviewRoute: AuthedAdminPreviewRoute, AuthedAdminPreviewRoute: AuthedAdminPreviewRoute,
AuthedAdminTelemetryRoute: AuthedAdminTelemetryRouteWithChildren,
AuthedAdminIndexRoute: AuthedAdminIndexRoute, AuthedAdminIndexRoute: AuthedAdminIndexRoute,
AuthedAdminTournamentsIndexRoute: AuthedAdminTournamentsIndexRoute, AuthedAdminTournamentsIndexRoute: AuthedAdminTournamentsIndexRoute,
AuthedAdminTournamentsIdAssignPartnersRoute: AuthedAdminTournamentsIdAssignPartnersRoute:
AuthedAdminTournamentsIdAssignPartnersRoute, AuthedAdminTournamentsIdAssignPartnersRoute,
AuthedAdminTournamentsIdEnrollmentRoute:
AuthedAdminTournamentsIdEnrollmentRoute,
AuthedAdminTournamentsIdTeamsRoute: AuthedAdminTournamentsIdTeamsRoute, AuthedAdminTournamentsIdTeamsRoute: AuthedAdminTournamentsIdTeamsRoute,
AuthedAdminTournamentsRunIdRoute: AuthedAdminTournamentsRunIdRoute, AuthedAdminTournamentsRunIdRoute: AuthedAdminTournamentsRunIdRoute,
AuthedAdminTournamentsIdIndexRoute: AuthedAdminTournamentsIdIndexRoute, AuthedAdminTournamentsIdIndexRoute: AuthedAdminTournamentsIdIndexRoute,
@@ -944,6 +1147,7 @@ const rootRouteChildren: RootRouteChildren = {
ApiSpotifySearchRoute: ApiSpotifySearchRoute, ApiSpotifySearchRoute: ApiSpotifySearchRoute,
ApiSpotifyTokenRoute: ApiSpotifyTokenRoute, ApiSpotifyTokenRoute: ApiSpotifyTokenRoute,
ApiTeamsUploadLogoRoute: ApiTeamsUploadLogoRoute, ApiTeamsUploadLogoRoute: ApiTeamsUploadLogoRoute,
ApiTelemetryBeaconRoute: ApiTelemetryBeaconRoute,
ApiTournamentsUploadLogoRoute: ApiTournamentsUploadLogoRoute, ApiTournamentsUploadLogoRoute: ApiTournamentsUploadLogoRoute,
ApiFilesCollectionRecordIdFileRoute: ApiFilesCollectionRecordIdFileRoute, ApiFilesCollectionRecordIdFileRoute: ApiFilesCollectionRecordIdFileRoute,
} }
@@ -952,10 +1156,11 @@ export const routeTree = rootRouteImport
._addFileTypes<FileRouteTypes>() ._addFileTypes<FileRouteTypes>()
import type { getRouter } from './router.tsx' import type { getRouter } from './router.tsx'
import type { createStart } from '@tanstack/react-start' import type { startInstance } from './start.ts'
declare module '@tanstack/react-start' { declare module '@tanstack/react-start' {
interface Register { interface Register {
ssr: true ssr: true
router: Awaited<ReturnType<typeof getRouter>> router: Awaited<ReturnType<typeof getRouter>>
config: Awaited<ReturnType<typeof startInstance.getOptions>>
} }
} }
+4
View File
@@ -3,6 +3,7 @@ import { createRouter as createTanStackRouter } from "@tanstack/react-router";
import { setupRouterSsrQueryIntegration } from "@tanstack/react-router-ssr-query"; import { setupRouterSsrQueryIntegration } from "@tanstack/react-router-ssr-query";
import { routeTree } from "./routeTree.gen"; import { routeTree } from "./routeTree.gen";
import { DefaultCatchBoundary } from "../components/DefaultCatchBoundary"; import { DefaultCatchBoundary } from "../components/DefaultCatchBoundary";
import RoutePending from "../components/route-pending";
import { defaultHeaderConfig } from "@/features/core/hooks/use-router-config"; import { defaultHeaderConfig } from "@/features/core/hooks/use-router-config";
export function getRouter() { export function getRouter() {
@@ -30,6 +31,9 @@ export function getRouter() {
}, },
defaultPreload: "intent", defaultPreload: "intent",
defaultPreloadStaleTime: 60_000, defaultPreloadStaleTime: 60_000,
defaultPendingMs: 100,
defaultPendingMinMs: 0,
defaultPendingComponent: RoutePending,
defaultErrorComponent: DefaultCatchBoundary, defaultErrorComponent: DefaultCatchBoundary,
}); });
+22 -4
View File
@@ -5,11 +5,13 @@ import {
Scripts, Scripts,
createRootRouteWithContext, createRootRouteWithContext,
isRedirect, isRedirect,
useRouter,
} from "@tanstack/react-router"; } from "@tanstack/react-router";
import * as React from "react"; import * as React from "react";
import { DefaultCatchBoundary } from "@/components/DefaultCatchBoundary"; import { DefaultCatchBoundary } from "@/components/DefaultCatchBoundary";
import { type QueryClient } from "@tanstack/react-query"; import { type QueryClient } from "@tanstack/react-query";
import { ensureSuperTokensFrontend } from "@/lib/supertokens/client"; import { ensureSuperTokensFrontend } from "@/lib/supertokens/client";
import { initTelemetry, track } from "@/lib/telemetry/client";
import { AuthContextType } from "@/contexts/auth-context"; import { AuthContextType } from "@/contexts/auth-context";
import Providers from "@/features/core/components/providers"; import Providers from "@/features/core/components/providers";
import { SessionMonitor } from "@/components/session-monitor"; import { SessionMonitor } from "@/components/session-monitor";
@@ -29,6 +31,7 @@ import { getRootMeta } from "@/lib/i18n/meta";
import FullScreenLoader from "@/components/full-screen-loader"; import FullScreenLoader from "@/components/full-screen-loader";
import { CHROME_COLORS } from "@/lib/mantine/theme-colors"; import { CHROME_COLORS } from "@/lib/mantine/theme-colors";
import mantineCssUrl from '@mantine/core/styles.css?url' import mantineCssUrl from '@mantine/core/styles.css?url'
import appStylesUrl from '../styles.css?url'
import mantineDatesCssUrl from '@mantine/dates/styles.css?url' import mantineDatesCssUrl from '@mantine/dates/styles.css?url'
import mantineCarouselCssUrl from '@mantine/carousel/styles.css?url' import mantineCarouselCssUrl from '@mantine/carousel/styles.css?url'
import mantineTiptapCssUrl from '@mantine/tiptap/styles.css?url' import mantineTiptapCssUrl from '@mantine/tiptap/styles.css?url'
@@ -99,6 +102,7 @@ export const Route = createRootRouteWithContext<{
}, },
{ rel: "manifest", href: "/site.webmanifest" }, { rel: "manifest", href: "/site.webmanifest" },
{ rel: "icon", href: "/favicon.ico" }, { rel: "icon", href: "/favicon.ico" },
{ rel: 'stylesheet', href: appStylesUrl },
{ rel: 'stylesheet', href: mantineCssUrl }, { rel: 'stylesheet', href: mantineCssUrl },
{ rel: 'stylesheet', href: mantineCarouselCssUrl }, { rel: 'stylesheet', href: mantineCarouselCssUrl },
{ rel: 'stylesheet', href: mantineDatesCssUrl }, { rel: 'stylesheet', href: mantineDatesCssUrl },
@@ -175,12 +179,27 @@ export const Route = createRootRouteWithContext<{
}); });
function RootComponent() { function RootComponent() {
const router = useRouter();
React.useEffect(() => { React.useEffect(() => {
ensureSuperTokensFrontend(); ensureSuperTokensFrontend();
initTelemetry(router);
if (import.meta.env.PROD && 'serviceWorker' in navigator) { if (import.meta.env.PROD && 'serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch(() => {}); // 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'));
} }
}, []); }, [router]);
return ( return (
<RootDocument> <RootDocument>
@@ -193,7 +212,6 @@ function RootComponent() {
); );
} }
// todo: analytics -> process.env data-website-id
function RootDocument({ children }: { children: React.ReactNode }) { function RootDocument({ children }: { children: React.ReactNode }) {
return ( return (
<html <html
@@ -227,7 +245,6 @@ function RootDocument({ children }: { children: React.ReactNode }) {
'try{var d=document.documentElement,t=localStorage.getItem("flxn-theme");if(t&&t!=="default"){d.setAttribute("data-theme",t);}var s=localStorage.getItem("flxn-theme-scheme");if(s){d.setAttribute("data-mantine-color-scheme",s);}var f=JSON.parse(localStorage.getItem("flxn-theme-fonts")||"[]");for(var i=0;i<f.length;i++){var l=document.createElement("link");l.rel="stylesheet";l.href=f[i];l.setAttribute("data-flxn-theme-font",t||"default");document.head.appendChild(l);}}catch(e){}', 'try{var d=document.documentElement,t=localStorage.getItem("flxn-theme");if(t&&t!=="default"){d.setAttribute("data-theme",t);}var s=localStorage.getItem("flxn-theme-scheme");if(s){d.setAttribute("data-mantine-color-scheme",s);}var f=JSON.parse(localStorage.getItem("flxn-theme-fonts")||"[]");for(var i=0;i<f.length;i++){var l=document.createElement("link");l.rel="stylesheet";l.href=f[i];l.setAttribute("data-flxn-theme-font",t||"default");document.head.appendChild(l);}}catch(e){}',
}} }}
/> />
<link rel="stylesheet" href="/styles.css" />
</head> </head>
<body <body
style={{ style={{
@@ -239,6 +256,7 @@ function RootDocument({ children }: { children: React.ReactNode }) {
}} }}
> >
<div className="app">{children}</div> <div className="app">{children}</div>
<div className="flxn-theme-overlay" aria-hidden="true" />
<Scripts /> <Scripts />
</body> </body>
</html> </html>
+2 -1
View File
@@ -1,11 +1,12 @@
import { Outlet, redirect, createFileRoute } from "@tanstack/react-router"; import { Outlet, redirect, createFileRoute } from "@tanstack/react-router";
import { msg } from "@lingui/core/macro"; import { msg } from "@lingui/core/macro";
import type { HeaderConfig } from "@/features/core/types/header-config"; import type { HeaderConfig } from "@/features/core/types/header-config";
import { isAdminRole } from "@/features/core/utils/roles";
export const Route = createFileRoute("/_authed/admin")({ export const Route = createFileRoute("/_authed/admin")({
component: Outlet, component: Outlet,
beforeLoad: ({ context }) => { beforeLoad: ({ context }) => {
if (!context.auth?.roles?.includes("Admin")) { if (!isAdminRole(context.auth?.roles)) {
throw redirect({ to: "/" }); throw redirect({ to: "/" });
} }
+3 -71
View File
@@ -1,75 +1,7 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute, redirect } from "@tanstack/react-router";
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
import { ActivitiesTable, activityQueries } from "@/features/activities";
import { PlayersActivityTable, playerQueries } from "@/features/players";
import { Box, Divider, Group, Skeleton, Stack, Tabs } from "@mantine/core";
import { Suspense, useState } from "react";
import { Trans } from "@lingui/react/macro";
import { msg } from "@lingui/core/macro";
export const Route = createFileRoute("/_authed/admin/activities")({ export const Route = createFileRoute("/_authed/admin/activities")({
component: Stats, beforeLoad: () => {
beforeLoad: ({ context }) => { throw redirect({ to: "/admin/telemetry/functions" });
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>
);
}
+7 -2
View File
@@ -1,8 +1,13 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { AdminPage } from "@/features/admin";
import { msg } from "@lingui/core/macro"; import { msg } from "@lingui/core/macro";
import { AdminDashboard } from "@/features/admin";
import { tournamentQueries } from "@/features/tournaments/queries";
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
export const Route = createFileRoute("/_authed/admin/")({ export const Route = createFileRoute("/_authed/admin/")({
beforeLoad: ({ context }) => {
prefetchServerQuery(context.queryClient, tournamentQueries.current());
},
loader: () => ({ loader: () => ({
header: { header: {
withBackButton: true, withBackButton: true,
@@ -10,5 +15,5 @@ export const Route = createFileRoute("/_authed/admin/")({
}, },
withPadding: false, withPadding: false,
}), }),
component: () => <AdminPage />, component: () => <AdminDashboard />,
}); });
@@ -0,0 +1,70 @@
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>
);
}
@@ -0,0 +1,27 @@
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>
);
}
@@ -0,0 +1,28 @@
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>
);
}
@@ -0,0 +1,7 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
export const Route = createFileRoute("/_authed/admin/telemetry/")({
beforeLoad: () => {
throw redirect({ to: "/admin/telemetry/usage" });
},
});
@@ -0,0 +1,25 @@
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>
);
}
@@ -0,0 +1,25 @@
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>
);
}
@@ -0,0 +1,21 @@
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,6 +1,7 @@
import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router"; import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router";
import { tournamentQueries, useFreeAgents, useTournament } from "@/features/tournaments/queries"; import { tournamentQueries, useFreeAgents, useTournament } from "@/features/tournaments/queries";
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure"; import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
import { StatTileSkeleton } from "@/components/skeletons";
import { Stack, Text, Button, Alert, LoadingOverlay, Group, Skeleton } from "@mantine/core"; import { Stack, Text, Button, Alert, LoadingOverlay, Group, Skeleton } from "@mantine/core";
import { useState } from "react"; import { useState } from "react";
import useGenerateRandomTeams from "@/features/tournaments/hooks/use-generate-random-teams"; import useGenerateRandomTeams from "@/features/tournaments/hooks/use-generate-random-teams";
@@ -37,10 +38,7 @@ function AssignPartnersPending() {
return ( return (
<Stack gap="lg"> <Stack gap="lg">
<Stack gap="xs"> <Stack gap="xs">
<Group gap="xs" align="baseline"> <StatTileSkeleton align="row" />
<Skeleton height={28} width={36} radius="sm" />
<Skeleton height={14} width={110} radius="sm" />
</Group>
<Skeleton height={36} w="100%" radius="sm" style={{ opacity: 0.7 }} /> <Skeleton height={36} w="100%" radius="sm" style={{ opacity: 0.7 }} />
</Stack> </Stack>
</Stack> </Stack>
@@ -0,0 +1,56 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
import { tournamentQueries } from "@/features/tournaments/queries";
import Enrollment from "@/features/tournaments/components/enrollment";
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
import { ListSkeleton, StatTileSkeleton } from "@/components/skeletons";
import { Skeleton, Stack } from "@mantine/core";
import { msg } from "@lingui/core/macro";
export const Route = createFileRoute("/_authed/admin/tournaments/$id/enrollment")({
beforeLoad: async ({ context, params }) => {
const { queryClient } = context;
const tournament = await ensureServerQueryData(
queryClient,
tournamentQueries.details(params.id)
);
if (!tournament) throw redirect({ to: "/admin/tournaments" });
if (tournament.regional) {
await ensureServerQueryData(
queryClient,
tournamentQueries.free_agents(params.id)
);
} else {
await ensureServerQueryData(
queryClient,
tournamentQueries.unenrolled(params.id)
);
}
return { tournament };
},
loader: () => ({
header: {
withBackButton: true,
title: msg`Enrollment`,
},
}),
component: RouteComponent,
pendingComponent: EnrollmentPending,
});
function EnrollmentPending() {
return (
<Stack gap="lg">
<StatTileSkeleton align="row" />
<Stack gap="xs">
<Skeleton height={14} width={80} radius="sm" />
<Skeleton height={42} radius="sm" />
</Stack>
<ListSkeleton rows={6} withAvatar avatarSize={32} withTrailing padding="xs" />
</Stack>
);
}
function RouteComponent() {
const { tournament } = Route.useRouteContext();
return <Enrollment tournament={tournament} />;
}
@@ -2,7 +2,7 @@ import { createFileRoute, redirect } from "@tanstack/react-router";
import { tournamentQueries } from "@/features/tournaments/queries"; import { tournamentQueries } from "@/features/tournaments/queries";
import ManageTournament from "@/features/tournaments/components/manage-tournament"; import ManageTournament from "@/features/tournaments/components/manage-tournament";
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure"; import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
import { Divider, Group, Skeleton, Stack } from "@mantine/core"; import { ListSkeleton } from "@/components/skeletons";
import { msg } from "@lingui/core/macro"; import { msg } from "@lingui/core/macro";
export const Route = createFileRoute("/_authed/admin/tournaments/$id/")({ export const Route = createFileRoute("/_authed/admin/tournaments/$id/")({
@@ -31,18 +31,14 @@ export const Route = createFileRoute("/_authed/admin/tournaments/$id/")({
function ManageTournamentPending() { function ManageTournamentPending() {
return ( return (
<Stack gap={0}> <ListSkeleton
{Array.from({ length: 5 }).map((_, index) => ( rows={5}
<div key={`manage-tournament-skeleton-${index}`} style={{ opacity: Math.max(1 - index * 0.14, 0.4) }}> withAvatar
<Group p="md" wrap="nowrap" w="100%"> avatarSize={20}
<Skeleton height={20} width={20} radius="sm" /> withTrailing
<Skeleton height={16} width="45%" radius="sm" /> fadeStep={0.14}
<Skeleton ml="auto" height={20} width={20} radius="sm" /> fadeFloor={0.4}
</Group> />
<Divider />
</div>
))}
</Stack>
); );
} }
@@ -2,7 +2,8 @@ import { createFileRoute, redirect } from "@tanstack/react-router";
import { tournamentQueries } from "@/features/tournaments/queries"; import { tournamentQueries } from "@/features/tournaments/queries";
import ManageTeams from "@/features/teams/components/manage-teams"; import ManageTeams from "@/features/teams/components/manage-teams";
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure"; import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
import { Box, Divider, Group, Skeleton, Stack } from "@mantine/core"; import { ListSkeleton } from "@/components/skeletons";
import { Box, Skeleton, Stack } from "@mantine/core";
import { msg } from "@lingui/core/macro"; import { msg } from "@lingui/core/macro";
export const Route = createFileRoute("/_authed/admin/tournaments/$id/teams")({ export const Route = createFileRoute("/_authed/admin/tournaments/$id/teams")({
@@ -38,21 +39,7 @@ function ManageTeamsPending() {
<Box px="md"> <Box px="md">
<Skeleton height={12} width={90} radius="sm" /> <Skeleton height={12} width={90} radius="sm" />
</Box> </Box>
<Stack gap={0}> <ListSkeleton rows={8} withAvatar avatarSize={40} withTrailing="double" padding="xs" />
{Array.from({ length: 8 }).map((_, index) => (
<div key={`manage-teams-skeleton-${index}`} style={{ opacity: Math.max(1 - index * 0.1, 0.35) }}>
<Group p="xs" wrap="nowrap" w="100%">
<Skeleton height={40} width={40} radius="sm" />
<Skeleton height={16} width="45%" radius="sm" />
<Stack ml="auto" gap={6} align="flex-end">
<Skeleton height={10} width={90} radius="sm" />
<Skeleton height={10} width={70} radius="sm" />
</Stack>
</Group>
<Divider />
</div>
))}
</Stack>
</Stack> </Stack>
); );
} }
@@ -1,7 +1,7 @@
import ManageTournaments from "@/features/admin/components/manage-tournaments"; import ManageTournaments from "@/features/admin/components/manage-tournaments";
import { tournamentQueries } from "@/features/tournaments/queries"; import { tournamentQueries } from "@/features/tournaments/queries";
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch"; import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
import { Divider, Group, Skeleton, Stack } from "@mantine/core"; import { ListSkeleton } from "@/components/skeletons";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { Suspense } from "react"; import { Suspense } from "react";
import { msg } from "@lingui/core/macro"; import { msg } from "@lingui/core/macro";
@@ -22,25 +22,13 @@ export const Route = createFileRoute("/_authed/admin/tournaments/")({
component: RouteComponent, component: RouteComponent,
}); });
function TournamentListSkeleton() {
return (
<Stack gap={0}>
{Array.from({ length: 6 }).map((_, index) => (
<div key={`manage-tournaments-skeleton-${index}`} style={{ opacity: Math.max(1 - index * 0.12, 0.4) }}>
<Group p="md" wrap="nowrap" w="100%">
<Skeleton height={16} width="55%" radius="sm" />
<Skeleton ml="auto" height={20} width={20} radius="sm" />
</Group>
<Divider />
</div>
))}
</Stack>
);
}
function RouteComponent() { function RouteComponent() {
return ( return (
<Suspense fallback={<TournamentListSkeleton />}> <Suspense
fallback={
<ListSkeleton rows={6} titleWidth="55%" withTrailing fadeStep={0.12} fadeFloor={0.4} />
}
>
<ManageTournaments /> <ManageTournaments />
</Suspense> </Suspense>
); );
@@ -8,12 +8,10 @@ import SeedTournament from "@/features/tournaments/components/seed-tournament";
import SetupGroupStage from "@/features/tournaments/components/setup-group-stage"; import SetupGroupStage from "@/features/tournaments/components/setup-group-stage";
import GroupStageView from "@/features/tournaments/components/group-stage-view"; import GroupStageView from "@/features/tournaments/components/group-stage-view";
import { Container, Stack, Divider, Title, Box, Card, Group, Skeleton, SimpleGrid } from "@mantine/core"; import { Container, Stack, Divider, Title, Box, Card, Group, Skeleton, SimpleGrid } from "@mantine/core";
import { useMemo } from "react";
import { BracketData } from "@/features/bracket/types";
import { Match } from "@/features/matches/types";
import BracketView from "@/features/bracket/components/bracket-view"; import BracketView from "@/features/bracket/components/bracket-view";
import { SpotifyControlsBar } from "@/features/spotify/components"; import { SpotifyControlsBar } from "@/features/spotify/components";
import { useAuth } from "@/contexts/auth-context"; import { useIsAdmin } from "@/features/core/hooks/use-is-admin";
import { useTournamentRunState } from "@/features/tournaments/hooks/use-tournament-run-state";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { msg } from "@lingui/core/macro"; import { msg } from "@lingui/core/macro";
@@ -83,67 +81,10 @@ function RunTournamentPending() {
function RouteComponent() { function RouteComponent() {
const { id } = Route.useParams(); const { id } = Route.useParams();
const { data: tournament } = useTournament(id); const { data: tournament } = useTournament(id);
const { roles } = useAuth(); const isAdmin = useIsAdmin();
const isAdmin = roles?.includes('Admin') || false;
const hasGroupStage = useMemo(() => { const { hasGroupStage, hasKnockout, knockoutBracketPopulated, nextUpMatchId, bracket } =
return tournament.matches?.some((match) => match.round === -1) || false; useTournamentRunState(tournament.matches);
}, [tournament.matches]);
const hasKnockout = useMemo(() => {
return tournament.matches?.some((match) => match.round !== -1) || false;
}, [tournament.matches]);
const knockoutBracketPopulated = useMemo(() => {
return tournament.matches?.some((match) =>
match.round === 0 && match.lid >= 0 && (match.home || match.away)
) || false;
}, [tournament.matches]);
const nextUpMatchId = useMemo(() => {
const ready = (tournament.matches ?? [])
.filter(
(match) =>
match.status === "ready" && match.home && match.away && !match.bye
)
.sort((a, b) => a.order - b.order);
return ready[0]?.id;
}, [tournament.matches]);
const bracket: BracketData = useMemo(() => {
if (!tournament.matches || tournament.matches.length === 0) {
return { winners: [], losers: [] };
}
const winnersMap = new Map<number, Match[]>();
const losersMap = new Map<number, Match[]>();
tournament.matches
.filter((match) => match.round !== -1)
.sort((a, b) => a.lid - b.lid)
.forEach((match) => {
if (!match.is_losers_bracket) {
if (!winnersMap.has(match.round)) {
winnersMap.set(match.round, []);
}
winnersMap.get(match.round)!.push(match);
} else {
if (!losersMap.has(match.round)) {
losersMap.set(match.round, []);
}
losersMap.get(match.round)!.push(match);
}
});
const winners = Array.from(winnersMap.entries())
.sort(([a], [b]) => a - b)
.map(([, matches]) => matches);
const losers = Array.from(losersMap.entries())
.sort(([a], [b]) => a - b)
.map(([, matches]) => matches);
return { winners, losers };
}, [tournament.matches]);
return ( return (
<Container size="md" px={0}> <Container size="md" px={0}>
+5 -7
View File
@@ -1,7 +1,8 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { tournamentQueries, useCurrentTournament } from "@/features/tournaments/queries"; import { tournamentQueries, useCurrentTournament } from "@/features/tournaments/queries";
import UpcomingTournament from "@/features/tournaments/components/upcoming-tournament"; import UpcomingTournament from "@/features/tournaments/components/upcoming-tournament";
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure"; import { getServerQueryData } from "@/lib/tanstack-query/utils/ensure";
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
import StartedTournament from "@/features/tournaments/components/started-tournament"; import StartedTournament from "@/features/tournaments/components/started-tournament";
import { Suspense } from "react"; import { Suspense } from "react";
import UpcomingTournamentSkeleton from "@/features/tournaments/components/upcoming-tournament/skeleton"; import UpcomingTournamentSkeleton from "@/features/tournaments/components/upcoming-tournament/skeleton";
@@ -10,16 +11,13 @@ export const Route = createFileRoute("/_authed/")({
component: () => <Suspense fallback={<UpcomingTournamentSkeleton />}> component: () => <Suspense fallback={<UpcomingTournamentSkeleton />}>
<Home /> <Home />
</Suspense>, </Suspense>,
beforeLoad: async ({ context }) => { beforeLoad: ({ context }) => {
const queryClient = context.queryClient; prefetchServerQuery(context.queryClient, tournamentQueries.current());
const tournament = await ensureServerQueryData(queryClient, tournamentQueries.current())
return { tournament }
}, },
loader: ({ context }) => ({ loader: ({ context }) => ({
withPadding: false, withPadding: false,
header: { header: {
title: context.tournament.name || "FLXN" title: getServerQueryData(context.queryClient, tournamentQueries.current())?.name || "FLXN"
} }
}), }),
pendingComponent: () => <UpcomingTournamentSkeleton /> pendingComponent: () => <UpcomingTournamentSkeleton />
+5 -19
View File
@@ -14,26 +14,12 @@ const searchSchema = z.object({
export const Route = createFileRoute("/_authed/profile/$playerId")({ export const Route = createFileRoute("/_authed/profile/$playerId")({
validateSearch: searchSchema, validateSearch: searchSchema,
beforeLoad: async ({ params, context }) => { beforeLoad: ({ params, context }) => {
const { queryClient } = context; const { queryClient } = context;
await Promise.all([ prefetchServerQuery(queryClient, playerQueries.details(params.playerId));
prefetchServerQuery( prefetchServerQuery(queryClient, playerQueries.matches(params.playerId));
queryClient, prefetchServerQuery(queryClient, playerQueries.stats(params.playerId));
playerQueries.details(params.playerId) prefetchServerQuery(queryClient, badgeQueries.playerBadges(params.playerId));
),
prefetchServerQuery(
queryClient,
playerQueries.matches(params.playerId)
),
prefetchServerQuery(
queryClient,
playerQueries.stats(params.playerId)
),
prefetchServerQuery(
queryClient,
badgeQueries.playerBadges(params.playerId)
),
]);
}, },
loader: ({ params, context }) => ({ loader: ({ params, context }) => ({
header: { header: {
+13 -4
View File
@@ -1,7 +1,7 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { playerQueries } from "@/features/players/queries"; import { playerQueries } from "@/features/players/queries";
import PlayerStatsTable from "@/features/players/components/player-stats-table"; import PlayerStatsTable from "@/features/players/components/player-stats-table";
import { Suspense, useState, useDeferredValue, useEffect } from "react"; import { Suspense, useState, useDeferredValue, useEffect, useRef } from "react";
import PlayerStatsTableSkeleton from "@/features/players/components/player-stats-table-skeleton"; import PlayerStatsTableSkeleton from "@/features/players/components/player-stats-table-skeleton";
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch"; import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
import LeagueHeadToHead from "@/features/players/components/league-head-to-head"; import LeagueHeadToHead from "@/features/players/components/league-head-to-head";
@@ -9,6 +9,7 @@ import { Box, Loader, Tabs, Button, Group, Container, Stack } from "@mantine/cor
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { msg } from "@lingui/core/macro"; import { msg } from "@lingui/core/macro";
import { track } from "@/lib/telemetry/client";
export const Route = createFileRoute("/_authed/stats")({ export const Route = createFileRoute("/_authed/stats")({
component: Stats, component: Stats,
@@ -32,6 +33,14 @@ function Stats() {
const isStale = viewType !== deferredViewType; const isStale = viewType !== deferredViewType;
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const viewTypeRef = useRef(viewType);
const changeViewType = (next: typeof viewType) => {
if (viewTypeRef.current === next) return;
viewTypeRef.current = next;
track("stats_filtered", { value: next });
setViewType(next);
};
useEffect(() => { useEffect(() => {
const timeout = window.setTimeout(() => { const timeout = window.setTimeout(() => {
prefetchServerQuery(queryClient, playerQueries.allStats('mainline')); prefetchServerQuery(queryClient, playerQueries.allStats('mainline'));
@@ -54,21 +63,21 @@ function Stats() {
<Button <Button
variant={viewType === 'all' ? 'filled' : 'light'} variant={viewType === 'all' ? 'filled' : 'light'}
size="compact-xs" size="compact-xs"
onClick={() => setViewType('all')} onClick={() => changeViewType('all')}
> >
<Trans>All</Trans> <Trans>All</Trans>
</Button> </Button>
<Button <Button
variant={viewType === 'mainline' ? 'filled' : 'light'} variant={viewType === 'mainline' ? 'filled' : 'light'}
size="compact-xs" size="compact-xs"
onClick={() => setViewType('mainline')} onClick={() => changeViewType('mainline')}
> >
<Trans>Mainline</Trans> <Trans>Mainline</Trans>
</Button> </Button>
<Button <Button
variant={viewType === 'regional' ? 'filled' : 'light'} variant={viewType === 'regional' ? 'filled' : 'light'}
size="compact-xs" size="compact-xs"
onClick={() => setViewType('regional')} onClick={() => changeViewType('regional')}
> >
<Trans>Regional</Trans> <Trans>Regional</Trans>
</Button> </Button>
+2 -2
View File
@@ -12,9 +12,9 @@ const searchSchema = z.object({
export const Route = createFileRoute("/_authed/teams/$teamId")({ export const Route = createFileRoute("/_authed/teams/$teamId")({
validateSearch: searchSchema, validateSearch: searchSchema,
beforeLoad: async ({ params, context }) => { beforeLoad: ({ params, context }) => {
const { queryClient } = context; const { queryClient } = context;
await prefetchServerQuery(queryClient, teamQueries.details(params.teamId)); prefetchServerQuery(queryClient, teamQueries.details(params.teamId));
}, },
loader: ({ params }) => ({ loader: ({ params }) => ({
header: { header: {
@@ -1,6 +1,7 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router'
import { tournamentQueries } from '@/features/tournaments/queries'; import { tournamentQueries } from '@/features/tournaments/queries';
import Profile from '@/features/tournaments/components/profile'; import Profile from '@/features/tournaments/components/profile';
import { isAdminRole } from '@/features/core/utils/roles';
import { z } from "zod"; import { z } from "zod";
import { prefetchServerQuery } from '@/lib/tanstack-query/utils/prefetch'; import { prefetchServerQuery } from '@/lib/tanstack-query/utils/prefetch';
import { Suspense } from 'react'; import { Suspense } from 'react';
@@ -20,7 +21,7 @@ export const Route = createFileRoute('/_authed/tournaments/$tournamentId')({
header: { header: {
collapsed: true, collapsed: true,
withBackButton: true, withBackButton: true,
settingsLink: context.auth.roles.includes("Admin") ? `/admin/tournaments/${params.tournamentId}` : undefined settingsLink: isAdminRole(context.auth.roles) ? `/admin/tournaments/${params.tournamentId}` : undefined
}, },
refresh: [tournamentQueries.details(params.tournamentId).queryKey], refresh: [tournamentQueries.details(params.tournamentId).queryKey],
withPadding: false withPadding: false
-2
View File
@@ -8,8 +8,6 @@ ensureSuperTokensBackend();
// forwards all supertokens api methods to our API // forwards all supertokens api methods to our API
const superTokensHandler = handleAuthAPIRequest(); const superTokensHandler = handleAuthAPIRequest();
const handleRequest = async ({ request }: {request: Request}) => { const handleRequest = async ({ request }: {request: Request}) => {
console.log("=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=");
console.log("Handling auth request:", request.method, request.url);
return superTokensHandler(request); return superTokensHandler(request);
}; };
export const Route = createFileRoute('/api/auth/$')({ export const Route = createFileRoute('/api/auth/$')({
+13 -7
View File
@@ -1,9 +1,14 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { serverEvents, EVENT_TYPES, type ServerEvent } from "@/lib/events/emitter"; import {
serverEvents,
EVENT_TYPES,
incrementConnections,
decrementConnections,
type ServerEvent,
} from "@/lib/events/emitter";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
import { superTokensRequestMiddleware } from "@/utils/supertokens"; import { superTokensRequestMiddleware } from "@/utils/supertokens";
let activeConnections = 0;
const encoder = new TextEncoder(); const encoder = new TextEncoder();
export const Route = createFileRoute("/api/events/$")({ export const Route = createFileRoute("/api/events/$")({
@@ -11,9 +16,9 @@ export const Route = createFileRoute("/api/events/$")({
middleware: [superTokensRequestMiddleware], middleware: [superTokensRequestMiddleware],
handlers: { handlers: {
GET: ({ request }) => { GET: ({ request }) => {
activeConnections++; const active = incrementConnections();
const connectionId = `conn_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`; const connectionId = `conn_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
logger.info(`ServerEvents | New connection ${connectionId}. Active: ${activeConnections}`); logger.info(`ServerEvents | New connection ${connectionId}. Active: ${active}`);
let cleanedUp = false; let cleanedUp = false;
let cleanup = () => {}; let cleanup = () => {};
@@ -34,19 +39,20 @@ export const Route = createFileRoute("/api/events/$")({
serverEvents.on(type, handleEvent); serverEvents.on(type, handleEvent);
} }
// Under the shortest idle timeout observed on proxies/PWA paths.
const pingInterval = setInterval(() => { const pingInterval = setInterval(() => {
send({ type: "ping", timestamp: Date.now() }); send({ type: "ping", timestamp: Date.now() });
}, 15000); }, 8000);
cleanup = () => { cleanup = () => {
if (cleanedUp) return; if (cleanedUp) return;
cleanedUp = true; cleanedUp = true;
activeConnections--; const remaining = decrementConnections();
for (const type of EVENT_TYPES) { for (const type of EVENT_TYPES) {
serverEvents.off(type, handleEvent); serverEvents.off(type, handleEvent);
} }
clearInterval(pingInterval); clearInterval(pingInterval);
logger.info(`ServerEvents | Connection ${connectionId} cleanup completed. Active: ${activeConnections}`); logger.info(`ServerEvents | Connection ${connectionId} cleanup completed. Active: ${remaining}`);
}; };
request.signal?.addEventListener("abort", cleanup); request.signal?.addEventListener("abort", cleanup);
+12 -5
View File
@@ -1,19 +1,26 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { ensureTelemetryScheduler } from "@/lib/telemetry/scheduler.server";
import { getHealthSnapshot } from "@/lib/telemetry/health.server";
import { ensureSuperTokensBackend } from "@/lib/supertokens/server";
export const Route = createFileRoute("/api/health")({ export const Route = createFileRoute("/api/health")({
server: { server: {
handlers: { handlers: {
GET: () => { GET: async () => {
ensureSuperTokensBackend();
ensureTelemetryScheduler();
const snapshot = await getHealthSnapshot();
// Always 200: a dependency outage must not fail the k8s probes.
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
status: "ok", status: snapshot.status,
checks: snapshot.checks,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}), }),
{ {
status: 200, status: 200,
headers: { headers: { "Content-Type": "application/json" },
"Content-Type": "application/json",
},
} }
); );
}, },
+2 -1
View File
@@ -1,5 +1,6 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { superTokensRequestMiddleware } from "@/utils/supertokens"; import { superTokensRequestMiddleware } from "@/utils/supertokens";
import { apiLoggingMiddleware } from "@/lib/telemetry/api-logging.server";
import { pbAdmin } from "@/lib/pocketbase/client"; import { pbAdmin } from "@/lib/pocketbase/client";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
@@ -11,7 +12,7 @@ const json = (body: unknown, status = 200) =>
export const Route = createFileRoute("/api/push/subscribe")({ export const Route = createFileRoute("/api/push/subscribe")({
server: { server: {
middleware: [superTokensRequestMiddleware], middleware: [apiLoggingMiddleware, superTokensRequestMiddleware],
handlers: { handlers: {
POST: async ({ request, context }) => { POST: async ({ request, context }) => {
const player = (context as any).player; const player = (context as any).player;
+2 -1
View File
@@ -1,6 +1,7 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { msg } from "@lingui/core/macro"; import { msg } from "@lingui/core/macro";
import { superTokensRequestMiddleware } from "@/utils/supertokens"; import { superTokensRequestMiddleware } from "@/utils/supertokens";
import { apiLoggingMiddleware } from "@/lib/telemetry/api-logging.server";
import { sendPushToPlayer } from "@/lib/push"; import { sendPushToPlayer } from "@/lib/push";
import { isPushConfigured } from "@/lib/config"; import { isPushConfigured } from "@/lib/config";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
@@ -13,7 +14,7 @@ const json = (body: unknown, status = 200) =>
export const Route = createFileRoute("/api/push/test")({ export const Route = createFileRoute("/api/push/test")({
server: { server: {
middleware: [superTokensRequestMiddleware], middleware: [apiLoggingMiddleware, superTokensRequestMiddleware],
handlers: { handlers: {
POST: async ({ context }) => { POST: async ({ context }) => {
const player = (context as any).player; const player = (context as any).player;
+2 -1
View File
@@ -1,5 +1,6 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { superTokensRequestMiddleware } from "@/utils/supertokens"; import { superTokensRequestMiddleware } from "@/utils/supertokens";
import { apiLoggingMiddleware } from "@/lib/telemetry/api-logging.server";
import { pbAdmin } from "@/lib/pocketbase/client"; import { pbAdmin } from "@/lib/pocketbase/client";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
@@ -11,7 +12,7 @@ const json = (body: unknown, status = 200) =>
export const Route = createFileRoute("/api/push/unsubscribe")({ export const Route = createFileRoute("/api/push/unsubscribe")({
server: { server: {
middleware: [superTokensRequestMiddleware], middleware: [apiLoggingMiddleware, superTokensRequestMiddleware],
handlers: { handlers: {
POST: async ({ request, context }) => { POST: async ({ request, context }) => {
const player = (context as any).player; const player = (context as any).player;
+4 -2
View File
@@ -1,6 +1,8 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { msg } from "@lingui/core/macro"; import { msg } from "@lingui/core/macro";
import { superTokensRequestMiddleware } from "@/utils/supertokens"; import { superTokensRequestMiddleware } from "@/utils/supertokens";
import { isAdminRole } from "@/features/core/utils/roles";
import { apiLoggingMiddleware } from "@/lib/telemetry/api-logging.server";
import { pbAdmin } from "@/lib/pocketbase/client"; import { pbAdmin } from "@/lib/pocketbase/client";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
import { localizedFor } from "@/lib/i18n/server-messages"; import { localizedFor } from "@/lib/i18n/server-messages";
@@ -12,13 +14,13 @@ const uploadSchema = z.object({
export const Route = createFileRoute("/api/teams/upload-logo")({ export const Route = createFileRoute("/api/teams/upload-logo")({
server: { server: {
middleware: [superTokensRequestMiddleware], middleware: [apiLoggingMiddleware, superTokensRequestMiddleware],
handlers: { handlers: {
POST: async ({ request, context }) => { POST: async ({ request, context }) => {
const i18n = localizedFor(context.metadata); const i18n = localizedFor(context.metadata);
try { try {
const userId = context.userAuthId; const userId = context.userAuthId;
const isAdmin = context.roles.includes("Admin"); const isAdmin = isAdminRole(context.roles);
if (!userId) return new Response("Unauthenticated", { status: 401 }); if (!userId) return new Response("Unauthenticated", { status: 401 });
+132
View File
@@ -0,0 +1,132 @@
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,6 +1,8 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { msg } from "@lingui/core/macro"; import { msg } from "@lingui/core/macro";
import { superTokensRequestMiddleware } from "@/utils/supertokens"; import { superTokensRequestMiddleware } from "@/utils/supertokens";
import { isAdminRole } from "@/features/core/utils/roles";
import { apiLoggingMiddleware } from "@/lib/telemetry/api-logging.server";
import { pbAdmin } from "@/lib/pocketbase/client"; import { pbAdmin } from "@/lib/pocketbase/client";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
import { localizedFor } from "@/lib/i18n/server-messages"; import { localizedFor } from "@/lib/i18n/server-messages";
@@ -12,13 +14,13 @@ const uploadSchema = z.object({
export const Route = createFileRoute("/api/tournaments/upload-logo")({ export const Route = createFileRoute("/api/tournaments/upload-logo")({
server: { server: {
middleware: [superTokensRequestMiddleware], middleware: [apiLoggingMiddleware, superTokensRequestMiddleware],
handlers: { handlers: {
POST: async ({ request, context }) => { POST: async ({ request, context }) => {
const i18n = localizedFor(context.metadata); const i18n = localizedFor(context.metadata);
try { try {
const userId = context.userAuthId; const userId = context.userAuthId;
const isAdmin = context.roles.includes("Admin"); const isAdmin = isAdminRole(context.roles);
if (!userId) return new Response("Unauthenticated", { status: 401 }); if (!userId) return new Response("Unauthenticated", { status: 401 });
if (!isAdmin) return new Response("Unauthorized", { status: 403 }); if (!isAdmin) return new Response("Unauthorized", { status: 403 });
+11
View File
@@ -0,0 +1,11 @@
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],
}));
+26
View File
@@ -2,6 +2,26 @@
overscroll-behavior: none; overscroll-behavior: none;
} }
/* Keep app chrome non-selectable so iOS long-press doesn't open the
edit menu (AutoFill/Copy/Siri). Inputs and opted-in regions stay
selectable. */
body {
-webkit-touch-callout: none;
-webkit-user-select: none;
user-select: none;
}
input,
textarea,
[contenteditable="true"],
[contenteditable=""],
[data-selectable],
[data-selectable] * {
-webkit-touch-callout: default;
-webkit-user-select: text;
user-select: text;
}
@keyframes spin { @keyframes spin {
from { transform: rotate(0deg); } from { transform: rotate(0deg); }
to { transform: rotate(360deg); } to { transform: rotate(360deg); }
@@ -30,6 +50,12 @@
transform: scale(0.90) translateY(-4px); transform: scale(0.90) translateY(-4px);
} }
/* themes paint their full-page texture through this element */
.flxn-theme-overlay {
position: fixed;
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.app, .app,
[data-drawer-level] { [data-drawer-level] {
+2
View File
@@ -22,6 +22,7 @@ import { useDisclosure } from '@mantine/hooks'
import { useEffect } from 'react' import { useEffect } from 'react'
import toast from '@/lib/sonner' import toast from '@/lib/sonner'
import { logger } from '@/lib/logger' import { logger } from '@/lib/logger'
import { captureBoundaryError } from '@/lib/telemetry/client'
import { XCircleIcon, WarningIcon } from '@phosphor-icons/react' import { XCircleIcon, WarningIcon } from '@phosphor-icons/react'
import { Trans, useLingui } from '@lingui/react/macro' import { Trans, useLingui } from '@lingui/react/macro'
import Button from './button' import Button from './button'
@@ -41,6 +42,7 @@ export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
useEffect(() => { useEffect(() => {
logger.error('DefaultCatchBoundary | ', error) logger.error('DefaultCatchBoundary | ', error)
captureBoundaryError(error)
if (errorMessage.toLowerCase().includes('unauthenticated')) { if (errorMessage.toLowerCase().includes('unauthenticated')) {
toast.error(t`You've been logged out`) toast.error(t`You've been logged out`)
+3
View File
@@ -53,12 +53,14 @@ export function Countdown({ date, label, color }: CountdownProps) {
return ( return (
<Group gap="xs"> <Group gap="xs">
{label && <Text size='sm' fw={500}>{label}:</Text>} {label && <Text size='sm' fw={500}>{label}:</Text>}
{/* Server and client render different seconds; the first client tick corrects it. */}
<Text <Text
size='sm' size='sm'
fw={600} fw={600}
c={isFinalStretch ? 'red' : color} c={isFinalStretch ? 'red' : color}
ff="monospace" ff="monospace"
className={classes.time} className={classes.time}
suppressHydrationWarning
> >
{prefix} {prefix}
<span <span
@@ -66,6 +68,7 @@ export function Countdown({ date, label, color }: CountdownProps) {
className={`${classes.seconds} ${ className={`${classes.seconds} ${
isFinalStretch ? classes.finalTick : classes.tick isFinalStretch ? classes.finalTick : classes.tick
}`} }`}
suppressHydrationWarning
> >
{pad(timeLeft.seconds)} {pad(timeLeft.seconds)}
</span> </span>
+35
View File
@@ -0,0 +1,35 @@
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;
+6 -1
View File
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { track } from '@/lib/telemetry/client'
import { Box, Paper, Group, Text, ActionIcon } from '@mantine/core' import { Box, Paper, Group, Text, ActionIcon } from '@mantine/core'
import { DownloadIcon, XIcon } from '@phosphor-icons/react' import { DownloadIcon, XIcon } from '@phosphor-icons/react'
import { Trans, useLingui } from '@lingui/react/macro' import { Trans, useLingui } from '@lingui/react/macro'
@@ -26,13 +27,17 @@ export function IOSInstallPrompt() {
if ((isIOS || isAndroid) && !isInStandaloneMode && !hasBeenDismissed) { if ((isIOS || isAndroid) && !isInStandaloneMode && !hasBeenDismissed) {
setPlatform(isIOS ? 'ios' : 'android') setPlatform(isIOS ? 'ios' : 'android')
const timer = setTimeout(() => setShow(true), 3000) const timer = setTimeout(() => {
setShow(true)
track('pwa_ios_prompt_shown')
}, 3000)
return () => clearTimeout(timer) return () => clearTimeout(timer)
} }
}, []) }, [])
const handleDismiss = () => { const handleDismiss = () => {
localStorage.setItem('pwa-install-prompt-dismissed', 'true') localStorage.setItem('pwa-install-prompt-dismissed', 'true')
track('pwa_ios_prompt_dismissed')
setShow(false) setShow(false)
} }
+28
View File
@@ -0,0 +1,28 @@
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;
@@ -0,0 +1,43 @@
.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
@@ -0,0 +1,17 @@
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;
+12 -7
View File
@@ -9,7 +9,7 @@ import {
import { useIsMobile } from "@/hooks/use-is-mobile"; import { useIsMobile } from "@/hooks/use-is-mobile";
import Drawer from "./drawer"; import Drawer from "./drawer";
import Modal from "./modal"; import Modal from "./modal";
import { ScrollArea, Flex, Loader } from "@mantine/core"; import { Box, Flex, Loader } from "@mantine/core";
interface SheetProps extends PropsWithChildren { interface SheetProps extends PropsWithChildren {
title?: string; title?: string;
@@ -60,14 +60,19 @@ const Sheet: React.FC<SheetProps> = ({ title, children, opened, onChange }) => {
<Loader size='lg' /> <Loader size='lg' />
</Flex> </Flex>
}> }>
<ScrollArea.Autosize {/* Native overflow, not ScrollArea: vaul's drag-vs-scroll detection
style={{ flex: 1, maxHeight: '75dvh' }} only recognizes natively scrollable elements. */}
scrollbarSize={8} <Box
scrollbars="y" style={{
type="scroll" flex: 1,
maxHeight: "75dvh",
overflowY: "auto",
overscrollBehavior: "contain",
WebkitOverflowScrolling: "touch",
}}
> >
{children} {children}
</ScrollArea.Autosize> </Box>
</Suspense> </Suspense>
</SheetComponent> </SheetComponent>
); );
@@ -0,0 +1,35 @@
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
@@ -0,0 +1,3 @@
export * from "./list-skeleton";
export * from "./card-skeleton";
export * from "./stat-tile-skeleton";
@@ -0,0 +1,76 @@
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;
@@ -0,0 +1,42 @@
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;
+8 -2
View File
@@ -6,7 +6,7 @@ import {
ReactNode, ReactNode,
useRef, useRef,
useCallback, useCallback,
useMemo, startTransition,
} from "react"; } from "react";
import { useRouter } from "@tanstack/react-router"; import { useRouter } from "@tanstack/react-router";
@@ -48,6 +48,9 @@ function SwipeableTabs({
}, [search?.tab, tabs, defaultTab]); }, [search?.tab, tabs, defaultTab]);
const [activeTab, setActiveTab] = useState(getActiveTabFromUrl); const [activeTab, setActiveTab] = useState(getActiveTabFromUrl);
const [visitedTabs, setVisitedTabs] = useState<ReadonlySet<number>>(
() => new Set([getActiveTabFromUrl()])
);
const [stuck, setStuck] = useState(false); const [stuck, setStuck] = useState(false);
const sentinelRef = useRef<HTMLDivElement>(null); const sentinelRef = useRef<HTMLDivElement>(null);
const [rootRef, setRootRef] = useState<HTMLDivElement | null>(null); const [rootRef, setRootRef] = useState<HTMLDivElement | null>(null);
@@ -60,6 +63,9 @@ function SwipeableTabs({
if (index === activeTab || index < 0 || index >= tabs.length) return; if (index === activeTab || index < 0 || index >= tabs.length) return;
setActiveTab(index); setActiveTab(index);
startTransition(() => {
setVisitedTabs((prev) => (prev.has(index) ? prev : new Set(prev).add(index)));
});
embla?.scrollTo(index); embla?.scrollTo(index);
onTabChange?.(index, tabs[index]); onTabChange?.(index, tabs[index]);
@@ -225,7 +231,7 @@ function SwipeableTabs({
{tabs.map((tab, index) => ( {tabs.map((tab, index) => (
<Carousel.Slide key={`${tab.label}-content-${index}`}> <Carousel.Slide key={`${tab.label}-content-${index}`}>
<Box ref={setSlideRef(index)} style={{ height: "auto", width: "100%", maxWidth: "100vw", overflow: "hidden" }}> <Box ref={setSlideRef(index)} style={{ height: "auto", width: "100%", maxWidth: "100vw", overflow: "hidden" }}>
{tab.content} {(visitedTabs.has(index) || index === activeTab) ? tab.content : null}
</Box> </Box>
</Carousel.Slide> </Carousel.Slide>
))} ))}
+1
View File
@@ -18,6 +18,7 @@ interface AuthData {
theme?: string; theme?: string;
locale?: string; locale?: string;
seenThemesHint?: boolean; seenThemesHint?: boolean;
telemetryAlerts?: boolean;
}; };
roles: string[]; roles: string[];
phone: string; phone: string;
+2 -3
View File
@@ -1,7 +1,7 @@
import { createContext, useCallback, useEffect, useMemo, useState, PropsWithChildren } from 'react'; import { createContext, useCallback, useEffect, useMemo, useState, PropsWithChildren } from 'react';
import { useLingui } from '@lingui/react/macro'; import { useLingui } from '@lingui/react/macro';
import { SpotifyAuth } from '@/lib/spotify/auth'; import { SpotifyAuth } from '@/lib/spotify/auth';
import { useAuth } from './auth-context'; import { useIsAdmin } from '@/features/core/hooks/use-is-admin';
import { useConfig } from '@/hooks/use-config'; import { useConfig } from '@/hooks/use-config';
import type { import type {
SpotifyContextType, SpotifyContextType,
@@ -40,8 +40,7 @@ const deepEqual = (a: unknown, b: unknown): boolean => {
}; };
export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => { export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
const { roles } = useAuth(); const isAdmin = useIsAdmin();
const isAdmin = roles?.includes('Admin') || false;
const config = useConfig(); const config = useConfig();
const { t } = useLingui(); const { t } = useLingui();
@@ -1,268 +1,32 @@
import { useState, useMemo, memo } from "react"; import { useState, useMemo } from "react";
import { Trans, Plural, useLingui } from "@lingui/react/macro"; import { Trans, Plural, useLingui } from "@lingui/react/macro";
import { import { Alert, Code, Container, Group, Stack, Text } from "@mantine/core";
Text, import { CheckIcon, XIcon } from "@phosphor-icons/react";
TextInput,
Stack,
Group,
Box,
Container,
Divider,
UnstyledButton,
Select,
Pagination,
Code,
Alert,
ThemeIcon,
Title,
} from "@mantine/core";
import {
MagnifyingGlassIcon,
CaretUpIcon,
CaretDownIcon,
CheckIcon,
XIcon,
ChecksIcon,
PulseIcon,
} from "@phosphor-icons/react";
import { Activity, ActivitySearchParams } from "../types"; import { Activity, ActivitySearchParams } from "../types";
import { useActivities } from "../queries"; import { useActivities } from "../queries";
import Sheet from "@/components/sheet/sheet"; import { usePlayers } from "@/features/players/queries";
import { useSheet } from "@/hooks/use-sheet"; import { useSheet } from "@/hooks/use-sheet";
import {
DetailSheet,
PaginatedList,
SearchFilterBar,
TelemetryListItem,
} from "@/features/telemetry/components/shared";
interface ActivityListItemProps { const SLOW_THRESHOLD_MS = 1000;
activity: Activity;
onClick: () => void;
}
const ActivityListItem = memo(({ activity, onClick }: ActivityListItemProps) => {
const { t, i18n } = useLingui();
const playerName = typeof activity.player === "object" && activity.player
? `${activity.player.first_name} ${activity.player.last_name}`
: t`System`;
const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
return date.toLocaleString(i18n.locale);
};
return (
<UnstyledButton
w="100%"
p="md"
onClick={onClick}
style={{
borderRadius: 0,
transition: "background-color 0.15s ease",
}}
styles={{
root: {
'&:hover': {
backgroundColor: 'var(--mantine-color-gray-0)',
},
},
}}
>
<Group justify="space-between" align="flex-start" w="100%">
<Stack gap={4} flex={1}>
<Group gap="xs">
<Text size="sm" fw={600}>
{activity.name}
</Text>
{activity.success ? (
<CheckIcon size={16} color="var(--mantine-color-green-6)" />
) : (
<XIcon size={16} color="var(--mantine-color-red-6)" />
)}
</Group>
<Group gap="md">
<Text size="xs" c="dimmed">
{playerName}
</Text>
<Text size="xs" c="dimmed">
<Trans>{activity.duration}ms</Trans>
</Text>
<Text size="xs" c="dimmed">
{formatDate(activity.created)}
</Text>
</Group>
{activity.error && (
<Text size="xs" c="red" lineClamp={1}>
{activity.error}
</Text>
)}
</Stack>
</Group>
</UnstyledButton>
);
});
interface ActivityDetailsSheetProps {
activity: Activity | null;
isOpen: boolean;
onClose: () => void;
}
const ActivityDetailsSheet = memo(({ activity, isOpen, onClose }: ActivityDetailsSheetProps) => {
const { t, i18n } = useLingui();
if (!activity) return null;
const playerName = typeof activity.player === "object" && activity.player
? `${activity.player.first_name} ${activity.player.last_name}`
: t`System`;
const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
return date.toLocaleString(i18n.locale);
};
return (
<Sheet title={t`Activity Details`} opened={isOpen} onChange={onClose}>
<Stack gap="md" p="md">
<Stack gap="xs">
<Text size="xs" fw={700} c="dimmed">
<Trans>Function Name</Trans>
</Text>
<Text size="sm">{activity.name}</Text>
</Stack>
<Stack gap="xs">
<Text size="xs" fw={700} c="dimmed">
<Trans>Status</Trans>
</Text>
<Group gap="xs">
{activity.success ? (
<>
<CheckIcon size={16} color="var(--mantine-color-green-6)" />
<Text size="sm" c="green">
<Trans>Success</Trans>
</Text>
</>
) : (
<>
<XIcon size={16} color="var(--mantine-color-red-6)" />
<Text size="sm" c="red">
<Trans>Failed</Trans>
</Text>
</>
)}
</Group>
</Stack>
<Stack gap="xs">
<Text size="xs" fw={700} c="dimmed">
<Trans>Player</Trans>
</Text>
<Text size="sm">{playerName}</Text>
</Stack>
<Stack gap="xs">
<Text size="xs" fw={700} c="dimmed">
<Trans>Duration</Trans>
</Text>
<Text size="sm"><Trans>{activity.duration}ms</Trans></Text>
</Stack>
<Stack gap="xs">
<Text size="xs" fw={700} c="dimmed">
<Trans>Created</Trans>
</Text>
<Text size="sm">{formatDate(activity.created)}</Text>
</Stack>
{activity.user_agent && (
<Stack gap="xs">
<Text size="xs" fw={700} c="dimmed">
<Trans>User Agent</Trans>
</Text>
<Text size="xs" style={{ wordBreak: "break-word" }}>
{activity.user_agent}
</Text>
</Stack>
)}
{activity.error && (
<Stack gap="xs">
<Text size="xs" fw={700} c="dimmed">
<Trans>Error Message</Trans>
</Text>
<Alert color="red" variant="light">
<Text size="sm" style={{ wordBreak: "break-word" }}>
{activity.error}
</Text>
</Alert>
</Stack>
)}
{activity.arguments && (
<Stack gap="xs">
<Text size="xs" fw={700} c="dimmed">
<Trans>Arguments</Trans>
</Text>
<Code block style={{ fontSize: "11px" }}>
{JSON.stringify(activity.arguments, null, 2)}
</Code>
</Stack>
)}
</Stack>
</Sheet>
);
});
const ActivitiesResults = ({ searchParams, page, setPage, onActivityClick }: any) => {
const { data: result } = useActivities(searchParams);
return (
<>
<Stack gap={0}>
{result.items.map((activity: Activity, index: number) => (
<Box key={activity.id}>
<ActivityListItem
activity={activity}
onClick={() => onActivityClick(activity)}
/>
{index < result.items.length - 1 && <Divider />}
</Box>
))}
</Stack>
{result.items.length === 0 && (
<Stack align="center" gap="md" py="xl">
<ThemeIcon size="xl" variant="light" radius="md">
<PulseIcon size={32} />
</ThemeIcon>
<Title order={3} c="dimmed">
<Trans>No Activities Found</Trans>
</Title>
</Stack>
)}
{result.totalPages > 1 && (
<Group justify="center" py="md">
<Pagination
total={result.totalPages}
value={page}
onChange={setPage}
size="sm"
/>
</Group>
)}
</>
);
};
export const ActivitiesTable = () => { export const ActivitiesTable = () => {
const { t } = useLingui(); const { t, i18n } = useLingui();
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [successFilter, setSuccessFilter] = useState<string | null>(null); const [successFilter, setSuccessFilter] = useState<string | null>(null);
const [playerFilter, setPlayerFilter] = useState<string | null>(null);
const [slowOnly, setSlowOnly] = useState(false);
const [sortBy, setSortBy] = useState("-created"); const [sortBy, setSortBy] = useState("-created");
const [selectedActivity, setSelectedActivity] = useState<Activity | null>(null); const [selectedActivity, setSelectedActivity] = useState<Activity | null>(null);
const { isOpen: detailsOpened, open: openDetails, close: closeDetails } = useSheet();
const { const { data: players } = usePlayers();
isOpen: detailsOpened,
open: openDetails,
close: closeDetails,
} = useSheet();
const searchParams: ActivitySearchParams = useMemo( const searchParams: ActivitySearchParams = useMemo(
() => ({ () => ({
@@ -270,13 +34,24 @@ export const ActivitiesTable = () => {
perPage: 100, perPage: 100,
name: search || undefined, name: search || undefined,
success: successFilter === "success" ? true : successFilter === "failure" ? false : undefined, success: successFilter === "success" ? true : successFilter === "failure" ? false : undefined,
player: playerFilter || undefined,
sortBy, sortBy,
}), }),
[page, search, successFilter, sortBy] [page, search, successFilter, playerFilter, sortBy]
); );
const { data: result } = useActivities(searchParams); const { data: result } = useActivities(searchParams);
const items = useMemo(
() => (slowOnly ? result.items.filter((a) => a.duration > SLOW_THRESHOLD_MS) : result.items),
[result.items, slowOnly]
);
const playerName = (activity: Activity) =>
typeof activity.player === "object" && activity.player
? `${activity.player.first_name} ${activity.player.last_name}`
: t`System`;
const handleSort = (field: string) => { const handleSort = (field: string) => {
setSortBy((prev) => { setSortBy((prev) => {
if (prev === field) return `-${field}`; if (prev === field) return `-${field}`;
@@ -285,12 +60,6 @@ export const ActivitiesTable = () => {
}); });
}; };
const getSortIcon = (field: string) => {
if (sortBy === field) return <CaretUpIcon size={14} />;
if (sortBy === `-${field}`) return <CaretDownIcon size={14} />;
return null;
};
const handleActivityClick = (activity: Activity) => { const handleActivityClick = (activity: Activity) => {
setSelectedActivity(activity); setSelectedActivity(activity);
openDetails(); openDetails();
@@ -304,90 +73,163 @@ export const ActivitiesTable = () => {
return ( return (
<Container size="100%" px={0}> <Container size="100%" px={0}>
<Stack gap="xs"> <Stack gap="xs">
<Stack gap="xs" px="md"> <SearchFilterBar
<TextInput searchValue={search}
placeholder={t`serverFn name`} onSearchChange={(value) => {
value={search} setSearch(value);
onChange={(e) => { setPage(1);
setSearch(e.currentTarget.value); }}
setPage(1); searchPlaceholder={t`serverFn name`}
}} filters={[
leftSection={<MagnifyingGlassIcon size={16} />} {
size="md" placeholder: t`Status`,
/> value: successFilter,
onChange: (value) => {
<Group>
<Select
placeholder={t`Status`}
value={successFilter}
onChange={(value) => {
setSuccessFilter(value); setSuccessFilter(value);
setPage(1); setPage(1);
}} },
data={[ data: [
{ value: "all", label: t`All` }, { value: "all", label: t`All` },
{ value: "success", label: t`Success` }, { value: "success", label: t`Success` },
{ value: "failure", label: t`Failure` }, { value: "failure", label: t`Failure` },
]} ],
clearable },
size="sm" {
style={{ flex: 1 }} placeholder: t`Player`,
/> value: playerFilter,
</Group> onChange: (value) => {
</Stack> setPlayerFilter(value);
setPage(1);
<Group px="md" justify="space-between" align="center"> },
<Text size="10px" lh={0} c="dimmed"> 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" /> <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 <PaginatedList
searchParams={searchParams} items={items}
keyOf={(activity) => activity.id}
empty={t`No Activities Found`}
page={page} page={page}
setPage={setPage} totalPages={result.totalPages}
onActivityClick={handleActivityClick} 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)}
/>
)}
/> />
</Stack> </Stack>
<ActivityDetailsSheet <DetailSheet
activity={selectedActivity} title={t`Activity Details`}
isOpen={detailsOpened} opened={detailsOpened}
onClose={handleCloseDetails} onClose={handleCloseDetails}
fields={
selectedActivity
? [
{ label: <Trans>Function Name</Trans>, value: selectedActivity.name },
{
label: <Trans>Status</Trans>,
value: (
<Group gap="xs">
{selectedActivity.success ? (
<>
<CheckIcon size={16} color="var(--mantine-color-green-6)" />
<Text size="sm" c="green">
<Trans>Success</Trans>
</Text>
</>
) : (
<>
<XIcon size={16} color="var(--mantine-color-red-6)" />
<Text size="sm" c="red">
<Trans>Failed</Trans>
</Text>
</>
)}
</Group>
),
},
{ label: <Trans>Player</Trans>, value: playerName(selectedActivity) },
{
label: <Trans>Duration</Trans>,
value: (
<Text size="sm">
<Trans>{selectedActivity.duration}ms</Trans>
</Text>
),
},
{
label: <Trans>Created</Trans>,
value: new Date(selectedActivity.created).toLocaleString(i18n.locale),
},
...(selectedActivity.user_agent
? [{ label: <Trans>User Agent</Trans>, value: selectedActivity.user_agent }]
: []),
...(selectedActivity.error
? [
{
label: <Trans>Error Message</Trans>,
value: (
<Alert color="red" variant="light">
<Text size="sm" style={{ wordBreak: "break-word" }}>
{selectedActivity.error}
</Text>
</Alert>
),
},
]
: []),
...(selectedActivity.arguments
? [
{
label: <Trans>Arguments</Trans>,
value: (
<Code block style={{ fontSize: "11px" }}>
{JSON.stringify(selectedActivity.arguments, null, 2)}
</Code>
),
},
]
: []),
]
: []
}
/> />
</Container> </Container>
); );
+2 -2
View File
@@ -1,4 +1,4 @@
import { superTokensAdminFunctionMiddleware } from "@/utils/supertokens"; import { superTokensDeveloperFunctionMiddleware } from "@/utils/supertokens";
import { createServerFn } from "@tanstack/react-start"; import { createServerFn } from "@tanstack/react-start";
import { pbAdmin } from "@/lib/pocketbase/client"; import { pbAdmin } from "@/lib/pocketbase/client";
import { z } from "zod"; import { z } from "zod";
@@ -17,7 +17,7 @@ const activitySearchParamsSchema = z.object({
export const searchActivities = createServerFn() export const searchActivities = createServerFn()
.validator(activitySearchParamsSchema) .validator(activitySearchParamsSchema)
.middleware([superTokensAdminFunctionMiddleware]) .middleware([superTokensDeveloperFunctionMiddleware])
.handler(async ({ data }) => .handler(async ({ data }) =>
toServerResult<ActivityListResult>(async () => { toServerResult<ActivityListResult>(async () => {
const result = await pbAdmin.searchActivities(data); const result = await pbAdmin.searchActivities(data);
+19 -25
View File
@@ -1,7 +1,7 @@
import { useState } from "react"; import { useState } from "react";
import { Box, Card, Text, Select, Button, Group, Stack, Badge, Divider } from "@mantine/core"; import { Box, Card, Text, Select, Button, Group, Stack, Badge, Divider } from "@mantine/core";
import { Trans, useLingui } from "@lingui/react/macro"; import { Trans, useLingui } from "@lingui/react/macro";
import { awardManualBadge } from "@/features/badges/server"; import useAwardManualBadge from "@/features/badges/hooks/use-award-manual-badge";
import { useAllBadges } from "@/features/badges/queries"; import { useAllBadges } from "@/features/badges/queries";
import toast from "@/lib/sonner"; import toast from "@/lib/sonner";
import { usePlayers } from "@/features/players/queries"; import { usePlayers } from "@/features/players/queries";
@@ -13,35 +13,29 @@ const AwardBadges = () => {
const [selectedPlayerId, setSelectedPlayerId] = useState<string | null>(null); const [selectedPlayerId, setSelectedPlayerId] = useState<string | null>(null);
const [selectedBadgeId, setSelectedBadgeId] = useState<string | null>(null); const [selectedBadgeId, setSelectedBadgeId] = useState<string | null>(null);
const [isAwarding, setIsAwarding] = useState(false);
const awardBadge = useAwardManualBadge();
const manualBadges = allBadges.filter((badge) => badge.type === "manual"); const manualBadges = allBadges.filter((badge) => badge.type === "manual");
const handleAwardBadge = async () => { const handleAwardBadge = () => {
if (!selectedPlayerId || !selectedBadgeId) return; if (!selectedPlayerId || !selectedBadgeId) return;
setIsAwarding(true); awardBadge.mutate(
try { { playerId: selectedPlayerId, badgeId: selectedBadgeId },
await awardManualBadge({ {
data: { onSuccess: () => {
playerId: selectedPlayerId, const selectedPlayer = players.find((p) => p.id === selectedPlayerId);
badgeId: selectedBadgeId, const playerName = selectedPlayer
? `${selectedPlayer.first_name} ${selectedPlayer.last_name}`
: t`Player`;
toast.success(t`Badge awarded to ${playerName}`);
setSelectedPlayerId(null);
}, },
}); }
);
const selectedPlayer = players.find((p) => p.id === selectedPlayerId);
const playerName = selectedPlayer
? `${selectedPlayer.first_name} ${selectedPlayer.last_name}`
: t`Player`;
toast.success(t`Badge awarded to ${playerName}`);
setSelectedPlayerId(null);
} catch (error) {
toast.error(t`Failed to award badge`);
} finally {
setIsAwarding(false);
}
}; };
const playerOptions = players.map((player) => ({ const playerOptions = players.map((player) => ({
@@ -98,7 +92,7 @@ const AwardBadges = () => {
<Button <Button
onClick={handleAwardBadge} onClick={handleAwardBadge}
disabled={!selectedPlayerId} disabled={!selectedPlayerId}
loading={isAwarding} loading={awardBadge.isPending}
size="md" size="md"
> >
<Trans>Award Badge</Trans> <Trans>Award Badge</Trans>
@@ -0,0 +1,30 @@
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;
@@ -0,0 +1,32 @@
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;
@@ -0,0 +1,14 @@
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,21 +1,22 @@
import { List, Text } from "@mantine/core"; import { List, Text } from "@mantine/core";
import { Plural, Trans, useLingui } from "@lingui/react/macro"; import { Plural, Trans, useLingui } from "@lingui/react/macro";
import ListLink from "@/components/list-link";
import { import {
DatabaseIcon,
TreeStructureIcon, TreeStructureIcon,
TrophyIcon, TrophyIcon,
MedalIcon, MedalIcon,
CrownIcon, CrownIcon,
ListIcon, PulseIcon,
} from "@phosphor-icons/react"; } from "@phosphor-icons/react";
import ListLink from "@/components/list-link";
import ListButton from "@/components/list-button"; import ListButton from "@/components/list-button";
import { useIsDeveloper } from "@/features/core/hooks/use-is-developer";
import { getBadgeMigrationStatus, migrateBadgeProgress } from "@/features/badges/server"; import { getBadgeMigrationStatus, migrateBadgeProgress } from "@/features/badges/server";
import { useServerQuery } from "@/lib/tanstack-query/hooks"; import { useServerQuery } from "@/lib/tanstack-query/hooks";
import toast from "@/lib/sonner"; import toast from "@/lib/sonner";
const AdminPage = () => { export const ToolsList = () => {
const { t } = useLingui(); const { t } = useLingui();
const isDeveloper = useIsDeveloper();
const { data: migrationStatus, refetch: refetchMigrationStatus } = useServerQuery({ const { data: migrationStatus, refetch: refetchMigrationStatus } = useServerQuery({
queryKey: ["admin", "badge-migration-status"], queryKey: ["admin", "badge-migration-status"],
@@ -51,6 +52,13 @@ const AdminPage = () => {
Icon={TrophyIcon} Icon={TrophyIcon}
to="/admin/tournaments" to="/admin/tournaments"
/> />
{isDeveloper && (
<ListLink
label={t`Telemetry`}
Icon={PulseIcon}
to="/admin/telemetry/usage"
/>
)}
<ListLink <ListLink
label={t`Award Badges`} label={t`Award Badges`}
Icon={CrownIcon} Icon={CrownIcon}
@@ -86,18 +94,6 @@ const AdminPage = () => {
<Trans>Badge recalculation failed. Check the server logs.</Trans> <Trans>Badge recalculation failed. Check the server logs.</Trans>
</Text> </Text>
)} )}
<ListLink
label={t`Activities`}
Icon={ListIcon}
to="/admin/activities"
/>
<ListButton
label={t`Open Pocketbase`}
Icon={DatabaseIcon}
onClick={() =>
window.location.replace(process.env.POCKETBASE_URL! + "/_/")
}
/>
<ListLink <ListLink
label={t`Bracket Preview`} label={t`Bracket Preview`}
Icon={TreeStructureIcon} Icon={TreeStructureIcon}
@@ -107,4 +103,4 @@ const AdminPage = () => {
); );
}; };
export default AdminPage; export default ToolsList;
@@ -0,0 +1,48 @@
import { SimpleGrid, Text, Title } from "@mantine/core";
import { useLingui } from "@lingui/react/macro";
import { useNavigate } from "@tanstack/react-router";
import { GearIcon, PlayIcon, UsersThreeIcon } from "@phosphor-icons/react";
import { useCurrentTournament } from "@/features/tournaments/queries";
import { NavTile } from "@/components/nav-tile";
import { Countdown } from "@/components/countdown";
import SectionBox from "./section-box";
export const TournamentSection = () => {
const { t } = useLingui();
const navigate = useNavigate();
const { data: tournament } = useCurrentTournament();
if (!tournament) return null;
const upcoming = !!tournament.start_time && Date.parse(tournament.start_time) > Date.now();
return (
<SectionBox>
<Title order={3}>{tournament.name}</Title>
{upcoming && (
<Text size="sm" c="dimmed" component="div">
<Countdown date={new Date(tournament.start_time)} label={t`Starts in`} />
</Text>
)}
<SimpleGrid cols={3} spacing="sm" mt="sm">
<NavTile
label={t`Run`}
Icon={PlayIcon}
onClick={() => navigate({ to: `/admin/tournaments/run/${tournament.id}` })}
/>
<NavTile
label={t`Enrollment`}
Icon={UsersThreeIcon}
onClick={() => navigate({ to: `/admin/tournaments/${tournament.id}/enrollment` })}
/>
<NavTile
label={t`Manage`}
Icon={GearIcon}
onClick={() => navigate({ to: `/admin/tournaments/${tournament.id}` })}
/>
</SimpleGrid>
</SectionBox>
);
};
export default TournamentSection;
+1 -1
View File
@@ -2,4 +2,4 @@ import { Logger } from "@/lib/logger";
export const logger = new Logger("Admin"); export const logger = new Logger("Admin");
export { default as AdminPage } from "./components/admin-page"; export { default as AdminDashboard } from "./components/dashboard";
@@ -49,6 +49,7 @@ export const BadgeIcon = ({ badge, filled, size = 48 }: BadgeIconProps & { size?
width={size} width={size}
height={size} height={size}
loading="lazy" loading="lazy"
decoding="async"
onError={() => setImageError(true)} onError={() => setImageError(true)}
style={{ style={{
objectFit: 'contain', objectFit: 'contain',
@@ -19,6 +19,7 @@ import PlayerList from '@/features/players/components/player-list';
import { useAuth } from '@/contexts/auth-context'; import { useAuth } from '@/contexts/auth-context';
import { Player } from '@/features/players/types'; import { Player } from '@/features/players/types';
import { Trans, useLingui } from '@lingui/react/macro'; import { Trans, useLingui } from '@lingui/react/macro';
import { track } from '@/lib/telemetry/client';
const BadgeStatsTable = () => { const BadgeStatsTable = () => {
const { data: allBadges } = useAllBadges(); const { data: allBadges } = useAllBadges();
@@ -141,7 +142,13 @@ const BadgeStatRow: React.FC<BadgeStatRowProps> = ({
return ( return (
<Box key={badge.id}> <Box key={badge.id}>
<UnstyledButton onClick={badgeSheet.open} w='100%'> <UnstyledButton
onClick={() => {
track("badge_viewed", { value: badge.key });
badgeSheet.open();
}}
w='100%'
>
<Grid p={'xs'} align='center'> <Grid p={'xs'} align='center'>
<Grid.Col span={2} style={{ display: 'flex', alignItems: 'center' }}> <Grid.Col span={2} style={{ display: 'flex', alignItems: 'center' }}>
<BadgeIcon badge={badge} filled={true} /> <BadgeIcon badge={badge} filled={true} />
@@ -0,0 +1,21 @@
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() });
},
});
}
+2 -3
View File
@@ -1,6 +1,5 @@
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result"; import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
import { superTokensAdminFunctionMiddleware, superTokensFunctionMiddleware } from "@/utils/supertokens"; import { superTokensAdminFunctionMiddleware, superTokensFunctionMiddleware } from "@/utils/supertokens";
import { serverFnLoggingMiddleware } from "@/utils/activities";
import { createServerFn } from "@tanstack/react-start"; import { createServerFn } from "@tanstack/react-start";
import { pbAdmin } from "@/lib/pocketbase/client"; import { pbAdmin } from "@/lib/pocketbase/client";
import { z } from "zod"; import { z } from "zod";
@@ -15,7 +14,7 @@ export const getPlayerBadges = createServerFn()
); );
export const migrateBadgeProgress = createServerFn() export const migrateBadgeProgress = createServerFn()
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware]) .middleware([superTokensAdminFunctionMiddleware])
.handler(async () => .handler(async () =>
toServerResult(async () => startBadgeMigration()) toServerResult(async () => startBadgeMigration())
); );
@@ -41,7 +40,7 @@ export const awardManualBadge = createServerFn()
playerId: z.string(), playerId: z.string(),
badgeId: z.string(), badgeId: z.string(),
})) }))
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware]) .middleware([superTokensAdminFunctionMiddleware])
.handler(async ({ data }) => .handler(async ({ data }) =>
toServerResult(async () => { toServerResult(async () => {
const result = await pbAdmin.awardManualBadge(data.playerId, data.badgeId); const result = await pbAdmin.awardManualBadge(data.playerId, data.badgeId);
@@ -399,7 +399,7 @@ export const MatchCard: React.FC<MatchCardProps> = ({
</Sheet> </Sheet>
</Flex> </Flex>
{!showControls && <MatchReport match={match} compact />} {!showControls && <MatchReport match={match} compact source="match_card" />}
</Stack> </Stack>
); );
}; };
+14 -26
View File
@@ -13,7 +13,7 @@ import {
Tooltip, Tooltip,
} from "@mantine/core"; } from "@mantine/core";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion"; import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { FootballHelmetIcon } from "@phosphor-icons/react"; import HeadToHeadIcon from "@/components/head-to-head-icon";
import { useNavigate } from "@tanstack/react-router"; import { useNavigate } from "@tanstack/react-router";
import { Match } from "@/features/matches/types"; import { Match } from "@/features/matches/types";
import TeamAvatar from "@/components/team-avatar"; import TeamAvatar from "@/components/team-avatar";
@@ -24,6 +24,7 @@ import { MatchReport } from "./match-report";
import Sheet from "@/components/sheet/sheet"; import Sheet from "@/components/sheet/sheet";
import { useSheet } from "@/hooks/use-sheet"; import { useSheet } from "@/hooks/use-sheet";
import { Trans, useLingui } from "@lingui/react/macro"; import { Trans, useLingui } from "@lingui/react/macro";
import { track } from "@/lib/telemetry/client";
const EASE: [number, number, number, number] = [0.32, 0.72, 0, 1]; const EASE: [number, number, number, number] = [0.32, 0.72, 0, 1];
@@ -155,7 +156,7 @@ const MatchDock = ({ match, onClose }: MatchDockProps) => {
ended={ended} ended={ended}
/> />
<MatchReport match={match} /> <MatchReport match={match} source="match_dock" />
<Group justify="space-between" wrap="nowrap" gap="sm"> <Group justify="space-between" wrap="nowrap" gap="sm">
<Box style={{ flex: 1, minWidth: 0 }}> <Box style={{ flex: 1, minWidth: 0 }}>
@@ -166,7 +167,7 @@ const MatchDock = ({ match, onClose }: MatchDockProps) => {
</Center> </Center>
} }
> >
<EmojiBar matchId={match.id} /> <EmojiBar matchId={match.id} source="match_dock" />
</Suspense> </Suspense>
</Box> </Box>
{!hasPrivate && ( {!hasPrivate && (
@@ -174,33 +175,20 @@ const MatchDock = ({ match, onClose }: MatchDockProps) => {
<ActionIcon <ActionIcon
variant="subtle" variant="subtle"
size="sm" size="sm"
onClick={h2hSheet.open} onClick={() => {
track("h2h_viewed", {
source: "match_dock",
context: [match?.home?.name, match?.away?.name]
.filter(Boolean)
.join(" v "),
});
h2hSheet.open();
}}
aria-label={t`View head-to-head`} aria-label={t`View head-to-head`}
w={40} w={40}
style={{ flexShrink: 0 }} style={{ flexShrink: 0 }}
> >
<Group <HeadToHeadIcon />
style={{ position: "relative", width: 27.5, height: 16 }}
>
<FootballHelmetIcon
size={14}
style={{
position: "absolute",
left: 0,
top: 0,
transform: "rotate(25deg)",
}}
/>
<FootballHelmetIcon
size={14}
style={{
position: "absolute",
right: 0,
top: 0,
transform: "scaleX(-1) rotate(25deg)",
}}
/>
</Group>
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
)} )}
@@ -13,6 +13,7 @@ import Sheet from "@/components/sheet/sheet";
import { MatchForm } from "./match-form"; import { MatchForm } from "./match-form";
import toast from "@/lib/sonner"; import toast from "@/lib/sonner";
import { Trans, useLingui } from "@lingui/react/macro"; import { Trans, useLingui } from "@lingui/react/macro";
import { track } from "@/lib/telemetry/client";
const teamId = (t: Match["home"]): string | undefined => const teamId = (t: Match["home"]): string | undefined =>
!t ? undefined : typeof t === "string" ? t : t.id; !t ? undefined : typeof t === "string" ? t : t.id;
@@ -26,9 +27,10 @@ const teamName = (t: Match["home"]): string | undefined =>
interface MatchReportProps { interface MatchReportProps {
match: Match; match: Match;
compact?: boolean; compact?: boolean;
source?: string;
} }
export const MatchReport: React.FC<MatchReportProps> = ({ match, compact }) => { export const MatchReport: React.FC<MatchReportProps> = ({ match, compact, source }) => {
const { user } = useAuth(); const { user } = useAuth();
const formSheet = useSheet(); const formSheet = useSheet();
const { t } = useLingui(); const { t } = useLingui();
@@ -129,8 +131,9 @@ export const MatchReport: React.FC<MatchReportProps> = ({ match, compact }) => {
const openForm = useCallback(() => { const openForm = useCallback(() => {
setAcknowledgedReport(pendingSignature); setAcknowledgedReport(pendingSignature);
track("score_report_opened", source ? { source } : undefined);
formSheet.open(); formSheet.open();
}, [pendingSignature, formSheet.open]); }, [pendingSignature, formSheet.open, source]);
const dismissTakeover = useCallback(() => { const dismissTakeover = useCallback(() => {
setAcknowledgedReport(pendingSignature); setAcknowledgedReport(pendingSignature);
+1 -1
View File
@@ -14,7 +14,7 @@ const BackButton = ({ top=20, left=20 }: { top?: number, left?: number }) => {
onClick={() => router.history.back()} onClick={() => router.history.back()}
pos='absolute' pos='absolute'
left={left} left={left}
top={top} top={`calc(${top}px + env(safe-area-inset-top, 0px))`}
> >
<ArrowLeftIcon weight='bold' size={20} /> <ArrowLeftIcon weight='bold' size={20} />
</UnstyledButton> </UnstyledButton>
+1
View File
@@ -59,6 +59,7 @@ const Header = ({ collapsed, title, titleValues, withBackButton }: HeaderConfig)
style={{ style={{
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
paddingTop: 'env(safe-area-inset-top, 0px)',
}} }}
> >
{ withBackButton && <BackButton /> } { withBackButton && <BackButton /> }
+11 -9
View File
@@ -1,24 +1,28 @@
import { AppShell, Box } from '@mantine/core'; import { AppShell } from '@mantine/core';
import { PropsWithChildren } from 'react'; import { PropsWithChildren } from 'react';
import { useLocation } from '@tanstack/react-router';
import Header from './header'; import Header from './header';
import Navbar from './navbar'; import Navbar from './navbar';
import Pullable from './pullable'; import Pullable from './pullable';
import useVisualViewportSize from '../hooks/use-visual-viewport-size'; import useVisualViewportSize from '../hooks/use-visual-viewport-size';
import useRouterConfig from '../hooks/use-router-config'; import useRouterConfig from '../hooks/use-router-config';
import Page from '@/components/page'; import Page from '@/components/page';
import './route-transition.css';
const Layout: React.FC<PropsWithChildren> = ({ children }) => { const Layout: React.FC<PropsWithChildren> = ({ children }) => {
const { header, withPadding, fullWidth } = useRouterConfig(); const { header, withPadding, fullWidth } = useRouterConfig();
const viewport = useVisualViewportSize(); const viewport = useVisualViewportSize();
const location = useLocation();
return ( return (
<AppShell <AppShell
id='app-shell' id='app-shell'
layout='alt' layout='alt'
header={{ height: 60, collapsed: header.collapsed }} // 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,
}}
navbar={{ navbar={{
width: { base: 0, sm: 100, md: 200, lg: 300 }, width: { base: 0, sm: 100, md: 200, lg: 300 },
breakpoint: 'sm', breakpoint: 'sm',
@@ -43,16 +47,14 @@ const Layout: React.FC<PropsWithChildren> = ({ children }) => {
pos='relative' pos='relative'
h='100%' h='100%'
mah='100%' mah='100%'
pb={{ base: 'calc(65px + env(safe-area-inset-bottom, 0px))', sm: 0 }} pb={{ base: 'calc(73px + env(safe-area-inset-bottom, 0px))', sm: 0 }}
px={{ base: 0.01, sm: 100, md: 200, lg: 300 }} px={{ base: 0.01, sm: 100, md: 200, lg: 300 }}
maw='100dvw' maw='100dvw'
style={{ overflow: 'hidden' }} style={{ overflow: 'hidden' }}
> >
<Pullable> <Pullable>
<Page noPadding={!withPadding} fullWidth={fullWidth}> <Page noPadding={!withPadding} fullWidth={fullWidth}>
<Box key={location.pathname} className="flxn-route-enter"> {children}
{children}
</Box>
</Page> </Page>
</Pullable> </Pullable>
</AppShell.Main> </AppShell.Main>
@@ -0,0 +1,68 @@
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,30 +32,19 @@ export const NavLink = ({
<Box <Box
component={Link} component={Link}
to={href} to={href}
className={styles.navLinkBox} className={`flxn-nav-link ${styles.navLinkBox}`}
p={{ base: 0, sm: 8 }} p={{ base: 0, sm: 8 }}
aria-label={label} aria-label={label}
aria-current={isActive ? "page" : undefined} aria-current={isActive ? "page" : undefined}
data-active={isActive || undefined}
> >
<Flex <Flex
direction={{ base: "column", md: "row" }} direction={{ base: "column", md: "row" }}
align="center" align="center"
gap={{ base: 0, md: "xs" }} gap={{ base: 0, md: "xs" }}
> >
<Icon <Icon weight={isActive ? "fill" : "regular"} size={28} />
weight={isActive ? "fill" : "regular"} <Text visibleFrom="md" ta="center" size="md" fw={isActive ? 800 : 500}>
size={28}
style={{
color: isActive ? "var(--mantine-primary-color-filled)" : undefined,
}}
/>
<Text
visibleFrom="md"
ta="center"
size="md"
fw={isActive ? 800 : 500}
c={isActive ? "var(--mantine-primary-color-filled)" : undefined}
>
{label} {label}
</Text> </Text>
</Flex> </Flex>
@@ -11,11 +11,6 @@
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
} }
.navLinkBox svg,
.navLinkBox p {
transition: color 150ms ease-out;
}
@media (prefers-reduced-motion: no-preference) { @media (prefers-reduced-motion: no-preference) {
.navLinkBox { .navLinkBox {
transition: transform 150ms cubic-bezier(0.32, 0.72, 0, 1); transition: transform 150ms cubic-bezier(0.32, 0.72, 0, 1);
+14 -4
View File
@@ -5,11 +5,13 @@ import { useAuth } from "@/contexts/auth-context";
import { useLinks } from "../hooks/use-links"; import { useLinks } from "../hooks/use-links";
import { memo } from "react"; import { memo } from "react";
import { useIsPWA } from "@/hooks/use-is-pwa"; import { useIsPWA } from "@/hooks/use-is-pwa";
import { useNavIndicator } from "./nav-indicator/use-nav-indicator";
const Navbar = () => { const Navbar = () => {
const { user, roles } = useAuth() const { user, roles } = useAuth()
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const isPWA = useIsPWA(); const isPWA = useIsPWA();
const { containerRef, indicatorRef, shapeRef } = useNavIndicator(isMobile);
const links = useLinks(user?.id, roles); const links = useLinks(user?.id, roles);
@@ -17,9 +19,16 @@ const Navbar = () => {
? 'max(env(safe-area-inset-bottom, 0px), 0.5rem)' ? 'max(env(safe-area-inset-bottom, 0px), 0.5rem)'
: 'env(safe-area-inset-bottom, 0px)'; : 'env(safe-area-inset-bottom, 0px)';
const indicator = (
<span ref={indicatorRef} className="flxn-nav-indicator" aria-hidden="true">
<span ref={shapeRef} className="flxn-nav-indicator-shape" />
</span>
);
if (isMobile) return ( if (isMobile) return (
<Paper component='nav' role='navigation' withBorder shadow="sm" radius='lg' h='4rem' w='calc(100% - 1rem)' pos='fixed' m='0.5rem' bottom={bottomOffset} style={{ zIndex: 10 }}> <Paper 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 gap='xs' justify='space-around' h='100%' w='100%' px={{ base: 12, sm: 0 }}> <Group ref={containerRef} pos='relative' gap='xs' justify='space-around' h='100%' w='100%' px={{ base: 12, sm: 0 }}>
{indicator}
{links.map((link) => ( {links.map((link) => (
<NavLink key={link.href} {...link} /> <NavLink key={link.href} {...link} />
))} ))}
@@ -27,9 +36,10 @@ const Navbar = () => {
</Paper> </Paper>
) )
return <AppShell.Navbar p="xs" role='navigation'> return <AppShell.Navbar className="flxn-nav" p="xs" role='navigation'>
<AppShell.Section grow component={ScrollArea}> <AppShell.Section grow component={ScrollArea}>
<Stack gap='xs' mx='auto' w='fit-content' justify='end' mt='md'> <Stack ref={containerRef} pos='relative' gap='xs' mx='auto' w='fit-content' justify='end' mt='md'>
{indicator}
{links.map((link) => ( {links.map((link) => (
<NavLink key={link.href} {...link} /> <NavLink key={link.href} {...link} />
))} ))}
@@ -1,11 +0,0 @@
@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
@@ -0,0 +1,7 @@
import { useAuth } from "@/contexts/auth-context";
import { isAdminRole } from "../utils/roles";
export const useIsAdmin = (): boolean => {
const { roles } = useAuth();
return isAdminRole(roles);
};
@@ -0,0 +1,7 @@
import { useAuth } from "@/contexts/auth-context";
import { isDeveloperRole } from "../utils/roles";
export const useIsDeveloper = (): boolean => {
const { roles } = useAuth();
return isDeveloperRole(roles);
};
+2 -1
View File
@@ -1,6 +1,7 @@
import { HouseIcon, RankingIcon, ShieldIcon, TrophyIcon, UserCircleIcon } from "@phosphor-icons/react"; import { HouseIcon, RankingIcon, ShieldIcon, TrophyIcon, UserCircleIcon } from "@phosphor-icons/react";
import { useMemo } from "react"; import { useMemo } from "react";
import { useLingui } from "@lingui/react/macro"; import { useLingui } from "@lingui/react/macro";
import { isAdminRole } from "../utils/roles";
export const useLinks = (userId: string | undefined, roles: string[]) => { export const useLinks = (userId: string | undefined, roles: string[]) => {
const { t } = useLingui(); const { t } = useLingui();
@@ -31,7 +32,7 @@ export const useLinks = (userId: string | undefined, roles: string[]) => {
} }
] ]
if (roles.includes('Admin')) { if (isAdminRole(roles)) {
links.push({ links.push({
label: t`Admin`, label: t`Admin`,
href: '/admin', href: '/admin',
+8
View File
@@ -0,0 +1,8 @@
export const ADMIN_ROLE = "Admin";
export const DEVELOPER_ROLE = "Developer";
export const isDeveloperRole = (roles?: string[] | null): boolean =>
!!roles?.includes(DEVELOPER_ROLE);
export const isAdminRole = (roles?: string[] | null): boolean =>
!!roles?.includes(ADMIN_ROLE) || isDeveloperRole(roles);
+1 -1
View File
@@ -21,7 +21,7 @@ const useConsumeCode = (onWrongCode: () => void) => {
} else { } else {
queryClient.setQueryData(playerKeys.auth, response.data); queryClient.setQueryData(playerKeys.auth, response.data);
toast.success(t`Successfully logged in. Welcome back!`); toast.success(t`Successfully logged in. Welcome back!`);
navigate({ to: '/' }) window.location.href = '/';
} }
} else if (data.status === 'INCORRECT_USER_INPUT_CODE_ERROR') { } else if (data.status === 'INCORRECT_USER_INPUT_CODE_ERROR') {
onWrongCode(); onWrongCode();
+1 -1
View File
@@ -21,7 +21,7 @@ const useCreateUser = () => {
...old, ...old,
user: data user: data
})); }));
navigate({ to: '/' }); window.location.href = '/';
} }
}); });
}; };
+8 -22
View File
@@ -1,5 +1,5 @@
import { Text, Group, Stack, Paper, Indicator, Box, Tooltip, ActionIcon } from "@mantine/core"; import { Text, Group, Stack, Paper, Indicator, Box, Tooltip, ActionIcon } from "@mantine/core";
import { FootballHelmetIcon } from "@phosphor-icons/react"; import HeadToHeadIcon from "@/components/head-to-head-icon";
import { useNavigate } from "@tanstack/react-router"; import { useNavigate } from "@tanstack/react-router";
import { Match } from "../types"; import { Match } from "../types";
import TeamAvatar from "@/components/team-avatar"; import TeamAvatar from "@/components/team-avatar";
@@ -10,6 +10,7 @@ import Sheet from "@/components/sheet/sheet";
import TeamHeadToHeadSheet from "./team-head-to-head-sheet"; import TeamHeadToHeadSheet from "./team-head-to-head-sheet";
import AnimatedScore from "./animated-score"; import AnimatedScore from "./animated-score";
import { Trans, useLingui } from "@lingui/react/macro"; import { Trans, useLingui } from "@lingui/react/macro";
import { track } from "@/lib/telemetry/client";
interface MatchCardProps { interface MatchCardProps {
match: Match; match: Match;
@@ -41,6 +42,10 @@ const MatchCard = ({ match, hideH2H = false }: MatchCardProps) => {
const handleH2HClick = (e: React.MouseEvent) => { const handleH2HClick = (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
track("h2h_viewed", {
source: "match_card",
context: [match.home?.name, match.away?.name].filter(Boolean).join(" v "),
});
h2hSheet.open(); h2hSheet.open();
}; };
@@ -89,26 +94,7 @@ const MatchCard = ({ match, hideH2H = false }: MatchCardProps) => {
aria-label={t`View head-to-head`} aria-label={t`View head-to-head`}
w={40} w={40}
> >
<Group style={{ position: 'relative', width: 27.5, height: 16 }}> <HeadToHeadIcon />
<FootballHelmetIcon
size={14}
style={{
position: 'absolute',
left: 0,
top: 0,
transform: 'rotate(25deg)',
}}
/>
<FootballHelmetIcon
size={14}
style={{
position: 'absolute',
right: 0,
top: 0,
transform: 'scaleX(-1) rotate(25deg)',
}}
/>
</Group>
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
)} )}
@@ -218,7 +204,7 @@ const MatchCard = ({ match, hideH2H = false }: MatchCardProps) => {
}} }}
> >
<Suspense> <Suspense>
<EmojiBar matchId={match.id} /> <EmojiBar matchId={match.id} source="match_card" />
</Suspense> </Suspense>
</Paper> </Paper>
</Box> </Box>
@@ -1,4 +1,4 @@
import { Stack, Text } from "@mantine/core"; import { MantineSpacing, Stack, Text } from "@mantine/core";
import { useMemo } from "react"; import { useMemo } from "react";
import { Match } from "../types"; import { Match } from "../types";
import MatchCard from "./match-card"; import MatchCard from "./match-card";
@@ -7,9 +7,10 @@ import { Trans } from "@lingui/react/macro";
interface MatchListProps { interface MatchListProps {
matches: Match[]; matches: Match[];
hideH2H?: boolean; hideH2H?: boolean;
p?: MantineSpacing;
} }
const MatchList = ({ matches, hideH2H = false }: MatchListProps) => { const MatchList = ({ matches, hideH2H = false, p = "md" }: MatchListProps) => {
const filteredMatches = useMemo( const filteredMatches = useMemo(
() => () =>
[...(matches ?? [])] [...(matches ?? [])]
@@ -27,7 +28,7 @@ const MatchList = ({ matches, hideH2H = false }: MatchListProps) => {
const isRegional = filteredMatches[0]?.tournament?.regional; const isRegional = filteredMatches[0]?.tournament?.regional;
return ( return (
<Stack p="md" gap="sm"> <Stack p={p} gap="sm">
{isRegional && ( {isRegional && (
<Text size="xs" c="dimmed" ta="center" px="md"> <Text size="xs" c="dimmed" ta="center" px="md">
<Trans>Matches for regionals are unordered</Trans> <Trans>Matches for regionals are unordered</Trans>
@@ -200,10 +200,10 @@ const TeamHeadToHeadContent = ({ team1, team2, isOpen = true }: TeamHeadToHeadSh
</Stack> </Stack>
<Stack gap="xs"> <Stack gap="xs">
<Text size="sm" fw={600} px="md"> <Text size="sm" fw={600}>
<Trans>Match History (<Plural value={totalMatches} one="# match" other="# matches" />)</Trans> <Trans>Match History (<Plural value={totalMatches} one="# match" other="# matches" />)</Trans>
</Text> </Text>
<MatchList matches={matches} hideH2H /> <MatchList matches={matches} hideH2H p={0} />
</Stack> </Stack>
</Stack> </Stack>
); );
+7 -8
View File
@@ -9,7 +9,6 @@ import { Match, MatchInput } from "@/features/matches/types";
import { emitServerEvent } from "@/lib/events/emitter"; import { emitServerEvent } from "@/lib/events/emitter";
import { superTokensFunctionMiddleware } from "@/utils/supertokens"; import { superTokensFunctionMiddleware } from "@/utils/supertokens";
import { PlayerInfo } from "../players/types"; import { PlayerInfo } from "../players/types";
import { serverFnLoggingMiddleware } from "@/utils/activities";
const orderedTeamsSchema = z.object({ const orderedTeamsSchema = z.object({
tournamentId: z.string(), tournamentId: z.string(),
@@ -18,7 +17,7 @@ const orderedTeamsSchema = z.object({
export const generateTournamentBracket = createServerFn() export const generateTournamentBracket = createServerFn()
.validator(orderedTeamsSchema) .validator(orderedTeamsSchema)
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware]) .middleware([superTokensAdminFunctionMiddleware])
.handler(async ({ data: { tournamentId, orderedTeamIds } }) => .handler(async ({ data: { tournamentId, orderedTeamIds } }) =>
toServerResult(async () => { toServerResult(async () => {
logger.info("Generating tournament bracket", { logger.info("Generating tournament bracket", {
@@ -141,7 +140,7 @@ export const generateTournamentBracket = createServerFn()
export const startMatch = createServerFn() export const startMatch = createServerFn()
.validator(z.string()) .validator(z.string())
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware]) .middleware([superTokensAdminFunctionMiddleware])
.handler(async ({ data }) => .handler(async ({ data }) =>
toServerResult(async () => { toServerResult(async () => {
logger.info("Starting match", data); logger.info("Starting match", data);
@@ -170,7 +169,7 @@ export const populateKnockoutBracket = createServerFn()
.validator(z.object({ .validator(z.object({
tournamentId: z.string(), tournamentId: z.string(),
})) }))
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware]) .middleware([superTokensAdminFunctionMiddleware])
.handler(async ({ data: { tournamentId } }) => .handler(async ({ data: { tournamentId } }) =>
toServerResult(async () => { toServerResult(async () => {
const tournament = await pbAdmin.getTournament(tournamentId); const tournament = await pbAdmin.getTournament(tournamentId);
@@ -801,7 +800,7 @@ const endMatchSchema = z.object({
}); });
export const endMatch = createServerFn() export const endMatch = createServerFn()
.validator(endMatchSchema) .validator(endMatchSchema)
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware]) .middleware([superTokensAdminFunctionMiddleware])
.handler(async ({ data: { matchId, home_cups, away_cups, ot_count } }) => .handler(async ({ data: { matchId, home_cups, away_cups, ot_count } }) =>
toServerResult(async () => { toServerResult(async () => {
logger.info("Ending match", matchId); logger.info("Ending match", matchId);
@@ -827,7 +826,7 @@ const reportScoreSchema = z.object({
export const reportMatchScore = createServerFn() export const reportMatchScore = createServerFn()
.validator(reportScoreSchema) .validator(reportScoreSchema)
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware]) .middleware([superTokensFunctionMiddleware])
.handler(async ({ data: { matchId, home_cups, away_cups, ot_count }, context }) => .handler(async ({ data: { matchId, home_cups, away_cups, ot_count }, context }) =>
toServerResult(async () => { toServerResult(async () => {
const player = await pbAdmin.getPlayerByAuthId(context.userAuthId); const player = await pbAdmin.getPlayerByAuthId(context.userAuthId);
@@ -890,7 +889,7 @@ const matchIdSchema = z.object({ matchId: z.string() });
export const confirmMatchScore = createServerFn() export const confirmMatchScore = createServerFn()
.validator(matchIdSchema) .validator(matchIdSchema)
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware]) .middleware([superTokensFunctionMiddleware])
.handler(async ({ data: { matchId }, context }) => .handler(async ({ data: { matchId }, context }) =>
toServerResult(async () => { toServerResult(async () => {
const player = await pbAdmin.getPlayerByAuthId(context.userAuthId); const player = await pbAdmin.getPlayerByAuthId(context.userAuthId);
@@ -929,7 +928,7 @@ export const confirmMatchScore = createServerFn()
export const clearMatchReport = createServerFn() export const clearMatchReport = createServerFn()
.validator(matchIdSchema) .validator(matchIdSchema)
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware]) .middleware([superTokensFunctionMiddleware])
.handler(async ({ data: { matchId }, context }) => .handler(async ({ data: { matchId }, context }) =>
toServerResult(async () => { toServerResult(async () => {
const player = await pbAdmin.getPlayerByAuthId(context.userAuthId); const player = await pbAdmin.getPlayerByAuthId(context.userAuthId);
+9
View File
@@ -0,0 +1,9 @@
import { describe, expect, it } from "vitest";
import { matchInputSchema } from "./types";
describe("matchInputSchema.partial()", () => {
it("does not inject keys the caller did not send", () => {
const parsed = matchInputSchema.partial().parse({ home_cups: 7 });
expect(parsed).toEqual({ home_cups: 7 });
});
});
+9 -9
View File
@@ -42,20 +42,20 @@ export interface Match {
export const matchInputSchema = z.object({ export const matchInputSchema = z.object({
order: z.number().int().min(1).optional(), order: z.number().int().min(1).optional(),
lid: z.number().int().min(1), lid: z.number().int().min(1),
reset: z.boolean().optional().default(false), reset: z.boolean().optional(),
round: z.number().int().min(1), round: z.number().int().min(1),
home_cups: z.number().int().min(0).optional().default(0), home_cups: z.number().int().min(0).optional(),
away_cups: z.number().int().min(0).optional().default(0), away_cups: z.number().int().min(0).optional(),
ot_count: z.number().int().min(0).optional().default(0), ot_count: z.number().int().min(0).optional(),
start_time: z.iso.datetime().optional(), start_time: z.iso.datetime().optional(),
end_time: z.iso.datetime().optional(), end_time: z.iso.datetime().optional(),
bye: z.boolean().optional().default(false), bye: z.boolean().optional(),
home_from_lid: z.number().int().min(1).optional(), home_from_lid: z.number().int().min(1).optional(),
away_from_lid: z.number().int().min(1).optional(), away_from_lid: z.number().int().min(1).optional(),
home_from_loser: z.boolean().optional().default(false), home_from_loser: z.boolean().optional(),
away_from_loser: z.boolean().optional().default(false), away_from_loser: z.boolean().optional(),
is_losers_bracket: z.boolean().optional().default(false), is_losers_bracket: z.boolean().optional(),
status: z.enum(["tbd", "ready", "started", "ended"]).optional().default("tbd"), status: z.enum(["tbd", "ready", "started", "ended"]).optional(),
tournament: z.string().min(1), tournament: z.string().min(1),
home: z.string().min(1).nullable().optional(), home: z.string().min(1).nullable().optional(),
away: z.string().min(1).nullable().optional(), away: z.string().min(1).nullable().optional(),
@@ -1,5 +1,5 @@
import { Stack, Text, TextInput, Box, Paper, Group, Divider, ActionIcon, Button } from "@mantine/core"; import { Stack, Text, TextInput, Box, Paper, Group, Divider, ActionIcon, Button } from "@mantine/core";
import { useState, useMemo } from "react"; import { useState, useMemo, useRef } from "react";
import { MagnifyingGlassIcon, XIcon, ArrowRightIcon, PlusIcon } from "@phosphor-icons/react"; import { MagnifyingGlassIcon, XIcon, ArrowRightIcon, PlusIcon } from "@phosphor-icons/react";
import { Trans, useLingui } from "@lingui/react/macro"; import { Trans, useLingui } from "@lingui/react/macro";
import { useAllPlayerStats } from "../queries"; import { useAllPlayerStats } from "../queries";
@@ -8,6 +8,7 @@ import { useSheet } from "@/hooks/use-sheet";
import Sheet from "@/components/sheet/sheet"; import Sheet from "@/components/sheet/sheet";
import PlayerHeadToHeadSheet from "./player-head-to-head-sheet"; import PlayerHeadToHeadSheet from "./player-head-to-head-sheet";
import PlayerAvatar from "@/components/player-avatar"; import PlayerAvatar from "@/components/player-avatar";
import { track } from "@/lib/telemetry/client";
const LeagueHeadToHead = () => { const LeagueHeadToHead = () => {
const { t } = useLingui(); const { t } = useLingui();
@@ -15,6 +16,7 @@ const LeagueHeadToHead = () => {
const [player1Id, setPlayer1Id] = useState<string | null>(null); const [player1Id, setPlayer1Id] = useState<string | null>(null);
const [player2Id, setPlayer2Id] = useState<string | null>(null); const [player2Id, setPlayer2Id] = useState<string | null>(null);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const searchedRef = useRef(false);
const { data: allPlayerStats } = useAllPlayerStats(); const { data: allPlayerStats } = useAllPlayerStats();
const h2hSheet = useSheet(); const h2hSheet = useSheet();
@@ -51,6 +53,12 @@ const LeagueHeadToHead = () => {
setPlayer1Id(playerId); setPlayer1Id(playerId);
} else { } else {
setPlayer2Id(playerId); setPlayer2Id(playerId);
const opponent =
allPlayerStats?.find((p) => p.player_id === playerId)?.player_name || "";
track("h2h_viewed", {
source: "stats",
context: [player1Name, opponent].filter(Boolean).join(" v "),
});
h2hSheet.open(); h2hSheet.open();
} }
}; };
@@ -128,7 +136,14 @@ const LeagueHeadToHead = () => {
<TextInput <TextInput
placeholder={t`Search players`} placeholder={t`Search players`}
value={search} value={search}
onChange={(e) => setSearch(e.currentTarget.value)} onChange={(e) => {
const value = e.currentTarget.value;
if (value && !searchedRef.current) {
searchedRef.current = true;
track("player_searched");
}
setSearch(value);
}}
leftSection={<MagnifyingGlassIcon size={16} />} leftSection={<MagnifyingGlassIcon size={16} />}
size="md" size="md"
px="md" px="md"
@@ -260,10 +260,10 @@ const PlayerHeadToHeadContent = ({
</Stack> </Stack>
<Stack gap="xs"> <Stack gap="xs">
<Text size="sm" fw={600} px="md"> <Text size="sm" fw={600}>
<Trans>Match History ({totalMatches})</Trans> <Trans>Match History ({totalMatches})</Trans>
</Text> </Text>
<MatchList matches={matches} hideH2H /> <MatchList matches={matches} hideH2H p={0} />
</Stack> </Stack>
</Stack> </Stack>
); );
@@ -28,6 +28,7 @@ import { useNavigate } from "@tanstack/react-router";
import { useAllPlayerStats } from "../queries"; import { useAllPlayerStats } from "../queries";
import { PlayerListItemSkeleton } from "./player-stats-table-skeleton"; import { PlayerListItemSkeleton } from "./player-stats-table-skeleton";
import { Trans, useLingui } from "@lingui/react/macro"; import { Trans, useLingui } from "@lingui/react/macro";
import { track } from "@/lib/telemetry/client";
type SortKey = keyof PlayerStats | "mmr"; type SortKey = keyof PlayerStats | "mmr";
type SortDirection = "asc" | "desc"; type SortDirection = "asc" | "desc";
@@ -177,6 +178,7 @@ const PlayerStatsTable = ({ viewType = 'all' }: PlayerStatsTableProps) => {
const navigate = useNavigate(); const navigate = useNavigate();
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const deferredSearch = useDeferredValue(search); const deferredSearch = useDeferredValue(search);
const searchedRef = useRef(false);
const [sortConfig, setSortConfig] = useState<SortConfig>({ const [sortConfig, setSortConfig] = useState<SortConfig>({
key: "mmr" as SortKey, key: "mmr" as SortKey,
direction: "desc", direction: "desc",
@@ -291,10 +293,10 @@ const PlayerStatsTable = ({ viewType = 'all' }: PlayerStatsTableProps) => {
}, [navigate]); }, [navigate]);
const handleSort = (key: SortKey) => { const handleSort = (key: SortKey) => {
setSortConfig((prev) => ({ const direction =
key, sortConfig.key === key && sortConfig.direction === "desc" ? "asc" : "desc";
direction: prev.key === key && prev.direction === "desc" ? "asc" : "desc", track("stats_sorted", { value: key, context: direction });
})); setSortConfig({ key, direction });
}; };
const getSortIcon = (key: SortKey) => { const getSortIcon = (key: SortKey) => {
@@ -328,7 +330,14 @@ const PlayerStatsTable = ({ viewType = 'all' }: PlayerStatsTableProps) => {
<TextInput <TextInput
placeholder={t`Search players`} placeholder={t`Search players`}
value={search} value={search}
onChange={(e) => setSearch(e.currentTarget.value)} onChange={(e) => {
const value = e.currentTarget.value;
if (value && !searchedRef.current) {
searchedRef.current = true;
track("player_searched");
}
setSearch(value);
}}
leftSection={<MagnifyingGlassIcon size={16} />} leftSection={<MagnifyingGlassIcon size={16} />}
size="md" size="md"
px="md" px="md"
@@ -1,7 +1,8 @@
import Sheet from "@/components/sheet/sheet"; import Sheet from "@/components/sheet/sheet";
import { useAuth } from "@/contexts/auth-context"; import { useAuth } from "@/contexts/auth-context";
import { Flex, Title, ActionIcon, Stack, Button, Box } from "@mantine/core"; import { Flex, Title, ActionIcon, Stack, Button } from "@mantine/core";
import { PencilIcon, FootballHelmetIcon } from "@phosphor-icons/react"; import { PencilIcon } from "@phosphor-icons/react";
import HeadToHeadIcon from "@/components/head-to-head-icon";
import { useMemo } from "react"; import { useMemo } from "react";
import { useLingui } from "@lingui/react/macro"; import { useLingui } from "@lingui/react/macro";
import NameUpdateForm from "./name-form"; import NameUpdateForm from "./name-form";
@@ -9,6 +10,7 @@ import PlayerAvatar from "@/components/player-avatar";
import { useSheet } from "@/hooks/use-sheet"; import { useSheet } from "@/hooks/use-sheet";
import { Player } from "../../types"; import { Player } from "../../types";
import PlayerHeadToHeadSheet from "../player-head-to-head-sheet"; import PlayerHeadToHeadSheet from "../player-head-to-head-sheet";
import { track } from "@/lib/telemetry/client";
interface HeaderProps { interface HeaderProps {
player: Player; player: Player;
@@ -53,30 +55,17 @@ const Header = ({ player }: HeaderProps) => {
variant="subtle" variant="subtle"
size="sm" size="sm"
radius="xl" radius="xl"
onClick={h2hSheet.open} onClick={() => {
track("h2h_viewed", {
source: "profile",
context: [player.first_name, player.last_name].filter(Boolean).join(" "),
});
h2hSheet.open();
}}
w={40} w={40}
display={!owner ? 'block' : 'none'} display={!owner ? 'block' : 'none'}
> >
<Box style={{ position: 'relative', width: 27.5, height: 16 }}> <HeadToHeadIcon />
<FootballHelmetIcon
size={14}
style={{
position: 'absolute',
left: 0,
top: 0,
transform: 'rotate(25deg)'
}}
/>
<FootballHelmetIcon
size={14}
style={{
position: 'absolute',
right: 0,
top: 0,
transform: 'scaleX(-1) rotate(25deg)'
}}
/>
</Box>
</ActionIcon> </ActionIcon>
</Flex> </Flex>
</Flex> </Flex>
@@ -1,5 +1,5 @@
import { Box, Stack, Text, Divider, Group, Button } from "@mantine/core"; import { Box, Stack, Text, Divider, Group, Button } from "@mantine/core";
import { Suspense, useState, useDeferredValue } from "react"; import { Suspense, useState, useDeferredValue, useRef } from "react";
import { Trans, useLingui } from "@lingui/react/macro"; import { Trans, useLingui } from "@lingui/react/macro";
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
import Header from "./header"; import Header from "./header";
@@ -10,6 +10,7 @@ import StatsOverview, { StatsSkeleton } from "@/components/stats-overview";
import MatchList from "@/features/matches/components/match-list"; import MatchList from "@/features/matches/components/match-list";
import BadgeShowcase from "@/features/badges/components/badge-showcase"; import BadgeShowcase from "@/features/badges/components/badge-showcase";
import BadgeShowcaseSkeleton from "@/features/badges/components/badge-showcase-skeleton"; import BadgeShowcaseSkeleton from "@/features/badges/components/badge-showcase-skeleton";
import { track } from "@/lib/telemetry/client";
interface ProfileProps { interface ProfileProps {
id: string; id: string;
@@ -20,14 +21,22 @@ const StatsWithFilter = ({ id }: { id: string }) => {
const deferredViewType = useDeferredValue(viewType); const deferredViewType = useDeferredValue(viewType);
const isStale = viewType !== deferredViewType; const isStale = viewType !== deferredViewType;
const viewTypeRef = useRef(viewType);
const changeViewType = (next: typeof viewType) => {
if (viewTypeRef.current === next) return;
viewTypeRef.current = next;
track("stats_filtered", { value: next, source: "profile" });
setViewType(next);
};
return ( return (
<Stack> <Stack>
<Group gap="xs" px="md" justify="space-between" align="center"> <Group gap="xs" px="md" justify="space-between" align="center">
<Text size="md" fw={700}><Trans>Statistics</Trans></Text> <Text size="md" fw={700}><Trans>Statistics</Trans></Text>
<Group gap="xs"> <Group gap="xs">
<Button variant={viewType === 'all' ? 'filled' : 'light'} size="compact-xs" onClick={() => setViewType('all')}><Trans>All</Trans></Button> <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={() => setViewType('mainline')}><Trans>Mainline</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={() => setViewType('regional')}><Trans>Regional</Trans></Button> <Button variant={viewType === 'regional' ? 'filled' : 'light'} size="compact-xs" onClick={() => changeViewType('regional')}><Trans>Regional</Trans></Button>
</Group> </Group>
</Group> </Group>
<Box style={{ opacity: isStale ? 0.6 : 1, transition: 'opacity 150ms' }}> <Box style={{ opacity: isStale ? 0.6 : 1, transition: 'opacity 150ms' }}>
@@ -90,7 +99,12 @@ const Profile = ({ id }: ProfileProps) => {
<> <>
<Header player={player} /> <Header player={player} />
<Box mt="lg"> <Box mt="lg">
<SwipeableTabs tabs={tabs} /> <SwipeableTabs
tabs={tabs}
onTabChange={(_, tab) => {
if (tab.value === "matches") track("match_history_viewed", { source: "profile" });
}}
/>
</Box> </Box>
</> </>
); );

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